diff --git a/.repository-projection.json b/.repository-projection.json index f246578..369408c 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "0d25f444e7799874298801a23f5a5c3a5e5003a2", + "sourceSha": "68580466ba8135da1cfa25c6259800c32659d6d1", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "5b09a4650684da900ad94b518e4360f64d7fdd5a", + "priorProjectedBase": "7287d525f43da516baa3f76c63d4e13603f30785", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", - "contentDigest": "f104a7a0f4ffd84a4a0f643452fee149c06a0a15320c3a0aec207f71d53ce472", + "contentDigest": "a0d68f21dc50f154471ba8bbb8ef0d5ad91a560020bd9e09c6c3b91817235b81", "publicationEligible": true } diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index 8dd540f..af6835c 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -46,8 +46,8 @@ struct DeviceInventory: Encodable, Sendable { struct DeviceAgentCLI: Encodable, Sendable { let name: String } struct DeviceAgentApp: Encodable, Sendable { let name: String } -struct DeviceMCPServer: Encodable, Sendable { let client: String; let name: String } -struct DeviceAgentAsset: Encodable, Sendable { let client: String; let kind: String; let name: String } +struct DeviceMCPServer: Encodable, Sendable { let client: String; let name: String; let source: String } +struct DeviceAgentAsset: Encodable, Sendable { let client: String; let kind: String; let name: String; let source: String } struct DevicePackage: Encodable, Sendable { let name: String @@ -214,9 +214,12 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ ("amp", ".config/amp/settings.json", false), ("qwen", ".qwen/settings.json", false), ("pi", ".pi/agent/settings.json", false), + ("maestro", ".maestro/config.toml", true), + ("maestro", ".composer/config.toml", true), ] var found = Set() var assetNames = Set() + var pluginConfigReads = 0 for home in homes.prefix(65) { let assetDirs: [(String, String, String, String)] = [ ("agents", "skill", ".agents/skills", "skill"), @@ -232,6 +235,9 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ ("qwen", "skill", ".qwen/skills", "skill"), ("pi", "skill", ".pi/agent/skills", "skill"), ("pi", "extension", ".pi/agent/extensions", "js-ts"), + ("maestro", "skill", ".composer/skills", "skill"), + ("maestro", "plugin", ".maestro/plugins", "plugin-directory"), + ("maestro", "plugin", ".composer/plugins", "plugin-directory"), ] for (client, kind, relative, format) in assetDirs { let directory = "\(home)/\(relative)" @@ -248,17 +254,29 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ } else if format == "directory" && type == mode_t(S_IFDIR) { var manifest = stat() name = lstat("\(path)/gemini-extension.json", &manifest) == 0 && (manifest.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) ? entry : nil + } else if format == "plugin-directory" && type == mode_t(S_IFDIR) { + name = entry } else if type == mode_t(S_IFREG) && format == "md" && entry.hasSuffix(".md") { name = String(entry.dropLast(3)) } else if type == mode_t(S_IFREG) && format == "js-ts" && (entry.hasSuffix(".js") || entry.hasSuffix(".ts")) { name = String(entry.dropLast(3)) } else { name = nil } if let name, safeAgentAssetName(name) { - assetNames.insert("\(client)\u{0}\(kind)\u{0}\(name)") + assetNames.insert("\(client)\u{0}\(kind)\u{0}\(name)\u{0}\(relative)") if client == "gemini" && kind == "extension", let data = readAgentConfigNoFollow("\(path)/gemini-extension.json") { let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] for server in ((object?["mcpServers"] as? [String: Any]).map { Array($0.keys) } ?? []) where safeAgentAssetName(server) { - found.insert("gemini\u{0}\(server)") + found.insert("gemini\u{0}\(server)\u{0}.gemini/extensions/*/gemini-extension.json") + } + } + if client == "maestro" && kind == "plugin" { + for config in ["mcp.json", ".mcp.json"] where pluginConfigReads < 32 { + guard let data = readAgentConfigNoFollow("\(path)/\(config)") else { continue } + pluginConfigReads += 1 + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + for server in ((object?["mcpServers"] as? [String: Any]).map { Array($0.keys) } ?? []) where safeAgentAssetName(server) { + found.insert("maestro\u{0}\(server)\u{0}\(relative)/*/\(config)") + } } } if assetNames.count >= 128 { break } @@ -268,12 +286,12 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ } for (client, relative, isTOML) in configs { guard let data = readAgentConfigNoFollow("\(home)/\(relative)") else { continue } - assetNames.insert("\(client)\u{0}config\u{0}user") + assetNames.insert("\(client)\u{0}config\u{0}user\u{0}\(relative)") if client == "claude" && relative == ".claude/settings.json" { let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] let plugins = object?["enabledPlugins"] as? [String: Bool] ?? [:] for (name, enabled) in plugins where enabled && safeAgentAssetName(name) { - assetNames.insert("claude\u{0}plugin\u{0}\(name)") + assetNames.insert("claude\u{0}plugin\u{0}\(name)\u{0}.claude/settings.json") } continue } @@ -300,7 +318,7 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ names = entries.map { Array($0.keys) } ?? [] } for name in names where name.utf8.count <= 128 && !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) { - found.insert("\(client)\u{0}\(name)") + found.insert("\(client)\u{0}\(name)\u{0}\(relative)") if found.count >= 128 { break } } if found.count >= 128 { break } @@ -308,14 +326,14 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ if found.count >= 128 { break } } let servers = found.sorted().prefix(128).compactMap { entry -> DeviceMCPServer? in - let parts = entry.split(separator: "\u{0}", maxSplits: 1) - guard parts.count == 2 else { return nil } - return DeviceMCPServer(client: String(parts[0]), name: String(parts[1])) + let parts = entry.split(separator: "\u{0}") + guard parts.count == 3 else { return nil } + return DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2])) } let assets = assetNames.sorted().prefix(128).compactMap { entry -> DeviceAgentAsset? in let parts = entry.split(separator: "\u{0}") - guard parts.count == 3 else { return nil } - return DeviceAgentAsset(client: String(parts[0]), kind: String(parts[1]), name: String(parts[2])) + guard parts.count == 4 else { return nil } + return DeviceAgentAsset(client: String(parts[0]), kind: String(parts[1]), name: String(parts[2]), source: String(parts[3])) } return (clis, apps, servers, assets) } diff --git a/macos/Tests/MerlinMacOSTests/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index 1d26356..a41d514 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -150,6 +150,9 @@ struct SyncTests { try FileManager.default.createDirectory(atPath: home + "/.claude/agents", withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: home + "/.config/amp", withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: home + "/.config/opencode/plugins", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.maestro/plugins/audit/.plugin", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.maestro/plugins/convention", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.composer/skills/review", withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: home + "/Applications/Cursor.app", withIntermediateDirectories: true) try FileManager.default.createSymbolicLink(atPath: home + "/Applications/Codex.app", withDestinationPath: home + "/Applications/Cursor.app") try "[mcp_servers.github]\nurl = 'https://secret.example'\n".write(toFile: home + "/.codex/config.toml", atomically: true, encoding: .utf8) @@ -161,6 +164,10 @@ struct SyncTests { try #"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false}}"#.write(toFile: home + "/.claude/settings.json", atomically: true, encoding: .utf8) try #"{"amp.mcpServers":{"db":{"command":"secret"}}}"#.write(toFile: home + "/.config/amp/settings.json", atomically: true, encoding: .utf8) try "secret plugin".write(toFile: home + "/.config/opencode/plugins/trace.ts", atomically: true, encoding: .utf8) + try "secret plugin".write(toFile: home + "/.maestro/plugins/audit/.plugin/plugin.json", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"pluginsearch":{"command":"secret"}}}"#.write(toFile: home + "/.maestro/plugins/audit/mcp.json", atomically: true, encoding: .utf8) + try "secret skill".write(toFile: home + "/.composer/skills/review/SKILL.md", atomically: true, encoding: .utf8) + try "[mcp_servers.managed]\nurl = 'https://secret.example'\n".write(toFile: home + "/.maestro/config.toml", atomically: true, encoding: .utf8) let cli = home + "/.local/bin/codex" try "#!/bin/sh\n".write(toFile: cli, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cli) @@ -168,11 +175,18 @@ struct SyncTests { let discovered = collectMacAgentDiscovery(homes: [home], systemBins: [], appRoots: [home + "/Applications"]) #expect(discovered.clis.map(\.name) == ["codex"]) #expect(discovered.apps.map(\.name) == ["cursor"]) - #expect(discovered.servers.map { "\($0.client):\($0.name)" } == ["amp:db", "codex:github", "cursor:docs", "gemini:search"]) + #expect(discovered.servers.map { "\($0.client):\($0.name)" } == ["amp:db", "codex:github", "cursor:docs", "gemini:search", "maestro:managed", "maestro:pluginsearch"]) + #expect(discovered.servers.contains { $0.client == "codex" && $0.source == ".codex/config.toml" }) + #expect(discovered.servers.contains { $0.client == "gemini" && $0.source == ".gemini/extensions/*/gemini-extension.json" }) + #expect(discovered.servers.contains { $0.client == "maestro" && $0.name == "managed" && $0.source == ".maestro/config.toml" }) + #expect(discovered.servers.contains { $0.client == "maestro" && $0.name == "pluginsearch" && $0.source == ".maestro/plugins/*/mcp.json" }) #expect(discovered.assets.contains { $0.client == "agents" && $0.kind == "skill" && $0.name == "review" }) #expect(discovered.assets.contains { $0.client == "claude" && $0.kind == "agent" && $0.name == "reviewer" }) #expect(discovered.assets.contains { $0.client == "claude" && $0.kind == "plugin" && $0.name == "audit@marketplace" }) #expect(discovered.assets.contains { $0.client == "opencode" && $0.kind == "plugin" && $0.name == "trace" }) + #expect(discovered.assets.contains { $0.client == "maestro" && $0.kind == "plugin" && $0.name == "audit" }) + #expect(discovered.assets.contains { $0.client == "maestro" && $0.kind == "skill" && $0.name == "review" }) + #expect(discovered.assets.contains { $0.client == "maestro" && $0.kind == "plugin" && $0.name == "convention" }) #expect(!discovered.assets.contains { $0.name == "off@marketplace" }) #expect(!discovered.assets.contains { $0.name == "not-extension" }) #expect(!discovered.assets.contains { $0.client == "pi" && $0.name == "review" }) @@ -182,7 +196,7 @@ struct SyncTests { try FileManager.default.removeItem(atPath: home + "/.cursor/mcp.json") try FileManager.default.createSymbolicLink(atPath: home + "/.cursor/mcp.json", withDestinationPath: home + "/.codex/config.toml") - #expect(collectMacAgentDiscovery(homes: [home], systemBins: []).servers.count == 3) + #expect(collectMacAgentDiscovery(homes: [home], systemBins: []).servers.count == 5) } @Test("host id is a 16-char hash, not the raw UUID") diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index 524d1e1..1861e2a 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -407,6 +407,7 @@ struct DeviceAgentCLI { struct DeviceMCPServer { client: String, name: String, + source: String, } #[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] @@ -414,6 +415,7 @@ struct DeviceAgentAsset { client: String, kind: String, name: String, + source: String, } #[derive(Serialize, Debug, PartialEq)] @@ -1903,10 +1905,13 @@ fn collect_agent_discovery_from( ("amp", ".config/amp/settings.json", false), ("qwen", ".qwen/settings.json", false), ("pi", ".pi/agent/settings.json", false), + ("maestro", ".maestro/config.toml", true), + ("maestro", ".composer/config.toml", true), ]; let mut clis = BTreeSet::new(); let mut servers = BTreeSet::new(); let mut assets = BTreeSet::new(); + let mut plugin_config_reads = 0; for name in CLIS { let found = system_bins .iter() @@ -1945,6 +1950,9 @@ fn collect_agent_discovery_from( ("qwen", "skill", ".qwen/skills", ""), ("pi", "skill", ".pi/agent/skills", ""), ("pi", "extension", ".pi/agent/extensions", "js-ts"), + ("maestro", "skill", ".composer/skills", ""), + ("maestro", "plugin", ".maestro/plugins", "plugin"), + ("maestro", "plugin", ".composer/plugins", "plugin"), ] { let directory = home.join(relative); if !directory @@ -1987,6 +1995,8 @@ fn collect_agent_discovery_from( .is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) { Some(file_name.as_str()) + } else if extension == "plugin" && kind_on_disk.is_dir() { + Some(file_name.as_str()) } else { None }; @@ -1995,6 +2005,7 @@ fn collect_agent_discovery_from( client: client.into(), kind: kind.into(), name: name.into(), + source: relative.into(), }); if client == "gemini" && kind == "extension" { if let Some(body) = read_agent_config( @@ -2005,11 +2016,33 @@ fn collect_agent_discovery_from( servers.insert(DeviceMCPServer { client: "gemini".into(), name: server, + source: ".gemini/extensions/*/gemini-extension.json".into(), }); } } } } + if client == "maestro" && kind == "plugin" { + for config in ["mcp.json", ".mcp.json"] { + if plugin_config_reads >= 32 { + break; + } + if let Some(body) = + read_agent_config(&directory.join(&file_name).join(config)) + { + plugin_config_reads += 1; + for server in json_mcp_names(&body) { + if safe_agent_asset_name(&server) { + servers.insert(DeviceMCPServer { + client: "maestro".into(), + name: server, + source: format!("{relative}/*/{config}"), + }); + } + } + } + } + } if assets.len() >= 128 { break; } @@ -2027,6 +2060,7 @@ fn collect_agent_discovery_from( client: (*client).into(), kind: "config".into(), name: "user".into(), + source: (*relative).into(), }); if *client == "claude" && *relative == ".claude/settings.json" { if let Ok(value) = serde_json::from_str::(&body) { @@ -2040,6 +2074,7 @@ fn collect_agent_discovery_from( client: "claude".into(), kind: "plugin".into(), name: name.clone(), + source: ".claude/settings.json".into(), }); } } @@ -2077,6 +2112,7 @@ fn collect_agent_discovery_from( servers.insert(DeviceMCPServer { client: (*client).into(), name, + source: (*relative).into(), }); if servers.len() >= 128 { break; @@ -2321,6 +2357,29 @@ mod tests { "secret plugin", ) .unwrap(); + fs::create_dir_all(home.join(".maestro/plugins/audit/.plugin")).unwrap(); + fs::write( + home.join(".maestro/plugins/audit/.plugin/plugin.json"), + "secret plugin", + ) + .unwrap(); + fs::write( + home.join(".maestro/plugins/audit/mcp.json"), + r#"{"mcpServers":{"pluginsearch":{"command":"secret"}}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".maestro/plugins/convention")).unwrap(); + fs::create_dir_all(home.join(".composer/skills/review")).unwrap(); + fs::write( + home.join(".composer/skills/review/SKILL.md"), + "secret skill", + ) + .unwrap(); + fs::write( + home.join(".maestro/config.toml"), + "[mcp_servers.managed]\nurl = 'https://secret.example'\n", + ) + .unwrap(); let (clis, servers, assets) = collect_agent_discovery_from(&[home.clone()], &[]); assert_eq!( clis, @@ -2338,19 +2397,33 @@ mod tests { vec![ DeviceMCPServer { client: "amp".into(), - name: "db".into() + name: "db".into(), + source: ".config/amp/settings.json".into() }, DeviceMCPServer { client: "codex".into(), - name: "github".into() + name: "github".into(), + source: ".codex/config.toml".into() }, DeviceMCPServer { client: "cursor".into(), - name: "docs".into() + name: "docs".into(), + source: ".cursor/mcp.json".into() }, DeviceMCPServer { client: "gemini".into(), - name: "search".into() + name: "search".into(), + source: ".gemini/extensions/*/gemini-extension.json".into() + }, + DeviceMCPServer { + client: "maestro".into(), + name: "managed".into(), + source: ".maestro/config.toml".into() + }, + DeviceMCPServer { + client: "maestro".into(), + name: "pluginsearch".into(), + source: ".maestro/plugins/*/mcp.json".into() }, ] ); @@ -2375,6 +2448,19 @@ mod tests { assert!(assets.iter().any(|item| item.client == "opencode" && item.kind == "plugin" && item.name == "trace")); + assert!( + assets.iter().any(|item| item.client == "maestro" + && item.kind == "plugin" + && item.name == "audit") + ); + assert!( + assets.iter().any(|item| item.client == "maestro" + && item.kind == "skill" + && item.name == "review") + ); + assert!(assets.iter().any(|item| item.client == "maestro" + && item.kind == "plugin" + && item.name == "convention")); assert!(!assets.iter().any(|item| item.name == "off@marketplace")); assert!(!assets.iter().any(|item| item.name == "not-extension")); assert!( @@ -2389,7 +2475,7 @@ mod tests { home.join(".cursor/mcp.json"), ) .unwrap(); - assert_eq!(collect_agent_discovery_from(&[home], &[]).1.len(), 3); + assert_eq!(collect_agent_discovery_from(&[home], &[]).1.len(), 5); fs::remove_dir_all(root).unwrap(); }