Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 38 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,33 @@ cargo install --git https://github.com/xb3sox/omnid
└── .sync-state.json # drift hashes
```

## Agents
## Commands

| Command | Does |
|---------|------|
| `omnid` | Status. Auto-sync on drift. |
| `omnid init` | Guided setup |
| `omnid add server` | Add MCP (interactive) |
| `omnid add rule` | Append `rules/global.md` |
| `omnid add skill` | New skill scaffold |
| `omnid check` | Summary health check (`doctor --summary`) |
| `omnid doctor` | Deep health checks with fix hints |
| `omnid credential list` | List credentials and resolution status |
| `omnid sync` | Force sync (`--dry-run` = preview) |
| `omnid sync --force` | Sync even if no drift |
| `omnid daemon` | Watch + re-sync |
| `omnid proxy --stdio` | MCP multiplexer (agents spawn this) |
| `omnid proxy --transport http` | MCP multiplexer over HTTP JSON-RPC |
| `omnid uninstall` | Remove omnid artifacts |
| `omnid agents list` | Paths + compatibility |

## Supported agents

| Tier | Agents | MCP | Rules | Skills | Hooks |
|------|--------|:---:|:-----:|:------:|:-----:|
| Full | Cursor, Claude Code | ✓ | ✓ | ✓ | opt-in |
| Rules + MCP | Windsurf, Codex, OpenCode, Cline, Roo, Kilo, VS Code | ✓ | ✓ | — | — |
| Rules + Skills + MCP | Windsurf, VS Code | ✓ | ✓ | ✓ | — |
| Rules + MCP | Codex, OpenCode, Cline, Roo, Kilo | ✓ | ✓ | — | — |
| MCP only | Claude Desktop, Copilot CLI, Gemini CLI, Antigravity, Amazon Q, Junie, Kiro, Zed, Augment CLI/IDE, Goose | ✓ | — | — | — |

`omnid agents list` → paths on your machine.
Expand All @@ -111,23 +132,7 @@ matrix.yaml → sync → agent configs
- **Drift-aware** — `.sync-state.json` hashes. Sync when needed.
- **Daemon** — watch config. Re-sync.
- **Multiplexer** — `server__tool` → backend via `omnid proxy --stdio`

## Commands

| Command | Does |
|---------|------|
| `omnid` | Status. Auto-sync on drift. |
| `omnid init` | Guided setup |
| `omnid add server` | Add MCP (interactive) |
| `omnid add rule` | Append `rules/global.md` |
| `omnid add skill` | New skill scaffold |
| `omnid check` | Validate config + agents |
| `omnid sync` | Force sync (`--dry-run` = preview) |
| `omnid sync --force` | Sync even if no drift |
| `omnid daemon` | Watch + re-sync |
| `omnid proxy --stdio` | MCP multiplexer (agents spawn this) |
| `omnid uninstall` | Remove omnid artifacts |
| `omnid agents list` | Paths + compatibility |
- **Backend reload** — with daemon running, proxy reloads backends when `matrix.yaml` changes.

## Config

Expand All @@ -149,9 +154,21 @@ targets:
# enabled: false
```

`omnid add server` drops YAML comments (serde). Edit file by hand to keep them.
`omnid add server` appends a YAML snippet (preserves comments). Use `--print` for snippet only.

See [examples/matrix.yaml](examples/matrix.yaml) for a full example config.

## Troubleshooting

| Symptom | Fix |
|---------|-----|
| Sync pending but nothing changes | Run `omnid` and read drift reasons, or `omnid doctor` |
| MCP tools missing in agent | `omnid doctor` → stub check; then `omnid sync --force` |
| Credential errors | `omnid credential list` then `omnid credential set --keychain NAME secret` |
| Broken skill symlinks | `omnid doctor` skills check; on Windows ensure Developer Mode for symlinks |
| Stale MCP backends | Restart agent MCP, or run `omnid daemon` for auto proxy reload |

## Dev
## Development

```bash
cargo install just cargo-deny
Expand Down
32 changes: 32 additions & 0 deletions examples/matrix.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Example omnid matrix — copy to ~/.config/omni/matrix.yaml
version: 1
config_dir: ~/.config/omni

servers:
example_stdio:
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-everything"]

example_http:
transport: http
url: https://api.example.com/mcp
headers:
Authorization: "${credential:example_token}"

credentials:
example_token:
source: keychain
service: omnid
account: example_token

targets:
auto_detect: true

artifacts:
rules:
enabled: true
skills:
enabled: true
hooks:
enabled: false
230 changes: 228 additions & 2 deletions src/agents/adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,121 @@ use anyhow::{bail, Context, Result};
use serde_json::{json, Map, Value};
use toml::Value as TomlValue;

use crate::config::MatrixConfig;
use crate::config::MatrixPaths;
use crate::ui::diff::unified_diff_opt;

use super::family::AgentFamily;
use super::stub::OmnidStub;

const OMNID_KEY: &str = "omnid";

pub fn remove_stub_at_path(
path: &Path,
family: AgentFamily,
pointer: Option<&str>,
dry_run: bool,
) -> Result<Option<String>> {
if !path.exists() {
return Ok(None);
}

let before = serialize_doc(path, family)?;
let mut doc = read_doc(path, family)?;
let changed = remove_stub(&mut doc, family, pointer)?;
if !changed {
return Ok(None);
}
let after = format_doc(family, &doc)?;
let diff = unified_diff_opt(&path.to_string_lossy(), &before, &after);

if !dry_run {
write_doc(path, family, &doc)?;
}
Ok(diff)
}

pub fn remove_codex_toml(path: &Path, dry_run: bool) -> Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
let before = fs::read_to_string(path)?;
let mut doc: toml::Table = toml::from_str(&before).context("parse codex toml")?;
let Some(servers) = doc.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) else {
return Ok(None);
};
if servers.remove(OMNID_KEY).is_none() {
return Ok(None);
}
let after = format!("{}\n", toml::to_string_pretty(&doc)?);
let diff = unified_diff_opt(&path.to_string_lossy(), &before, &after);
if !dry_run {
atomic_write(path, &after)?;
}
Ok(diff)
}

pub fn remove_goose_yaml(path: &Path, dry_run: bool) -> Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
let before = fs::read_to_string(path)?;
let mut doc: serde_yaml::Value = serde_yaml::from_str(&before)?;
let removed = doc
.as_mapping_mut()
.and_then(|m| m.get_mut(serde_yaml::Value::from("extensions")))
.and_then(|e| e.as_mapping_mut())
.and_then(|ext| ext.remove(serde_yaml::Value::from(OMNID_KEY)))
.is_some();
if !removed {
return Ok(None);
}
let after = serde_yaml::to_string(&doc)?;
let diff = unified_diff_opt(&path.to_string_lossy(), &before, &after);
if !dry_run {
atomic_write(path, &after)?;
}
Ok(diff)
}

fn remove_stub(doc: &mut Value, family: AgentFamily, pointer: Option<&str>) -> Result<bool> {
match family {
AgentFamily::McpServersJson => {
let ptr = pointer.unwrap_or("/mcpServers");
remove_json_pointer_key(doc, ptr, OMNID_KEY)
}
AgentFamily::ServersJson => remove_json_pointer_key(doc, "/servers", OMNID_KEY),
AgentFamily::ContextServers => remove_json_pointer_key(doc, "/context_servers", OMNID_KEY),
AgentFamily::OpenCodeMcp => remove_json_pointer_key(doc, "/mcp", OMNID_KEY),
AgentFamily::JsonArrayRoot => {
if let Some(arr) = doc.as_array_mut() {
let before = arr.len();
arr.retain(|v| v.get("name").and_then(|n| n.as_str()) != Some(OMNID_KEY));
Ok(arr.len() != before)
} else {
Ok(false)
}
}
AgentFamily::NestedClaudeProjects => {
let ptr = pointer.context("nested claude requires merge pointer")?;
remove_json_pointer_key(doc, ptr, OMNID_KEY)
}
AgentFamily::TomlCodex | AgentFamily::YamlGoose | AgentFamily::YamlContinue => {
bail!("use format-specific remover for {family:?}");
}
}
}

fn remove_json_pointer_key(doc: &mut Value, pointer: &str, key: &str) -> Result<bool> {
let Some(slot) = get_mut_at_pointer(doc, pointer) else {
return Ok(false);
};
let Some(obj) = slot.as_object_mut() else {
return Ok(false);
};
Ok(obj.remove(key).is_some())
}

pub fn merge_stub_at_path(
path: &Path,
family: AgentFamily,
Expand Down Expand Up @@ -372,9 +480,127 @@ fn atomic_write(path: &Path, content: &str) -> Result<()> {
Ok(())
}

pub fn has_omnid_stub(path: &Path, family: AgentFamily, pointer: Option<&str>) -> bool {
match family {
AgentFamily::TomlCodex => {
if !path.exists() {
return false;
}
let Ok(raw) = fs::read_to_string(path) else {
return false;
};
let Ok(doc) = toml::from_str::<toml::Table>(&raw) else {
return false;
};
doc.get("mcp_servers")
.and_then(|v| v.as_table())
.and_then(|t| t.get(OMNID_KEY))
.is_some()
}
AgentFamily::YamlGoose => {
if !path.exists() {
return false;
}
let Ok(raw) = fs::read_to_string(path) else {
return false;
};
let Ok(doc) = serde_yaml::from_str::<serde_yaml::Value>(&raw) else {
return false;
};
doc.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::from("extensions")))
.and_then(|e| e.as_mapping())
.and_then(|ext| ext.get(serde_yaml::Value::from(OMNID_KEY)))
.is_some()
}
AgentFamily::YamlContinue => false,
_ => {
let Ok(doc) = read_doc(path, family) else {
return false;
};
match family {
AgentFamily::McpServersJson => {
let ptr = pointer.unwrap_or("/mcpServers");
get_at_pointer(&doc, ptr)
.and_then(|v| v.as_object())
.map(|o| o.contains_key(OMNID_KEY))
.unwrap_or(false)
}
AgentFamily::ServersJson => doc
.get("servers")
.and_then(|v| v.as_object())
.map(|o| o.contains_key(OMNID_KEY))
.unwrap_or(false),
AgentFamily::ContextServers => doc
.get("context_servers")
.and_then(|v| v.as_object())
.map(|o| o.contains_key(OMNID_KEY))
.unwrap_or(false),
AgentFamily::OpenCodeMcp => doc
.get("mcp")
.and_then(|v| v.as_object())
.map(|o| o.contains_key(OMNID_KEY))
.unwrap_or(false),
AgentFamily::JsonArrayRoot => doc
.as_array()
.map(|arr| {
arr.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(OMNID_KEY))
})
.unwrap_or(false),
AgentFamily::NestedClaudeProjects => {
let Some(ptr) = pointer else {
return false;
};
get_at_pointer(&doc, ptr)
.and_then(|v| v.as_object())
.map(|o| o.contains_key(OMNID_KEY))
.unwrap_or(false)
}
_ => false,
}
}
}
}

fn get_at_pointer<'a>(doc: &'a Value, pointer: &str) -> Option<&'a Value> {
if pointer == "/" || pointer.is_empty() {
return Some(doc);
}
let mut current = doc;
for segment in pointer
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
{
let key = decode_pointer_token(segment);
current = current.as_object()?.get(&key)?;
}
Some(current)
}

pub fn claude_project_pointer(home: &Path) -> String {
let key = home.to_string_lossy();
format!("/projects/{key}/mcpServers")
format!("/projects/{}/mcpServers", home.to_string_lossy())
}

pub fn claude_project_pointers(config: &MatrixConfig, home: &Path) -> Vec<String> {
let mut roots = Vec::new();
if let Some(spec) = config.targets.agents.get("claude_code") {
for root in &spec.project_roots {
roots.push(MatrixPaths::expand(root));
}
}
if roots.is_empty() {
roots.push(home.to_path_buf());
}
claude_project_pointers_from_roots(&roots)
}

fn claude_project_pointers_from_roots(roots: &[PathBuf]) -> Vec<String> {
roots
.iter()
.map(|root| format!("/projects/{}/mcpServers", root.to_string_lossy()))
.collect()
}

pub fn project_mcp_path(root: &Path, rel: &str) -> PathBuf {
Expand Down
6 changes: 4 additions & 2 deletions src/agents/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub fn capabilities(agent_id: &str) -> ArtifactCapabilities {
"windsurf" => ArtifactCapabilities {
mcp: true,
rules: true,
skills: false,
skills: true,
hooks: false,
},
"codex_cli" | "opencode" => ArtifactCapabilities {
Expand All @@ -67,7 +67,7 @@ pub fn capabilities(agent_id: &str) -> ArtifactCapabilities {
"vscode" => ArtifactCapabilities {
mcp: true,
rules: true,
skills: false,
skills: true,
hooks: false,
},
_ => ArtifactCapabilities {
Expand Down Expand Up @@ -101,6 +101,8 @@ pub fn skills_dir(agent_id: &str, platform: &PlatformPaths) -> Option<PathBuf> {
match agent_id {
"cursor" => Some(platform.home.join(".cursor/skills")),
"claude_code" => Some(platform.home.join(".claude/skills")),
"windsurf" => Some(platform.home.join(".codeium/windsurf/skills")),
"vscode" => Some(platform.vscode_user_dir().join("skills")),
_ => None,
}
}
Expand Down
Loading
Loading