diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16a5079..c044863 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,3 +81,26 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo build --release --locked - run: ./target/release/omnid --version + + publish-npm: + needs: upload-assets + if: ${{ secrets.NPM_TOKEN != '' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + - name: Sync npm version with release tag + run: | + VERSION="${GITHUB_REF_NAME#v}" + cd npm + npm version "$VERSION" --no-git-tag-version --allow-same-version + - run: cd npm && npm test + - run: cd npm && npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index ed4b061..dd17389 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,8 @@ cargo deny check # requires: cargo install cargo-deny - **MSRV:** `rust-version = "1.78"` in `Cargo.toml` — CI enforces via dedicated job - **Local toolchain:** stable (`rust-toolchain.toml`); develop on stable, CI guarantees 1.78 -- **Automation:** set `OMNID_NONINTERACTIVE=1` to skip interactive prompts +- **Automation:** `OMNID_NONINTERACTIVE=1` skips `omnid add server` prompts when `--preset` or `--name` + `--command` are set; skips daemon confirm on `omnid init --install-daemon` +- **npm:** `npm/` wraps release binaries (`postinstall` downloads from GitHub, shim in `bin/omnid.js`). Release publish needs `NPM_TOKEN` in repo secrets; `publish-npm` job skips if unset. ## Git workflow diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a89df..34d1c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- npm package: `npx omnid` and `npm install -g omnid` download the native binary from GitHub releases +- MCP presets on `omnid add server` (`everything`, `filesystem`) with `--preset` flag +- Non-interactive `omnid add server` flags: `--name`, `--command`, `--args`, `--transport`, `--url` + +### Changed + +- `publish-npm` release job skips when `NPM_TOKEN` is not configured +- First `omnid` run scaffolds config, force-syncs, and shows a short welcome with one next step +- Status output uses plain labels (Tools, synced) and context-aware next-step hints +- README leads with `npx omnid`; `OMNID_NONINTERACTIVE` docs match actual behavior + ## [0.1.1] - 2026-06-05 ### Changed diff --git a/README.md b/README.md index 0a1a9d2..b3b247d 100644 --- a/README.md +++ b/README.md @@ -34,33 +34,45 @@ omnid owns `~/.config/omni/`. One source of truth → compiled, linked, and push - [Development](#development) - [License](#license) -## Quick start +## Start here ```bash -omnid init # first time -omnid # status + sync if drift -omnid add server # add MCP backend +npx omnid ``` -No config? `omnid` alone makes `~/.config/omni/` and shows what's on your machine. +Open your coding app. Done. -CI: `OMNID_NONINTERACTIVE=1` skips prompts. +Want a tool inside your agent? Run `omnid add server`. -### Example +Install for good: `npm install -g omnid` + +> **Note:** `npx omnid` / `npm install -g omnid` work after the package is published to npm (starting with the next tagged release). Until then, use [GitHub Releases](#install) or `cargo install`. + +### Example (first run) ``` -omnid — agent config sync -──────────────────────────────────────── -Matrix ~/.config/omni/matrix.yaml valid -Agents 3 installed · in sync - cursor mcp ok rules ok skills ok hooks n/a - claude_code mcp ok rules ok skills ok hooks n/a - vscode mcp ok rules ok skills n/a hooks n/a -Backends 2 configured +omnid +───── +Made ~/.config/omni/ +Synced cursor, claude_code + +Next: open Cursor. omnid is ready. + (no tools yet — run: omnid add server) ``` +`OMNID_NONINTERACTIVE=1` skips prompts on `omnid add server` when you pass `--preset` or `--name` + `--command`. + ## Install +**npm** (easiest if you have Node.js — available after first npm publish): + +```bash +npx omnid # try without installing +npm install -g omnid # install globally +``` + +Requires the `omnid` package on [npm](https://www.npmjs.com/package/omnid). Not live until the next release; use binaries below until then. + **Binaries** — [GitHub Releases](https://github.com/xb3sox/omnid/releases) (SHA256 checksum sidecars included) ```bash @@ -162,9 +174,10 @@ Use `OMNID_MATRIX` or `--matrix` if your config lives outside the default path. | Command | Does | |---------|------| -| `omnid` | Status. Auto-sync on drift. | -| `omnid init` | Guided setup | -| `omnid add server` | Add MCP (interactive) | +| `omnid` | Setup on first run. Status after that. Auto-sync on drift. | +| `omnid init` | Same as first `omnid`, plus optional `--install-daemon` | +| `omnid add server` | Add a tool (pick a preset or type your own) | +| `omnid add server --preset everything --write` | Add demo tool, no prompts | | `omnid add rule` | Append `rules/global.md` | | `omnid add skill` | New skill scaffold | | `omnid check` | Summary health check (`doctor --summary`) | diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..1cf3bdf --- /dev/null +++ b/npm/README.md @@ -0,0 +1,18 @@ +# omnid + +Sync MCP, rules, and skills across 20+ AI coding agents. + +## Quick start + +```bash +npx omnid +``` + +Or install globally: + +```bash +npm install -g omnid +omnid +``` + +Full docs: [github.com/xb3sox/omnid](https://github.com/xb3sox/omnid) diff --git a/npm/bin/omnid.js b/npm/bin/omnid.js new file mode 100644 index 0000000..dcc32fa --- /dev/null +++ b/npm/bin/omnid.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +"use strict"; + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const { installBinary, cacheRoot, VERSION } = require("../install"); +const { resolvePlatform } = require("../lib/platform"); + +function binaryPath() { + const spec = resolvePlatform(process.platform, process.arch); + if (!spec) { + console.error("omnid: unsupported platform"); + process.exit(1); + } + const cached = path.join(cacheRoot(), spec.binary); + if (fs.existsSync(cached)) { + return cached; + } + return null; +} + +async function main() { + let bin = binaryPath(); + if (!bin) { + try { + bin = await installBinary(); + } catch (err) { + console.error(`omnid: could not install v${VERSION}: ${err.message}`); + console.error("Run: npm rebuild omnid"); + process.exit(1); + } + } + + const result = spawnSync(bin, process.argv.slice(2), { stdio: "inherit" }); + if (result.error) { + console.error(`omnid: ${result.error.message}`); + process.exit(1); + } + process.exit(result.status ?? 1); +} + +main(); diff --git a/npm/install.js b/npm/install.js new file mode 100644 index 0000000..da20ed7 --- /dev/null +++ b/npm/install.js @@ -0,0 +1,127 @@ +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const https = require("https"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const { resolvePlatform } = require("./lib/platform"); + +const REPO = "xb3sox/omnid"; +const VERSION = process.env.OMNID_VERSION || require("./package.json").version; + +function cacheRoot() { + if (process.platform === "win32") { + const base = process.env.LOCALAPPDATA || path.join(process.env.USERPROFILE || ".", "AppData", "Local"); + return path.join(base, "omnid", "versions", VERSION); + } + const home = process.env.HOME || process.env.USERPROFILE || "."; + return path.join(home, ".omnid", "versions", VERSION); +} + +function download(url) { + return new Promise((resolve, reject) => { + const follow = (current) => { + https + .get(current, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + follow(res.headers.location); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`download failed: HTTP ${res.statusCode} for ${current}`)); + res.resume(); + return; + } + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve(Buffer.concat(chunks))); + }) + .on("error", reject); + }; + follow(url); + }); +} + +function sha256(buf) { + return crypto.createHash("sha256").update(buf).digest("hex"); +} + +async function fetchChecksum(asset) { + const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${asset}.sha256`; + const buf = await download(url); + const line = buf.toString("utf8").trim().split(/\s+/)[0]; + if (!line) { + throw new Error(`empty checksum file for ${asset}`); + } + return line.toLowerCase(); +} + +function extractTarGz(archivePath, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + execFileSync("tar", ["-xzf", archivePath, "-C", destDir], { stdio: "inherit" }); +} + +function extractZip(archivePath, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + if (process.platform === "win32") { + const ps = `Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}' -Force`; + execFileSync("powershell", ["-NoProfile", "-Command", ps], { stdio: "inherit" }); + } else { + execFileSync("unzip", ["-o", archivePath, "-d", destDir], { stdio: "inherit" }); + } +} + +async function installBinary() { + const spec = resolvePlatform(process.platform, process.arch); + if (!spec) { + console.error( + `omnid: no binary for ${process.platform}/${process.arch}. See https://github.com/${REPO}/releases` + ); + process.exit(1); + } + + const destDir = cacheRoot(); + const binaryPath = path.join(destDir, spec.binary); + if (fs.existsSync(binaryPath)) { + return binaryPath; + } + + const base = `https://github.com/${REPO}/releases/download/v${VERSION}`; + const assetUrl = `${base}/${spec.asset}`; + const archivePath = path.join(destDir, spec.asset); + + fs.mkdirSync(destDir, { recursive: true }); + const data = await download(assetUrl); + const expected = await fetchChecksum(spec.asset); + const got = sha256(data); + if (got !== expected) { + throw new Error(`checksum mismatch for ${spec.asset}`); + } + + fs.writeFileSync(archivePath, data); + if (spec.asset.endsWith(".tar.gz")) { + extractTarGz(archivePath, destDir); + } else { + extractZip(archivePath, destDir); + } + fs.unlinkSync(archivePath); + + if (!fs.existsSync(binaryPath)) { + throw new Error(`binary not found after extract: ${binaryPath}`); + } + if (process.platform !== "win32") { + fs.chmodSync(binaryPath, 0o755); + } + return binaryPath; +} + +if (require.main === module) { + installBinary().catch((err) => { + console.error(`omnid install failed: ${err.message}`); + process.exit(1); + }); +} + +module.exports = { installBinary, cacheRoot, sha256, REPO, VERSION }; diff --git a/npm/lib/platform.js b/npm/lib/platform.js new file mode 100644 index 0000000..e9a1d63 --- /dev/null +++ b/npm/lib/platform.js @@ -0,0 +1,36 @@ +"use strict"; + +/** @returns {{ target: string, asset: string, binary: string } | null} */ +function resolvePlatform(platform, arch) { + if (platform === "linux" && arch === "x64") { + return { + target: "x86_64-unknown-linux-gnu", + asset: "omnid-x86_64-unknown-linux-gnu.tar.gz", + binary: "omnid", + }; + } + if (platform === "darwin" && arch === "arm64") { + return { + target: "aarch64-apple-darwin", + asset: "omnid-aarch64-apple-darwin.tar.gz", + binary: "omnid", + }; + } + if (platform === "darwin" && arch === "x64") { + return { + target: "x86_64-apple-darwin", + asset: "omnid-x86_64-apple-darwin.tar.gz", + binary: "omnid", + }; + } + if (platform === "win32" && arch === "x64") { + return { + target: "x86_64-pc-windows-msvc", + asset: "omnid-x86_64-pc-windows-msvc.zip", + binary: "omnid.exe", + }; + } + return null; +} + +module.exports = { resolvePlatform }; diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..3ed4468 --- /dev/null +++ b/npm/package.json @@ -0,0 +1,31 @@ +{ + "name": "omnid", + "version": "0.1.1", + "description": "Sync MCP, rules, and skills across 20+ AI coding agents", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/xb3sox/omnid.git" + }, + "homepage": "https://github.com/xb3sox/omnid#readme", + "bugs": { + "url": "https://github.com/xb3sox/omnid/issues" + }, + "keywords": ["mcp", "cursor", "claude", "ai", "agents"], + "bin": { + "omnid": "bin/omnid.js" + }, + "scripts": { + "postinstall": "node install.js", + "test": "node --test test/install.test.js" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "bin/omnid.js", + "install.js", + "lib/platform.js", + "README.md" + ] +} diff --git a/npm/test/install.test.js b/npm/test/install.test.js new file mode 100644 index 0000000..a5f133b --- /dev/null +++ b/npm/test/install.test.js @@ -0,0 +1,35 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { resolvePlatform } = require("../lib/platform"); +const { sha256 } = require("../install"); + +describe("resolvePlatform", () => { + it("maps linux x64", () => { + const spec = resolvePlatform("linux", "x64"); + assert.equal(spec?.target, "x86_64-unknown-linux-gnu"); + assert.equal(spec?.asset, "omnid-x86_64-unknown-linux-gnu.tar.gz"); + }); + + it("maps darwin arm64", () => { + const spec = resolvePlatform("darwin", "arm64"); + assert.equal(spec?.target, "aarch64-apple-darwin"); + }); + + it("maps win32 x64", () => { + const spec = resolvePlatform("win32", "x64"); + assert.equal(spec?.binary, "omnid.exe"); + }); + + it("returns null for unsupported", () => { + assert.equal(resolvePlatform("linux", "arm64"), null); + }); +}); + +describe("sha256", () => { + it("hashes buffer", () => { + const hash = sha256(Buffer.from("test")); + assert.equal(hash.length, 64); + }); +}); diff --git a/src/first_run.rs b/src/first_run.rs new file mode 100644 index 0000000..94680f3 --- /dev/null +++ b/src/first_run.rs @@ -0,0 +1,37 @@ +use std::path::Path; + +use crate::sync::state::{sync_state_path, SyncState}; + +pub fn is_first_run(config_dir: &Path, matrix_just_created: bool) -> bool { + if matrix_just_created { + return true; + } + let state_path = sync_state_path(config_dir); + if !state_path.exists() { + return true; + } + SyncState::load(&state_path) + .map(|s| s.entries.is_empty()) + .unwrap_or(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matrix_just_created_is_first_run() { + let dir = std::env::temp_dir().join(format!("omnid-fr-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(is_first_run(&dir, true)); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_sync_state_is_first_run() { + let dir = std::env::temp_dir().join(format!("omnid-fr2-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(is_first_run(&dir, false)); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8a9cf3a..e1dd539 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,9 @@ pub mod agents; pub mod config; pub mod credentials; pub mod daemon_install; +pub mod first_run; pub mod mcp; +pub mod presets; pub mod proxy; pub mod proxy_reload; pub mod symlink; diff --git a/src/main.rs b/src/main.rs index 850298d..36fd6a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use omnid::agents::AgentRegistry; use omnid::config::MatrixConfig; use omnid::credentials::set_keychain; use omnid::daemon_install::install_daemon_unit; +use omnid::first_run::is_first_run; use omnid::proxy::{run_from_matrix, McpMultiplexer}; use omnid::symlink::{ensure_matrix_exists, materialize_target_paths, SymlinkEngine}; use omnid::sync::artifacts::scaffold_omni_tree; @@ -110,6 +111,18 @@ enum AddCommands { print: bool, #[arg(long)] write: bool, + #[arg(long)] + name: Option, + #[arg(long)] + preset: Option, + #[arg(long)] + transport: Option, + #[arg(long)] + command: Option, + #[arg(long)] + args: Option, + #[arg(long)] + url: Option, }, Rule, Skill, @@ -163,13 +176,32 @@ async fn main() -> Result<()> { let matrix_path = resolve_matrix_path(matrix)?; match command { None => add::run_add_interactive(&matrix_path), - Some(AddCommands::Server { print, write }) => add::run_add_server_with_options( - &matrix_path, - add::AddServerOptions { - print_only: print, - write, - }, - ), + Some(AddCommands::Server { + print, + write, + name, + preset, + transport, + command, + args, + url, + }) => { + let parsed_args = + args.map(|a| a.split_whitespace().map(str::to_string).collect()); + add::run_add_server_with_options( + &matrix_path, + add::AddServerOptions { + print_only: print, + write, + name, + preset, + transport, + command, + args: parsed_args, + url, + }, + ) + } Some(AddCommands::Rule) => add::run_add_rule(&matrix_path), Some(AddCommands::Skill) => add::run_add_skill(&matrix_path), } @@ -217,9 +249,19 @@ fn run_default_status(matrix: Option) -> Result<()> { let matrix_path = resolve_matrix_path(matrix)?; let paths = MatrixConfig::resolve_paths(&matrix_path)?; scaffold_omni_tree(&paths.config_dir, true).ok(); + let matrix_existed = matrix_path.exists(); ensure_matrix_exists(&matrix_path)?; let config = load_config(&matrix_path)?; - status::run_status(&matrix_path, &config, status::StatusOptions::default()) + let first = is_first_run(&paths.config_dir, !matrix_existed); + status::run_status( + &matrix_path, + &config, + status::StatusOptions { + sync_if_drift: true, + force_sync: first, + first_run: first, + }, + ) } fn resolve_matrix_path(matrix: Option) -> Result { diff --git a/src/presets.rs b/src/presets.rs new file mode 100644 index 0000000..f9e43d6 --- /dev/null +++ b/src/presets.rs @@ -0,0 +1,74 @@ +use anyhow::{bail, Result}; + +use crate::ui::add::{format_http_snippet, format_stdio_snippet}; + +pub struct Preset { + pub id: &'static str, + pub label: &'static str, +} + +pub const PRESETS: &[Preset] = &[ + Preset { + id: "everything", + label: "Demo playground", + }, + Preset { + id: "filesystem", + label: "Files on your computer", + }, +]; + +pub fn list_presets() -> &'static [Preset] { + PRESETS +} + +pub fn preset_snippet(id: &str, home_dir: &str) -> Result { + match id { + "everything" => Ok(format_stdio_snippet( + "everything", + "npx", + &["-y", "@modelcontextprotocol/server-everything"], + )), + "filesystem" => Ok(format_stdio_snippet( + "filesystem", + "npx", + &["-y", "@modelcontextprotocol/server-filesystem", home_dir], + )), + other => bail!("unknown preset '{other}' (use everything or filesystem)"), + } +} + +pub fn preset_server_name(id: &str) -> Result<&'static str> { + match id { + "everything" => Ok("everything"), + "filesystem" => Ok("filesystem"), + other => bail!("unknown preset '{other}'"), + } +} + +pub fn format_custom_stdio_snippet(name: &str, command: &str, args: &[String]) -> String { + format_stdio_snippet(name, command, args) +} + +pub fn format_custom_http_snippet(name: &str, url: &str) -> String { + format_http_snippet(name, url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn everything_snippet_shape() { + let s = preset_snippet("everything", "/home/user").unwrap(); + assert!(s.contains("everything:")); + assert!(s.contains("server-everything")); + } + + #[test] + fn filesystem_uses_home() { + let s = preset_snippet("filesystem", "/home/me").unwrap(); + assert!(s.contains("server-filesystem")); + assert!(s.contains("/home/me")); + } +} diff --git a/src/sync/artifacts.rs b/src/sync/artifacts.rs index afd3fc3..21ba858 100644 --- a/src/sync/artifacts.rs +++ b/src/sync/artifacts.rs @@ -53,7 +53,7 @@ pub fn scaffold_omni_tree(config_dir: &Path, include_rules: bool) -> std::io::Re std::fs::create_dir_all(rules.parent().unwrap())?; std::fs::write( &rules, - "# Global agent rules\n\nEdit this file — omnid syncs it to all your coding agents.\n", + "# Your rules for every agent\n\nWrite here once. omnid copies to Cursor, Claude Code, and the rest.\n", )?; } } diff --git a/src/ui/add.rs b/src/ui/add.rs index 1505787..7ef0900 100644 --- a/src/ui/add.rs +++ b/src/ui/add.rs @@ -5,6 +5,7 @@ use anyhow::{bail, Context, Result}; use dialoguer::{Confirm, Input, MultiSelect, Select}; use crate::config::MatrixConfig; +use crate::presets::{list_presets, preset_server_name, preset_snippet, Preset}; use crate::symlink::materialize_target_paths; use crate::sync::SyncEngine; @@ -12,6 +13,18 @@ use crate::sync::SyncEngine; pub struct AddServerOptions { pub print_only: bool, pub write: bool, + pub name: Option, + pub preset: Option, + pub transport: Option, + pub command: Option, + pub args: Option>, + pub url: Option, +} + +pub fn is_noninteractive() -> bool { + std::env::var("OMNID_NONINTERACTIVE") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) } pub fn run_add_server(matrix_path: &Path) -> Result<()> { @@ -19,31 +32,7 @@ pub fn run_add_server(matrix_path: &Path) -> Result<()> { } pub fn run_add_server_with_options(matrix_path: &Path, opts: AddServerOptions) -> Result<()> { - let name: String = Input::new().with_prompt("Server name").interact_text()?; - - let transports = &["stdio", "http"]; - let transport_idx = Select::new() - .with_prompt("Transport") - .items(transports) - .default(0) - .interact()?; - let transport = transports[transport_idx]; - - let snippet = if transport == "http" { - let url: String = Input::new().with_prompt("URL").interact_text()?; - format_http_snippet(&name, &url) - } else { - let command: String = Input::new() - .with_prompt("Command") - .default("npx".to_string()) - .interact_text()?; - let args_line: String = Input::new() - .with_prompt("Args (space-separated)") - .default("-y some-mcp-server".to_string()) - .interact_text()?; - let args: Vec = args_line.split_whitespace().map(str::to_string).collect(); - format_stdio_snippet(&name, &command, &args) - }; + let (name, snippet) = resolve_server_input(&opts)?; println!("\n--- YAML snippet ---\n{snippet}---"); @@ -51,11 +40,11 @@ pub fn run_add_server_with_options(matrix_path: &Path, opts: AddServerOptions) - return Ok(()); } - let should_write = if opts.write { + let should_write = if opts.write || is_noninteractive() { true } else { Confirm::new() - .with_prompt("Append to matrix.yaml?") + .with_prompt("Add to matrix.yaml?") .default(true) .interact()? }; @@ -71,10 +60,107 @@ pub fn run_add_server_with_options(matrix_path: &Path, opts: AddServerOptions) - materialize_target_paths(&mut config); let engine = SyncEngine::new(matrix_path.to_path_buf())?; engine.sync(&config, false, None, true)?; - println!("Added server '{name}' and synced."); + println!("Added '{name}' and synced."); Ok(()) } +fn resolve_server_input(opts: &AddServerOptions) -> Result<(String, String)> { + if let Some(preset) = &opts.preset { + let name = opts + .name + .clone() + .unwrap_or_else(|| preset_server_name(preset).unwrap_or(preset).to_string()); + let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string()); + let snippet = preset_snippet(preset, &home)?; + return Ok((name, snippet)); + } + + if let (Some(name), Some(command)) = (&opts.name, &opts.command) { + let transport = opts.transport.as_deref().unwrap_or("stdio"); + let snippet = if transport == "http" { + let url = opts + .url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("need --url for http transport"))?; + format_http_snippet(name, url) + } else { + let args = opts.args.clone().unwrap_or_default(); + format_stdio_snippet(name, command, &args) + }; + return Ok((name.clone(), snippet)); + } + + if is_noninteractive() { + bail!("need --preset or --name + --command (OMNID_NONINTERACTIVE is on)"); + } + + interactive_server_input() +} + +fn interactive_server_input() -> Result<(String, String)> { + let choices: Vec = list_presets() + .iter() + .map(|p: &Preset| p.label.to_string()) + .chain(std::iter::once("Type your own".to_string())) + .collect(); + + let idx = Select::new() + .with_prompt("Pick a tool") + .items(&choices) + .default(0) + .interact()?; + + if idx < list_presets().len() { + let preset = &list_presets()[idx]; + let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string()); + if !npx_available() { + eprintln!( + "Note: this preset needs Node.js (npx). Install from nodejs.org or pick 'Type your own'." + ); + } + let snippet = preset_snippet(preset.id, &home)?; + return Ok((preset_server_name(preset.id)?.to_string(), snippet)); + } + + let name: String = Input::new().with_prompt("Server name").interact_text()?; + + let transports = &["stdio", "http"]; + let transport_idx = Select::new() + .with_prompt("Connection type") + .items(transports) + .default(0) + .interact()?; + let transport = transports[transport_idx]; + + let snippet = if transport == "http" { + let url: String = Input::new().with_prompt("URL").interact_text()?; + format_http_snippet(&name, &url) + } else { + let command: String = Input::new() + .with_prompt("Command") + .default("npx".to_string()) + .interact_text()?; + let args_line: String = Input::new() + .with_prompt("Args (space-separated)") + .default("-y some-mcp-server".to_string()) + .interact_text()?; + let args: Vec = args_line.split_whitespace().map(str::to_string).collect(); + format_stdio_snippet(&name, &command, &args) + }; + + Ok((name, snippet)) +} + +fn npx_available() -> bool { + std::process::Command::new("npx") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + fn ensure_scalar(value: &str, field: &str) -> Result<()> { if value.contains('\n') || value.contains('\r') { bail!("{field} cannot contain newlines"); @@ -115,12 +201,12 @@ pub fn yaml_quote(value: &str) -> String { format!("\"{escaped}\"") } -pub fn format_stdio_snippet(name: &str, command: &str, args: &[String]) -> String { +pub fn format_stdio_snippet(name: &str, command: &str, args: &[impl AsRef]) -> String { let name_q = yaml_quote(name); let command_q = yaml_quote(command); let args_yaml: String = args .iter() - .map(|a| format!("\n - {}", yaml_quote(a))) + .map(|a| format!("\n - {}", yaml_quote(a.as_ref()))) .collect(); format!( r#" {name_q}: @@ -186,7 +272,9 @@ pub fn append_server_snippet(matrix_path: &Path, snippet: &str) -> Result<()> { } } - let updated = if let Some(insert_at) = servers_insert_at(&content) { + let updated = if content.contains("servers: {}") { + content.replacen("servers: {}", &format!("servers:\n{snippet}"), 1) + } else if let Some(insert_at) = servers_insert_at(&content) { let mut out = String::new(); out.push_str(&content[..insert_at]); if !snippet.starts_with('\n') && !content[..insert_at].ends_with('\n') { @@ -347,6 +435,23 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn append_replaces_empty_servers_block() { + let dir = std::env::temp_dir().join(format!("omnid-add-empty-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("matrix.yaml"); + std::fs::write(&path, "version: 1\nservers: {}\n").unwrap(); + append_server_snippet( + &path, + " demo:\n transport: stdio\n command: npx\n args:\n - -y\n - pkg\n", + ) + .unwrap(); + let updated = std::fs::read_to_string(&path).unwrap(); + assert!(updated.contains("servers:\n demo:")); + assert!(!updated.contains("servers: {}")); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn append_preserves_comments() { let dir = std::env::temp_dir().join(format!("omnid-add-{}", std::process::id())); diff --git a/src/ui/init.rs b/src/ui/init.rs index bbb6b0f..555063c 100644 --- a/src/ui/init.rs +++ b/src/ui/init.rs @@ -5,39 +5,30 @@ use console::style; use dialoguer::Confirm; use crate::config::MatrixConfig; +use crate::first_run::is_first_run; use crate::symlink::{ensure_matrix_exists, materialize_target_paths}; use crate::sync::artifacts::scaffold_omni_tree; -use crate::sync::SyncEngine; +use crate::ui::status::{self, StatusOptions}; pub fn run_init(matrix_path: &Path, install_daemon: bool) -> Result<()> { let paths = MatrixConfig::resolve_paths(matrix_path)?; scaffold_omni_tree(&paths.config_dir, true)?; + let matrix_existed = matrix_path.exists(); ensure_matrix_exists(matrix_path)?; let mut config = MatrixConfig::load(matrix_path)?; materialize_target_paths(&mut config); - let registry = crate::agents::AgentRegistry::builtin()?; - let installed = registry.detect_installed(); - - println!("{}", style("omnid init").bold().cyan()); - println!("Created {}", paths.config_dir.display()); - println!("\nInstalled agents:"); - for id in &installed { - if let Some(agent) = registry.get(id) { - println!(" {} ({})", agent.name, agent.id); - } - } - if installed.is_empty() { - println!( - " {}", - style("(none detected — install Cursor, Claude Code, etc.)").dim() - ); - } - - let engine = SyncEngine::new(matrix_path.to_path_buf())?; - engine.sync(&config, false, None, true)?; - println!("\n{}", style("Sync complete.").green()); + let first = is_first_run(&paths.config_dir, !matrix_existed); + status::run_status( + matrix_path, + &config, + StatusOptions { + sync_if_drift: true, + force_sync: first, + first_run: first, + }, + )?; let noninteractive = std::env::var("OMNID_NONINTERACTIVE") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) @@ -45,8 +36,8 @@ pub fn run_init(matrix_path: &Path, install_daemon: bool) -> Result<()> { if install_daemon && !noninteractive { let yes = Confirm::new() - .with_prompt("Install background daemon (auto-sync on changes)?") - .default(true) + .with_prompt("Sync in background when files change?") + .default(false) .interact()?; if yes { crate::daemon_install::install_daemon_unit()?; @@ -55,21 +46,17 @@ pub fn run_init(matrix_path: &Path, install_daemon: bool) -> Result<()> { crate::daemon_install::install_daemon_unit().ok(); } - if installed.iter().any(|id| id == "cursor") { - println!( - "\n{}", - style("Open Cursor — omnid MCP is ready.").green().bold() - ); - } else if !installed.is_empty() { - println!( - "\n{}", - style("Your agents are configured. Restart their MCP connection.").green() - ); - } else { - println!( - "\n{}", - style("Run omnid again after installing a coding agent.").yellow() - ); + if !first { + let registry = crate::agents::AgentRegistry::builtin()?; + let installed = registry.detect_installed(); + if installed.iter().any(|id| id == "cursor") { + println!("\n{}", style("Open Cursor. omnid is ready.").green()); + } else if !installed.is_empty() { + println!( + "\n{}", + style("Restart your agent's MCP connection.").green() + ); + } } Ok(()) diff --git a/src/ui/status.rs b/src/ui/status.rs index 193a0a3..3cbb102 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -13,6 +13,7 @@ use crate::validate::{validate_matrix, ValidationReport}; pub struct StatusOptions { pub sync_if_drift: bool, pub force_sync: bool, + pub first_run: bool, } impl Default for StatusOptions { @@ -20,6 +21,7 @@ impl Default for StatusOptions { Self { sync_if_drift: true, force_sync: false, + first_run: false, } } } @@ -28,11 +30,11 @@ pub fn run_status(matrix_path: &Path, config: &MatrixConfig, opts: StatusOptions let engine = SyncEngine::new(matrix_path.to_path_buf())?; let registry = engine.registry(); - if opts.sync_if_drift { + if opts.sync_if_drift || opts.force_sync { let needs = engine.needs_sync(config)?; if needs || opts.force_sync { let report = engine.sync(config, false, None, opts.force_sync)?; - if report.synced && !report.written.is_empty() { + if report.synced && !report.written.is_empty() && !opts.first_run { println!("{}", style("Synced changes to agents.").green()); } } @@ -41,7 +43,13 @@ pub fn run_status(matrix_path: &Path, config: &MatrixConfig, opts: StatusOptions let paths = MatrixConfig::resolve_paths(matrix_path)?; let validation = validate_matrix(config, matrix_path)?; - println!("{}", style("omnid — agent config sync").bold().cyan()); + if opts.first_run { + print_first_run_welcome(matrix_path, registry, config, &validation); + let _ = sync_state_path(&paths.config_dir); + return Ok(()); + } + + println!("{}", style("omnid").bold().cyan()); println!("{}", style("─".repeat(40)).dim()); print_matrix_line(matrix_path, &validation); @@ -50,19 +58,48 @@ pub fn run_status(matrix_path: &Path, config: &MatrixConfig, opts: StatusOptions if let Some(drift) = drift_report.as_ref() { print_drift_reasons(drift); } - print_backends(config); + print_tools(config); print_daemon_hint(); print_proxy_hint(); - print_next_steps(config); + print_next_steps(registry, config); let _ = sync_state_path(&paths.config_dir); Ok(()) } +fn print_first_run_welcome( + matrix_path: &Path, + registry: &AgentRegistry, + config: &MatrixConfig, + validation: &ValidationReport, +) { + let paths = MatrixConfig::resolve_paths(matrix_path).ok(); + println!("{}", style("omnid").bold().cyan()); + println!("{}", style("─".repeat(40)).dim()); + if let Some(paths) = paths { + println!("Made {}", paths.config_dir.display()); + } + if validation.ok() { + let installed: Vec<_> = registry + .list() + .into_iter() + .filter(|a| registry.installed(a)) + .map(|a| a.id.as_str()) + .collect(); + if installed.is_empty() { + println!("Synced (no coding app found yet)"); + } else { + println!("Synced {}", installed.join(", ")); + } + } + println!(); + print_next_steps(registry, config); +} + fn print_matrix_line(matrix_path: &Path, validation: &ValidationReport) { - let label = format!("Matrix {}", matrix_path.display()); + let label = format!("Config {}", matrix_path.display()); if validation.ok() { - println!("{} {}", label, style("valid").green()); + println!("{} {}", label, style("ok").green()); } else { println!("{} {}", label, style("invalid").red()); } @@ -89,12 +126,12 @@ fn print_agents( }); println!( - "Agents {} installed{}", + "Agents {} · {}", installed.len(), if drift { - " · sync pending" + style("sync pending").yellow() } else { - " · in sync" + style("synced").green() } ); @@ -146,15 +183,12 @@ fn artifact_status( } } -fn print_backends(config: &MatrixConfig) { +fn print_tools(config: &MatrixConfig) { let count = config.servers.len(); if count == 0 { - println!( - "Backends 0 configured {}", - style("(omnid add server)").dim() - ); + println!("Tools 0 {}", style("(run: omnid add server)").dim()); } else { - println!("Backends {count} configured"); + println!("Tools {count}"); } } @@ -163,17 +197,17 @@ fn print_daemon_hint() { { let installed = crate::daemon_install::daemon_unit_installed(); if installed { - println!("Daemon {}", style("installed").green()); + println!("Daemon {}", style("on").green()); } else { println!( - "Daemon not installed {}", - style("(omnid init → install)").dim() + "Daemon off {}", + style("(omnid init --install-daemon)").dim() ); } } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { - println!("Daemon {}", style("run: omnid daemon").dim()); + println!("Daemon {}", style("run: omnid daemon").dim()); } } @@ -199,29 +233,46 @@ fn print_proxy_hint() { #[cfg(not(any(target_os = "macos", target_os = "linux")))] let daemon = false; - if daemon { - println!( - "{}", - style("Proxy backends reload automatically when matrix.yaml changes").dim() - ); - } else { + if !daemon { + println!("{}", style("Tip: restart your agent to reload tools").dim()); + } +} + +fn print_next_steps(registry: &AgentRegistry, config: &MatrixConfig) { + let installed: Vec<_> = registry + .list() + .into_iter() + .filter(|a| registry.installed(a)) + .collect(); + + if installed.is_empty() { println!( - "{}", - style("Proxy backends reload when agents restart MCP").dim() + "\nNext: install Cursor or Claude Code. Run {} again.", + style("omnid").cyan() ); + return; + } + + if installed.iter().any(|a| a.id == "cursor") { + if config.servers.is_empty() { + println!( + "\nNext: open Cursor. omnid is ready.\n {}", + style("(no tools yet — run: omnid add server)").dim() + ); + } else { + println!("\nNext: open Cursor. omnid is ready."); + } + return; } -} -fn print_next_steps(config: &MatrixConfig) { if config.servers.is_empty() { println!( - "\nNext: {} or edit ~/.config/omni/rules/global.md", + "\nNext: restart your agent's MCP connection. Then run {}.", style("omnid add server").cyan() ); } else { println!( - "\nNext: edit ~/.config/omni/rules/global.md or run {}", - style("omnid sync").cyan() + "\nNext: restart your agent's MCP connection.\n Edit rules: ~/.config/omni/rules/global.md" ); } } diff --git a/tests/first_run.rs b/tests/first_run.rs new file mode 100644 index 0000000..14732fe --- /dev/null +++ b/tests/first_run.rs @@ -0,0 +1,55 @@ +use std::fs; +use std::path::PathBuf; + +use assert_cmd::Command; +use tempfile::TempDir; + +fn temp_home() -> (TempDir, PathBuf) { + let dir = TempDir::new().unwrap(); + let home = dir.path().to_path_buf(); + (dir, home) +} + +#[test] +fn bare_omnid_scaffolds_on_first_run() { + let (_dir, home) = temp_home(); + let config_dir = home.join(".config/omni"); + let matrix = config_dir.join("matrix.yaml"); + + Command::cargo_bin("omnid") + .unwrap() + .env("HOME", &home) + .env("USERPROFILE", &home) + .assert() + .success(); + + assert!(matrix.exists(), "matrix.yaml should be created"); + let content = fs::read_to_string(&matrix).unwrap(); + assert!(content.contains("servers:")); +} + +#[test] +fn add_server_preset_noninteractive() { + let (_dir, home) = temp_home(); + + Command::cargo_bin("omnid") + .unwrap() + .env("HOME", &home) + .env("USERPROFILE", &home) + .assert() + .success(); + + Command::cargo_bin("omnid") + .unwrap() + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("OMNID_NONINTERACTIVE", "1") + .args(["add", "server", "--preset", "everything", "--write"]) + .assert() + .success(); + + let matrix = home.join(".config/omni/matrix.yaml"); + let content = fs::read_to_string(&matrix).unwrap(); + assert!(content.contains("everything:")); + assert!(content.contains("server-everything")); +}