From 9fb92343406fdf5b493aac36180860e67e4c8d2c Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Mon, 14 Sep 2026 14:45:16 +1000 Subject: [PATCH 1/6] feat(clients): add Pi and OMP support --- CHANGELOG.md | 8 + README.md | 10 +- docs/src/content/docs/docs/index.mdx | 2 +- docs/src/content/docs/docs/reference/cli.mdx | 12 + .../content/docs/docs/reference/clients.mdx | 30 +- .../docs/docs/reference/configuration.mdx | 20 + src/cli/agent-help.ts | 2 + src/cli/commands/plugin.ts | 683 ++++-- src/cli/commands/workspace.ts | 35 +- src/cli/format-sync.ts | 89 +- src/cli/metadata/plugin.ts | 74 +- src/cli/metadata/workspace.ts | 44 +- src/cli/tui/actions/clients.ts | 9 +- src/cli/tui/prompt-clients.ts | 14 +- src/core/claude-native.ts | 18 - src/core/client-context.ts | 467 +++++ src/core/native/claude.ts | 257 ++- src/core/native/copilot.ts | 246 ++- src/core/native/index.ts | 52 +- src/core/native/omp.ts | 1111 ++++++++++ src/core/native/pi-mcp.ts | 363 ++++ src/core/native/pi.ts | 1016 +++++++++ src/core/native/registry.ts | 6 + src/core/native/types.ts | 361 +++- src/core/repo-skills.ts | 103 +- src/core/status.ts | 320 ++- src/core/sync-state.ts | 167 +- src/core/sync.ts | 1856 +++++++++++++---- src/core/transform.ts | 126 +- src/core/user-workspace.ts | 13 + src/core/workspace-modify.ts | 14 + src/models/client-mapping.ts | 18 + src/models/sync-state.ts | 84 +- src/models/workspace-config.ts | 2 + tests/e2e/cli-enriched-help.test.ts | 3 +- tests/unit/cli/agent-help.test.ts | 23 +- tests/unit/cli/format-sync.test.ts | 115 +- tests/unit/core/claude-native.test.ts | 55 - tests/unit/core/client-context.test.ts | 238 +++ .../native-marketplace-registration.test.ts | 79 +- tests/unit/core/native/omp.test.ts | 630 ++++++ tests/unit/core/native/pi-mcp.test.ts | 216 ++ tests/unit/core/native/pi.test.ts | 483 +++++ tests/unit/core/native/registry.test.ts | 30 +- tests/unit/core/native/types.test.ts | 50 +- tests/unit/core/repo-skills.test.ts | 82 +- tests/unit/core/sync-dedup.test.ts | 131 +- tests/unit/core/sync-install-mode.test.ts | 91 +- tests/unit/core/sync-merge.test.ts | 50 +- tests/unit/core/sync-state-native.test.ts | 119 +- tests/unit/core/sync-user.test.ts | 50 + tests/unit/models/client-entry-schema.test.ts | 14 + tests/unit/models/client-mapping.test.ts | 37 +- 53 files changed, 8875 insertions(+), 1253 deletions(-) delete mode 100644 src/core/claude-native.ts create mode 100644 src/core/client-context.ts create mode 100644 src/core/native/omp.ts create mode 100644 src/core/native/pi-mcp.ts create mode 100644 src/core/native/pi.ts delete mode 100644 tests/unit/core/claude-native.test.ts create mode 100644 tests/unit/core/client-context.test.ts create mode 100644 tests/unit/core/native/omp.test.ts create mode 100644 tests/unit/core/native/pi-mcp.test.ts create mode 100644 tests/unit/core/native/pi.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 163a80ca..85bcf7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ **Migration**: Replace `pin:` with `ref:` in plugin objects and `--pin` with `--ref` in scripts. This is a clean cutover; the old names are not accepted. +### Added + +- Pi and OMP as file-sync clients at project and user scope, including native + runtime skill paths and agent instructions. +- Native Pi package and OMP marketplace-plugin lifecycle support for install, + update, uninstall, status, and list output, with fail-closed trust and + ownership checks. + ## [1.0.0] - 2026-03-13 diff --git a/README.md b/README.md index 467fc9cb..a590a3c4 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docs](https://img.shields.io/badge/docs-allagents.dev-blue)](https://allagents.dev) -Write AI agent skills once. Sync to 23 clients. Manage across multiple repos. +Write AI agent skills once. Sync to 25 clients. Manage across multiple repos. -AllAgents keeps your AI tooling (skills, agents, hooks, MCP servers) in one workspace and syncs it to every client your team uses — Claude, Copilot, Cursor, Codex, Gemini, and 18 more. +AllAgents keeps your AI tooling (skills, agents, hooks, MCP servers) in one workspace and syncs it to every client your team uses — Claude, Copilot, Cursor, Codex, Gemini, and 20 more. ## Quick Start @@ -55,7 +55,7 @@ Tools like `npx skills` and `npx plugins` install skills to one project for one | **Config** | Imperative | Imperative | Declarative (`workspace.yaml`) | | **Scope** | Single project | Single project | Multi-repo workspace | | **Artifacts** | Skills | Skills, agents, hooks, commands, MCP | Skills, agents, hooks, commands, MCP | -| **Clients** | 43 agents | 2 (Claude, Cursor) | 23 clients simultaneously | +| **Clients** | 43 agents | 2 (Claude, Cursor) | 25 clients simultaneously | | **Team sharing** | Each dev runs install | Each dev runs install | Git-versioned — clone and go | | **Ongoing sync** | One-shot install | One-shot install | `allagents update` keeps everything current | | **Workspace awareness** | None | None | WORKSPACE-RULES injected so AI knows all repos and skills | @@ -106,11 +106,11 @@ See the [full CLI reference](https://allagents.dev/docs/reference/cli/) for all ## Supported Clients -**23 AI coding assistants** across two tiers: +**25 AI coding assistants** across two tiers: **Universal** (share `.agents/skills/`): Copilot, Codex, OpenCode, Gemini, Amp Code, VSCode, Replit, Kimi -**Provider-specific**: Claude, Cursor, Factory, OpenClaw, Windsurf, Cline, Continue, Roo, Kilo, Trae, Augment, Zencoder, Junie, OpenHands, Kiro +**Provider-specific**: Claude, Pi, OMP, Cursor, Factory, OpenClaw, Windsurf, Cline, Continue, Roo, Kilo, Trae, Augment, Zencoder, Junie, OpenHands, Kiro See the [client support matrix](https://allagents.dev/docs/reference/clients/) for paths, hooks, commands, and MCP support per client. diff --git a/docs/src/content/docs/docs/index.mdx b/docs/src/content/docs/docs/index.mdx index b0355c32..db6781ad 100644 --- a/docs/src/content/docs/docs/index.mdx +++ b/docs/src/content/docs/docs/index.mdx @@ -16,7 +16,7 @@ AllAgents solves this by providing: | Feature | Claude Code Plugins | AllAgents | |---------|--------------------|-----------| | Scope | Single project | Multi-repo workspace | -| Client support | Claude only | 8 AI clients | +| Client support | Claude only | 25 AI clients | | File location | Runtime lookup from cache | Copied to workspace (git-versioned) | | Project structure | AI config mixed with code | Separate workspace repo | diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index e72dc5e1..7150f15d 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -30,6 +30,12 @@ When `--scope user` is used, sync targets the user-level workspace at `~/.allage Sync state is tracked in `.allagents/sync-state.json`. +For clients configured with native install mode, sync first inspects the real +client inventory, then delegates only the required scoped install, update, or +removal. Pi package and OMP marketplace-plugin effects are included in human and +JSON output. Native state is tracked separately from copied files so AllAgents +only removes resources whose ownership it can corroborate. + When `vscode` is in the `clients` list, sync also generates a `.code-workspace` file with repository paths resolved to absolute paths. See the [Workspaces guide](/docs/guides/workspaces/#vscode-workspace-generation) for details. ### status @@ -217,6 +223,12 @@ allagents plugin install superpowers@marketplace --skill brainstorming --skill t When used, all other skills from the plugin are implicitly disabled. Skills can be added later with `skill add`. +When the selected client uses native install mode, this command validates the +runtime, source, scope, and live inventory before editing `workspace.yaml`. +Supported native resources include Pi npm/Git/local packages and OMP +marketplace plugins. Use `plugin@owner/marketplace-repository` for OMP so +AllAgents can register and verify the marketplace before install. + ### skill list List all skills from installed plugins with their enabled/disabled status. diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index f942ae7d..bbac1d9f 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -3,7 +3,7 @@ title: Supported Clients description: AI coding assistant clients supported by AllAgents. --- -AllAgents supports 23 AI coding assistants, organized into universal clients (sharing `.agents/skills/`) and provider-specific clients. +AllAgents supports 25 AI coding assistants, organized into universal clients (sharing `.agents/skills/`) and provider-specific clients. ## Universal Clients @@ -27,6 +27,8 @@ These clients use their own skills directory: | Client | Skills | Agent File | Hooks | Commands | |--------|--------|------------|-------|----------| | Claude | `.claude/skills/` | `CLAUDE.md` | `.claude/hooks/` | `.claude/commands/` | +| Pi | `.pi/skills/` | `AGENTS.md` | No | No | +| OMP | `.omp/skills/` | `AGENTS.md` | No | No | | Cursor | `.cursor/skills/` | `AGENTS.md` | No | No | | Factory | `.factory/skills/` | `AGENTS.md` | `.factory/hooks/` | No | | OpenClaw | `skills/` | `AGENTS.md` | No | No | @@ -46,6 +48,32 @@ These clients use their own skills directory: Skills are the cross-client way to share reusable prompts. GitHub overrides (`.github/prompts/`, `.github/agents/`, `.github/hooks/`, `copilot-instructions.md`) are copied to the workspace's `.github/` folder for Copilot/VSCode; package-only `.github/plugin/` metadata is omitted. Root `agents/` and `hooks/` also map to `.github/agents/` and `.github/hooks/` for Copilot. At project scope, plugin hook declarations from `hooks.json` or `hooks/hooks.json` are combined in `.github/hooks/allagents.json`. This translation does not require marketplace registration or `plugin.json`; a missing or disabled declaration adds no managed entries. AllAgents warns and omits a declaration that cannot be read or parsed, lacks the version-1 `hooks` object envelope, or—unless disabled—has a non-array event value. Skipping a declaration does not itself suppress other eligible plugin artifacts or valid declarations from other plugins. At user scope, root `hooks/` maps to `~/.copilot/hooks/`, while repository `.github/hooks/` remains project-scoped. ::: +### Pi + +File sync writes project skills to `.pi/skills/` and user skills to +`~/.pi/agent/skills/`. Set `PI_CODING_AGENT_DIR` to relocate Pi's user agent +root. + +Native install mode delegates plugin lifecycle to Pi's package manager. Pi +0.85.1 through 0.85.x is supported. Package sources may be npm specs such as +`npm:pi-extension`, Git repositories, or local paths. Project-native operations +fail before mutation unless Pi's trusted settings outside the project boundary +authorize that project. AllAgents inspects `pi-mcp-adapter` state but does not +install or configure the adapter. + +### OMP + +File sync writes project skills to `.omp/skills/` and user skills to +`~/.omp/agent/skills/`. Native install mode requires OMP 18.1.17 or newer and a +marketplace-backed plugin spec such as +`compound-engineering@EveryInc/compound-engineering-plugin`. AllAgents registers +the required marketplace, delegates scoped install, update, and uninstall to +OMP, and preserves marketplace registrations on uninstall. + +Ordinary OMP operations always address the unnamed profile: AllAgents removes +inherited `OMP_PROFILE`, `PI_PROFILE`, and `PI_CONFIG_FILES` selectors while +preserving the active HOME and XDG roots. + ### VSCode VSCode syncs the same skills, agent file, and GitHub overrides as Copilot. Additionally, adding `vscode` to `clients` triggers automatic `.code-workspace` file generation and MCP config syncing during `allagents update`. See the [Workspaces guide](/docs/guides/workspaces/#vscode-workspace-generation) for details. diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index 7de6ef4c..a3b1698c 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -53,6 +53,26 @@ clients: - cursor ``` +### Client Install Modes + +A bare client name uses file sync. Native mode delegates compatible plugin +resources to the client's own package or plugin manager: + +```yaml +clients: + - pi:native + - name: omp + install: native +``` + +The colon shorthand and object form are equivalent. A plugin object may set its +own `install: file` or `install: native` override. + +Pi native mode supports npm, Git, and local package sources at project or user +scope. Project installs require trust recorded outside the project-controlled +Pi agent root. OMP native mode accepts marketplace-backed plugin specs at +project or user scope and requires OMP 18.1.17 or newer. + ### Setup Commands The optional top-level `setup` field is an ordered list. A string runs on every diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts index d99f1142..b69c8058 100644 --- a/src/cli/agent-help.ts +++ b/src/cli/agent-help.ts @@ -17,6 +17,7 @@ import { pluginInstallMeta, pluginListMeta, pluginUninstallMeta, + pluginUpdateMeta, pluginValidateMeta, } from './metadata/plugin.js'; import { updateMeta } from './metadata/self.js'; @@ -34,6 +35,7 @@ const allCommands: AgentCommandMeta[] = [ statusMeta, pluginInstallMeta, pluginUninstallMeta, + pluginUpdateMeta, marketplaceListMeta, marketplaceAddMeta, marketplaceRemoveMeta, diff --git a/src/cli/commands/plugin.ts b/src/cli/commands/plugin.ts index 9d7fa837..32c381fd 100644 --- a/src/cli/commands/plugin.ts +++ b/src/cli/commands/plugin.ts @@ -20,15 +20,19 @@ import { } from '../../core/marketplace.js'; import { buildPluginSyncPlans, + nativeIdentityMatches, + preflightNativePluginDeclaration, syncWorkspace, syncUserWorkspace, + type SyncOptions, } from '../../core/sync.js'; +import type { NativeEffectData } from '../../core/native/types.js'; import { loadSyncState } from '../../core/sync-state.js'; -import { addPlugin, removePlugin, hasPlugin, ensureWorkspace, addEnabledSkill, extractPluginNames } from '../../core/workspace-modify.js'; +import { addPlugin, addPluginDeclaration, removePlugin, ensureWorkspace, addEnabledSkill, extractPluginNames } from '../../core/workspace-modify.js'; import { addUserPlugin, + addUserPluginDeclaration, removeUserPlugin, - hasUserPlugin, isUserConfigPath, getInstalledUserPlugins, getInstalledProjectPlugins, @@ -40,7 +44,10 @@ import { } from '../../core/user-workspace.js'; import { updatePlugin, type InstalledPluginUpdateResult } from '../../core/plugin.js'; import { getAllSkillsFromPlugins } from '../../core/skills.js'; -import { getWorkspaceStatus } from '../../core/status.js'; +import { + getWorkspaceStatus, + type NativePluginStatus, +} from '../../core/status.js'; import { parseMarketplaceManifest } from '../../utils/marketplace-manifest-parser.js'; import { isJsonMode, jsonOutput } from '../json-output.js'; import { buildDescription, conciseSubcommands } from '../help.js'; @@ -57,10 +64,18 @@ import { pluginUpdateMeta, } from '../metadata/plugin.js'; import { skillsCmd } from './plugin-skills.js'; -import { formatMcpResult, formatNativeResult, buildSyncData, formatPluginArtifacts, formatPluginHeader } from '../format-sync.js'; +import { + formatMcpResult, + formatNativeEffectData, + formatNativeResult, + buildSyncData, + formatPluginArtifacts, + formatPluginHeader, +} from '../format-sync.js'; import { getPluginSource, - type PluginEntry, + type ClientEntry, + type WorkspaceConfig, } from '../../models/workspace-config.js'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE, getHomeDir } from '../../constants.js'; import { existsSync } from 'node:fs'; @@ -75,17 +90,14 @@ import { parseWorkspaceConfig } from '../../utils/workspace-parser.js'; /** * Run sync and print results. Returns true if sync succeeded. */ -async function runSyncAndPrint(options?: { skipAgentFiles?: boolean }): Promise<{ ok: boolean; syncData: ReturnType | null }> { +async function runSyncAndPrint(options: SyncOptions = {}) { if (!isJsonMode()) { console.log('\nUpdating workspace...\n'); } const result = await syncWorkspace(process.cwd(), options); - if (!result.success && result.error) { - if (!isJsonMode()) { - console.error(`Sync error: ${result.error}`); - } - return { ok: false, syncData: null }; + if (!result.success && result.error && !isJsonMode()) { + console.error(`Sync error: ${result.error}`); } const syncData = buildSyncData(result); @@ -161,14 +173,11 @@ async function runSyncAndPrint(options?: { skipAgentFiles?: boolean }): Promise< /** * Run user-scope sync and print results. Returns true if sync succeeded. */ -async function runUserSyncAndPrint(): Promise<{ ok: boolean; syncData: ReturnType | null }> { - const result = await syncUserWorkspace(); +async function runUserSyncAndPrint(options: SyncOptions = {}) { + const result = await syncUserWorkspace(options); - if (!result.success && result.error) { - if (!isJsonMode()) { - console.error(`Sync error: ${result.error}`); - } - return { ok: false, syncData: null }; + if (!result.success && result.error && !isJsonMode()) { + console.error(`Sync error: ${result.error}`); } const syncData = buildSyncData(result); @@ -241,6 +250,48 @@ async function runUserSyncAndPrint(): Promise<{ ok: boolean; syncData: ReturnTyp return { ok: result.success && result.totalFailed === 0, syncData }; } + +async function hasTrackedNativeTarget( + target: string, + scope: 'user' | 'project', +): Promise { + const state = await loadSyncState( + scope === 'user' ? getHomeDir() : process.cwd(), + ); + return (state?.nativeResources?.resources ?? []).some( + (resource) => + resource.scope === scope && + nativeIdentityMatches( + target, + resource.requestedIdentity, + resource.resolvedIdentity, + ), + ); +} + +async function configuredPluginTarget( + target: string, + scope: 'user' | 'project', +): Promise { + const config = + scope === 'user' + ? await getUserWorkspaceConfig() + : existsSync(join(process.cwd(), CONFIG_DIR, WORKSPACE_CONFIG_FILE)) + ? await parseWorkspaceConfig( + join(process.cwd(), CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ) + : null; + const matches = (config?.plugins ?? []) + .map(getPluginSource) + .filter((source) => nativeIdentityMatches(target, source, source)); + if (matches.length > 1) { + throw new Error( + `Plugin target '${target}' is ambiguous: ${matches.join(', ')}. Use an exact qualified declaration.`, + ); + } + return matches[0]; +} + // ============================================================================= // plugin marketplace list // ============================================================================= @@ -797,28 +848,25 @@ const pluginListCmd = command({ if (!cwdIsHome) { await loadConfigClients(projectConfigPath, 'project'); } + const userSyncState = await loadSyncState(getHomeDir()); + const projectSyncState = cwdIsHome + ? null + : await loadSyncState(process.cwd()); const userPlugins = await getInstalledUserPlugins(); const projectPlugins = await getInstalledProjectPlugins(process.cwd()); const allInstalled = [...userPlugins, ...projectPlugins]; const kindBySource = new Map(); - try { - const status = await getWorkspaceStatus(process.cwd()); - for (const p of [ - ...status.plugins, - ...(status.userPlugins ?? []), - ]) { - kindBySource.set(p.source, p.kind); - } - } catch { - // Best-effort: unresolved sources are plugins. + const workspaceStatus = await getWorkspaceStatus(process.cwd()); + for (const p of [ + ...workspaceStatus.plugins, + ...(workspaceStatus.userPlugins ?? []), + ]) { + kindBySource.set(p.source, p.kind); } - const userSyncState = await loadSyncState(getHomeDir()); - const projectSyncState = cwdIsHome - ? null - : await loadSyncState(process.cwd()); + interface MergedPlugin { spec: string; @@ -829,6 +877,7 @@ const pluginListCmd = command({ kind: 'skill' | 'plugin'; fileClients: string[]; nativeClients: string[]; + nativeResources: NativePluginStatus[]; } const merged = new Map(); @@ -856,10 +905,13 @@ const pluginListCmd = command({ kind: kindBySource.get(plugin.spec) ?? 'plugin', fileClients: [...clients], nativeClients: [], + nativeResources: [], }); } - for (const [state, scope] of [ + // Use the legacy identity list only when typed state/live status cannot + // corroborate the same adapter, scope, and resource. + for (const [state, stateScope] of [ [userSyncState, 'user'], [projectSyncState, 'project'], ] as const) { @@ -867,10 +919,25 @@ const pluginListCmd = command({ state?.nativePlugins ?? {}, )) { for (const spec of specs) { + const corroborated = workspaceStatus.nativeResources.some( + (resource) => + resource.client === client && + resource.scope === stateScope && + nativeIdentityMatches( + spec, + resource.requestedIdentity, + resource.resolvedIdentity, + ), + ); + if (corroborated) continue; const parsed = parsePluginSpec(spec); const key = parsed - ? marketplaceKey(parsed.plugin, parsed.marketplaceName, scope) - : sourceKey(spec, scope); + ? marketplaceKey( + parsed.plugin, + parsed.marketplaceName, + stateScope, + ) + : sourceKey(spec, stateScope); const existing = merged.get(key); if (existing) { if (!existing.nativeClients.includes(client)) { @@ -883,20 +950,54 @@ const pluginListCmd = command({ effectiveSpec: spec, name: parsed?.plugin ?? getPluginDisplayName(spec), marketplace: parsed?.marketplaceName ?? '', - scope, + scope: stateScope, kind: kindBySource.get(spec) ?? 'plugin', fileClients: [], nativeClients: [client], + nativeResources: [], }); } } } + for (const nativeResource of workspaceStatus.nativeResources) { + const spec = nativeResource.requestedIdentity; + const parsed = + parsePluginSpec(nativeResource.resolvedIdentity) ?? + parsePluginSpec(spec); + const key = parsed + ? marketplaceKey( + parsed.plugin, + parsed.marketplaceName, + nativeResource.scope, + ) + : sourceKey(spec, nativeResource.scope); + const existing = merged.get(key); + if (existing) { + if (!existing.nativeClients.includes(nativeResource.client)) { + existing.nativeClients.push(nativeResource.client); + } + existing.nativeResources.push(nativeResource); + continue; + } + merged.set(key, { + spec, + effectiveSpec: spec, + name: parsed?.plugin ?? getPluginDisplayName(spec), + marketplace: parsed?.marketplaceName ?? '', + scope: nativeResource.scope, + kind: kindBySource.get(spec) ?? 'plugin', + fileClients: [], + nativeClients: [nativeResource.client], + nativeResources: [nativeResource], + }); + } + const plugins = [...merged.values()]; if (isJsonMode()) { jsonOutput({ - success: true, + success: workspaceStatus.success, command: 'plugin list', data: { plugins: plugins.map((p) => ({ @@ -907,10 +1008,17 @@ const pluginListCmd = command({ kind: p.kind, ...(p.fileClients.length > 0 && { clients: p.fileClients }), ...(p.nativeClients.length > 0 && { nativeClients: p.nativeClients }), + ...(p.nativeResources.length > 0 && { + nativeResources: p.nativeResources, + }), })), total: plugins.length, }, + ...(!workspaceStatus.success && { + error: workspaceStatus.error ?? 'Native inspection failed', + }), }); + if (!workspaceStatus.success) process.exit(1); return; } @@ -920,13 +1028,17 @@ const pluginListCmd = command({ console.log(' allagents plugin marketplace browse \n'); console.log('To see registered marketplaces:'); console.log(' allagents plugin marketplace list'); + if (!workspaceStatus.success) { + console.error(`Error: ${workspaceStatus.error ?? 'Native inspection failed'}`); + process.exit(1); + } return; } const skillCount = plugins.filter((p) => p.kind === 'skill').length; const pluginCount = plugins.length - skillCount; - console.log('Installed plugins:\n'); + console.log('Plugins:\n'); for (const p of plugins) { console.log(` ❯ ${p.marketplace ? p.spec : p.name}`); console.log(` Type: ${p.kind}`); @@ -943,6 +1055,11 @@ const pluginListCmd = command({ ]; console.log(` Clients: ${parts.join(', ')}`); } + for (const nativeResource of p.nativeResources) { + console.log( + `${formatNativeEffectData(nativeResource)} declared=${String(nativeResource.declared)} ownership=${nativeResource.ownership}${nativeResource.transition ? ` transition=${nativeResource.transition}` : ''}`, + ); + } console.log(''); } @@ -950,6 +1067,10 @@ const pluginListCmd = command({ if (pluginCount > 0) summaryParts.push(`${pluginCount} plugin${pluginCount === 1 ? '' : 's'}`); if (skillCount > 0) summaryParts.push(`${skillCount} skill${skillCount === 1 ? '' : 's'}`); console.log(`Total: ${summaryParts.join(', ')}`); + if (!workspaceStatus.success) { + console.error(`Error: ${workspaceStatus.error ?? 'Native inspection failed'}`); + process.exit(1); + } } catch (error) { if (error instanceof Error) { if (isJsonMode()) { @@ -1007,13 +1128,20 @@ const pluginInstallCmd = command({ }, handler: async ({ plugin, scope, skills }) => { try { + if (scope && scope !== 'user' && scope !== 'project') { + throw new Error( + `Invalid scope '${scope}'. Must be 'user' or 'project'.`, + ); + } // Treat as user scope if explicitly requested or if cwd resolves to user config const isUser = scope === 'user' || (!scope && isUserConfigPath(process.cwd())); - // If no workspace.yaml exists, prompt for clients first + let selectedClients: ClientEntry[] | undefined; + let workspaceExists: boolean; if (isUser) { const userConfigPath = getUserWorkspaceConfigPath(); - if (!existsSync(userConfigPath)) { + workspaceExists = existsSync(userConfigPath); + if (!workspaceExists) { const { promptForClients } = await import('../tui/prompt-clients.js'); const clients = await promptForClients(); if (clients === null) { @@ -1022,11 +1150,12 @@ const pluginInstallCmd = command({ } return; } - await ensureUserWorkspace(clients); + selectedClients = clients; } } else { const configPath = join(process.cwd(), CONFIG_DIR, WORKSPACE_CONFIG_FILE); - if (!existsSync(configPath)) { + workspaceExists = existsSync(configPath); + if (!workspaceExists) { const { promptForClients } = await import('../tui/prompt-clients.js'); const clients = await promptForClients(); if (clients === null) { @@ -1035,7 +1164,7 @@ const pluginInstallCmd = command({ } return; } - await ensureWorkspace(process.cwd(), clients); + selectedClients = clients; } } @@ -1050,10 +1179,57 @@ const pluginInstallCmd = command({ } } + const nativePreflightConfig = workspaceExists + ? isUser + ? await getUserWorkspaceConfig() + : await parseWorkspaceConfig( + join(process.cwd(), CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ) + : ({ + repositories: [], + plugins: [], + clients: selectedClients ?? [], + } as WorkspaceConfig); + if (!nativePreflightConfig) { + throw new Error('Workspace configuration is unavailable'); + } + const nativePreflightErrors = await preflightNativePluginDeclaration( + plugin, + nativePreflightConfig.clients, + isUser ? 'user' : 'project', + process.cwd(), + ); + if (nativePreflightErrors.length > 0) { + throw new Error( + `Native preflight failed; workspace declaration was not changed: ${nativePreflightErrors.join('; ')}`, + ); + } + + if (!workspaceExists) { + if (isUser) { + await ensureUserWorkspace(selectedClients); + } else { + await ensureWorkspace(process.cwd(), selectedClients); + } + } + const installPlan = buildPluginSyncPlans( + [plugin], + nativePreflightConfig.clients, + isUser ? 'user' : 'project', + ).plans[0]; + const nativeOnly = + !!installPlan && + installPlan.clients.length === 0 && + installPlan.nativeClients.length > 0; + // Always force-reinstall if the plugin already exists (no error, just overwrite) const result = isUser - ? await addUserPlugin(plugin, true) - : await addPlugin(plugin, process.cwd(), true); + ? nativeOnly + ? await addUserPluginDeclaration(plugin, true) + : await addUserPlugin(plugin, true) + : nativeOnly + ? await addPluginDeclaration(plugin, process.cwd(), true) + : await addPlugin(plugin, process.cwd(), true); if (!result.success) { if (isJsonMode()) { @@ -1195,50 +1371,86 @@ const pluginUninstallCmd = command({ }, handler: async ({ plugin, scope }) => { try { - // When an explicit scope is given, only uninstall from that scope - if (scope) { - const isUser = scope === 'user'; - const result = isUser - ? await removeUserPlugin(plugin) - : await removePlugin(plugin); - - if (!result.success) { - if (isJsonMode()) { - jsonOutput({ success: false, command: 'plugin uninstall', error: result.error ?? 'Unknown error' }); - process.exit(1); + if (scope && scope !== 'user' && scope !== 'project') { + throw new Error( + `Invalid scope '${scope}'. Must be 'user' or 'project'.`, + ); + } + const scopes: Array<'project' | 'user'> = + scope === 'user' + ? ['user'] + : scope === 'project' + ? ['project'] + : isUserConfigPath(process.cwd()) + ? ['user'] + : ['project', 'user']; + const declarations: Array<{ + scope: 'project' | 'user'; + action: 'removed' | 'absent' | 'failed'; + error?: string; + }> = []; + const syncResults: Record = {}; + let found = false; + let allOk = true; + + for (const targetScope of scopes) { + const configuredTarget = await configuredPluginTarget( + plugin, + targetScope, + ); + const declared = configuredTarget !== undefined; + const nativeTarget = configuredTarget ?? plugin; + const tracked = await hasTrackedNativeTarget(nativeTarget, targetScope); + if (!declared && !tracked) continue; + found = true; + + if (declared) { + const removal = + targetScope === 'user' + ? await removeUserPlugin(nativeTarget) + : await removePlugin(nativeTarget); + if (!removal.success) { + allOk = false; + declarations.push({ + scope: targetScope, + action: 'failed', + error: removal.error ?? 'Declaration removal failed', + }); + if (!isJsonMode()) { + console.error( + `\u2717 Declaration removal (${targetScope}): ${removal.error ?? 'Unknown error'}`, + ); + } + continue; + } + declarations.push({ scope: targetScope, action: 'removed' }); + if (!isJsonMode()) { + console.log( + `\u2713 Declaration removed (${targetScope} scope): ${plugin}`, + ); + } + } else { + declarations.push({ scope: targetScope, action: 'absent' }); + if (!isJsonMode()) { + console.log( + `= Declaration already absent (${targetScope} scope): ${plugin}`, + ); } - console.error(`Error: ${result.error}`); - process.exit(1); - } - - if (isJsonMode()) { - const { ok, syncData } = isUser - ? await runUserSyncAndPrint() - : await runSyncAndPrint(); - jsonOutput({ - success: ok, - command: 'plugin uninstall', - data: { plugin, scope, syncResult: syncData }, - ...(!ok && { error: 'Sync completed with failures' }), - }); - if (!ok) process.exit(1); - return; } - console.log(`\u2713 Uninstalled plugin (${scope} scope): ${plugin}`); - const { ok: syncOk } = isUser - ? await runUserSyncAndPrint() - : await runSyncAndPrint(); - if (!syncOk) process.exit(1); - return; + const nativeSelection = { + mode: 'remove' as const, + targets: [nativeTarget], + }; + const sync = + targetScope === 'user' + ? await runUserSyncAndPrint({ nativeSelection }) + : await runSyncAndPrint({ nativeSelection }); + syncResults[targetScope] = sync.syncData; + if (!sync.ok) allOk = false; } - // No explicit scope: uninstall from all scopes where the plugin exists - // Skip project scope if it resolves to the user config (e.g., cwd is ~) - const inProject = isUserConfigPath(process.cwd()) ? false : await hasPlugin(plugin); - const inUser = await hasUserPlugin(plugin); - - if (!inProject && !inUser) { + if (!found) { const error = `Plugin not found: ${plugin}`; if (isJsonMode()) { jsonOutput({ success: false, command: 'plugin uninstall', error }); @@ -1248,70 +1460,22 @@ const pluginUninstallCmd = command({ process.exit(1); } - const removedScopes: string[] = []; - - if (inProject) { - const result = await removePlugin(plugin); - if (!result.success) { - if (isJsonMode()) { - jsonOutput({ success: false, command: 'plugin uninstall', error: result.error ?? 'Unknown error' }); - process.exit(1); - } - console.error(`Error: ${result.error}`); - process.exit(1); - } - removedScopes.push('project'); - } - - if (inUser) { - const result = await removeUserPlugin(plugin); - if (!result.success) { - if (isJsonMode()) { - jsonOutput({ success: false, command: 'plugin uninstall', error: result.error ?? 'Unknown error' }); - process.exit(1); - } - console.error(`Error: ${result.error}`); - process.exit(1); - } - removedScopes.push('user'); - } - if (isJsonMode()) { - const syncResults: Record | null> = {}; - let allOk = true; - if (removedScopes.includes('project')) { - const { ok, syncData } = await runSyncAndPrint(); - syncResults.project = syncData; - if (!ok) allOk = false; - } - if (removedScopes.includes('user')) { - const { ok, syncData } = await runUserSyncAndPrint(); - syncResults.user = syncData; - if (!ok) allOk = false; - } jsonOutput({ success: allOk, command: 'plugin uninstall', - data: { plugin, scopes: removedScopes, syncResults }, - ...(!allOk && { error: 'Sync completed with failures' }), + data: { + plugin, + scopes: declarations.map((result) => result.scope), + declarations, + syncResults, + }, + ...(!allOk && { + error: 'Declaration removal or native cleanup failed', + }), }); - if (!allOk) process.exit(1); - return; } - - const scopeLabel = removedScopes.join(' + '); - console.log(`\u2713 Uninstalled plugin (${scopeLabel} scope): ${plugin}`); - - let syncOk = true; - if (removedScopes.includes('project')) { - const { ok } = await runSyncAndPrint(); - if (!ok) syncOk = false; - } - if (removedScopes.includes('user')) { - const { ok } = await runUserSyncAndPrint(); - if (!ok) syncOk = false; - } - if (!syncOk) process.exit(1); + if (!allOk) process.exit(1); } catch (error) { if (error instanceof Error) { if (isJsonMode()) { @@ -1339,6 +1503,16 @@ const pluginUpdateCmd = command({ }, handler: async ({ plugin, scope }) => { try { + if ( + scope && + scope !== 'user' && + scope !== 'project' && + scope !== 'all' + ) { + throw new Error( + `Invalid scope '${scope}'. Must be 'user', 'project', or 'all'.`, + ); + } // Determine which plugins to update based on scope const updateAll = scope === 'all'; const updateUser = scope === 'user' || updateAll; @@ -1368,20 +1542,23 @@ const pluginUpdateCmd = command({ } } - // Also include raw plugin entries (GitHub URLs, local paths) + const configs: Partial< + Record<'project' | 'user', WorkspaceConfig> + > = {}; + + // Include declarations, including native-only sources that have no + // generic installed-plugin cache entry. if (updateProject && !isUserConfigPath(process.cwd())) { - const { existsSync } = await import('node:fs'); - const { readFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - const { load } = await import('js-yaml'); - const { CONFIG_DIR, WORKSPACE_CONFIG_FILE } = await import('../../constants.js'); - const configPath = join(process.cwd(), CONFIG_DIR, WORKSPACE_CONFIG_FILE); + const configPath = join( + process.cwd(), + CONFIG_DIR, + WORKSPACE_CONFIG_FILE, + ); if (existsSync(configPath)) { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as { plugins?: PluginEntry[] }; - for (const entry of config.plugins ?? []) { - const p = getPluginSource(entry); - addPluginToUpdate(p, 'project'); + const config = await parseWorkspaceConfig(configPath); + configs.project = config; + for (const entry of config.plugins) { + addPluginToUpdate(getPluginSource(entry), 'project'); } } } @@ -1389,21 +1566,17 @@ const pluginUpdateCmd = command({ if (updateUser) { const userConfig = await getUserWorkspaceConfig(); if (userConfig) { - for (const entry of userConfig.plugins ?? []) { - const p = getPluginSource(entry); - addPluginToUpdate(p, 'user'); + configs.user = userConfig; + for (const entry of userConfig.plugins) { + addPluginToUpdate(getPluginSource(entry), 'user'); } } } // Filter to specific plugin if provided const toUpdate = plugin - ? pluginsToUpdate.filter(({ spec }) => { - // Match by full spec or just plugin name - if (spec === plugin) return true; - const parsed = parsePluginSpec(spec); - return parsed?.plugin === plugin || spec.endsWith(`/${plugin}`); - }) + ? pluginsToUpdate.filter(({ spec }) => + nativeIdentityMatches(plugin, spec, spec)) : pluginsToUpdate; if (plugin && toUpdate.length === 0) { @@ -1429,6 +1602,42 @@ const pluginUpdateCmd = command({ return; } + const nativeTargets = { + project: [] as string[], + user: [] as string[], + }; + const nativeOnly = new Set(); + for (const entry of toUpdate) { + const config = configs[entry.scope]; + if (!config) continue; + const declaration = + config.plugins.find( + (candidate) => getPluginSource(candidate) === entry.spec, + ) ?? entry.spec; + const preflightErrors = await preflightNativePluginDeclaration( + declaration, + config.clients, + entry.scope, + process.cwd(), + ); + if (preflightErrors.length > 0) { + throw new Error( + `Native preflight failed before update: ${preflightErrors.join('; ')}`, + ); + } + const plan = buildPluginSyncPlans( + [declaration], + config.clients, + entry.scope, + ).plans[0]; + if ((plan?.nativeClients.length ?? 0) > 0) { + nativeTargets[entry.scope].push(entry.spec); + if (plan?.clients.length === 0) { + nativeOnly.add(`${entry.scope}:${entry.spec}`); + } + } + } + if (!isJsonMode()) { console.log(plugin ? `Updating plugin: ${plugin}...` : 'Updating plugins...'); console.log(); @@ -1468,47 +1677,122 @@ const pluginUpdateCmd = command({ user: createUpdateDeps('user'), }; + const updatedScopes = new Set<'project' | 'user'>(); for (const { spec: pluginSpec, scope: pluginScope } of toUpdate) { - const result = await updatePlugin(pluginSpec, depsByScope[pluginScope]); + const result = nativeOnly.has(`${pluginScope}:${pluginSpec}`) + ? { + plugin: pluginSpec, + success: true, + action: 'skipped' as const, + } + : await updatePlugin(pluginSpec, depsByScope[pluginScope]); + if (result.action === 'updated') updatedScopes.add(pluginScope); results.push(result); - if (!isJsonMode()) { - const icon = result.success - ? (result.action === 'updated' ? '\u2713' : '-') - : '\u2717'; - const actionLabel = result.action === 'updated' - ? 'updated' - : result.action === 'skipped' - ? 'skipped' - : 'failed'; - console.log(`${icon} ${pluginSpec} (${actionLabel})`); - if (result.error) { - console.log(` Error: ${result.error}`); - } - } } - const updated = results.filter((r) => r.action === 'updated').length; - const skipped = results.filter((r) => r.action === 'skipped').length; - const failed = results.filter((r) => r.action === 'failed').length; - // Sync plugin files only (skip AGENTS.md and other generated files) + // Sync each affected scope independently. Native mutation is constrained + // to the declarations named by this invocation. let syncOk = true; - let syncData: ReturnType | null = null; - - if (updated > 0) { - if (updateProject && !isUserConfigPath(process.cwd())) { - const { ok, syncData: data } = await runSyncAndPrint({ skipAgentFiles: true }); - if (!ok) syncOk = false; - syncData = data; - } - if (updateUser) { - const { ok, syncData: data } = await runUserSyncAndPrint(); - if (!ok) syncOk = false; - if (!syncData) syncData = data; - } + const syncResults: Record = {}; + const nativeEffects: Partial< + Record<'project' | 'user', NativeEffectData[]> + > = {}; + const targetsByScope = { + project: toUpdate + .filter((entry) => entry.scope === 'project') + .map((entry) => entry.spec), + user: toUpdate + .filter((entry) => entry.scope === 'user') + .map((entry) => entry.spec), + }; + if ( + targetsByScope.project.length > 0 && + (updatedScopes.has('project') || nativeTargets.project.length > 0) + ) { + const { ok, syncData } = await runSyncAndPrint({ + skipAgentFiles: true, + nativeSelection: { + mode: 'update', + targets: targetsByScope.project, + }, + }); + syncResults.project = syncData; + if (!ok) syncOk = false; + nativeEffects.project = syncData.nativeResources?.effects ?? []; + } + if ( + targetsByScope.user.length > 0 && + (updatedScopes.has('user') || nativeTargets.user.length > 0) + ) { + const { ok, syncData } = await runUserSyncAndPrint({ + skipAgentFiles: true, + nativeSelection: { + mode: 'update', + targets: targetsByScope.user, + }, + }); + syncResults.user = syncData; + if (!ok) syncOk = false; + nativeEffects.user = syncData.nativeResources?.effects ?? []; + } + + for (let index = 0; index < toUpdate.length; index++) { + const entry = toUpdate[index]; + if (!entry || !nativeOnly.has(`${entry.scope}:${entry.spec}`)) continue; + const effects = (nativeEffects[entry.scope] ?? []).filter((effect) => + nativeIdentityMatches( + entry.spec, + effect.requestedIdentity, + effect.resolvedIdentity, + )); + const failure = effects.find( + (effect) => effect.action === 'failed' || effect.action === 'unknown', + ); + results[index] = failure + ? { + plugin: entry.spec, + success: false, + action: 'failed', + error: + failure.error ?? + `Native ${failure.phase} did not establish a known result`, + } + : effects.some((effect) => effect.changed) + ? { + plugin: entry.spec, + success: true, + action: 'updated', + } + : effects.length > 0 + ? { + plugin: entry.spec, + success: true, + action: 'skipped', + } + : { + plugin: entry.spec, + success: false, + action: 'failed', + error: 'Native update produced no matching lifecycle effect', + }; } + for (const result of results) { + if (isJsonMode()) continue; + const icon = result.success + ? result.action === 'updated' + ? '\u2713' + : '-' + : '\u2717'; + console.log(`${icon} ${result.plugin} (${result.action})`); + if (result.error) console.log(` Error: ${result.error}`); + } + const updated = results.filter((result) => result.action === 'updated').length; + const skipped = results.filter((result) => result.action === 'skipped').length; + const failed = results.filter((result) => result.action === 'failed').length; + if (isJsonMode()) { jsonOutput({ success: failed === 0 && syncOk, @@ -1523,9 +1807,14 @@ const pluginUpdateCmd = command({ updated, skipped, failed, - ...(syncData && { syncResult: syncData }), + ...(Object.keys(syncResults).length > 0 && { syncResults }), }, - ...(failed > 0 && { error: `${failed} plugin(s) failed to update` }), + ...((failed > 0 || !syncOk) && { + error: + failed > 0 + ? `${failed} plugin(s) failed to update` + : 'Native update or sync failed', + }), }); if (failed > 0 || !syncOk) { process.exit(1); diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index 666b8149..c4091f8b 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -33,6 +33,7 @@ import { buildSyncData, formatManagedRepoResults, formatMcpResult, + formatNativeEffectData, formatNativeResult, formatPluginArtifacts, formatPluginHeader, @@ -563,29 +564,21 @@ const statusCmd = command({ try { const result = await getWorkspaceStatus(); - if (!result.success) { - if (isJsonMode()) { - jsonOutput({ - success: false, - command: 'workspace status', - error: result.error ?? 'Unknown error', - }); - process.exit(1); - } - console.error(`Error: ${result.error}`); - process.exit(1); - } - if (isJsonMode()) { jsonOutput({ - success: true, + success: result.success, command: 'workspace status', data: { plugins: result.plugins, userPlugins: result.userPlugins ?? [], clients: result.clients, + nativeResources: result.nativeResources, }, + ...(!result.success && { + error: result.error ?? 'Native inspection failed', + }), }); + if (!result.success) process.exit(1); return; } @@ -611,6 +604,15 @@ const statusCmd = command({ } } + if (result.nativeResources.length > 0) { + console.log(`\nNative Resources (${result.nativeResources.length}):`); + for (const nativeResource of result.nativeResources) { + console.log( + `${formatNativeEffectData(nativeResource)} declared=${String(nativeResource.declared)} ownership=${nativeResource.ownership}${nativeResource.transition ? ` transition=${nativeResource.transition}` : ''}`, + ); + } + } + // Display clients console.log(`\nClients (${result.clients.length}):`); if (result.clients.length === 0) { @@ -618,6 +620,11 @@ const statusCmd = command({ } else { console.log(` ${result.clients.join(', ')}`); } + + if (!result.success) { + console.error(`Error: ${result.error ?? 'Native inspection failed'}`); + process.exit(1); + } } catch (error) { if (error instanceof Error) { if (isJsonMode()) { diff --git a/src/cli/format-sync.ts b/src/cli/format-sync.ts index 2dacc68b..a3c73c8e 100644 --- a/src/cli/format-sync.ts +++ b/src/cli/format-sync.ts @@ -1,4 +1,8 @@ -import type { NativeSyncResult } from '../core/native/types.js'; +import { + toNativeEffectData, + type NativeEffectData, + type NativeSyncResult, +} from '../core/native/types.js'; import type { SyncResult, DeletedArtifact, PluginSyncResult } from '../core/sync.js'; import type { CopyResult } from '../core/transform.js'; import type { McpMergeResult } from '../core/vscode-mcp.js'; @@ -92,7 +96,10 @@ export function classifyCopyResults(copyResults: CopyResult[]): Map 0) { - lines.push( - `Marketplaces registered: ${nativeResult.marketplacesAdded.join(', ')}`, - ); - } - - for (const { plugin, client } of nativeResult.pluginsInstalled) { - const cliName = client ? `${client} CLI` : 'native CLI'; - lines.push(` + ${plugin} (installed via ${cliName})`); - } - - for (const { client, plugin, error } of nativeResult.pluginsFailed) { - const provider = client ? `[${client}] ` : ''; - lines.push(` \u2717 ${provider}${plugin}: ${error}`); +function nativeActionIcon(action: NativeEffectData['action']): string { + switch (action) { + case 'registered': + case 'installed': + case 'would-register': + case 'would-install': + return '+'; + case 'updated': + case 'would-update': + return '\u2191'; + case 'removed': + case 'would-remove': + return '-'; + case 'failed': + return '\u2717'; + case 'unknown': + return '?'; + case 'configured-missing': + case 'disabled': + case 'unusable': + case 'retained': + return '!'; + case 'unchanged': + return '='; } +} - for (const plugin of nativeResult.skipped) { - lines.push(` \u2298 ${plugin} (skipped \u2014 not a marketplace plugin)`); - } +export function formatNativeEffectData(data: NativeEffectData): string { + const provider = `[${data.client}:${data.scope}]`; + const details = [ + `kind=${data.kind}`, + `requested=${JSON.stringify(data.requestedIdentity)}`, + `resolved=${JSON.stringify(data.resolvedIdentity)}`, + `root=${JSON.stringify(data.root)}`, + `action=${data.action}`, + `phase=${data.phase}`, + `changed=${String(data.changed)}`, + ].join(' '); + return ` ${nativeActionIcon(data.action)} ${provider} ${details}${data.error ? ` error=${JSON.stringify(data.error)}` : ''}`; +} - return lines; +/** + * Format typed native lifecycle results. Human and JSON rendering both consume + * toNativeEffectData so identity, scope, phase, root, and sanitized failures + * cannot drift between output modes. + */ +export function formatNativeResult(nativeResult: NativeSyncResult): string[] { + return nativeResult.effects.map((effect) => + formatNativeEffectData(toNativeEffectData(effect))); } /** @@ -404,11 +433,9 @@ export function buildSyncData(result: SyncResult) { ), }), ...(result.nativeResult && { - nativePlugins: { - installed: result.nativeResult.pluginsInstalled.map((p) => p.plugin), - failed: result.nativeResult.pluginsFailed, - skipped: result.nativeResult.skipped, - marketplacesAdded: result.nativeResult.marketplacesAdded, + nativeResources: { + success: result.nativeResult.success, + effects: result.nativeResult.effects.map(toNativeEffectData), }, }), ...(result.managedRepoResults && result.managedRepoResults.length > 0 && { diff --git a/src/cli/metadata/plugin.ts b/src/cli/metadata/plugin.ts index 14eb41f8..838727fc 100644 --- a/src/cli/metadata/plugin.ts +++ b/src/cli/metadata/plugin.ts @@ -102,13 +102,14 @@ export const marketplaceBrowseMeta: AgentCommandMeta = { export const pluginListMeta: AgentCommandMeta = { command: 'plugin list', - description: 'List installed plugins and standalone skills', - whenToUse: 'To see which plugins and skills are currently installed in your workspace', + description: 'List declared plugins with durable ownership and live state', + whenToUse: + 'To distinguish configured files and Pi/OMP native resources from what is installed, disabled, missing, retained, or uncertain in each scope', examples: [ 'allagents plugin list', ], expectedOutput: - 'Lists marketplace specs and friendly direct-plugin names with type, scope, clients, and a compact direct source. JSON preserves each raw spec. If none are installed, suggests marketplace browse.', + 'Merges declarations, AllAgents ownership/provenance, and exact live native observation. Native inspection failures retain partial results and exit 1.', outputSchema: { plugins: [{ name: 'string', @@ -118,6 +119,20 @@ export const pluginListMeta: AgentCommandMeta = { kind: 'string', clients: 'string[] | undefined', nativeClients: 'string[] | undefined', + nativeResources: [{ + client: 'string', + scope: 'user | project', + kind: 'plugin | package', + requestedIdentity: 'string', + resolvedIdentity: 'string', + root: 'string', + action: 'string', + phase: 'inspection', + changed: 'boolean', + declared: 'boolean', + ownership: 'managed | referenced | uncertain | none', + error: 'string | undefined', + }], }], total: 'number', }, @@ -145,16 +160,16 @@ export const pluginValidateMeta: AgentCommandMeta = { export const pluginInstallMeta: AgentCommandMeta = { command: 'plugin install', - description: 'Install plugin to workspace (supports plugin@marketplace, GitHub URL, or local path). Use --scope user for user-level install.', - whenToUse: 'To add a new plugin to your workspace (or user-level config with --scope user) and immediately sync it', + description: 'Install a file plugin or ordinary Pi/OMP native resource. Use --scope user for user-level install.', + whenToUse: + 'To add a plugin declaration and sync it after native source, runtime, scope, trust, and live inventory preflight succeeds', examples: [ 'allagents plugin install my-plugin@official', - 'allagents plugin install https://github.com/user/plugin', - 'allagents plugin install ../local-plugin', + 'allagents plugin install npm:pi-extension --scope user', 'allagents plugin install my-plugin@official --scope user', ], expectedOutput: - 'Confirms the plugin was added, then runs sync. Shows sync results. Exit 0 on success, exit 1 on failure.', + 'Fails before declaration edits or fetching when native preflight is invalid; otherwise reports the declaration and typed file/native sync outcomes.', positionals: [ { name: 'plugin', type: 'string', required: true, description: 'Plugin identifier (plugin@marketplace, GitHub URL, or local path)' }, ], @@ -179,15 +194,16 @@ export const pluginInstallMeta: AgentCommandMeta = { export const pluginUninstallMeta: AgentCommandMeta = { command: 'plugin uninstall', - description: 'Uninstall plugin from workspace config. Use --scope user for user-level uninstall.', - whenToUse: 'To remove a plugin from your workspace (or user-level config with --scope user) and re-sync', + description: 'Remove a declaration and safely reconcile its scoped resources', + whenToUse: + 'To remove a project/user declaration or retry retained native cleanup after the declaration is already absent', examples: [ 'allagents plugin uninstall my-plugin@official', - 'allagents plugin uninstall https://github.com/user/plugin', + 'allagents plugin uninstall npm:pi-extension --scope user', 'allagents plugin uninstall my-plugin@official --scope user', ], expectedOutput: - 'Confirms the plugin was removed, then runs sync to clean up. Exit 0 on success, exit 1 on failure.', + 'Reports declaration removal separately, removes only corroborated AllAgents-managed native resources in the selected scope, retains referenced/uncertain resources, and exits 1 on failed or unknown cleanup.', positionals: [ { name: 'plugin', type: 'string', required: true, description: 'Plugin identifier to uninstall' }, ], @@ -196,28 +212,32 @@ export const pluginUninstallMeta: AgentCommandMeta = { ], outputSchema: { plugin: 'string', - scope: 'string', - syncResult: { - copied: 'number', - generated: 'number', - failed: 'number', - skipped: 'number', - plugins: [{ plugin: 'string', success: 'boolean', copied: 'number', generated: 'number', failed: 'number' }], + scopes: ['user | project'], + declarations: [{ + scope: 'user | project', + action: 'removed | absent | failed', + error: 'string | undefined', + }], + syncResults: { + project: 'sync result | undefined', + user: 'sync result | undefined', }, }, }; export const pluginUpdateMeta: AgentCommandMeta = { command: 'plugin update', - description: 'Update installed plugins to latest version and sync plugin files (skips AGENTS.md)', - whenToUse: 'To pull the latest changes for installed plugins and deploy plugin files only, without regenerating AGENTS.md', + description: 'Update only selected file and ordinary native resources', + whenToUse: + 'To update one declared plugin or all selected-scope plugins while targeting each Pi/OMP adapter by exact identity and scope', examples: [ 'allagents plugin update', 'allagents plugin update my-plugin@official', - 'allagents plugin update --scope user', + 'allagents plugin update npm:pi-extension --scope user', + 'allagents plugin update --scope all', ], expectedOutput: - 'Shows update status per plugin, then syncs plugin files. Exit 0 if all succeed, exit 1 if any fail.', + 'Preflights native targets before generic fetching, updates only requested identities/scopes, reports per-scope sync/native outcomes, and exits 1 if any pass fails.', positionals: [ { name: 'plugin', type: 'string', required: false, description: 'Specific plugin to update (updates all if omitted)' }, ], @@ -229,11 +249,9 @@ export const pluginUpdateMeta: AgentCommandMeta = { updated: 'number', skipped: 'number', failed: 'number', - syncResult: { - copied: 'number', - generated: 'number', - failed: 'number', - skipped: 'number', + syncResults: { + project: 'sync result | undefined', + user: 'sync result | undefined', }, }, }; diff --git a/src/cli/metadata/workspace.ts b/src/cli/metadata/workspace.ts index c30eecc2..fc964c45 100644 --- a/src/cli/metadata/workspace.ts +++ b/src/cli/metadata/workspace.ts @@ -78,8 +78,9 @@ export const setupMeta: AgentCommandMeta = { export const syncMeta: AgentCommandMeta = { command: 'update', - description: 'Update plugins in workspace', - whenToUse: 'After modifying workspace.yaml or pulling shared config changes', + description: 'Reconcile ordinary file and native plugin resources', + whenToUse: + 'After modifying workspace.yaml or pulling shared config changes, including Pi packages or OMP plugins declared with native install mode', examples: [ 'allagents update', 'allagents update --dry-run', @@ -87,7 +88,7 @@ export const syncMeta: AgentCommandMeta = { 'allagents update --verbose', ], expectedOutput: - 'Lists synced files with status per plugin. Exit 0 on success, exit 1 if any files failed.', + 'Attempts user and project scopes independently, lists file changes and typed Pi/OMP native outcomes, and exits 1 if any required native or file action fails.', options: [ { flag: '--offline', @@ -121,6 +122,22 @@ export const syncMeta: AgentCommandMeta = { failed: 'number', }, ], + nativeResources: { + success: 'boolean', + effects: [{ + client: 'string', + scope: 'user | project', + nativeScope: 'string', + kind: 'plugin | package', + requestedIdentity: 'string', + resolvedIdentity: 'string', + root: 'string', + action: 'string', + phase: 'string', + changed: 'boolean', + error: 'string | undefined', + }], + }, }, }; @@ -140,12 +157,12 @@ export const pruneMeta: AgentCommandMeta = { export const statusMeta: AgentCommandMeta = { command: 'status', - description: 'Show sync status of plugins', + description: 'Show declared, managed, and live plugin status', whenToUse: - 'To check which plugins and skills are configured and whether they are available locally', + 'To compare workspace declarations and AllAgents ownership with exact live Pi/OMP and file-resource state', examples: ['allagents status', 'allagents workspace status'], expectedOutput: - 'Lists all configured plugins/skills with availability status and configured clients. Exit 0 on success, exit 1 if workspace is not initialized.', + 'Lists configured files plus native client/scope identities as installed, configured-missing, disabled, unusable, or unknown. Native inspection failures exit 1 without hiding other scope outcomes.', outputSchema: { plugins: [ { @@ -156,5 +173,20 @@ export const statusMeta: AgentCommandMeta = { }, ], clients: ['string'], + nativeResources: [{ + client: 'string', + scope: 'user | project', + kind: 'plugin | package', + requestedIdentity: 'string', + resolvedIdentity: 'string', + root: 'string', + action: 'installed | configured-missing | disabled | unusable | unknown', + phase: 'inspection', + changed: 'boolean', + declared: 'boolean', + ownership: 'managed | referenced | uncertain | none', + transition: 'string | undefined', + error: 'string | undefined', + }], }, }; diff --git a/src/cli/tui/actions/clients.ts b/src/cli/tui/actions/clients.ts index 84e7887a..6475f266 100644 --- a/src/cli/tui/actions/clients.ts +++ b/src/cli/tui/actions/clients.ts @@ -51,16 +51,19 @@ export async function runManageClients(context: TuiContext, cache?: TuiCache): P const allClients = ClientTypeSchema.options; const options = buildClientOptions(); - const selectedClients = await autocompleteMultiselect({ + const selected = await autocompleteMultiselect({ message: `Select AI clients [${scope}]`, options, - initialValues: currentClients.filter((c): c is ClientType => (allClients as readonly string[]).includes(c)), + initialValues: currentClients.filter((client) => + (allClients as readonly string[]).includes(client), + ), required: false, }); - if (p.isCancel(selectedClients)) { + if (p.isCancel(selected)) { return; } + const selectedClients = selected as ClientType[]; // Check if anything changed const sortedCurrent = [...currentClients].sort(); diff --git a/src/cli/tui/prompt-clients.ts b/src/cli/tui/prompt-clients.ts index a83e7ce1..65b036e2 100644 --- a/src/cli/tui/prompt-clients.ts +++ b/src/cli/tui/prompt-clients.ts @@ -8,11 +8,15 @@ const { autocompleteMultiselect } = p; * Build a flat options list for searchable client selection. * Each option includes the skills path as a hint. */ -export function buildClientOptions(): { value: ClientType; label: string; hint?: string }[] { - return ClientTypeSchema.options.map((c) => ({ - value: c, - label: c, - hint: CLIENT_MAPPINGS[c].skillsPath, +export function buildClientOptions(): { + value: string; + label: string; + hint: string; +}[] { + return ClientTypeSchema.options.map((client) => ({ + value: client, + label: client, + hint: CLIENT_MAPPINGS[client].skillsPath, })); } diff --git a/src/core/claude-native.ts b/src/core/claude-native.ts deleted file mode 100644 index 974fcb1b..00000000 --- a/src/core/claude-native.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Re-export shim for backward compatibility. - * The implementation has moved to src/core/native/claude.ts. - */ -import { ClaudeNativeClient } from './native/claude.js'; -export type { NativeSyncResult, NativeCommandResult } from './native/types.js'; - -const _client = new ClaudeNativeClient(); - -export const toClaudePluginSpec = (source: string) => _client.toPluginSpec(source); -export const extractMarketplaceSource = (spec: string) => _client.extractMarketplaceSource(spec); -export const isClaudeCliAvailable = () => _client.isAvailable(); -export const addMarketplace = (source: string, options?: { cwd?: string }) => _client.addMarketplace(source, options); -export const installPlugin = (spec: string, scope: 'user' | 'project' = 'project', options?: { cwd?: string }) => _client.installPlugin(spec, scope, options); -export const uninstallPlugin = (spec: string, scope: 'user' | 'project' = 'project', options?: { cwd?: string }) => _client.uninstallPlugin(spec, scope, options); -export const syncNativePlugins = (plugins: string[], scope: 'user' | 'project' = 'project', options?: { cwd?: string; dryRun?: boolean }) => _client.syncPlugins(plugins, scope, options); - -export type { ClaudeNativeClient }; diff --git a/src/core/client-context.ts b/src/core/client-context.ts new file mode 100644 index 00000000..05dd5212 --- /dev/null +++ b/src/core/client-context.ts @@ -0,0 +1,467 @@ +import { existsSync } from 'node:fs'; +import { lstat, realpath } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { getHomeDir } from '../constants.js'; +import { + CLIENT_MAPPINGS, + USER_CLIENT_MAPPINGS, + type ClientMapping, +} from '../models/client-mapping.js'; +import type { ClientType } from '../models/workspace-config.js'; + +export type ClientScope = 'project' | 'user'; + +export interface ClientResolutionOptions { + homeDir?: string; + cwd?: string; + env?: Readonly>; + platform?: NodeJS.Platform; + pathExists?: (path: string) => boolean; + /** Explicit project boundary. `null` means no repository boundary. */ + repoRoot?: string | null; +} + +export interface OmpResolvedRoots { + config: string; + agent: string; + data: string; + state: string; + cache: string; + dataAgent: string; + stateAgent: string; + cacheAgent: string; +} + +export interface ResolvedClientContext { + client: ClientType; + scope: ClientScope; + /** Root that owns materialized files and bounds cleanup. */ + writeRoot: string; + /** Concrete mapping used by transforms and sync state. */ + mapping: ClientMapping; + /** Ordered, concrete directories inspected for repository skills. */ + skillDiscoveryRoots: readonly string[]; + commandCwd: string; + /** Environment for ordinary client commands. OMP profile selectors are absent. */ + commandEnv: Readonly>; + ompRoots?: OmpResolvedRoots; +} + +const MAPPING_PATH_KEYS = [ + 'commandsPath', + 'skillsPath', + 'agentsPath', + 'hooksPath', + 'githubPath', +] as const satisfies readonly (keyof ClientMapping)[]; + +function expandHome(input: string, homeDir: string): string { + if (input === '~') return homeDir; + if (input.startsWith('~/') || input.startsWith('~\\')) { + return join(homeDir, input.slice(2)); + } + return input; +} + +function resolveRuntimePath(input: string, cwd: string, homeDir: string): string { + return resolve(cwd, expandHome(input, homeDir)); +} + +export function pathIsWithin(root: string, candidate: string): boolean { + const rel = relative(resolve(root), resolve(candidate)); + return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)); +} + +/** Resolve a mapping path without allowing an absolute mapping to be rebased. */ +export function resolveMappedPath(base: string, mappedPath: string): string { + return resolve(base, mappedPath); +} + +function withDirectorySuffix(path: string, directory: boolean): string { + if (!directory || path.endsWith('/') || path.endsWith('\\')) return path; + return `${path}/`; +} + +function representDestination( + operationRoot: string, + destination: string, + directory: boolean, +): string { + const absoluteRoot = resolve(operationRoot); + const absoluteDestination = resolve(destination); + const represented = pathIsWithin(absoluteRoot, absoluteDestination) + ? relative(absoluteRoot, absoluteDestination) || '.' + : absoluteDestination; + return withDirectorySuffix(represented.replaceAll('\\', '/'), directory); +} + +function relocateMapping( + mapping: ClientMapping, + operationRoot: string, + fromRoot: string, + toRoot: string, +): ClientMapping { + const relocate = (value: string, directory: boolean): string => + representDestination( + operationRoot, + join(toRoot, relative(fromRoot, resolveMappedPath(operationRoot, value))), + directory, + ); + const relocated: ClientMapping = { + skillsPath: relocate(mapping.skillsPath, true), + agentFile: relocate(mapping.agentFile, false), + }; + + for (const key of MAPPING_PATH_KEYS) { + if (key === 'skillsPath') continue; + const value = mapping[key]; + if (!value) continue; + relocated[key] = relocate(value, true); + } + + if (mapping.agentFileFallback) { + relocated.agentFileFallback = relocate(mapping.agentFileFallback, false); + } + return relocated; +} + +/** + * Fail closed when a destination escapes its selected root or any existing + * component below that root is a symlink. Call immediately before mutation. + */ +export async function assertSafeDestination( + writeRoot: string, + destination: string, + options: { allowFinalSymlink?: boolean } = {}, +): Promise { + const root = resolve(writeRoot); + const candidate = resolve(destination); + if (!pathIsWithin(root, candidate)) { + throw new Error(`Destination escapes selected write root: ${candidate}`); + } + + let canonicalRoot: string; + try { + canonicalRoot = await realpath(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + canonicalRoot = root; + } + + const relativeDestination = relative(root, candidate); + let current = root; + let deepestExisting = root; + for (const segment of relativeDestination.split(sep).filter(Boolean)) { + current = join(current, segment); + try { + const stats = await lstat(current); + if (stats.isSymbolicLink()) { + if (current === candidate && options.allowFinalSymlink) break; + throw new Error(`Destination traverses a symbolic link: ${current}`); + } + deepestExisting = current; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') break; + throw error; + } + } + + let canonicalExisting: string; + try { + canonicalExisting = await realpath(deepestExisting); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + canonicalExisting = deepestExisting; + } + if (!pathIsWithin(canonicalRoot, canonicalExisting)) { + throw new Error(`Destination resolves outside selected write root: ${candidate}`); + } +} + +function findRepositoryRoot( + cwd: string, + pathExists: (path: string) => boolean, +): string | null { + let current = resolve(cwd); + while (true) { + if (pathExists(join(current, '.git'))) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function ancestorDirectories( + cwd: string, + stopAt: string | null, +): string[] { + const start = resolve(cwd); + const stop = stopAt && pathIsWithin(stopAt, start) ? resolve(stopAt) : null; + const ancestors: string[] = []; + let current = start; + while (true) { + ancestors.push(current); + if (current === stop) break; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return ancestors; +} + +function uniquePaths(paths: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const path of paths) { + const normalized = resolve(path); + if (seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function resolveBoundary( + client: ClientType, + cwd: string, + homeDir: string, + options: ClientResolutionOptions, + pathExists: (path: string) => boolean, +): string | null { + if (options.repoRoot !== undefined) return options.repoRoot; + const repoRoot = findRepositoryRoot(cwd, pathExists); + if (repoRoot) return repoRoot; + if (client === 'omp' && pathIsWithin(homeDir, cwd)) return homeDir; + return null; +} + +function resolvePiContext( + scope: ClientScope, + operationRoot: string, + homeDir: string, + cwd: string, + env: Readonly>, + options: ClientResolutionOptions, + pathExists: (path: string) => boolean, +): ResolvedClientContext { + const defaultAgentRoot = join(homeDir, '.pi', 'agent'); + const agentRoot = env.PI_CODING_AGENT_DIR + ? resolveRuntimePath(env.PI_CODING_AGENT_DIR, cwd, homeDir) + : defaultAgentRoot; + + if (scope === 'user') { + return { + client: 'pi', + scope, + writeRoot: agentRoot, + mapping: relocateMapping( + USER_CLIENT_MAPPINGS.pi, + operationRoot, + defaultAgentRoot, + agentRoot, + ), + skillDiscoveryRoots: uniquePaths([ + join(agentRoot, 'skills'), + join(homeDir, '.agents', 'skills'), + ]), + commandCwd: cwd, + commandEnv: { ...env }, + }; + } + + const boundary = resolveBoundary('pi', cwd, homeDir, options, pathExists); + const ancestors = ancestorDirectories(cwd, boundary); + return { + client: 'pi', + scope, + writeRoot: operationRoot, + mapping: CLIENT_MAPPINGS.pi, + skillDiscoveryRoots: uniquePaths([ + join(cwd, '.pi', 'skills'), + ...ancestors + .filter((dir) => dir !== homeDir) + .map((dir) => join(dir, '.agents', 'skills')), + ]), + commandCwd: cwd, + commandEnv: { ...env }, + }; +} + +function resolveOmpRoots( + homeDir: string, + cwd: string, + env: Readonly>, + platform: NodeJS.Platform, + pathExists: (path: string) => boolean, +): OmpResolvedRoots { + const config = join(homeDir, env.PI_CONFIG_DIR || '.omp'); + const defaultAgent = join(config, 'agent'); + const agent = env.PI_CODING_AGENT_DIR + ? resolve(cwd, env.PI_CODING_AGENT_DIR) + : defaultAgent; + const xdgEnabled = + (platform === 'linux' || platform === 'darwin') && agent === defaultAgent; + + const categoryRoot = (name: 'DATA' | 'STATE' | 'CACHE'): string | undefined => { + if (!xdgEnabled) return undefined; + const value = env[`XDG_${name}_HOME`]; + if (!value) return undefined; + const root = join(value, 'omp'); + return pathExists(root) ? root : undefined; + }; + + const data = categoryRoot('DATA'); + const state = categoryRoot('STATE'); + const cache = categoryRoot('CACHE'); + return { + config, + agent, + data: data ?? config, + state: state ?? config, + cache: cache ?? config, + dataAgent: data ?? agent, + stateAgent: state ?? agent, + cacheAgent: cache ?? agent, + }; +} + +function ordinaryOmpEnvironment( + env: Readonly>, +): Readonly> { + const commandEnv = { ...env }; + commandEnv.OMP_PROFILE = undefined; + commandEnv.PI_PROFILE = undefined; + commandEnv.PI_CONFIG_FILES = undefined; + return commandEnv; +} + +function resolveOmpContext( + scope: ClientScope, + operationRoot: string, + homeDir: string, + cwd: string, + env: Readonly>, + platform: NodeJS.Platform, + options: ClientResolutionOptions, + pathExists: (path: string) => boolean, +): ResolvedClientContext { + const roots = resolveOmpRoots(homeDir, cwd, env, platform, pathExists); + const commandEnv = ordinaryOmpEnvironment(env); + + if (scope === 'user') { + const defaultAgentRoot = join(homeDir, '.omp', 'agent'); + return { + client: 'omp', + scope, + writeRoot: roots.agent, + mapping: relocateMapping( + USER_CLIENT_MAPPINGS.omp, + operationRoot, + defaultAgentRoot, + roots.agent, + ), + skillDiscoveryRoots: uniquePaths([ + join(roots.agent, 'skills'), + join(homeDir, '.agent', 'skills'), + join(homeDir, '.agents', 'skills'), + ]), + commandCwd: cwd, + commandEnv, + ompRoots: roots, + }; + } + + const boundary = resolveBoundary('omp', cwd, homeDir, options, pathExists); + const ancestors = ancestorDirectories(cwd, boundary); + return { + client: 'omp', + scope, + writeRoot: operationRoot, + mapping: CLIENT_MAPPINGS.omp, + skillDiscoveryRoots: uniquePaths([ + ...ancestors.map((dir) => join(dir, '.omp', 'skills')), + ...ancestors + .filter((dir) => dir !== homeDir) + .flatMap((dir) => [ + join(dir, '.agent', 'skills'), + join(dir, '.agents', 'skills'), + ]), + ]), + commandCwd: cwd, + commandEnv, + ompRoots: roots, + }; +} + +export function resolveClientContext( + client: ClientType, + scope: ClientScope, + options: ClientResolutionOptions = {}, +): ResolvedClientContext { + const homeDir = resolve(options.homeDir ?? getHomeDir()); + const cwd = resolve(options.cwd ?? process.cwd()); + const operationRoot = scope === 'user' ? homeDir : cwd; + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const pathExists = options.pathExists ?? existsSync; + + if (client === 'pi') { + return resolvePiContext( + scope, + operationRoot, + homeDir, + cwd, + env, + options, + pathExists, + ); + } + if (client === 'omp') { + return resolveOmpContext( + scope, + operationRoot, + homeDir, + cwd, + env, + platform, + options, + pathExists, + ); + } + + const mapping = scope === 'user' + ? USER_CLIENT_MAPPINGS[client] + : CLIENT_MAPPINGS[client]; + return { + client, + scope, + writeRoot: operationRoot, + mapping, + skillDiscoveryRoots: [resolveMappedPath(operationRoot, mapping.skillsPath)], + commandCwd: cwd, + commandEnv: env, + }; +} + +export function resolveClientContexts( + clients: readonly ClientType[], + scope: ClientScope, + options: ClientResolutionOptions = {}, +): Map { + return new Map( + clients.map((client) => [client, resolveClientContext(client, scope, options)]), + ); +} + +export function clientMappingsFromContexts( + contexts: ReadonlyMap, + fallback: Record, +): Record { + if (contexts.size === 0) return fallback; + return { + ...fallback, + ...Object.fromEntries( + [...contexts].map(([client, context]) => [client, context.mapping]), + ), + }; +} diff --git a/src/core/native/claude.ts b/src/core/native/claude.ts index f8c66870..fddfba97 100644 --- a/src/core/native/claude.ts +++ b/src/core/native/claude.ts @@ -1,8 +1,72 @@ -import { executeCommand, type NativeClient, type NativeCommandResult, type NativeSyncResult } from './types.js'; +import { + executeCommand, + type NativeClient, + type NativeCommandOptions, + type NativeInspectionResult, + type NativeMutationResult, + type NativeOperationContext, + type NativeResource, + type NativeSourceResolution, +} from './types.js'; + +function commandOptions(context: NativeOperationContext): NativeCommandOptions { + return { + ...(context.cwd && { cwd: context.cwd }), + ...(context.env && { env: context.env }), + }; +} + +function commandError(result: { + error?: string; + exitCode?: number | null; + signal?: NodeJS.Signals | null; +}): string { + if (result.error) return result.error; + if (result.signal) return `Claude CLI terminated by ${result.signal}`; + return `Claude CLI exited with code ${result.exitCode ?? 'unknown'}`; +} + +function inventoryEntries(value: unknown): unknown[] | null { + if (Array.isArray(value)) return value; + if (!value || typeof value !== 'object') return null; + const record = value as Record; + for (const key of ['plugins', 'installedPlugins', 'installed_plugins']) { + if (Array.isArray(record[key])) return record[key] as unknown[]; + } + return null; +} + +function entryIdentity( + entry: unknown, + scope: 'user' | 'project', +): string | null { + if (typeof entry === 'string') return entry; + if (!entry || typeof entry !== 'object') return null; + const record = entry as Record; + if ( + typeof record.scope === 'string' && + record.scope !== scope && + !(scope === 'project' && record.scope === 'local') + ) { + return null; + } + for (const key of ['id', 'spec', 'plugin', 'name']) { + if (typeof record[key] === 'string' && record[key].length > 0) { + return record[key]; + } + } + return null; +} export class ClaudeNativeClient implements NativeClient { - async isAvailable(): Promise { - const result = await executeCommand('claude', ['--version']); + readonly client = 'claude'; + + async isAvailable(context?: NativeOperationContext): Promise { + const result = await executeCommand( + 'claude', + ['--version'], + context ? commandOptions(context) : undefined, + ); return result.success; } @@ -36,72 +100,145 @@ export class ClaudeNativeClient implements NativeClient { return null; } - addMarketplace(source: string, options?: { cwd?: string }): Promise { - return executeCommand('claude', ['plugin', 'marketplace', 'add', source], options); - } - - installPlugin(spec: string, scope: 'user' | 'project', options?: { cwd?: string }): Promise { - return executeCommand('claude', ['plugin', 'install', spec, '--scope', scope], options); - } - - uninstallPlugin(spec: string, scope: 'user' | 'project', options?: { cwd?: string }): Promise { - return executeCommand('claude', ['plugin', 'uninstall', spec, '--scope', scope], options); - } - - async syncPlugins( - plugins: string[], - scope: 'user' | 'project' = 'project', - options: { cwd?: string; dryRun?: boolean } = {}, - ): Promise { - const result: NativeSyncResult = { - marketplacesAdded: [], - pluginsInstalled: [], - pluginsFailed: [], - skipped: [], + resolveSource( + source: string, + context: NativeOperationContext, + provenance: Readonly> = {}, + ): NativeSourceResolution { + const spec = this.toPluginSpec(source); + if (!spec) { + return { + success: false, + error: `Claude native install does not support source '${source}'`, + }; + } + return { + success: true, + resource: { + kind: 'plugin', + requestedIdentity: source, + resolvedIdentity: spec, + context, + provenance, + }, }; + } - if (options.dryRun) { - for (const plugin of plugins) { - const spec = this.toPluginSpec(plugin); - if (spec) { - result.pluginsInstalled.push({ plugin: spec }); - } else { - result.skipped.push(plugin); - } - } - return result; + async inspect( + context: NativeOperationContext, + ): Promise { + const result = await executeCommand( + 'claude', + ['plugin', 'list', '--json'], + commandOptions(context), + ); + if (!result.success) { + return { success: false, resources: [], error: commandError(result) }; } - const marketplaceSources = new Set(); - for (const plugin of plugins) { - const source = this.extractMarketplaceSource(plugin); - if (source) marketplaceSources.add(source); + try { + const parsed = result.output ? JSON.parse(result.output) : []; + const entries = inventoryEntries(parsed); + if (!entries) throw new Error('expected a plugin array'); + return { + success: true, + resources: entries.flatMap((entry) => { + const identity = entryIdentity(entry, context.scope); + return identity + ? [{ + kind: 'plugin' as const, + requestedIdentity: identity, + resolvedIdentity: identity, + context, + provenance: {}, + }] + : []; + }), + }; + } catch (error) { + return { + success: false, + resources: [], + error: `Could not parse Claude plugin inventory: ${error instanceof Error ? error.message : String(error)}`, + }; } + } - for (const source of marketplaceSources) { - const addResult = await this.addMarketplace(source, options); - if (addResult.success) { - result.marketplacesAdded.push(source); + async install( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const registrations: string[] = []; + const marketplaceSource = resource.provenance.marketplaceSource; + if (marketplaceSource) { + const registration = await executeCommand( + 'claude', + ['plugin', 'marketplace', 'add', marketplaceSource], + commandOptions(context), + ); + if (!registration.success) { + return { success: false, error: commandError(registration) }; } + registrations.push(marketplaceSource); } + const result = await executeCommand( + 'claude', + [ + 'plugin', + 'install', + resource.resolvedIdentity, + '--scope', + context.nativeScope, + ], + commandOptions(context), + ); + return result.success + ? { success: true, ...(registrations.length > 0 && { registrations }) } + : { + success: false, + error: commandError(result), + ...(registrations.length > 0 && { registrations }), + }; + } - for (const plugin of plugins) { - const spec = this.toPluginSpec(plugin); - if (!spec) { - result.skipped.push(plugin); - continue; - } - const installResult = await this.installPlugin(spec, scope, options); - if (installResult.success) { - result.pluginsInstalled.push({ plugin: spec }); - } else { - result.pluginsFailed.push({ - plugin: spec, - error: installResult.error ?? 'Unknown error', - }); - } - } + async update( + resource: NativeResource, + _current: NativeResource, + context: NativeOperationContext, + ): Promise { + const result = await executeCommand( + 'claude', + [ + 'plugin', + 'update', + resource.resolvedIdentity, + '--scope', + context.nativeScope, + ], + commandOptions(context), + ); + return result.success + ? { success: true } + : { success: false, error: commandError(result) }; + } - return result; + async remove( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const result = await executeCommand( + 'claude', + [ + 'plugin', + 'uninstall', + resource.resolvedIdentity, + '--scope', + context.nativeScope, + ], + commandOptions(context), + ); + return result.success + ? { success: true } + : { success: false, error: commandError(result) }; } } diff --git a/src/core/native/copilot.ts b/src/core/native/copilot.ts index c9c7bcc6..f84e76c8 100644 --- a/src/core/native/copilot.ts +++ b/src/core/native/copilot.ts @@ -1,8 +1,62 @@ -import { executeCommand, type NativeClient, type NativeCommandResult, type NativeSyncResult } from './types.js'; +import { + executeCommand, + type NativeClient, + type NativeCommandOptions, + type NativeInspectionResult, + type NativeMutationResult, + type NativeOperationContext, + type NativeResource, + type NativeSourceResolution, +} from './types.js'; + +function commandOptions(context: NativeOperationContext): NativeCommandOptions { + return { + ...(context.cwd && { cwd: context.cwd }), + ...(context.env && { env: context.env }), + }; +} + +function commandError(result: { + error?: string; + exitCode?: number | null; + signal?: NodeJS.Signals | null; +}): string { + if (result.error) return result.error; + if (result.signal) return `Copilot CLI terminated by ${result.signal}`; + return `Copilot CLI exited with code ${result.exitCode ?? 'unknown'}`; +} + +function inventoryIdentities(value: unknown): string[] | null { + const record = + value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; + const entries = Array.isArray(value) + ? value + : (record?.plugins ?? record?.installedPlugins ?? record?.installed_plugins); + if (!Array.isArray(entries)) return null; + return entries.flatMap((entry) => { + if (typeof entry === 'string') return [entry]; + if (!entry || typeof entry !== 'object') return []; + const plugin = entry as Record; + for (const key of ['id', 'spec', 'plugin', 'name']) { + if (typeof plugin[key] === 'string' && plugin[key].length > 0) { + return [plugin[key]]; + } + } + return []; + }); +} export class CopilotNativeClient implements NativeClient { - async isAvailable(): Promise { - const result = await executeCommand('copilot', ['--version']); + readonly client = 'copilot'; + + async isAvailable(context?: NativeOperationContext): Promise { + const result = await executeCommand( + 'copilot', + ['--version'], + context ? commandOptions(context) : undefined, + ); return result.success; } @@ -15,17 +69,11 @@ export class CopilotNativeClient implements NativeClient { if (atIndex <= 0 || atIndex === allagentsSource.length - 1) return null; const marketplacePart = allagentsSource.slice(atIndex + 1); - - // Must have a marketplace part (not a URL) if (marketplacePart.includes('://')) return null; - - // Validate non-empty marketplace name if (marketplacePart.includes('/')) { const parts = marketplacePart.split('/'); - if (!parts[1]) return null; // trailing slash + if (!parts[1]) return null; } - - // Keep the full source as-is (copilot uses owner/repo format) return allagentsSource; } @@ -39,78 +87,130 @@ export class CopilotNativeClient implements NativeClient { return null; } - addMarketplace(source: string, options?: { cwd?: string }): Promise { - return executeCommand('copilot', ['plugin', 'marketplace', 'add', source], options); - } - - installPlugin(spec: string, _scope: 'user' | 'project', options?: { cwd?: string }): Promise { - // Copilot has no scope flag — plugins install globally - return executeCommand('copilot', ['plugin', 'install', spec], options); - } - - uninstallPlugin(spec: string, _scope: 'user' | 'project', options?: { cwd?: string }): Promise { - // Copilot has no scope flag — plugins uninstall globally - return executeCommand('copilot', ['plugin', 'uninstall', spec], options); - } - - async syncPlugins( - plugins: string[], - scope: 'user' | 'project' = 'user', - options: { cwd?: string; dryRun?: boolean } = {}, - ): Promise { - const result: NativeSyncResult = { - marketplacesAdded: [], - pluginsInstalled: [], - pluginsFailed: [], - skipped: [], + resolveSource( + source: string, + context: NativeOperationContext, + provenance: Readonly> = {}, + ): NativeSourceResolution { + const spec = this.toPluginSpec(source); + if (!spec) { + return { + success: false, + error: `Copilot native install does not support source '${source}'`, + }; + } + return { + success: true, + resource: { + kind: 'plugin', + requestedIdentity: source, + resolvedIdentity: spec, + context, + provenance, + }, }; + } - if (options.dryRun) { - for (const plugin of plugins) { - const spec = this.toPluginSpec(plugin); - if (spec) { - result.pluginsInstalled.push({ plugin: spec }); - } else { - result.skipped.push(plugin); - } - } - return result; + async inspect( + context: NativeOperationContext, + ): Promise { + const result = await executeCommand( + 'copilot', + ['plugin', 'list', '--json'], + commandOptions(context), + ); + if (!result.success) { + return { success: false, resources: [], error: commandError(result) }; } - const marketplaceSources = new Set(); - for (const plugin of plugins) { - const source = this.extractMarketplaceSource(plugin); - if (source) marketplaceSources.add(source); + try { + const parsed = result.output ? JSON.parse(result.output) : []; + const identities = inventoryIdentities(parsed); + if (!identities) throw new Error('expected a plugin array'); + return { + success: true, + resources: identities.map((identity) => ({ + kind: 'plugin', + requestedIdentity: identity, + resolvedIdentity: identity, + context, + provenance: {}, + })), + }; + } catch (error) { + return { + success: false, + resources: [], + error: `Could not parse Copilot plugin inventory: ${error instanceof Error ? error.message : String(error)}`, + }; } + } - for (const source of marketplaceSources) { - const addResult = await this.addMarketplace(source, options); - if (addResult.success) { - result.marketplacesAdded.push(source); + async install( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const registrations: string[] = []; + const marketplaceSource = resource.provenance.marketplaceSource; + if (marketplaceSource) { + const registration = await executeCommand( + 'copilot', + ['plugin', 'marketplace', 'add', marketplaceSource], + commandOptions(context), + ); + if (!registration.success) { + return { success: false, error: commandError(registration) }; } + registrations.push(marketplaceSource); } - - for (const plugin of plugins) { - const spec = this.toPluginSpec(plugin); - if (!spec) { - result.skipped.push(plugin); - continue; - } - const installResult = await this.installPlugin(spec, scope, options); - if (installResult.success) { - result.pluginsInstalled.push({ plugin: spec }); - } else { - const rawError = installResult.error ?? 'Unknown error'; - const error = rawError.includes('Plugin path escapes marketplace directory') - ? `${rawError} (Copilot rejected a plugin path from this marketplace manifest. Use file install for copilot to avoid native install for this plugin.)` - : rawError; - result.pluginsFailed.push({ - plugin: spec, - error, - }); - } + const result = await executeCommand( + 'copilot', + ['plugin', 'install', resource.resolvedIdentity], + commandOptions(context), + ); + if (result.success) { + return { + success: true, + ...(registrations.length > 0 && { registrations }), + }; } + const rawError = commandError(result); + const error = rawError.includes('Plugin path escapes marketplace directory') + ? `${rawError} (Copilot rejected a plugin path from this marketplace manifest. Use file install for copilot to avoid native install for this plugin.)` + : rawError; + return { + success: false, + error, + ...(registrations.length > 0 && { registrations }), + }; + } + + async update( + resource: NativeResource, + _current: NativeResource, + context: NativeOperationContext, + ): Promise { + const result = await executeCommand( + 'copilot', + ['plugin', 'update', resource.resolvedIdentity], + commandOptions(context), + ); + return result.success + ? { success: true } + : { success: false, error: commandError(result) }; + } - return result; + async remove( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const result = await executeCommand( + 'copilot', + ['plugin', 'uninstall', resource.resolvedIdentity], + commandOptions(context), + ); + return result.success + ? { success: true } + : { success: false, error: commandError(result) }; } } diff --git a/src/core/native/index.ts b/src/core/native/index.ts index b16eb6ea..922a57e7 100644 --- a/src/core/native/index.ts +++ b/src/core/native/index.ts @@ -1,4 +1,54 @@ -export { type NativeClient, type NativeCommandResult, type NativeSyncResult, executeCommand, mergeNativeSyncResults } from './types.js'; +export { + type NativeClient, + type NativeCommandOptions, + type NativeCommandResult, + type NativeEffect, + type NativeEffectAction, + type NativeEffectData, + type NativeEffectPhase, + type NativeObservationStatus, + type NativeMutationResult, + type NativeOperationContext, + type NativeResource, + type NativeResourceObservation, + type NativeResourceKind, + type NativeScope, + type NativeSourceResolution, + type NativeSyncResult, + executeCommand, + mergeNativeSyncResults, + sanitizeNativeError, + sanitizeNativeProvenance, + toNativeEffectData, +} from './types.js'; export { ClaudeNativeClient } from './claude.js'; export { CopilotNativeClient } from './copilot.js'; +export { + OmpNativeClient, + inspectOmpMarketplaceRegistry, + parseOmpPluginId, + type OmpMarketplaceCatalog, + type OmpMarketplaceCatalogPlugin, + type OmpMarketplaceInspection, + type OmpMarketplaceRegistryEntry, + type OmpNativeClientOptions, +} from './omp.js'; +export { + PiNativeClient, + inspectPiProjectTrust, + normalizePiPackageSource, + readPiSettings, + type PiNativeClientOptions, + type PiNormalizedSource, + type PiPackageEntry, + type PiProjectTrustInspection, + type PiProjectTrustStatus, + type PiSettings, +} from './pi.js'; +export { + inspectPiMcpAdapter, + isPiMcpAdapterSource, + type PiMcpAdapterClassification, + type PiMcpAdapterInspection, +} from './pi-mcp.js'; export { getNativeClient } from './registry.js'; diff --git a/src/core/native/omp.ts b/src/core/native/omp.ts new file mode 100644 index 00000000..cd8e0838 --- /dev/null +++ b/src/core/native/omp.ts @@ -0,0 +1,1111 @@ +import { readFile } from 'node:fs/promises'; +import { isAbsolute, join, resolve, win32 } from 'node:path'; +import { z } from 'zod'; +import { + executeCommand, + type NativeClient, + type NativeCommandOptions, + type NativeCommandResult, + type NativeInspectionResult, + type NativeMutationResult, + type NativeOperationContext, + type NativeResource, + type NativeResourceObservation, + type NativeSourceResolution, +} from './types.js'; + +const OMP_MINIMUM_VERSION = [18, 1, 17] as const; +const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/; +const GITHUB_SHORTHAND_PATTERN = /^([^/\s]+)\/([^/\s]+)$/; +const CONTROL_CHARACTER_PATTERN = /[\0\r\n]/; +const SENSITIVE_QUERY_KEY = + /(?:^|[-_.])(auth|credential|key|password|secret|signature|token)(?:$|[-_.])/i; + +type OmpCommandRunner = ( + binary: string, + args: string[], + options?: NativeCommandOptions, +) => Promise; + +type OmpFileReader = (path: string) => Promise; + +export interface OmpNativeClientOptions { + execute?: OmpCommandRunner; + readFile?: OmpFileReader; +} + +export interface OmpMarketplaceCatalogPlugin { + name: string; + source: unknown; + version?: string; +} + +export interface OmpMarketplaceCatalog { + name: string; + plugins: OmpMarketplaceCatalogPlugin[]; +} + +export interface OmpMarketplaceRegistryEntry { + name: string; + sourceType: 'github' | 'git' | 'url' | 'local'; + sourceUri: string; + sourceIdentity: string; + catalogPath: string; + catalog: OmpMarketplaceCatalog; +} + +export interface OmpMarketplaceInspection { + success: boolean; + registryPath: string; + marketplaces: OmpMarketplaceRegistryEntry[]; + missing: boolean; + error?: string; +} + +interface ParsedPluginId { + id: string; + name: string; + marketplace: string; +} + +interface OmpInventoryEntry { + id: string; + scope: 'user' | 'project'; + installPath: string; + version: string; + enabled: boolean; + shadowedByProject: boolean; +} + +const MarketplacePluginSourceSchema = z.union([ + z.string().refine( + (value) => + value.startsWith('./') && !CONTROL_CHARACTER_PATTERN.test(value), + ), + z.object({ + source: z.literal('github'), + repo: z.string().min(1).refine((value) => githubRepository(value) !== null), + ref: z.string().min(1).optional(), + sha: z.string().min(1).optional(), + }).passthrough(), + z.object({ + source: z.literal('url'), + url: z.string().url().refine(supportedRemoteUrl), + ref: z.string().min(1).optional(), + sha: z.string().min(1).optional(), + }).passthrough(), + z.object({ + source: z.literal('git-subdir'), + url: z.string().min(1).refine(supportedRemoteUrl), + path: z.string().min(1).refine( + (value) => + !absolutePath(value) && + !value.split(/[\\/]/).includes('..') && + !CONTROL_CHARACTER_PATTERN.test(value), + ), + ref: z.string().min(1).optional(), + sha: z.string().min(1).optional(), + }).passthrough(), + z.object({ + source: z.literal('npm'), + package: z.string().min(1), + version: z.string().min(1).optional(), + registry: z.string().min(1).optional(), + }).passthrough(), +]); + +const MarketplaceCatalogSchema = z.object({ + name: z.string(), + owner: z.object({ name: z.string().min(1) }).passthrough(), + plugins: z.array(z.object({ + name: z.string(), + source: MarketplacePluginSourceSchema, + version: z.string().min(1).optional(), + }).passthrough()), +}).passthrough(); + +const MarketplaceRegistrySchema = z.object({ + version: z.literal(1), + marketplaces: z.array(z.object({ + name: z.string(), + sourceType: z.enum(['github', 'git', 'url', 'local']), + sourceUri: z.string().min(1), + catalogPath: z.string().refine(absolutePath), + addedAt: z.string().refine(validTimestamp), + updatedAt: z.string().refine(validTimestamp), + }).passthrough()), +}).passthrough(); + +const PluginInventorySchema = z.object({ + npm: z.array(z.object({}).passthrough()), + marketplace: z.array(z.object({ + id: z.string(), + scope: z.enum(['user', 'project']), + entries: z.array(z.object({ + scope: z.enum(['user', 'project']), + installPath: z.string().refine(absolutePath), + version: z.string().min(1), + installedAt: z.string().refine(validTimestamp), + lastUpdated: z.string().refine(validTimestamp), + enabled: z.boolean().optional(), + gitCommitSha: z.string().optional(), + }).passthrough()), + shadowedBy: z.literal('project').optional(), + }).passthrough()), +}).passthrough(); + +function isName(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length <= 64 && + NAME_PATTERN.test(value) + ); +} + +function absolutePath(path: string): boolean { + return isAbsolute(path) || win32.isAbsolute(path); +} + +function contextCwd(context: NativeOperationContext): string { + return resolve(context.cwd ?? process.cwd()); +} + +function commandOptions(context?: NativeOperationContext): NativeCommandOptions { + return { + ...(context?.cwd && { cwd: contextCwd(context) }), + env: { + ...context?.env, + // Ordinary AllAgents operations always address OMP's unnamed profile. + // Undefined is significant to executeCommand: it removes inherited values. + OMP_PROFILE: undefined, + PI_PROFILE: undefined, + PI_CONFIG_FILES: undefined, + }, + }; +} + +function commandError(result: NativeCommandResult): string { + if (result.error) return result.error; + if (result.signal) return `OMP CLI terminated by ${result.signal}`; + return `OMP CLI exited with code ${result.exitCode ?? 'unknown'}`; +} + +function parseVersion(output: string): [number, number, number] | null { + const match = /^(?:omp\/)?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/.exec( + output.trim(), + ); + if (!match?.[1] || !match[2] || !match[3]) return null; + const version: [number, number, number] = [ + Number(match[1]), + Number(match[2]), + Number(match[3]), + ]; + return version.every(Number.isSafeInteger) ? version : null; +} + +function compareVersion( + left: readonly number[], + right: readonly number[], +): number { + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +export function parseOmpPluginId(value: string): ParsedPluginId | null { + const separator = value.lastIndexOf('@'); + if (separator <= 0 || separator === value.length - 1) return null; + const name = value.slice(0, separator); + const marketplace = value.slice(separator + 1); + if (!isName(name) || !isName(marketplace)) return null; + const id = `${name}@${marketplace}`; + return id.length <= 128 ? { id, name, marketplace } : null; +} + +function parseAllAgentsMarketplaceSpec(value: string): ParsedPluginId | null { + const separator = value.lastIndexOf('@'); + if (separator <= 0 || separator === value.length - 1) return null; + const name = value.slice(0, separator); + const marketplaceSource = value.slice(separator + 1); + if (!isName(name) || CONTROL_CHARACTER_PATTERN.test(marketplaceSource)) { + return null; + } + const parts = marketplaceSource.split('/'); + const marketplace = parts[1]; + if ( + parts.length < 2 || + !parts[0] || + !marketplace || + marketplace === '.' || + marketplace === '..' + ) { + return null; + } + const normalizedMarketplace = marketplace + .replace(/\.git$/i, '') + .toLowerCase(); + if (!isName(normalizedMarketplace)) return null; + const id = `${name}@${normalizedMarketplace}`; + return id.length <= 128 + ? { id, name, marketplace: normalizedMarketplace } + : null; +} + +function expandHome(value: string, context: NativeOperationContext): string { + if (value !== '~' && !value.startsWith('~/') && !value.startsWith('~\\')) { + return value; + } + const home = context.env?.HOME ?? context.env?.USERPROFILE; + return home ? join(home, value.slice(2)) : value; +} + +function normalizedRemoteUrl(value: string): string | null { + try { + const url = new URL(value); + if ( + url.protocol !== 'http:' && + url.protocol !== 'https:' && + url.protocol !== 'ssh:' + ) { + return null; + } + if (url.username || url.password) return null; + for (const [key, queryValue] of url.searchParams) { + if (queryValue && SENSITIVE_QUERY_KEY.test(key)) return null; + } + url.hostname = url.hostname.toLowerCase(); + url.searchParams.sort(); + url.hash = ''; + return url.toString().replace(/\/$/, ''); + } catch { + return null; + } +} + +function githubRepository(source: string): string | null { + const trimmed = source.trim(); + const shorthand = GITHUB_SHORTHAND_PATTERN.exec(trimmed); + if (shorthand?.[1] && shorthand[2]) { + return `${shorthand[1]}/${shorthand[2].replace(/\.git$/i, '')}`.toLowerCase(); + } + const ssh = /^git@github\.com:([^/\s]+)\/([^/\s]+)$/i.exec(trimmed); + if (ssh?.[1] && ssh[2]) { + return `${ssh[1]}/${ssh[2].replace(/\.git$/i, '')}`.toLowerCase(); + } + const normalized = normalizedRemoteUrl(trimmed); + if (!normalized) return null; + const url = new URL(normalized); + if (url.hostname !== 'github.com' || url.search) return null; + const segments = url.pathname.replace(/^\/+|\/+$/g, '').split('/'); + if (!segments[0] || !segments[1] || segments.length !== 2) return null; + return `${segments[0]}/${segments[1].replace(/\.git$/i, '')}`.toLowerCase(); +} + +function supportedRemoteUrl(value: string): boolean { + return normalizedRemoteUrl(value) !== null; +} + +function normalizeMarketplaceSource( + source: string, + sourceType: OmpMarketplaceRegistryEntry['sourceType'] | undefined, + context: NativeOperationContext, +): { identity: string; source: string } | null { + const trimmed = source.trim(); + if (!trimmed || CONTROL_CHARACTER_PATTERN.test(trimmed)) return null; + + const github = githubRepository(trimmed); + if (github) return { identity: `github:${github}`, source: github }; + + const looksLocal = + sourceType === 'local' || + trimmed === '~' || + trimmed.startsWith('~/') || + trimmed.startsWith('~\\') || + trimmed.startsWith('./') || + trimmed.startsWith('../') || + absolutePath(trimmed); + if (looksLocal) { + const expanded = expandHome(trimmed, context); + if (expanded.startsWith('~')) return null; + const local = resolve(contextCwd(context), expanded); + return { identity: `local:${local}`, source: local }; + } + + const remote = normalizedRemoteUrl(trimmed); + if (remote) return { identity: `remote:${remote}`, source: remote }; + if (/^(?:git@|[^\s]+:[^\s]+$)/.test(trimmed)) { + return { identity: `git:${trimmed}`, source: trimmed }; + } + return sourceType === 'github' + ? null + : { identity: `remote:${trimmed}`, source: trimmed }; +} + +function marketplaceSourceIdentity( + source: string, + sourceType: OmpMarketplaceRegistryEntry['sourceType'] | undefined, + context: NativeOperationContext, +): string | null { + return normalizeMarketplaceSource(source, sourceType, context)?.identity ?? null; +} + +function validTimestamp(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !Number.isNaN(Date.parse(value)); +} + +function parseCatalog( + value: unknown, + path: string, +): { catalog?: OmpMarketplaceCatalog; error?: string } { + const parsed = MarketplaceCatalogSchema.safeParse(value); + if (!parsed.success || !isName(parsed.data.name)) { + return { error: `OMP marketplace catalog is malformed: ${path}` }; + } + + const names = new Set(); + const plugins: OmpMarketplaceCatalogPlugin[] = []; + for (const raw of parsed.data.plugins) { + if (!isName(raw.name)) { + return { error: `OMP marketplace catalog contains an invalid plugin name: ${path}` }; + } + if (names.has(raw.name)) { + return { error: `OMP marketplace catalog contains duplicate plugin '${raw.name}': ${path}` }; + } + names.add(raw.name); + plugins.push({ + name: raw.name, + source: raw.source, + ...(raw.version && { version: raw.version }), + }); + } + return { catalog: { name: parsed.data.name, plugins } }; +} + +async function readJson( + path: string, + reader: OmpFileReader, +): Promise<{ value?: unknown; missing: boolean; error?: string }> { + try { + const contents = await reader(path); + if (!contents.trim()) return { missing: false, error: `OMP JSON file is empty: ${path}` }; + return { value: JSON.parse(contents.replace(/^\uFEFF/, '')), missing: false }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { missing: true }; + return { + missing: false, + error: `Could not read OMP JSON file ${path}: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +function marketplaceRegistryPath(context: NativeOperationContext): string | null { + const dataRoot = context.roots?.data; + return dataRoot && absolutePath(dataRoot) ? join(dataRoot, 'marketplaces.json') : null; +} + +export async function inspectOmpMarketplaceRegistry( + context: NativeOperationContext, + options: { readFile?: OmpFileReader; allowMissing?: boolean } = {}, +): Promise { + const registryPath = marketplaceRegistryPath(context); + if (!registryPath) { + return { + success: false, + registryPath: '', + marketplaces: [], + missing: false, + error: 'OMP operation context has no authoritative absolute data root', + }; + } + const reader = options.readFile ?? ((path) => readFile(path, 'utf8')); + const loaded = await readJson(registryPath, reader); + if (loaded.error) { + return { success: false, registryPath, marketplaces: [], missing: false, error: loaded.error }; + } + if (loaded.missing) { + return options.allowMissing + ? { success: true, registryPath, marketplaces: [], missing: true } + : { + success: false, + registryPath, + marketplaces: [], + missing: true, + error: `OMP marketplace registry is missing: ${registryPath}`, + }; + } + const parsedRegistry = MarketplaceRegistrySchema.safeParse(loaded.value); + if (!parsedRegistry.success) { + return { + success: false, + registryPath, + marketplaces: [], + missing: false, + error: `OMP marketplace registry has an unsupported or malformed version: ${registryPath}`, + }; + } + + const names = new Set(); + const sources = new Set(); + const catalogPaths = new Set(); + const marketplaces: OmpMarketplaceRegistryEntry[] = []; + for (const raw of parsedRegistry.data.marketplaces) { + if (!isName(raw.name)) { + return { + success: false, + registryPath, + marketplaces: [], + missing: false, + error: `OMP marketplace registry contains an invalid name: ${registryPath}`, + }; + } + const normalizedSource = normalizeMarketplaceSource( + raw.sourceUri, + raw.sourceType, + context, + ); + if (!normalizedSource) { + return { + success: false, + registryPath, + marketplaces: [], + missing: false, + error: `OMP marketplace '${raw.name}' has an invalid or credential-bearing source identity`, + }; + } + const sourceIdentity = normalizedSource.identity; + if (names.has(raw.name) || sources.has(sourceIdentity) || catalogPaths.has(raw.catalogPath)) { + return { + success: false, + registryPath, + marketplaces: [], + missing: false, + error: `OMP marketplace registry contains an ambiguous identity for '${raw.name}'`, + }; + } + + const catalogFile = await readJson(raw.catalogPath, reader); + if (catalogFile.error || catalogFile.missing) { + return { + success: false, + registryPath, + marketplaces: [], + missing: false, + error: catalogFile.error ?? `OMP marketplace catalog is missing: ${raw.catalogPath}`, + }; + } + const parsedCatalog = parseCatalog(catalogFile.value, raw.catalogPath); + if (!parsedCatalog.catalog) { + return { + success: false, + registryPath, + marketplaces: [], + missing: false, + error: parsedCatalog.error ?? `Malformed OMP marketplace catalog: ${raw.catalogPath}`, + }; + } + if (parsedCatalog.catalog.name !== raw.name) { + return { + success: false, + registryPath, + marketplaces: [], + missing: false, + error: `OMP marketplace registry name '${raw.name}' conflicts with catalog name '${parsedCatalog.catalog.name}'`, + }; + } + + names.add(raw.name); + sources.add(sourceIdentity); + catalogPaths.add(raw.catalogPath); + marketplaces.push({ + name: raw.name, + sourceType: raw.sourceType, + sourceUri: normalizedSource.source, + sourceIdentity, + catalogPath: raw.catalogPath, + catalog: parsedCatalog.catalog, + }); + } + + return { success: true, registryPath, marketplaces, missing: false }; +} + +function parseInventory(output: string): { entries?: OmpInventoryEntry[]; error?: string } { + let value: unknown; + try { + if (!output.trim()) throw new Error('empty output'); + value = JSON.parse(output.replace(/^\uFEFF/, '')); + } catch (error) { + return { error: `OMP plugin inventory is not valid JSON: ${error instanceof Error ? error.message : String(error)}` }; + } + const parsedInventory = PluginInventorySchema.safeParse(value); + if (!parsedInventory.success) { + return { error: 'OMP plugin inventory has an unsupported shape' }; + } + + const entries: OmpInventoryEntry[] = []; + const keys = new Set(); + const installPaths = new Map(); + for (const raw of parsedInventory.data.marketplace) { + const entry = raw.entries[0]; + if (!parseOmpPluginId(raw.id) || raw.entries.length !== 1 || !entry) { + return { error: 'OMP plugin inventory contains a malformed or ambiguous marketplace entry' }; + } + const key = `${raw.scope}:${raw.id}`; + if (keys.has(key)) return { error: `OMP plugin inventory contains duplicate identity '${key}'` }; + keys.add(key); + if (entry.scope !== raw.scope) { + return { error: `OMP plugin inventory contains scope-conflicting details for '${key}'` }; + } + const priorPathOwner = installPaths.get(entry.installPath); + if (priorPathOwner && priorPathOwner !== key) { + return { error: `OMP plugin inventory reuses one install path for '${priorPathOwner}' and '${key}'` }; + } + installPaths.set(entry.installPath, key); + + const shadowedByProject = raw.shadowedBy === 'project'; + if (raw.shadowedBy !== undefined && !shadowedByProject) { + return { error: `OMP plugin inventory has an invalid shadow marker for '${key}'` }; + } + entries.push({ + id: raw.id, + scope: raw.scope, + installPath: entry.installPath, + version: entry.version, + enabled: entry.enabled !== false, + shadowedByProject, + }); + } + + for (const entry of entries) { + if (!entry.shadowedByProject) continue; + const project = entries.find( + (candidate) => candidate.id === entry.id && candidate.scope === 'project', + ); + if (entry.scope !== 'user' || !project?.enabled) { + return { error: `OMP plugin inventory has an uncorroborated shadow marker for '${entry.id}'` }; + } + } + return { entries }; +} + +function desiredPlugin(resource: NativeResource): ParsedPluginId | null { + return parseOmpPluginId(resource.resolvedIdentity); +} + +function validateContext(context: NativeOperationContext): string | null { + if (context.client !== 'omp') return `OMP adapter received context for '${context.client}'`; + if (context.nativeScope !== context.scope) { + return `OMP native scope '${context.nativeScope}' conflicts with AllAgents scope '${context.scope}'`; + } + if (!absolutePath(context.root)) return 'OMP operation context root must be absolute'; + if (!marketplaceRegistryPath(context)) { + return 'OMP operation context has no authoritative absolute data root'; + } + return null; +} + +function validateResourceContext( + resource: NativeResource, + context: NativeOperationContext, +): string | null { + const contextError = validateContext(context); + if (contextError) return contextError; + if (resource.kind !== 'plugin' || !desiredPlugin(resource)) { + return 'OMP mutation requires one valid marketplace plugin identity'; + } + if ( + resource.context.client !== context.client || + resource.context.scope !== context.scope || + resource.context.nativeScope !== context.nativeScope || + resolve(resource.context.root) !== resolve(context.root) + ) { + return 'OMP resource context does not match the selected operation context'; + } + return null; +} + +function marketplaceForResource( + resource: NativeResource, + registry: OmpMarketplaceInspection, + context: NativeOperationContext, +): { + marketplace?: OmpMarketplaceRegistryEntry; + missing?: boolean; + error?: string; +} { + const plugin = desiredPlugin(resource); + if (!plugin) return { error: 'OMP resource has no valid plugin identity' }; + const marketplace = registry.marketplaces.find( + (candidate) => candidate.name === plugin.marketplace, + ); + const requestedSource = resource.provenance.marketplaceSource; + const requestedSourceIdentity = requestedSource + ? marketplaceSourceIdentity(requestedSource, undefined, context) + : null; + if (requestedSource && !requestedSourceIdentity) { + return { error: `OMP marketplace source is invalid: ${requestedSource}` }; + } + if (!marketplace) { + if (requestedSourceIdentity) { + const conflicting = registry.marketplaces.find( + (candidate) => candidate.sourceIdentity === requestedSourceIdentity, + ); + if (conflicting) { + return { + error: `OMP marketplace source is already registered as '${conflicting.name}', not '${plugin.marketplace}'`, + }; + } + } + return { + missing: true, + error: `OMP marketplace '${plugin.marketplace}' is not registered`, + }; + } + if (requestedSourceIdentity && marketplace.sourceIdentity !== requestedSourceIdentity) { + return { + error: `OMP marketplace '${plugin.marketplace}' is registered from a conflicting source`, + }; + } + const matches = marketplace.catalog.plugins.filter( + (candidate) => candidate.name === plugin.name, + ); + if (matches.length !== 1) { + return { + error: `OMP catalog '${plugin.marketplace}' does not resolve exactly one plugin '${plugin.name}'`, + }; + } + return { marketplace }; +} + +export class OmpNativeClient implements NativeClient { + readonly client = 'omp'; + private readonly run: OmpCommandRunner; + private readonly reader: OmpFileReader; + private versionResult: { success: boolean; error?: string } | undefined; + + constructor(options: OmpNativeClientOptions = {}) { + this.run = options.execute ?? executeCommand; + this.reader = options.readFile ?? ((path) => readFile(path, 'utf8')); + } + + private async supportedVersion( + context?: NativeOperationContext, + ): Promise<{ success: boolean; error?: string }> { + if (this.versionResult) return this.versionResult; + const result = await this.run('omp', ['--version'], commandOptions(context)); + if (!result.success) { + this.versionResult = { success: false, error: commandError(result) }; + return this.versionResult; + } + const version = parseVersion(result.output); + if (!version) { + this.versionResult = { + success: false, + error: `Could not parse OMP version: ${JSON.stringify(result.output)}`, + }; + return this.versionResult; + } + if (compareVersion(version, OMP_MINIMUM_VERSION) < 0) { + this.versionResult = { + success: false, + error: `OMP ${version.join('.')} is unsupported; version 18.1.17 or newer is required`, + }; + return this.versionResult; + } + this.versionResult = { success: true }; + return this.versionResult; + } + + async isAvailable(context?: NativeOperationContext): Promise { + return (await this.supportedVersion(context)).success; + } + + supportsScope(_scope: 'user' | 'project'): boolean { + return true; + } + + resolveSource( + source: string, + context: NativeOperationContext, + provenance: Readonly> = {}, + ): NativeSourceResolution { + const plugin = parseOmpPluginId(source) ?? parseAllAgentsMarketplaceSpec(source); + if (!plugin) { + return { + success: false, + error: `OMP native install requires a valid plugin@marketplace source, not '${source}'`, + }; + } + return { + success: true, + resource: { + kind: 'plugin', + requestedIdentity: source, + resolvedIdentity: plugin.id, + context, + provenance: { + ...provenance, + pluginName: plugin.name, + marketplaceName: plugin.marketplace, + }, + }, + }; + } + + async inspect(context: NativeOperationContext): Promise { + const contextError = validateContext(context); + if (contextError) return { success: false, resources: [], observations: [], error: contextError }; + const supported = await this.supportedVersion(context); + if (!supported.success) { + return { + success: false, + resources: [], + observations: [], + error: supported.error ?? 'OMP version inspection failed', + }; + } + + const result = await this.run( + 'omp', + ['plugin', 'list', '--json'], + commandOptions(context), + ); + if (!result.success) { + return { + success: false, + resources: [], + observations: [], + error: `Could not inspect OMP plugins: ${commandError(result)}`, + }; + } + const inventory = parseInventory(result.output); + if (!inventory.entries) { + return { + success: false, + resources: [], + observations: [], + error: inventory.error ?? 'Could not parse OMP plugin inventory', + }; + } + + const registry = await inspectOmpMarketplaceRegistry(context, { + readFile: this.reader, + allowMissing: inventory.entries.length === 0, + }); + if (!registry.success) { + return { + success: false, + resources: [], + observations: [], + error: registry.error ?? 'Could not inspect OMP marketplaces', + }; + } + + const observations: NativeResourceObservation[] = []; + for (const entry of inventory.entries) { + const parsed = parseOmpPluginId(entry.id); + if (!parsed) { + return { + success: false, + resources: [], + observations, + error: `Installed OMP plugin '${entry.id}' has invalid identity`, + }; + } + const marketplace = registry.marketplaces.find( + (candidate) => candidate.name === parsed.marketplace, + ); + if (!marketplace) { + return { + success: false, + resources: [], + observations, + error: `Installed OMP plugin '${entry.id}' references missing marketplace '${parsed.marketplace}'`, + }; + } + const catalogMatches = marketplace.catalog.plugins.filter( + (candidate) => candidate.name === parsed.name, + ); + const catalogPlugin = catalogMatches[0]; + if (catalogMatches.length !== 1 || !catalogPlugin) { + return { + success: false, + resources: [], + observations, + error: `Installed OMP plugin '${entry.id}' has no unambiguous catalog identity`, + }; + } + const resource: NativeResource = { + kind: 'plugin', + requestedIdentity: entry.id, + resolvedIdentity: entry.id, + context: { ...context, scope: entry.scope, nativeScope: entry.scope }, + provenance: { + pluginName: parsed.name, + marketplaceName: parsed.marketplace, + marketplaceSource: marketplace.sourceUri, + installedVersion: entry.version, + installPath: entry.installPath, + ...(catalogPlugin.version && { + catalogVersion: catalogPlugin.version, + }), + ...(entry.shadowedByProject && { shadowedBy: 'project' }), + }, + }; + observations.push({ + resource, + status: entry.enabled ? 'installed' : 'disabled', + installedPath: entry.installPath, + }); + } + + const selected = observations.filter( + (observation) => observation.resource.context.scope === context.scope, + ); + return { + success: true, + resources: selected + .filter((observation) => observation.status === 'installed') + .map((observation) => observation.resource), + observations: selected, + }; + } + + private async inspectForMutation( + resource: NativeResource, + context: NativeOperationContext, + ): Promise<{ inspection?: NativeInspectionResult; error?: string }> { + const validationError = validateResourceContext(resource, context); + if (validationError) return { error: validationError }; + const inspection = await this.inspect(context); + if (!inspection.success) { + return { error: inspection.error ?? 'Could not inspect OMP native state' }; + } + const disabled = inspection.observations?.find( + (candidate) => + candidate.resource.resolvedIdentity === resource.resolvedIdentity && + candidate.status === 'disabled', + ); + if (disabled) { + return { + error: `OMP plugin '${resource.resolvedIdentity}' is installed but disabled in ${context.scope} scope`, + }; + } + return { inspection }; + } + + private async registry( + context: NativeOperationContext, + allowMissing: boolean, + ): Promise { + return inspectOmpMarketplaceRegistry(context, { + readFile: this.reader, + allowMissing, + }); + } + + async install( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const before = await this.inspectForMutation(resource, context); + if (!before.inspection) { + return { success: false, error: before.error ?? 'OMP preflight failed' }; + } + let registry = await this.registry(context, true); + if (!registry.success) { + return { success: false, error: registry.error ?? 'OMP marketplace inspection failed' }; + } + let resolved = marketplaceForResource(resource, registry, context); + if ( + before.inspection.resources.some( + (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, + ) + ) { + return resolved.marketplace + ? { success: true } + : { + success: false, + error: resolved.error ?? 'OMP marketplace identity is unresolved', + }; + } + + const registrations: string[] = []; + if (!resolved.marketplace) { + const source = resource.provenance.marketplaceSource; + if (!source || !resolved.missing) { + return { + success: false, + error: resolved.error ?? 'OMP marketplace identity is unresolved', + }; + } + const registration = await this.run( + 'omp', + ['plugin', 'marketplace', 'add', source], + commandOptions(context), + ); + if (!registration.success) { + return { + success: false, + error: `Could not register OMP marketplace '${source}': ${commandError(registration)}`, + }; + } + registry = await this.registry(context, false); + if (!registry.success) { + return { + success: false, + error: `OMP marketplace registration completed but could not be verified: ${registry.error ?? 'registry inspection failed'}`, + }; + } + resolved = marketplaceForResource(resource, registry, context); + if (!resolved.marketplace) { + return { + success: false, + error: `OMP marketplace registration completed with an unexpected identity: ${resolved.error ?? 'identity not found'}`, + }; + } + registrations.push(source); + } + + const install = await this.run( + 'omp', + ['plugin', 'install', '--scope', context.scope, resource.resolvedIdentity], + commandOptions(context), + ); + if (!install.success) { + return { + success: false, + error: `Could not install OMP plugin '${resource.resolvedIdentity}': ${commandError(install)}`, + ...(registrations.length > 0 && { registrations }), + }; + } + const after = await this.inspect(context); + if ( + !after.success || + !after.resources.some( + (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, + ) + ) { + return { + success: false, + error: after.error ?? `OMP install completed but '${resource.resolvedIdentity}' is absent from ${context.scope} inventory`, + ...(registrations.length > 0 && { registrations }), + }; + } + return { + success: true, + ...(registrations.length > 0 && { registrations }), + }; + } + + async update( + resource: NativeResource, + current: NativeResource, + context: NativeOperationContext, + ): Promise { + if (resource.resolvedIdentity !== current.resolvedIdentity) { + return { + success: false, + error: `Refusing OMP targeted update from '${current.resolvedIdentity}' to '${resource.resolvedIdentity}'`, + }; + } + const before = await this.inspectForMutation(resource, context); + if (!before.inspection) { + return { success: false, error: before.error ?? 'OMP preflight failed' }; + } + if ( + !before.inspection.resources.some( + (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, + ) + ) { + return { + success: false, + error: `OMP plugin '${resource.resolvedIdentity}' is not installed in ${context.scope} scope`, + }; + } + const registry = await this.registry(context, false); + if (!registry.success) { + return { success: false, error: registry.error ?? 'OMP marketplace inspection failed' }; + } + const resolved = marketplaceForResource(resource, registry, context); + if (!resolved.marketplace) { + return { + success: false, + error: resolved.error ?? 'OMP marketplace identity is unresolved', + }; + } + + const result = await this.run( + 'omp', + ['plugin', 'upgrade', '--scope', context.scope, resource.resolvedIdentity], + commandOptions(context), + ); + if (!result.success) { + return { + success: false, + error: `Could not upgrade OMP plugin '${resource.resolvedIdentity}': ${commandError(result)}`, + }; + } + const after = await this.inspect(context); + return after.success && after.resources.some( + (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, + ) + ? { success: true } + : { + success: false, + error: after.error ?? `OMP upgrade completed but '${resource.resolvedIdentity}' is absent from ${context.scope} inventory`, + }; + } + + async remove( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const before = await this.inspectForMutation(resource, context); + if (!before.inspection) { + return { success: false, error: before.error ?? 'OMP preflight failed' }; + } + if ( + !before.inspection.resources.some( + (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, + ) + ) { + return { success: true }; + } + const result = await this.run( + 'omp', + ['plugin', 'uninstall', '--scope', context.scope, resource.resolvedIdentity], + commandOptions(context), + ); + if (!result.success) { + return { + success: false, + error: `Could not uninstall OMP plugin '${resource.resolvedIdentity}': ${commandError(result)}`, + }; + } + const after = await this.inspect(context); + if (!after.success) { + return { + success: false, + error: after.error ?? 'OMP removal verification failed', + }; + } + if ( + after.resources.some( + (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, + ) + ) { + return { + success: false, + error: `OMP uninstall completed but '${resource.resolvedIdentity}' remains in ${context.scope} inventory`, + }; + } + + // Marketplace registrations are shared by both plugin scopes and may be + // referenced by other AllAgents workspaces. Ordinary state cannot prove + // exclusive ownership, so PR 1 deliberately preserves the registration. + return { success: true }; + } +} diff --git a/src/core/native/pi-mcp.ts b/src/core/native/pi-mcp.ts new file mode 100644 index 00000000..736fe767 --- /dev/null +++ b/src/core/native/pi-mcp.ts @@ -0,0 +1,363 @@ +import { relative, resolve } from 'node:path'; +import { readFile, realpath, stat } from 'node:fs/promises'; +import micromatch from 'micromatch'; +import { pathIsWithin } from '../client-context.js'; +import { + PiNativeClient, + normalizePiPackageSource, + readPiSettings, + type PiPackageEntry, + type PiSettings, +} from './pi.js'; +import type { + NativeOperationContext, + NativeResourceObservation, +} from './types.js'; + +const MCP_PACKAGE_NAME = 'pi-mcp-adapter'; +const MCP_PACKAGE_IDENTITY = `npm:${MCP_PACKAGE_NAME}`; + +export type PiMcpAdapterClassification = + | 'absent' + | 'configured-missing' + | 'installed-disabled' + | 'usable' + | 'inspection-failed'; + +export interface PiMcpAdapterInspection { + classification: PiMcpAdapterClassification; + root: string; + packageSource?: string; + packagePath?: string; + manifestPath?: string; + version?: string; + extensionPath?: string; + error?: string; +} + +function configuredEntry( + settings: PiSettings, + source: string, +): PiPackageEntry | undefined { + return settings.packages.find((entry) => entry.source === source); +} + +function normalizedPattern(pattern: string): string { + const withoutMarker = /^[!+-]/.test(pattern) ? pattern.slice(1) : pattern; + return withoutMarker.replace(/^\.\//, '').replaceAll('\\', '/'); +} + +function patternMatches(path: string, pattern: string): boolean { + const normalizedPath = path.replaceAll('\\', '/'); + const normalized = normalizedPattern(pattern); + const basename = normalizedPath.split('/').at(-1) ?? normalizedPath; + return ( + micromatch.isMatch(normalizedPath, [normalized]) || + micromatch.isMatch(basename, [normalized]) + ); +} + +function extensionEnabled(entry: PiPackageEntry, path: string): boolean { + const patterns = entry.extensions; + if (entry.autoload === false) { + let enabled = false; + for (const pattern of patterns ?? []) { + if (!patternMatches(path, pattern)) continue; + enabled = !pattern.startsWith('!') && !pattern.startsWith('-'); + } + return enabled; + } + if (patterns === undefined) return true; + if (patterns.length === 0) return false; + + const includes = patterns.filter((pattern) => !/^[!+-]/.test(pattern)); + let enabled = + includes.length === 0 || includes.some((pattern) => patternMatches(path, pattern)); + if ( + patterns.some( + (pattern) => pattern.startsWith('!') && patternMatches(path, pattern), + ) + ) { + enabled = false; + } + if ( + patterns.some( + (pattern) => pattern.startsWith('+') && patternMatches(path, pattern), + ) + ) { + enabled = true; + } + if ( + patterns.some( + (pattern) => pattern.startsWith('-') && patternMatches(path, pattern), + ) + ) { + enabled = false; + } + return enabled; +} + +async function canonicalExistingPath(path: string): Promise { + await stat(path); + return realpath(path); +} + +function failed( + root: string, + error: string, + details: Partial = {}, +): PiMcpAdapterInspection { + return { classification: 'inspection-failed', root, ...details, error }; +} + +function recognizableAdapterSource(observation: NativeResourceObservation): boolean { + const normalized = observation.resource.provenance.packageIdentity; + if (normalized === MCP_PACKAGE_IDENTITY) return true; + const source = observation.resource.requestedIdentity; + return /(?:^|[\\/])pi-mcp-adapter(?:\.git)?(?:@[^\\/]*)?$/.test(source); +} + +export async function inspectPiMcpAdapter( + selectedRoot: string, +): Promise { + const root = resolve(selectedRoot); + const context: NativeOperationContext = { + client: 'pi', + scope: 'user', + nativeScope: 'user', + root, + cwd: root, + roots: { agent: root }, + }; + + let settings: PiSettings; + try { + settings = await readPiSettings(resolve(root, 'settings.json')); + } catch (error) { + return failed( + root, + error instanceof Error ? error.message : String(error), + ); + } + + const inventory = await new PiNativeClient().inspect(context); + if (!inventory.success) { + return failed(root, inventory.error ?? 'Could not inspect selected Pi root'); + } + + const candidates: NativeResourceObservation[] = []; + for (const observation of inventory.observations ?? []) { + if (recognizableAdapterSource(observation)) { + candidates.push(observation); + continue; + } + if (!observation.installedPath || observation.status === 'configured-missing') { + continue; + } + try { + const manifest = JSON.parse( + await readFile(resolve(observation.installedPath, 'package.json'), 'utf8'), + ) as unknown; + if ( + manifest && + typeof manifest === 'object' && + !Array.isArray(manifest) && + (manifest as Record).name === MCP_PACKAGE_NAME + ) { + candidates.push(observation); + } + } catch { + // Non-adapter packages are irrelevant; the generic inventory already + // records malformed installed packages as unusable. + } + } + + if (candidates.length === 0) { + return { classification: 'absent', root }; + } + if (candidates.length > 1) { + return failed(root, 'Multiple configured packages resolve to pi-mcp-adapter'); + } + + const candidate = candidates[0]; + if (!candidate) return { classification: 'absent', root }; + const packageSource = candidate.resource.requestedIdentity; + const packagePath = candidate.installedPath; + if (!packagePath || candidate.status === 'configured-missing') { + return { + classification: 'configured-missing', + root, + packageSource, + ...(packagePath && { packagePath }), + }; + } + if (candidate.status === 'unusable') { + return failed(root, candidate.error ?? 'Configured adapter package is unusable', { + packageSource, + packagePath, + }); + } + + let canonicalRoot: string; + let canonicalPackagePath: string; + try { + canonicalRoot = await canonicalExistingPath(root); + canonicalPackagePath = await canonicalExistingPath(packagePath); + } catch (error) { + return failed( + root, + `Could not resolve selected Pi package path: ${error instanceof Error ? error.message : String(error)}`, + { packageSource, packagePath }, + ); + } + if (!pathIsWithin(canonicalRoot, canonicalPackagePath)) { + return failed(root, 'Configured pi-mcp-adapter package escapes the selected Pi root', { + packageSource, + packagePath, + }); + } + + const manifestPath = resolve(packagePath, 'package.json'); + let manifest: Record; + try { + const parsed = JSON.parse(await readFile(manifestPath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('expected an object'); + } + manifest = parsed as Record; + } catch (error) { + return failed( + root, + `Could not parse pi-mcp-adapter manifest: ${error instanceof Error ? error.message : String(error)}`, + { packageSource, packagePath, manifestPath }, + ); + } + if (manifest.name !== MCP_PACKAGE_NAME) { + return failed(root, `Configured adapter manifest name is not ${MCP_PACKAGE_NAME}`, { + packageSource, + packagePath, + manifestPath, + }); + } + if (typeof manifest.version !== 'string' || !manifest.version) { + return failed(root, 'Configured pi-mcp-adapter manifest has no version', { + packageSource, + packagePath, + manifestPath, + }); + } + + const pi = manifest.pi; + if (!pi || typeof pi !== 'object' || Array.isArray(pi)) { + return failed(root, 'Configured pi-mcp-adapter manifest has no Pi resource declaration', { + packageSource, + packagePath, + manifestPath, + version: manifest.version, + }); + } + const extensions = (pi as Record).extensions; + if ( + !Array.isArray(extensions) || + extensions.length === 0 || + extensions.some((entry) => typeof entry !== 'string' || !entry) + ) { + return failed(root, 'Configured pi-mcp-adapter manifest has no valid extension path', { + packageSource, + packagePath, + manifestPath, + version: manifest.version, + }); + } + + const settingsEntry = configuredEntry(settings, packageSource); + if (!settingsEntry) { + return failed(root, 'Configured adapter entry disappeared during inspection', { + packageSource, + packagePath, + manifestPath, + version: manifest.version, + }); + } + + let enabledExtension: string | undefined; + for (const extension of extensions as string[]) { + if (/[*?]/.test(extension) || /^[!+-]/.test(extension)) { + return failed(root, 'pi-mcp-adapter extension path must resolve unambiguously', { + packageSource, + packagePath, + manifestPath, + version: manifest.version, + }); + } + const extensionPath = resolve(packagePath, extension); + if (!pathIsWithin(resolve(packagePath), extensionPath)) { + return failed(root, 'pi-mcp-adapter extension path escapes its package', { + packageSource, + packagePath, + manifestPath, + version: manifest.version, + }); + } + let canonicalExtension: string; + try { + canonicalExtension = await canonicalExistingPath(extensionPath); + } catch (error) { + return failed( + root, + `Could not resolve pi-mcp-adapter extension: ${error instanceof Error ? error.message : String(error)}`, + { + packageSource, + packagePath, + manifestPath, + version: manifest.version, + }, + ); + } + if ( + !pathIsWithin(canonicalPackagePath, canonicalExtension) || + !pathIsWithin(canonicalRoot, canonicalExtension) + ) { + return failed(root, 'pi-mcp-adapter extension escapes the selected Pi root', { + packageSource, + packagePath, + manifestPath, + version: manifest.version, + }); + } + const relativeExtension = relative(packagePath, extensionPath).replaceAll( + '\\', + '/', + ); + if (extensionEnabled(settingsEntry, relativeExtension)) { + enabledExtension = canonicalExtension; + } + } + + if (candidate.status === 'disabled' || !enabledExtension) { + return { + classification: 'installed-disabled', + root, + packageSource, + packagePath, + manifestPath, + version: manifest.version, + }; + } + return { + classification: 'usable', + root, + packageSource, + packagePath, + manifestPath, + version: manifest.version, + extensionPath: enabledExtension, + }; +} + +export function isPiMcpAdapterSource( + source: string, + context: NativeOperationContext, +): boolean { + return normalizePiPackageSource(source, context)?.packageIdentity === MCP_PACKAGE_IDENTITY; +} diff --git a/src/core/native/pi.ts b/src/core/native/pi.ts new file mode 100644 index 00000000..ece7240b --- /dev/null +++ b/src/core/native/pi.ts @@ -0,0 +1,1016 @@ +import type { Stats } from 'node:fs'; +import { homedir } from 'node:os'; +import { + dirname, + isAbsolute, + join, + resolve, + win32, +} from 'node:path'; +import { readFile, realpath, stat } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { + executeCommand, + type NativeClient, + type NativeCommandOptions, + type NativeCommandResult, + type NativeInspectionResult, + type NativeMutationResult, + type NativeOperationContext, + type NativeResource, + type NativeResourceObservation, + type NativeSourceResolution, +} from './types.js'; +import { pathIsWithin } from '../client-context.js'; + +const PI_MINIMUM_VERSION = [0, 85, 1] as const; +const PI_MAXIMUM_VERSION = [0, 86, 0] as const; +const PACKAGE_FILTER_KEYS = [ + 'extensions', + 'skills', + 'prompts', + 'themes', +] as const; + + +type PiCommandRunner = ( + binary: string, + args: string[], + options?: NativeCommandOptions, +) => Promise; + +export interface PiNativeClientOptions { + execute?: PiCommandRunner; +} + +export interface PiPackageEntry { + source: string; + autoload?: boolean; + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + themes?: string[]; +} + +export interface PiNormalizedSource { + kind: 'npm' | 'git' | 'local'; + requested: string; + resolvedIdentity: string; + packageIdentity: string; + commandSource: string; + packageName?: string; + host?: string; + repositoryPath?: string; + localPath?: string; +} + +export type PiProjectTrustStatus = + | 'allowed' + | 'ask' + | 'denied' + | 'inspection-failed' + | 'ambiguous'; + +export interface PiProjectTrustInspection { + status: PiProjectTrustStatus; + allowed: boolean; + source?: 'saved' | 'default'; + matchedPath?: string; + error?: string; +} + +export interface PiSettings { + packages: PiPackageEntry[]; + defaultProjectTrust: 'ask' | 'always' | 'never'; +} + +function contextCwd(context: NativeOperationContext): string { + return resolve(context.cwd ?? process.cwd()); +} + +function piAgentRoot(context: NativeOperationContext): string { + const namedRoot = context.roots?.agent; + if (namedRoot) return resolve(namedRoot); + if (context.scope === 'user') return resolve(context.root); + + const cwd = contextCwd(context); + const configured = context.env?.PI_CODING_AGENT_DIR; + if (configured) { + if (configured === '~') return contextHome(context); + if (configured.startsWith('~/') || configured.startsWith('~\\')) { + return resolve(contextHome(context), configured.slice(2)); + } + return resolve(cwd, configured); + } + return join(contextHome(context), '.pi', 'agent'); +} + +function contextHome(context: NativeOperationContext): string { + return resolve(context.env?.HOME ?? context.env?.USERPROFILE ?? homedir()); +} + +function settingsRoot(context: NativeOperationContext): string { + return context.scope === 'user' + ? piAgentRoot(context) + : join(contextCwd(context), '.pi'); +} + +function commandOptions(context: NativeOperationContext): NativeCommandOptions { + return { + cwd: contextCwd(context), + ...(context.env && { env: context.env }), + }; +} + +function commandError(result: NativeCommandResult): string { + if (result.error) return result.error; + if (result.signal) return `Pi CLI terminated by ${result.signal}`; + return `Pi CLI exited with code ${result.exitCode ?? 'unknown'}`; +} + +function parseNpmSource(source: string): PiNormalizedSource | null { + if (!source.startsWith('npm:')) return null; + const spec = source.slice(4).trim(); + const match = spec.startsWith('@') + ? /^(@[^/@\s]+\/[^/@\s]+)(?:@([^\s]+))?$/.exec(spec) + : /^([^/@\s]+)(?:@([^\s]+))?$/.exec(spec); + if (!match?.[1]) return null; + const name = match[1]; + let decodedName: string; + try { + decodedName = decodeURIComponent(name); + } catch { + return null; + } + if ( + decodedName.includes('\0') || + decodedName.includes('\\') || + decodedName.split('/').some((segment) => segment === '.' || segment === '..') || + decodedName.split('/').length !== name.split('/').length + ) { + return null; + } + const version = match[2]; + return { + kind: 'npm', + requested: source, + resolvedIdentity: `npm:${name}${version ? `@${version}` : ''}`, + packageIdentity: `npm:${name}`, + commandSource: `npm:${name}${version ? `@${version}` : ''}`, + packageName: name, + }; +} + +function unsafeGitPart(value: string, allowSlash: boolean): boolean { + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + return true; + } + for (const candidate of [value, decoded]) { + if ( + candidate.length === 0 || + candidate.includes('\0') || + candidate.includes('\\') || + candidate.startsWith('/') || + (!allowSlash && candidate.includes('/')) || + candidate.split('/').includes('..') + ) { + return true; + } + } + return false; +} + +function splitGitRef(path: string): { path: string; ref?: string } | null { + const separator = path.indexOf('@'); + if (separator < 0) return { path }; + const repositoryPath = path.slice(0, separator); + const ref = path.slice(separator + 1); + return repositoryPath && ref ? { path: repositoryPath, ref } : null; +} + +function buildGitSource( + requested: string, + host: string, + pathWithRef: string, +): PiNormalizedSource | null { + const split = splitGitRef(pathWithRef.replace(/^\/+/, '')); + if (!split) return null; + const repositoryPath = split.path.replace(/\.git$/, '').replace(/\/$/, ''); + const normalizedHost = host.toLowerCase(); + if ( + unsafeGitPart(normalizedHost, false) || + unsafeGitPart(repositoryPath, true) || + repositoryPath.split('/').length < 2 || + (split.ref && unsafeGitPart(split.ref, true)) + ) { + return null; + } + const base = `${normalizedHost}/${repositoryPath}`; + return { + kind: 'git', + requested, + resolvedIdentity: `git:${base}${split.ref ? `@${split.ref}` : ''}`, + packageIdentity: `git:${base}`, + commandSource: requested, + host: normalizedHost, + repositoryPath, + }; +} + +function parseGitSource(source: string): PiNormalizedSource | null { + const hasGitPrefix = source.startsWith('git:'); + const value = (hasGitPrefix ? source.slice(4) : source).trim(); + if (!value) return null; + + const scp = /^git@([^:]+):(.+)$/.exec(value); + if (scp?.[1] && scp[2]) { + return buildGitSource(source, scp[1], scp[2]); + } + + if (/^(?:https?|ssh|git):\/\//i.test(value)) { + try { + const url = new URL(value); + if (url.search || url.hash) return null; + return buildGitSource(source, url.hostname, url.pathname); + } catch { + return null; + } + } + + if (!hasGitPrefix) return null; + const slash = value.indexOf('/'); + if (slash <= 0) return null; + const host = value.slice(0, slash); + if (!host.includes('.') && host !== 'localhost') return null; + return buildGitSource(source, host, value.slice(slash + 1)); +} + +function expandTilde(value: string, home: string): string { + if (value === '~') return home; + if (value.startsWith('~/') || value.startsWith('~\\')) { + return join(home, value.slice(2)); + } + return value; +} + +function parseLocalSource( + source: string, + context: NativeOperationContext, +): PiNormalizedSource | null { + const isFileUrl = source.startsWith('file://'); + let localCandidate: string; + try { + localCandidate = isFileUrl + ? fileURLToPath(source) + : expandTilde(source, contextHome(context)); + } catch { + return null; + } + if ( + !isFileUrl && + !isAbsolute(localCandidate) && + !win32.isAbsolute(localCandidate) && + !localCandidate.startsWith('./') && + !localCandidate.startsWith('../') && + !localCandidate.startsWith('.\\') && + !localCandidate.startsWith('..\\') + ) { + return null; + } + const localPath = resolve(contextCwd(context), localCandidate); + return { + kind: 'local', + requested: source, + resolvedIdentity: `local:${localPath}`, + packageIdentity: `local:${localPath}`, + commandSource: localPath, + localPath, + }; +} + +export function normalizePiPackageSource( + source: string, + context: NativeOperationContext, + options: { baseDir?: string } = {}, +): PiNormalizedSource | null { + const trimmed = source.trim(); + if (!trimmed) return null; + + const npm = parseNpmSource(trimmed); + if (npm) return npm; + const git = parseGitSource(trimmed); + if (git) return git; + + const localContext = options.baseDir + ? { ...context, cwd: options.baseDir } + : context; + return parseLocalSource(trimmed, localContext); +} + +function parsePackageEntry(value: unknown, index: number): PiPackageEntry { + if (typeof value === 'string' && value.trim()) { + return { source: value.trim() }; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`packages[${index}] must be a source string or object`); + } + const record = value as Record; + if (typeof record.source !== 'string' || !record.source.trim()) { + throw new Error(`packages[${index}].source must be a non-empty string`); + } + if (record.autoload !== undefined && typeof record.autoload !== 'boolean') { + throw new Error(`packages[${index}].autoload must be a boolean`); + } + + const entry: PiPackageEntry = { source: record.source.trim() }; + if (typeof record.autoload === 'boolean') entry.autoload = record.autoload; + for (const key of PACKAGE_FILTER_KEYS) { + const value = record[key]; + if (value === undefined) continue; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error(`packages[${index}].${key} must be an array of strings`); + } + entry[key] = [...value] as string[]; + } + return entry; +} + +export async function readPiSettings(path: string): Promise { + let contents: string; + try { + contents = await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { packages: [], defaultProjectTrust: 'ask' }; + } + throw new Error( + `Could not read Pi settings ${path}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + let value: unknown; + try { + value = JSON.parse(contents.replace(/^\uFEFF/, '')); + } catch (error) { + throw new Error( + `Could not parse Pi settings ${path}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Invalid Pi settings ${path}: expected an object`); + } + const record = value as Record; + if (record.packages !== undefined && !Array.isArray(record.packages)) { + throw new Error(`Invalid Pi settings ${path}: packages must be an array`); + } + const defaultProjectTrust = record.defaultProjectTrust ?? 'ask'; + if ( + defaultProjectTrust !== 'ask' && + defaultProjectTrust !== 'always' && + defaultProjectTrust !== 'never' + ) { + throw new Error( + `Invalid Pi settings ${path}: defaultProjectTrust must be ask, always, or never`, + ); + } + return { + packages: (record.packages ?? []).map(parsePackageEntry), + defaultProjectTrust, + }; +} + +async function canonicalPath(path: string): Promise { + try { + return await realpath(resolve(path)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return resolve(path); + throw error; + } +} + + +export async function inspectPiProjectTrust( + context: NativeOperationContext, +): Promise { + const agentRoot = piAgentRoot(context); + let projectBoundary: string; + let canonicalAgentRoot: string; + try { + projectBoundary = await canonicalPath(context.root); + canonicalAgentRoot = await canonicalPath(agentRoot); + } catch (error) { + return { + status: 'inspection-failed', + allowed: false, + error: `Could not canonicalize Pi trust boundary: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (pathIsWithin(projectBoundary, canonicalAgentRoot)) { + return { + status: 'denied', + allowed: false, + error: + 'Pi project trust cannot be authorized from an agent root inside the project boundary', + }; + } + + const settingsPath = join(agentRoot, 'settings.json'); + let settings: PiSettings; + try { + settings = await readPiSettings(settingsPath); + } catch (error) { + return { + status: 'inspection-failed', + allowed: false, + error: error instanceof Error ? error.message : String(error), + }; + } + + const trustPath = join(agentRoot, 'trust.json'); + let contents: string; + try { + contents = await readFile(trustPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + return { + status: 'inspection-failed', + allowed: false, + error: `Could not read Pi trust store ${trustPath}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + contents = '{}'; + } + + let value: unknown; + try { + value = JSON.parse(contents.replace(/^\uFEFF/, '')); + } catch (error) { + return { + status: 'inspection-failed', + allowed: false, + error: `Could not parse Pi trust store ${trustPath}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { + status: 'inspection-failed', + allowed: false, + error: `Invalid Pi trust store ${trustPath}: expected an object`, + }; + } + + let cwd: string; + try { + cwd = await canonicalPath(contextCwd(context)); + } catch (error) { + return { + status: 'inspection-failed', + allowed: false, + error: `Could not canonicalize Pi project cwd: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + const decisions = new Map(); + for (const [savedPath, decision] of Object.entries( + value as Record, + )) { + if (decision !== true && decision !== false && decision !== null) { + return { + status: 'inspection-failed', + allowed: false, + error: `Invalid Pi trust store ${trustPath}: decision for ${JSON.stringify(savedPath)} must be true, false, or null`, + }; + } + if (!isAbsolute(savedPath) && !win32.isAbsolute(savedPath)) { + return { + status: 'ambiguous', + allowed: false, + error: `Pi trust store contains non-absolute path ${JSON.stringify(savedPath)}`, + }; + } + let canonicalSavedPath: string; + try { + canonicalSavedPath = await canonicalPath(savedPath); + } catch (error) { + return { + status: 'inspection-failed', + allowed: false, + error: `Could not canonicalize Pi trust path ${JSON.stringify(savedPath)}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + const existing = decisions.get(canonicalSavedPath); + if ( + existing !== undefined && + decision !== null && + existing !== null && + existing !== decision + ) { + return { + status: 'ambiguous', + allowed: false, + error: `Pi trust store has conflicting decisions for ${canonicalSavedPath}`, + }; + } + if (existing === undefined || decision !== null) { + decisions.set(canonicalSavedPath, decision); + } + } + + let current = cwd; + while (true) { + const decision = decisions.get(current); + if (decision === true || decision === false) { + return { + status: decision ? 'allowed' : 'denied', + allowed: decision, + source: 'saved', + matchedPath: current, + ...(!decision && { + error: `Pi project trust is denied by saved decision ${current}`, + }), + }; + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + + if (settings.defaultProjectTrust === 'always') { + return { status: 'allowed', allowed: true, source: 'default' }; + } + if (settings.defaultProjectTrust === 'never') { + return { + status: 'denied', + allowed: false, + source: 'default', + error: 'Pi defaultProjectTrust is never', + }; + } + return { + status: 'ask', + allowed: false, + source: 'default', + error: + 'Pi project trust requires confirmation. Trust this project in Pi before running native package operations.', + }; +} + +function packageIsEntirelyDisabled(entry: PiPackageEntry): boolean { + if (entry.autoload === false) { + return !PACKAGE_FILTER_KEYS.some((key) => + (entry[key] ?? []).some( + (pattern) => pattern.length > 0 && !pattern.startsWith('!') && !pattern.startsWith('-'), + ), + ); + } + return PACKAGE_FILTER_KEYS.every( + (key) => entry[key] !== undefined && entry[key]?.length === 0, + ); +} + +function installPathForSource( + source: PiNormalizedSource, + root: string, +): string { + if (source.kind === 'npm') { + return join(root, 'npm', 'node_modules', source.packageName ?? ''); + } + if (source.kind === 'git') { + return join(root, 'git', source.host ?? '', source.repositoryPath ?? ''); + } + return source.localPath ?? ''; +} + +async function manifestStatus( + normalized: PiNormalizedSource, + installedPath: string, +): Promise<{ status: 'installed' | 'unusable'; error?: string }> { + let installedStat: Stats; + try { + installedStat = await stat(installedPath); + } catch (error) { + return { + status: 'unusable', + error: `Could not inspect installed Pi package ${installedPath}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (installedStat.isFile()) { + return normalized.kind === 'local' && /\.(?:[cm]?[jt]s)$/.test(installedPath) + ? { status: 'installed' } + : { status: 'unusable', error: `Pi package path is not a directory: ${installedPath}` }; + } + if (!installedStat.isDirectory()) { + return { status: 'unusable', error: `Pi package path is not a directory: ${installedPath}` }; + } + + const manifestPath = join(installedPath, 'package.json'); + let contents: string; + try { + contents = await readFile(manifestPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + if (normalized.kind === 'npm') { + return { status: 'unusable', error: `Installed npm package has no manifest: ${manifestPath}` }; + } + for (const key of PACKAGE_FILTER_KEYS) { + try { + if ((await stat(join(installedPath, key))).isDirectory()) { + return { status: 'installed' }; + } + } catch { + // Try the next conventional resource directory. + } + } + return { + status: 'unusable', + error: `Installed Pi package has no manifest or conventional resources: ${installedPath}`, + }; + } + return { + status: 'unusable', + error: `Could not read installed Pi package manifest ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + try { + const manifest = JSON.parse(contents.replace(/^\uFEFF/, '')) as unknown; + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + throw new Error('expected an object'); + } + const name = (manifest as Record).name; + if (typeof name !== 'string' || !name) throw new Error('missing package name'); + if (normalized.kind === 'npm' && name !== normalized.packageName) { + throw new Error( + `manifest name ${JSON.stringify(name)} does not match ${JSON.stringify(normalized.packageName)}`, + ); + } + return { status: 'installed' }; + } catch (error) { + return { + status: 'unusable', + error: `Invalid installed Pi package manifest ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +async function inspectScopePackages( + context: NativeOperationContext, +): Promise { + if (context.scope === 'project') { + const trust = await inspectPiProjectTrust(context); + if (!trust.allowed) { + return { + success: false, + resources: [], + observations: [], + error: trust.error ?? `Pi project trust is ${trust.status}`, + }; + } + } + + const root = settingsRoot(context); + let settings: PiSettings; + try { + settings = await readPiSettings(join(root, 'settings.json')); + } catch (error) { + return { + success: false, + resources: [], + observations: [], + error: error instanceof Error ? error.message : String(error), + }; + } + + const observations: NativeResourceObservation[] = []; + const identities = new Set(); + for (const entry of settings.packages) { + const normalized = normalizePiPackageSource(entry.source, context, { + baseDir: root, + }); + if (!normalized) { + return { + success: false, + resources: [], + observations, + error: `Unsupported Pi package source in ${join(root, 'settings.json')}: ${entry.source}`, + }; + } + if (identities.has(normalized.packageIdentity)) { + return { + success: false, + resources: [], + observations, + error: `Ambiguous duplicate Pi package identity ${normalized.packageIdentity} in ${join(root, 'settings.json')}`, + }; + } + identities.add(normalized.packageIdentity); + + const installedPath = installPathForSource(normalized, root); + const resource: NativeResource = { + kind: 'package', + requestedIdentity: entry.source, + resolvedIdentity: normalized.resolvedIdentity, + context, + provenance: { + sourceType: normalized.kind, + packageIdentity: normalized.packageIdentity, + commandSource: normalized.commandSource, + installedPath, + }, + }; + + let exists = true; + try { + await stat(installedPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') exists = false; + else { + observations.push({ + resource, + status: 'unusable', + installedPath, + error: `Could not inspect Pi package path ${installedPath}: ${error instanceof Error ? error.message : String(error)}`, + }); + continue; + } + } + if (!exists) { + observations.push({ + resource, + status: 'configured-missing', + installedPath, + }); + continue; + } + + const manifest = await manifestStatus(normalized, installedPath); + if (manifest.status === 'unusable') { + observations.push({ + resource, + status: 'unusable', + installedPath, + ...(manifest.error && { error: manifest.error }), + }); + continue; + } + observations.push({ + resource, + status: packageIsEntirelyDisabled(entry) ? 'disabled' : 'installed', + installedPath, + }); + } + + return { + success: true, + resources: observations + .filter((observation) => observation.status === 'installed') + .map((observation) => observation.resource), + observations, + }; +} + +function versionTuple(output: string): [number, number, number] | null { + const match = /(?:^|\s)(\d+)\.(\d+)\.(\d+)(?:[-+\s]|$)/.exec(output.trim()); + return match?.[1] && match[2] && match[3] + ? [Number(match[1]), Number(match[2]), Number(match[3])] + : null; +} + +function compareVersion( + left: readonly number[], + right: readonly number[], +): number { + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +function sourceFromResource(resource: NativeResource): string { + return resource.provenance.commandSource ?? resource.requestedIdentity; +} + +function packageIdentityFromResource( + resource: NativeResource, + context: NativeOperationContext, +): string | undefined { + return ( + resource.provenance.packageIdentity ?? + normalizePiPackageSource(sourceFromResource(resource), context)?.packageIdentity + ); +} + +function mutationArgs( + operation: 'install' | 'remove', + source: string, + context: NativeOperationContext, +): string[] { + return context.scope === 'project' + ? [operation, source, '-l', '--approve'] + : [operation, source, '--no-approve']; +} + +export class PiNativeClient implements NativeClient { + readonly client = 'pi'; + private readonly run: PiCommandRunner; + + constructor(options: PiNativeClientOptions = {}) { + this.run = options.execute ?? executeCommand; + } + + async isAvailable(context?: NativeOperationContext): Promise { + const result = await this.run( + 'pi', + ['--version'], + context ? commandOptions(context) : undefined, + ); + if (!result.success) return false; + const version = versionTuple(result.output); + return Boolean( + version && + compareVersion(version, PI_MINIMUM_VERSION) >= 0 && + compareVersion(version, PI_MAXIMUM_VERSION) < 0, + ); + } + + supportsScope(_scope: 'user' | 'project'): boolean { + return true; + } + + resolveSource( + source: string, + context: NativeOperationContext, + provenance: Readonly> = {}, + ): NativeSourceResolution { + const normalized = normalizePiPackageSource(source, context); + if (!normalized) { + return { + success: false, + error: `Pi native install does not support source '${source}'`, + }; + } + return { + success: true, + resource: { + kind: 'package', + requestedIdentity: source, + resolvedIdentity: normalized.resolvedIdentity, + context, + provenance: { + ...provenance, + sourceType: normalized.kind, + packageIdentity: normalized.packageIdentity, + commandSource: normalized.commandSource, + }, + }, + }; + } + + inspect(context: NativeOperationContext): Promise { + return inspectScopePackages(context); + } + + private async preflightMutation( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const inspection = await inspectScopePackages(context); + if (!inspection.success) return inspection.error ?? 'Could not inspect Pi packages'; + const packageIdentity = packageIdentityFromResource(resource, context); + if (!packageIdentity) return 'Pi package resource has no valid package identity'; + const observation = inspection.observations?.find( + (candidate) => + candidate.resource.provenance.packageIdentity === packageIdentity, + ); + if (observation?.status === 'disabled') { + return `${packageIdentity} is configured but disabled in Pi settings; enable it with pi config before syncing`; + } + if (observation?.status === 'unusable') { + return observation.error ?? `${packageIdentity} is unusable in Pi settings`; + } + return null; + } + + async install( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const preflightError = await this.preflightMutation(resource, context); + if (preflightError) return { success: false, error: preflightError }; + const result = await this.run( + 'pi', + mutationArgs('install', sourceFromResource(resource), context), + commandOptions(context), + ); + return result.success + ? { success: true } + : { success: false, error: commandError(result) }; + } + + async update( + resource: NativeResource, + current: NativeResource, + context: NativeOperationContext, + ): Promise { + const preflightError = await this.preflightMutation(resource, context); + if (preflightError) return { success: false, error: preflightError }; + + const selectedIdentity = packageIdentityFromResource(resource, context); + if (!selectedIdentity) { + return { success: false, error: 'Pi package resource has no valid package identity' }; + } + const opposite: NativeOperationContext = { + ...context, + scope: context.scope === 'user' ? 'project' : 'user', + nativeScope: context.scope === 'user' ? 'project' : 'user', + root: + context.scope === 'user' + ? contextCwd(context) + : piAgentRoot(context), + }; + if (opposite.scope === 'project') { + const trust = await inspectPiProjectTrust(opposite); + if (trust.allowed) { + const oppositeInspection = await inspectScopePackages(opposite); + if (!oppositeInspection.success) { + return { + success: false, + error: `Could not prove Pi update scope isolation: ${oppositeInspection.error ?? 'opposite scope inspection failed'}`, + }; + } + if ( + oppositeInspection.observations?.some( + (candidate) => + candidate.resource.provenance.packageIdentity === selectedIdentity, + ) + ) { + return { + success: false, + error: `Refusing targeted Pi update for ${selectedIdentity}: the same package is configured in both user and project scopes`, + }; + } + } + } else { + const oppositeInspection = await inspectScopePackages(opposite); + if (!oppositeInspection.success) { + return { + success: false, + error: `Could not prove Pi update scope isolation: ${oppositeInspection.error ?? 'opposite scope inspection failed'}`, + }; + } + if ( + oppositeInspection.observations?.some( + (candidate) => + candidate.resource.provenance.packageIdentity === selectedIdentity, + ) + ) { + return { + success: false, + error: `Refusing targeted Pi update for ${selectedIdentity}: the same package is configured in both user and project scopes`, + }; + } + } + + let args: string[]; + if (resource.resolvedIdentity !== current.resolvedIdentity) { + args = mutationArgs('install', sourceFromResource(resource), context); + } else { + args = [ + 'update', + sourceFromResource(resource), + context.scope === 'project' ? '--approve' : '--no-approve', + ]; + } + const result = await this.run('pi', args, commandOptions(context)); + return result.success + ? { success: true } + : { success: false, error: commandError(result) }; + } + + async remove( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + if (context.scope === 'project') { + const trust = await inspectPiProjectTrust(context); + if (!trust.allowed) { + return { + success: false, + error: trust.error ?? `Pi project trust is ${trust.status}`, + }; + } + } + const result = await this.run( + 'pi', + mutationArgs('remove', sourceFromResource(resource), context), + commandOptions(context), + ); + return result.success + ? { success: true } + : { success: false, error: commandError(result) }; + } +} diff --git a/src/core/native/registry.ts b/src/core/native/registry.ts index 71b9b7c3..d7042c26 100644 --- a/src/core/native/registry.ts +++ b/src/core/native/registry.ts @@ -2,6 +2,8 @@ import type { ClientType } from '../../models/workspace-config.js'; import type { NativeClient } from './types.js'; import { ClaudeNativeClient } from './claude.js'; import { CopilotNativeClient } from './copilot.js'; +import { PiNativeClient } from './pi.js'; +import { OmpNativeClient } from './omp.js'; export function getNativeClient(client: ClientType): NativeClient | null { switch (client) { @@ -9,6 +11,10 @@ export function getNativeClient(client: ClientType): NativeClient | null { return new ClaudeNativeClient(); case 'copilot': return new CopilotNativeClient(); + case 'pi': + return new PiNativeClient(); + case 'omp': + return new OmpNativeClient(); default: return null; } diff --git a/src/core/native/types.ts b/src/core/native/types.ts index c1fb8628..11a9ce1f 100644 --- a/src/core/native/types.ts +++ b/src/core/native/types.ts @@ -4,54 +4,309 @@ import { access, open } from 'node:fs/promises'; import { delimiter, dirname, extname, resolve } from 'node:path'; import readCmdShim from 'read-cmd-shim'; +export type NativeScope = 'user' | 'project'; +export type NativeResourceKind = 'plugin' | 'package'; + +export interface NativeCommandOptions { + cwd?: string; + /** + * Overlay the inherited process environment. Undefined removes a variable, + * which is required when an ordinary client operation must neutralize an + * ambient profile selector. + */ + env?: Readonly>; +} + export interface NativeCommandResult { success: boolean; output: string; error?: string; + exitCode?: number | null; + signal?: NodeJS.Signals | null; +} + +export interface NativeOperationContext { + client: string; + scope: NativeScope; + nativeScope: string; + root: string; + cwd?: string; + env?: Readonly>; + roots?: Readonly>; +} + +export interface NativeResource { + kind: NativeResourceKind; + requestedIdentity: string; + resolvedIdentity: string; + context: NativeOperationContext; + provenance: Readonly>; +} + +export interface NativeSourceResolution { + success: boolean; + resource?: NativeResource; + error?: string; +} + +export type NativeObservationStatus = + | 'installed' + | 'configured-missing' + | 'disabled' + | 'unusable'; + +export interface NativeResourceObservation { + resource: NativeResource; + status: NativeObservationStatus; + installedPath?: string; + error?: string; +} + +export interface NativeInspectionResult { + success: boolean; + resources: NativeResource[]; + error?: string; + /** + * Configured resources that are not safe to credit as installed. Adapters + * omit this when their native inventory has no richer observation model. + */ + observations?: NativeResourceObservation[]; +} + +export interface NativeMutationResult { + success: boolean; + error?: string; + registrations?: string[]; +} + +export type NativeEffectAction = + | 'registered' + | 'installed' + | 'configured-missing' + | 'disabled' + | 'unusable' + | 'unchanged' + | 'updated' + | 'removed' + | 'retained' + | 'would-register' + | 'would-install' + | 'would-update' + | 'would-remove' + | 'failed' + | 'unknown'; + +export type NativeEffectPhase = + | 'inspection' + | 'registration' + | 'install' + | 'update' + | 'remove' + | 'state'; + +export interface NativeEffect { + action: NativeEffectAction; + resource: NativeResource; + phase?: NativeEffectPhase; + changed?: boolean; + error?: string; +} + +export interface NativeEffectData { + action: NativeEffectAction; + phase: NativeEffectPhase; + changed: boolean; + client: string; + scope: NativeScope; + nativeScope: string; + kind: NativeResourceKind; + requestedIdentity: string; + resolvedIdentity: string; + root: string; + provenance: Readonly>; + error?: string; +} + +function defaultEffectPhase(action: NativeEffectAction): NativeEffectPhase { + switch (action) { + case 'registered': + case 'would-register': + return 'registration'; + case 'installed': + case 'would-install': + return 'install'; + case 'updated': + case 'would-update': + return 'update'; + case 'removed': + case 'would-remove': + return 'remove'; + case 'retained': + return 'state'; + default: + return 'inspection'; + } +} + +function defaultEffectChanged(action: NativeEffectAction): boolean { + return ( + action === 'registered' || + action === 'installed' || + action === 'updated' || + action === 'removed' + ); +} + +function stripNativeTerminalControls(value: string): string { + const safe: string[] = []; + let index = 0; + while (index < value.length) { + const code = value.charCodeAt(index); + if (code === 0x1b && value.charCodeAt(index + 1) === 0x5b) { + let end = index + 2; + while ( + value.charCodeAt(end) >= 0x30 && + value.charCodeAt(end) <= 0x3f + ) { + end++; + } + while ( + value.charCodeAt(end) >= 0x20 && + value.charCodeAt(end) <= 0x2f + ) { + end++; + } + const final = value.charCodeAt(end); + if (final >= 0x40 && final <= 0x7e) { + index = end + 1; + continue; + } + } + + if ( + code <= 0x08 || + code === 0x0b || + code === 0x0c || + (code >= 0x0e && code <= 0x1f) || + code === 0x7f + ) { + index++; + continue; + } + safe.push(value.charAt(index)); + index++; + } + return safe.join(''); +} + +/** + * Keep native failures single-line and free of terminal control sequences so + * the same safe value can be emitted in human and structured output. + */ +export function sanitizeNativeError(error: string | undefined): string | undefined { + if (!error) return undefined; + const sanitized = stripNativeTerminalControls(error) + .replace(/\s*\r?\n\s*/g, ' ') + .trim(); + return sanitized || undefined; } -export interface NativePluginInstalled { - plugin: string; - client?: string; +const SENSITIVE_PROVENANCE_KEY = + /(?:^|[-_.])(auth|credential|key|password|secret|signature|token)(?:$|[-_.])/i; + +export function sanitizeNativeProvenance( + provenance: Readonly>, +): Readonly> { + const sanitized: Record = {}; + for (const [key, rawValue] of Object.entries(provenance)) { + if (SENSITIVE_PROVENANCE_KEY.test(key)) continue; + let value = rawValue; + try { + const url = new URL(rawValue); + if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ssh:') { + url.username = ''; + url.password = ''; + for (const queryKey of [...url.searchParams.keys()]) { + if (SENSITIVE_PROVENANCE_KEY.test(queryKey)) { + url.searchParams.delete(queryKey); + } + } + url.searchParams.sort(); + url.hash = ''; + value = url.toString(); + } + } catch { + // Non-URL provenance is retained unless its key is sensitive. + } + sanitized[key] = value; + } + return sanitized; } -export interface NativePluginFailure { - plugin: string; - error: string; - client?: string; +export function toNativeEffectData(effect: NativeEffect): NativeEffectData { + const { resource } = effect; + const error = sanitizeNativeError(effect.error); + return { + action: effect.action, + phase: effect.phase ?? defaultEffectPhase(effect.action), + changed: effect.changed ?? defaultEffectChanged(effect.action), + client: resource.context.client, + scope: resource.context.scope, + nativeScope: resource.context.nativeScope, + kind: resource.kind, + requestedIdentity: resource.requestedIdentity, + resolvedIdentity: resource.resolvedIdentity, + root: resource.context.root, + provenance: sanitizeNativeProvenance(resource.provenance), + ...(error && { error }), + }; } export interface NativeSyncResult { - marketplacesAdded: string[]; - pluginsInstalled: NativePluginInstalled[]; - pluginsFailed: NativePluginFailure[]; - skipped: string[]; + success: boolean; + effects: NativeEffect[]; } export interface NativeClient { - /** Check if the CLI binary is available */ - isAvailable(): Promise; + readonly client: string; - /** Whether this client supports the given install scope */ - supportsScope(scope: 'user' | 'project'): boolean; + /** Check whether the CLI and required lifecycle commands are available. */ + isAvailable(context?: NativeOperationContext): Promise; - /** Convert allagents plugin source to this client's spec format. Null = not marketplace-based. */ - toPluginSpec(allagentsSource: string): string | null; + /** Whether this client supports the given AllAgents scope. */ + supportsScope(scope: NativeScope): boolean; - /** Extract marketplace owner/repo from a plugin spec. Null = not marketplace-based. */ - extractMarketplaceSource(pluginSpec: string): string | null; + /** + * Classify and normalize a configured source without mutating or fetching it. + * A failed result means explicit native installation is unsupported. + */ + resolveSource( + source: string, + context: NativeOperationContext, + provenance?: Readonly>, + ): NativeSourceResolution; - /** Register a marketplace */ - addMarketplace(source: string, options?: { cwd?: string }): Promise; + /** Inspect exact live native state for one selected client/scope/root. */ + inspect(context: NativeOperationContext): Promise; - /** Install a plugin */ - installPlugin(spec: string, scope: 'user' | 'project', options?: { cwd?: string }): Promise; + /** Install one absent resource. */ + install( + resource: NativeResource, + context: NativeOperationContext, + ): Promise; - /** Uninstall a plugin */ - uninstallPlugin(spec: string, scope: 'user' | 'project', options?: { cwd?: string }): Promise; + /** Update only the selected resource. */ + update( + resource: NativeResource, + current: NativeResource, + context: NativeOperationContext, + ): Promise; - /** High-level sync: register marketplaces + install plugins */ - syncPlugins(plugins: string[], scope: 'user' | 'project', options?: { cwd?: string; dryRun?: boolean }): Promise; + /** Remove only the selected observed resource. */ + remove( + resource: NativeResource, + context: NativeOperationContext, + ): Promise; } async function resolveWindowsBinary( @@ -206,7 +461,7 @@ async function resolveWindowsCommand( export async function executeCommand( binary: string, args: string[], - options: { cwd?: string } = {}, + options: NativeCommandOptions = {}, ): Promise { let command = { binary, args }; if (process.platform === 'win32') { @@ -217,54 +472,62 @@ export async function executeCommand( success: false, output: '', error: `Failed to execute ${binary} CLI: ${err instanceof Error ? err.message : String(err)}`, + exitCode: null, + signal: null, }; } } try { + const env = { ...process.env }; + for (const [name, value] of Object.entries(options.env ?? {})) { + if (value === undefined) delete env[name]; + else env[name] = value; + } const proc = spawn(command.binary, command.args, { cwd: options.cwd, stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env }, + env, }); - let stdout = ''; - let stderr = ''; + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; proc.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); + stdout.push(data); }); proc.stderr.on('data', (data: Buffer) => { - stderr += data.toString(); + stderr.push(data); }); - const [code] = (await once(proc, 'close')) as [number | null]; - const trimmedStderr = stderr.trim(); + const [code, signal] = (await once(proc, 'close')) as [ + number | null, + NodeJS.Signals | null, + ]; + const trimmedStderr = Buffer.concat(stderr).toString().trim(); return { success: code === 0, - output: stdout.trim(), + output: Buffer.concat(stdout).toString().trim(), ...(trimmedStderr && { error: trimmedStderr }), + exitCode: code, + signal, }; } catch (err) { return { success: false, output: '', error: `Failed to execute ${binary} CLI: ${err instanceof Error ? err.message : String(err)}`, + exitCode: null, + signal: null, }; } } -/** - * Merge multiple NativeSyncResult objects into one. - */ -export function mergeNativeSyncResults(results: NativeSyncResult[]): NativeSyncResult { - return results.reduce( - (acc, r) => ({ - marketplacesAdded: [...acc.marketplacesAdded, ...r.marketplacesAdded], - pluginsInstalled: [...acc.pluginsInstalled, ...r.pluginsInstalled], - pluginsFailed: [...acc.pluginsFailed, ...r.pluginsFailed], - skipped: [...acc.skipped, ...r.skipped], - }), - { marketplacesAdded: [], pluginsInstalled: [], pluginsFailed: [], skipped: [] } as NativeSyncResult, - ); +export function mergeNativeSyncResults( + results: NativeSyncResult[], +): NativeSyncResult { + return { + success: results.every((result) => result.success), + effects: results.flatMap((result) => result.effects), + }; } diff --git a/src/core/repo-skills.ts b/src/core/repo-skills.ts index c1fa7ed4..0a7ea876 100644 --- a/src/core/repo-skills.ts +++ b/src/core/repo-skills.ts @@ -1,9 +1,21 @@ -import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync, type Dirent } from 'node:fs'; +import { + existsSync, + lstatSync, + mkdirSync, + readdirSync, + rmSync, + writeFileSync, + type Dirent, +} from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import { basename, join, relative, resolve } from 'node:path'; import { parseSkillMetadata } from '../validators/skill.js'; import { CLIENT_MAPPINGS } from '../models/client-mapping.js'; import type { ClientType, Repository } from '../models/workspace-config.js'; +import { + resolveClientContext, + resolveMappedPath, +} from './client-context.js'; export interface WorkspaceSkillEntry { repoPath: string; name: string; @@ -31,6 +43,27 @@ interface DiscoverOptions { disabled?: boolean; } + +function codeUnitCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function runtimeSkillPrecedence(location: string): number { + const normalized = location.replaceAll('\\', '/'); + if ( + normalized.includes('/.pi/skills/') || + normalized.includes('/.omp/skills/') + ) { + return 0; + } + if ( + normalized.includes('/.agent/skills/') || + normalized.includes('/.agents/skills/') + ) { + return 1; + } + return 2; +} /** * Discover skills in a repository by scanning skill directories. * Parses SKILL.md frontmatter for name and description. @@ -43,30 +76,53 @@ export async function discoverRepoSkills( if (options.disabled) return []; const skillDirs = new Set(); + const runtimeOrderedDiscovery = options.clients?.some( + (client) => client === 'pi' || client === 'omp', + ) ?? false; if (options.skillPaths) { - for (const p of options.skillPaths) { - skillDirs.add(p); + for (const path of options.skillPaths) { + skillDirs.add(resolveMappedPath(repoPath, path)); } } else if (options.clients) { for (const client of options.clients) { + if (client === 'pi' || client === 'omp') { + const context = resolveClientContext(client, 'project', { + cwd: repoPath, + repoRoot: repoPath, + }); + for (const root of context.skillDiscoveryRoots) skillDirs.add(root); + continue; + } const mapping = CLIENT_MAPPINGS[client as ClientType]; if (mapping?.skillsPath) { - skillDirs.add(mapping.skillsPath); + skillDirs.add(resolveMappedPath(repoPath, mapping.skillsPath)); } } } const results: RepoSkillEntry[] = []; const seen = new Set(); - - for (const skillDir of skillDirs) { - const absDir = join(repoPath, skillDir); - if (!existsSync(absDir)) continue; + const seenNames = new Set(); + + const orderedSkillDirs = [...skillDirs].sort((left, right) => + runtimeOrderedDiscovery + ? runtimeSkillPrecedence(`${left}/`) - + runtimeSkillPrecedence(`${right}/`) || + codeUnitCompare(left, right) + : codeUnitCompare(left, right)); + for (const skillDir of orderedSkillDirs) { + const absDir = skillDir; + try { + if (lstatSync(absDir).isSymbolicLink()) continue; + } catch { + continue; + } let entries: Dirent[]; try { - entries = await readdir(absDir, { withFileTypes: true }); + entries = (await readdir(absDir, { withFileTypes: true })).sort((a, b) => + codeUnitCompare(a.name, b.name)); } catch { continue; } @@ -93,8 +149,10 @@ export async function discoverRepoSkills( const content = await readFile(skillMdPath, 'utf-8'); const metadata = parseSkillMetadata(content); if (!metadata) continue; + if (runtimeOrderedDiscovery && seenNames.has(metadata.name)) continue; seen.add(relPath); + seenNames.add(metadata.name); results.push({ name: metadata.name, description: metadata.description, @@ -110,20 +168,24 @@ export async function discoverRepoSkills( return results; } + + /** * Discover skills from all workspace repositories and return entries * suitable for embedding in WORKSPACE-RULES. * Shared by both updateAgentFiles() and the full sync pipeline. - * - * Deduplicates by skill name: - * - Skills under .agents/ take priority - * - Otherwise the skill with the largest file size wins + * Deduplicates by skill name. For Pi/OMP discovery, native `.pi`/`.omp` + * skills take priority over shared `.agent`/`.agents` skills. Existing clients + * retain the historical `.agents`-before-size precedence. */ export async function discoverWorkspaceSkills( workspacePath: string, repositories: Repository[], clientNames: string[], ): Promise { + const runtimeOrderedDiscovery = clientNames.some( + (client) => client === 'pi' || client === 'omp', + ); const skillsByName = new Map(); for (const repo of repositories) { @@ -152,16 +214,23 @@ export async function discoverWorkspaceSkills( skillsByName.set(skill.name, candidate); continue; } - - // .agents skills take priority const existingIsAgents = existing.location.includes('.agents/'); const candidateIsAgents = candidate.location.includes('.agents/'); - if (candidateIsAgents && !existingIsAgents) { + if (runtimeOrderedDiscovery) { + const existingRank = runtimeSkillPrecedence(existing.location); + const candidateRank = runtimeSkillPrecedence(candidate.location); + if ( + candidateRank < existingRank || + (candidateRank === existingRank && + candidate.fileSize > existing.fileSize) + ) { + skillsByName.set(skill.name, candidate); + } + } else if (candidateIsAgents && !existingIsAgents) { skillsByName.set(skill.name, candidate); } else if (!candidateIsAgents && existingIsAgents) { // keep existing } else if (candidate.fileSize > existing.fileSize) { - // same priority tier — larger file wins; equal size keeps first-seen (repo order) skillsByName.set(skill.name, candidate); } } diff --git a/src/core/status.ts b/src/core/status.ts index 83cd1f2c..34f183f0 100644 --- a/src/core/status.ts +++ b/src/core/status.ts @@ -1,19 +1,42 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE, getHomeDir } from '../constants.js'; -import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; -import { getPluginSource, getClientTypes } from '../models/workspace-config.js'; +import { + getPluginSource, + getClientTypes, + type ClientEntry, + type ClientType, + type WorkspaceConfig, +} from '../models/workspace-config.js'; import { parsePluginSource, parseGitHubUrl, getPluginCachePath, type ParsedPluginSource, } from '../utils/plugin-path.js'; -import { - isPluginSpec, - resolvePluginSpecWithAutoRegister, -} from './marketplace.js'; +import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; +import { isPluginSpec, resolvePluginSpec } from './marketplace.js'; import { getUserWorkspaceConfig, isUserConfigPath } from './user-workspace.js'; +import { + getNativeStateResources, + loadSyncState, + nativeStateOwnership, +} from './sync-state.js'; +import type { NativeStateResource } from '../models/sync-state.js'; +import { + getNativeClient, + toNativeEffectData, + type NativeEffectData, + type NativeOperationContext, + type NativeResource, + type NativeResourceObservation, +} from './native/index.js'; +import { + buildPluginSyncPlans, + nativeContextIdentity, + nativeOperationContext, +} from './sync.js'; +import { resolveClientContexts } from './client-context.js'; /** * Status of a single plugin @@ -54,6 +77,12 @@ function classifyKind(path: string): 'skill' | 'plugin' { return 'plugin'; } +export interface NativePluginStatus extends NativeEffectData { + declared: boolean; + ownership: 'managed' | 'referenced' | 'uncertain' | 'none'; + transition?: NativeStateResource['transition']; +} + /** * Result of workspace status check */ @@ -64,6 +93,229 @@ export interface WorkspaceStatusResult { /** User-level plugins from ~/.allagents/workspace.yaml */ userPlugins?: PluginStatus[]; clients: string[]; + nativeResources: NativePluginStatus[]; +} + +function nativeOwnership( + state: NativeStateResource | undefined, +): NativePluginStatus['ownership'] { + return state ? nativeStateOwnership(state.transition) : 'none'; +} + +function stateMatchesResource( + state: NativeStateResource, + resource: NativeResource, +): boolean { + return ( + state.kind === resource.kind && + (state.requestedIdentity === resource.requestedIdentity || + state.resolvedIdentity === resource.resolvedIdentity) + ); +} + +function resourceFromNativeState( + state: NativeStateResource, + context: NativeOperationContext, +): NativeResource { + return { + kind: state.kind, + requestedIdentity: state.requestedIdentity, + resolvedIdentity: state.resolvedIdentity, + context: { ...context, root: state.root ?? context.root }, + provenance: state.provenance, + }; +} + +function findExactNativeObservation( + resource: NativeResource, + observations: readonly NativeResourceObservation[] | undefined, + inspectedResources: readonly NativeResource[], +): NativeResourceObservation | undefined { + const observation = observations?.find( + (candidate) => + candidate.resource.kind === resource.kind && + candidate.resource.resolvedIdentity === resource.resolvedIdentity, + ); + if (observation) return observation; + + const inspectedResource = inspectedResources.find( + (candidate) => + candidate.kind === resource.kind && + candidate.resolvedIdentity === resource.resolvedIdentity, + ); + return inspectedResource + ? { resource: inspectedResource, status: 'installed' } + : undefined; +} + +function statusFromObservation( + resource: NativeResource, + observation: NativeResourceObservation | undefined, + state: NativeStateResource | undefined, + declared: boolean, + inspectionError?: string, +): NativePluginStatus { + const action: NativeEffectData['action'] = inspectionError + ? 'unknown' + : observation?.status ?? 'configured-missing'; + return { + ...toNativeEffectData({ + action, + phase: 'inspection', + changed: false, + resource, + ...(inspectionError && { error: inspectionError }), + ...(!inspectionError && observation?.error && { + error: observation.error, + }), + }), + declared, + ownership: nativeOwnership(state), + ...(state && { transition: state.transition }), + }; +} + +async function getNativeStatusesForScope( + config: WorkspaceConfig | null, + scope: 'user' | 'project', + workspacePath: string, +): Promise<{ statuses: NativePluginStatus[]; errors: string[] }> { + const homeDir = getHomeDir(); + const stateRoot = scope === 'user' ? homeDir : workspacePath; + const state = await loadSyncState(stateRoot); + const stateResources = (state?.nativeResources?.resources ?? []).filter( + (resource) => resource.scope === scope && !!getNativeClient(resource.client), + ); + const clientEntries: ClientEntry[] = config?.clients ?? []; + const { plans, errors: planErrors } = buildPluginSyncPlans( + config?.plugins ?? [], + clientEntries, + scope, + ); + const nativePlans = plans.filter((plan) => plan.nativeClients.length > 0); + const clients = [ + ...new Set([ + ...nativePlans.flatMap((plan) => plan.nativeClients), + ...stateResources.map((resource) => resource.client), + ]), + ]; + const contexts = resolveClientContexts(clients, scope, { + cwd: workspacePath, + homeDir, + env: process.env, + }); + const statuses: NativePluginStatus[] = []; + const errors = [...planErrors]; + + for (const client of clients) { + const adapter = getNativeClient(client); + const resolvedContext = contexts.get(client); + if (!adapter || !resolvedContext) { + errors.push(`${client} has no native lifecycle context`); + continue; + } + const context = nativeOperationContext(client, scope, resolvedContext); + const desired: NativeResource[] = []; + for (const plan of nativePlans) { + if (!plan.nativeClients.includes(client)) continue; + const resolution = adapter.resolveSource(plan.source, context, { + source: plan.source, + }); + if (!resolution.success || !resolution.resource) { + errors.push( + resolution.error ?? `${client} rejected '${plan.source}'`, + ); + continue; + } + desired.push(resolution.resource); + } + + const contextIdentity = nativeContextIdentity(context); + const tracked = getNativeStateResources( + state, + client, + scope, + contextIdentity, + ); + const staleContextState = stateResources.filter( + (resource) => + resource.client === client && resource.context !== contextIdentity, + ); + const available = await adapter.isAvailable(context); + const inspection = available + ? await adapter.inspect(context) + : { + success: false, + resources: [], + observations: [], + error: `${client} CLI is unavailable or unsupported`, + }; + const inspectionError = inspection.success + ? undefined + : (inspection.error ?? `${client} native inspection failed`); + if (inspectionError) errors.push(inspectionError); + + const emittedState = new Set(); + for (const resource of desired) { + const trackedResource = tracked.find((candidate) => + stateMatchesResource(candidate, resource)); + if (trackedResource) emittedState.add(trackedResource); + const observation = inspection.success + ? findExactNativeObservation( + resource, + inspection.observations, + inspection.resources, + ) + : undefined; + statuses.push( + statusFromObservation( + resource, + observation, + trackedResource, + true, + inspectionError, + ), + ); + } + + for (const trackedResource of tracked) { + if (emittedState.has(trackedResource)) continue; + const resource = resourceFromNativeState(trackedResource, context); + const observation = inspection.success + ? findExactNativeObservation( + resource, + inspection.observations, + inspection.resources, + ) + : undefined; + statuses.push( + statusFromObservation( + resource, + observation, + trackedResource, + false, + inspectionError, + ), + ); + } + + for (const trackedResource of staleContextState) { + const staleResource = resourceFromNativeState(trackedResource, context); + const error = `Recorded native root ${trackedResource.context} differs from selected root ${context.root}`; + errors.push(error); + statuses.push( + statusFromObservation( + staleResource, + undefined, + trackedResource, + false, + error, + ), + ); + } + } + + return { statuses, errors: [...new Set(errors)] }; } /** @@ -77,14 +329,22 @@ export async function getWorkspaceStatus( const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); // If no project workspace.yaml, or project config IS the user config - // (i.e. cwd is the home directory), return user-level plugins only + // (i.e. cwd is the home directory), return user-level plugins only. if (!existsSync(configPath) || isUserConfigPath(workspacePath)) { + const userConfig = await getUserWorkspaceConfig(); const userPlugins = await getUserPluginStatuses(); + const native = await getNativeStatusesForScope( + userConfig, + 'user', + workspacePath, + ); return { - success: true, + success: native.errors.length === 0, + ...(native.errors.length > 0 && { error: native.errors.join('; ') }), plugins: [], userPlugins, clients: [], + nativeResources: native.statuses, }; } @@ -95,7 +355,10 @@ export async function getWorkspaceStatus( for (const pluginEntry of config.plugins) { const pluginSource = getPluginSource(pluginEntry); if (isPluginSpec(pluginSource)) { - const status = await getMarketplacePluginStatus(pluginSource); + const status = await getMarketplacePluginStatus( + pluginSource, + workspacePath, + ); plugins.push(status); } else { const parsed = parsePluginSource(pluginSource, workspacePath); @@ -104,13 +367,32 @@ export async function getWorkspaceStatus( } } + const userConfig = await getUserWorkspaceConfig(); const userPlugins = await getUserPluginStatuses(); + const userNative = await getNativeStatusesForScope( + userConfig, + 'user', + workspacePath, + ); + const projectNative = await getNativeStatusesForScope( + config, + 'project', + workspacePath, + ); + const nativeErrors = [...userNative.errors, ...projectNative.errors]; return { - success: true, + success: nativeErrors.length === 0, + ...(nativeErrors.length > 0 && { + error: [...new Set(nativeErrors)].join('; '), + }), plugins, userPlugins, clients: getClientTypes(config.clients), + nativeResources: [ + ...userNative.statuses, + ...projectNative.statuses, + ], }; } catch (error) { return { @@ -118,6 +400,7 @@ export async function getWorkspaceStatus( error: error instanceof Error ? error.message : String(error), plugins: [], clients: [], + nativeResources: [], }; } } @@ -191,14 +474,21 @@ async function getUserPluginStatuses(): Promise { /** * Get status of a plugin@marketplace spec */ -async function getMarketplacePluginStatus(spec: string): Promise { - const resolved = await resolvePluginSpecWithAutoRegister(spec, { offline: true }); +async function getMarketplacePluginStatus( + spec: string, + workspacePath?: string, +): Promise { + const resolved = await resolvePluginSpec(spec, { + offline: true, + ...(workspacePath && { workspacePath }), + }); + const path = resolved?.path ?? ''; return { source: spec, type: 'marketplace', - kind: classifyKind(resolved.success ? (resolved.path ?? '') : ''), - available: resolved.success, - path: resolved.path ?? '', + kind: classifyKind(path), + available: resolved !== null, + path, }; } diff --git a/src/core/sync-state.ts b/src/core/sync-state.ts index 06241653..0c8f1a87 100644 --- a/src/core/sync-state.ts +++ b/src/core/sync-state.ts @@ -1,9 +1,10 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { readFile, writeFile, mkdir, rename, rm } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { CONFIG_DIR, SYNC_STATE_FILE } from '../constants.js'; import { SyncStateSchema, + type NativeResourceState, + type NativeStateResource, type SyncState, type SyncStateSource, } from '../models/sync-state.js'; @@ -20,7 +21,9 @@ export interface SyncStateData { files: Partial>; codexHooks?: SyncState['codexHooks']; mcpServers?: Partial>; + /** Legacy native identities, retained only for conservative migration. */ nativePlugins?: Partial>; + nativeResources?: NativeResourceState; vscodeWorkspaceHash?: string; vscodeWorkspaceRepos?: string[]; skillsIndex?: string[]; @@ -35,6 +38,31 @@ export interface SyncStateData { export function getSyncStatePath(workspacePath: string): string { return join(workspacePath, CONFIG_DIR, SYNC_STATE_FILE); } +async function readRawState(statePath: string): Promise | null> { + try { + const parsed: unknown = JSON.parse(await readFile(statePath, 'utf-8')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +async function writeStateAtomically( + statePath: string, + state: SyncState, +): Promise { + const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(tempPath, JSON.stringify(state, null, 2), 'utf-8'); + await rename(tempPath, statePath); + } catch (error) { + await rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } +} + /** * Load sync state from disk @@ -45,20 +73,14 @@ export function getSyncStatePath(workspacePath: string): string { export async function loadSyncState(workspacePath: string): Promise { const statePath = getSyncStatePath(workspacePath); - if (!existsSync(statePath)) { - return null; - } - try { - const content = await readFile(statePath, 'utf-8'); - const parsed = JSON.parse(content); + const parsed = await readRawState(statePath); + if (!parsed) return null; const result = SyncStateSchema.safeParse(parsed); - if (!result.success) { - // Corrupted state file - treat as no state (safe behavior) + // Unknown/corrupt state grants no deletion authority. return null; } - return result.data; } catch { // Read or parse error - treat as no state @@ -77,30 +99,46 @@ export async function saveSyncState( ): Promise { const statePath = getSyncStatePath(workspacePath); - // Support both old signature (just files) and new signature (SyncStateData) + // Support the historical files-only signature. const normalizedData: SyncStateData = 'files' in data ? data as SyncStateData : { files: data as Partial> }; - - const state: SyncState = { + const existing = (await readRawState(statePath)) ?? {}; + const candidate: Record = { + ...existing, version: 1, lastSync: new Date().toISOString(), - files: normalizedData.files as Record, - ...(normalizedData.codexHooks && { codexHooks: normalizedData.codexHooks }), - ...(normalizedData.mcpServers && { mcpServers: normalizedData.mcpServers }), - ...(normalizedData.nativePlugins && { nativePlugins: normalizedData.nativePlugins }), - ...(normalizedData.vscodeWorkspaceHash && { vscodeWorkspaceHash: normalizedData.vscodeWorkspaceHash }), - ...(normalizedData.vscodeWorkspaceRepos && { vscodeWorkspaceRepos: normalizedData.vscodeWorkspaceRepos }), - ...(normalizedData.skillsIndex && normalizedData.skillsIndex.length > 0 && { skillsIndex: normalizedData.skillsIndex }), - ...(normalizedData.sources && - Object.keys(normalizedData.sources).length > 0 && { sources: normalizedData.sources }), + files: normalizedData.files, }; + for (const key of [ + 'codexHooks', + 'mcpServers', + 'nativePlugins', + 'nativeResources', + 'vscodeWorkspaceHash', + 'vscodeWorkspaceRepos', + 'skillsIndex', + 'sources', + ] as const) { + if (key in normalizedData) { + const value = normalizedData[key]; + if (value === undefined) delete candidate[key]; + else candidate[key] = value; + } + } + + const parsed = SyncStateSchema.safeParse(candidate); + if (!parsed.success) { + throw new Error(`Refusing to write invalid sync state: ${parsed.error.message}`); + } + await mkdir(dirname(statePath), { recursive: true }); await ensureConfigGitignore(workspacePath); - await writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); + await writeStateAtomically(statePath, parsed.data); } + /** * Get files that were previously synced for a specific client * @param state - Loaded sync state (or null) @@ -136,17 +174,82 @@ export function getPreviouslySyncedMcpServers( } /** - * Get native plugins previously installed for a specific client - * @param state - Loaded sync state (or null) - * @param client - Client type to get native plugins for - * @returns Array of plugin names, empty if no state or no plugins for client + * Return exact native state records for one ordinary client/scope/context. + * Legacy string identities are intentionally excluded: they are not cleanup + * authority until live inspection and a desired declaration corroborate them. */ -export function getPreviouslySyncedNativePlugins( +export function getNativeStateResources( state: SyncState | null, client: ClientType, -): string[] { - if (!state?.nativePlugins) return []; - return state.nativePlugins[client] ?? []; + scope: 'user' | 'project', + context: string, +): NativeStateResource[] { + return (state?.nativeResources?.resources ?? []).filter( + (resource) => + resource.client === client && + resource.scope === scope && + resource.context === context, + ); +} + +export function nativeStateOwnership( + transition: NativeStateResource['transition'], +): 'managed' | 'referenced' | 'uncertain' { + if (transition === 'referenced') return 'referenced'; + if ( + transition === 'managed' || + transition === 'pending-install' || + transition === 'pending-update' || + transition === 'pending-remove' || + transition === 'cleanup-failed' + ) { + return 'managed'; + } + return 'uncertain'; +} + +export async function saveNativeStateResources( + workspacePath: string, + resources: NativeStateResource[], +): Promise { + const statePath = getSyncStatePath(workspacePath); + let raw: Record = {}; + try { + const parsed: unknown = JSON.parse(await readFile(statePath, 'utf-8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('expected an object'); + } + raw = parsed as Record; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error( + `Refusing to patch malformed sync state: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + const files = SyncStateSchema.shape.files.safeParse(raw.files); + if (!files.success) { + throw new Error( + `Refusing to patch native state because file ownership is invalid: ${files.error.message}`, + ); + } + const candidate = SyncStateSchema.safeParse({ + ...raw, + version: 1, + lastSync: new Date().toISOString(), + files: files.data, + nativeResources: { version: 1, resources }, + }); + if (!candidate.success) { + throw new Error( + `Refusing to patch native state because unrelated state is invalid: ${candidate.error.message}`, + ); + } + + await mkdir(dirname(statePath), { recursive: true }); + await ensureConfigGitignore(workspacePath); + await writeStateAtomically(statePath, candidate.data); } /** diff --git a/src/core/sync.ts b/src/core/sync.ts index 67a65827..3ed87ad4 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,4 +1,10 @@ -import { existsSync, readFileSync, writeFileSync, lstatSync } from 'node:fs'; +import { + existsSync, + readFileSync, + writeFileSync, + lstatSync, + type Stats, +} from 'node:fs'; import { rm, unlink, rmdir, copyFile } from 'node:fs/promises'; import { join, resolve, dirname, relative } from 'node:path'; import JSON5 from 'json5'; @@ -83,11 +89,17 @@ import { import { loadSyncState, saveSyncState, + saveNativeStateResources, getPreviouslySyncedFiles, getPreviouslySyncedMcpServers, - getPreviouslySyncedNativePlugins, + getNativeStateResources, + nativeStateOwnership, } from './sync-state.js'; -import type { SyncState, SyncStateSource } from '../models/sync-state.js'; +import type { + NativeStateResource, + SyncState, + SyncStateSource, +} from '../models/sync-state.js'; import { getUserWorkspaceConfig, migrateUserWorkspaceSkillsV1toV2, @@ -121,10 +133,23 @@ import { syncMcpServers as runMcpSync } from './mcp-sync.js'; import { getNativeClient, mergeNativeSyncResults, + sanitizeNativeProvenance, + type NativeEffect, + type NativeMutationResult, + type NativeOperationContext, + type NativeResource, type NativeSyncResult, } from './native/index.js'; import { Stopwatch } from '../utils/stopwatch.js'; import { processManagedRepos } from './managed-repos.js'; +import { + assertSafeDestination, + clientMappingsFromContexts, + pathIsWithin, + resolveClientContexts, + resolveMappedPath, + type ResolvedClientContext, +} from './client-context.js'; /** * Result of deduplicating clients by skillsPath @@ -236,24 +261,10 @@ export function mergeSyncResults(a: SyncResult, b: SyncResult): SyncResult { a.mcpResults || b.mcpResults ? { ...a.mcpResults, ...b.mcpResults } : undefined; - // Merge nativeResults when both scopes produce them + // Merge native effects in execution order across the two ordinary scopes. const nativeResult = a.nativeResult && b.nativeResult - ? { - marketplacesAdded: [ - ...a.nativeResult.marketplacesAdded, - ...b.nativeResult.marketplacesAdded, - ], - pluginsInstalled: [ - ...a.nativeResult.pluginsInstalled, - ...b.nativeResult.pluginsInstalled, - ], - pluginsFailed: [ - ...a.nativeResult.pluginsFailed, - ...b.nativeResult.pluginsFailed, - ], - skipped: [...a.nativeResult.skipped, ...b.nativeResult.skipped], - } + ? mergeNativeSyncResults([a.nativeResult, b.nativeResult]) : (a.nativeResult ?? b.nativeResult); return { success: a.success && b.success, @@ -321,6 +332,8 @@ export interface SyncOptions { offline?: boolean; /** Simulate sync without making changes */ dryRun?: boolean; + /** Overwrite differing MCP entries where the scoped sync supports it. */ + force?: boolean; /** * Base path for resolving relative workspace.source paths. * Used during init to resolve paths relative to the --from source directory @@ -331,6 +344,14 @@ export interface SyncOptions { skipAgentFiles?: boolean; /** Skip managed repository clone/pull operations */ skipManaged?: boolean; + /** + * Restrict native mutation to explicit declaration/state identities. + * Ordinary workspace sync leaves this unset and performs full reconciliation. + */ + nativeSelection?: { + mode: 'update' | 'remove'; + targets: readonly string[]; + }; } /** @@ -413,50 +434,269 @@ function resolveNativePluginSource(vp: ValidatedPlugin): { }; } -/** - * Collect native plugin specs and marketplace sources from validated plugins. - * Resolves canonical marketplace names so native CLI operations use the correct spec. - */ -export function collectNativePluginSources(validPlugins: ValidatedPlugin[]): { - pluginsByClient: Map; - marketplaceSourcesByClient: Map>; -} { - const pluginsByClient = new Map(); - const marketplaceSourcesByClient = new Map>(); - - for (const vp of validPlugins) { - for (const client of vp.nativeClients) { - const existing = pluginsByClient.get(client) ?? []; - const { spec, marketplaceSource } = resolveNativePluginSource(vp); - existing.push(spec); - pluginsByClient.set(client, existing); - - if (marketplaceSource) { - const sources = marketplaceSourcesByClient.get(client) ?? new Set(); - sources.add(marketplaceSource); - marketplaceSourcesByClient.set(client, sources); - } +export function nativeOperationContext( + client: ClientType, + scope: 'user' | 'project', + context: ResolvedClientContext, +): NativeOperationContext { + return { + client, + scope, + nativeScope: scope, + root: resolve(context.writeRoot), + cwd: context.commandCwd, + env: context.commandEnv, + ...(context.ompRoots && { roots: { ...context.ompRoots } }), + }; +} + +export function nativeContextIdentity(context: NativeOperationContext): string { + if (context.client !== 'omp') return resolve(context.root); + const roots = Object.entries(context.roots ?? {}) + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([name, path]) => [name, resolve(path)]); + return JSON.stringify({ + root: resolve(context.root), + roots, + }); +} + +function nativeLogicalIdentity( + client: ClientType, + resource: NativeResource, +): string { + if (client === 'pi') { + const packageIdentity = resource.provenance.packageIdentity; + if (packageIdentity) return `package:${packageIdentity}`; + } + return `${resource.kind}:${resource.resolvedIdentity}`; +} + +function collectNativeResources( + validPlugins: ValidatedPlugin[], + scope: 'user' | 'project', + contexts: Map, +): Map { + const resources = new Map(); + for (const plugin of validPlugins) { + const { spec, marketplaceSource } = resolveNativePluginSource(plugin); + for (const client of plugin.nativeClients) { + const adapter = getNativeClient(client); + const context = contexts.get(client); + if (!adapter || !context) continue; + const resolution = adapter.resolveSource( + spec, + nativeOperationContext(client, scope, context), + { + source: plugin.plugin, + ...(marketplaceSource && { marketplaceSource }), + }, + ); + if (!resolution.success || !resolution.resource) continue; + const existing = resources.get(client) ?? []; + existing.push({ + ...resolution.resource, + requestedIdentity: plugin.plugin, + }); + resources.set(client, existing); } } + return resources; +} +export function nativeIdentityMatches( + target: string, + requestedIdentity: string, + resolvedIdentity: string, +): boolean { + if (target === requestedIdentity || target === resolvedIdentity) return true; + const targetSpec = parsePluginSpec(target); + const requestedSpec = parsePluginSpec(requestedIdentity); + const resolvedSpec = parsePluginSpec(resolvedIdentity); + if (targetSpec) { + return [requestedSpec, resolvedSpec].some( + (candidate) => + candidate?.plugin === targetSpec.plugin && + candidate.marketplaceName === targetSpec.marketplaceName, + ); + } + if ( + requestedSpec?.plugin === target || + resolvedSpec?.plugin === target + ) { + return true; + } + + const packageName = (identity: string): string | null => { + if (!identity.startsWith('npm:')) return null; + const spec = identity.slice(4); + const versionSeparator = spec.startsWith('@') + ? spec.indexOf('@', spec.indexOf('/') + 1) + : spec.lastIndexOf('@'); + return versionSeparator > 0 ? spec.slice(0, versionSeparator) : spec; + }; + return ( + packageName(requestedIdentity) === target || + packageName(resolvedIdentity) === target + ); +} - return { pluginsByClient, marketplaceSourcesByClient }; +function nativeSelectionMatches( + selection: SyncOptions['nativeSelection'], + requestedIdentity: string, + resolvedIdentity: string, +): boolean { + return ( + !selection || + selection.targets.some((target) => + nativeIdentityMatches(target, requestedIdentity, resolvedIdentity)) + ); } -function attachNativeClientContext( - result: NativeSyncResult, - clientType: ClientType, +async function preflightNativePlans( + plans: PluginSyncPlan[], + scope: 'user' | 'project', + contexts: Map, + selection?: SyncOptions['nativeSelection'], +): Promise { + const desiredIdentities = new Map>(); + const errors: string[] = []; + const available = new Map(); + const inspected = new Map(); + for (const plan of plans) { + if ( + selection && + !nativeSelectionMatches(selection, plan.source, plan.source) + ) { + continue; + } + for (const client of plan.nativeClients) { + const adapter = getNativeClient(client); + const context = contexts.get(client); + if (!adapter || !context) { + errors.push(`${client} has no native lifecycle adapter`); + continue; + } + const operationContext = nativeOperationContext(client, scope, context); + const resolution = adapter.resolveSource(plan.source, operationContext, { + source: plan.source, + }); + if (!resolution.success || !resolution.resource) { + errors.push(resolution.error ?? `${client} rejected '${plan.source}'`); + continue; + } + const logicalIdentity = nativeLogicalIdentity(client, resolution.resource); + const clientIdentities = desiredIdentities.get(client) ?? new Map(); + const duplicate = clientIdentities.get(logicalIdentity); + if (duplicate) { + errors.push( + `${client} native declarations '${duplicate}' and '${plan.source}' both resolve to ${logicalIdentity}`, + ); + continue; + } + clientIdentities.set(logicalIdentity, plan.source); + desiredIdentities.set(client, clientIdentities); + let cliAvailable = available.get(client); + if (cliAvailable === undefined) { + cliAvailable = await adapter.isAvailable(operationContext); + available.set(client, cliAvailable); + } + if (!cliAvailable) { + errors.push(`${client} CLI is unavailable for required native install`); + continue; + } + if (!inspected.has(client)) { + const inspection = await adapter.inspect(operationContext); + inspected.set( + client, + inspection.success + ? null + : (inspection.error ?? 'native inspection failed'), + ); + } + const inspectionError = inspected.get(client); + if (inspectionError) { + errors.push(`${client} native inspection failed: ${inspectionError}`); + } + } + } + return [...new Set(errors)]; +} + +function nativePreflightFailureResult( + plans: PluginSyncPlan[], + scope: 'user' | 'project', + contexts: Map, + errors: string[], + selection?: SyncOptions['nativeSelection'], ): NativeSyncResult { - return { - ...result, - pluginsInstalled: result.pluginsInstalled.map((installed) => ({ - ...installed, - client: clientType, - })), - pluginsFailed: result.pluginsFailed.map((failure) => ({ - ...failure, - client: clientType, - })), - }; + const effects: NativeEffect[] = []; + for (const plan of plans) { + if ( + selection && + !nativeSelectionMatches(selection, plan.source, plan.source) + ) { + continue; + } + for (const client of plan.nativeClients) { + const resolvedContext = contexts.get(client); + if (!resolvedContext) continue; + const context = nativeOperationContext(client, scope, resolvedContext); + const adapter = getNativeClient(client); + const resolution = adapter?.resolveSource(plan.source, context, { + source: plan.source, + }); + const resource: NativeResource = + resolution?.resource ?? { + kind: client === 'pi' ? 'package' : 'plugin', + requestedIdentity: plan.source, + resolvedIdentity: plan.source, + context, + provenance: { source: plan.source }, + }; + const error = + errors.find( + (candidate) => + candidate.startsWith(`${client} `) || + candidate.startsWith(`${client.toUpperCase()} `) || + candidate.includes(`${client} native`), + ) ?? errors.join('; '); + effects.push({ + action: 'failed', + phase: 'inspection', + changed: false, + resource, + error, + }); + } + } + return { success: false, effects }; +} + +/** + * Validate a prospective plugin declaration against every native client before + * a CLI handler edits workspace.yaml or triggers generic source fetching. + */ +export async function preflightNativePluginDeclaration( + plugin: PluginEntry, + clientEntries: ClientEntry[], + scope: 'user' | 'project', + workspacePath: string, +): Promise { + const { plans, errors } = buildPluginSyncPlans( + [plugin], + clientEntries, + scope, + ); + if (errors.length > 0) return errors; + const nativePlans = plans.filter((plan) => plan.nativeClients.length > 0); + if (nativePlans.length === 0) return []; + const clients = collectSyncClients(clientEntries, nativePlans); + const contexts = resolveClientContexts(clients, scope, { + cwd: workspacePath, + homeDir: getHomeDir(), + env: process.env, + }); + return preflightNativePlans(nativePlans, scope, contexts); } export function collectSyncClients( @@ -599,6 +839,51 @@ export function getPurgePaths( return result; } +const MANAGED_DIRECTORY_KEYS = [ + 'commandsPath', + 'skillsPath', + 'hooksPath', + 'agentsPath', + 'githubPath', +] as const satisfies readonly (keyof ClientMapping)[]; + +function resolveTrackedPath( + workspacePath: string, + filePath: string, +): string { + return resolveMappedPath(workspacePath, filePath.replace(/[\\/]$/, '')); +} + +function trackedPathIsAllowed( + workspacePath: string, + filePath: string, + mapping: ClientMapping, + context?: ResolvedClientContext, +): boolean { + const candidate = resolveTrackedPath(workspacePath, filePath); + const agentFiles = [mapping.agentFile, mapping.agentFileFallback].filter( + (path): path is string => path !== undefined, + ); + if ( + agentFiles.some( + (path) => resolveMappedPath(workspacePath, path) === candidate, + ) + ) { + return true; + } + + const writeRoot = context?.writeRoot ?? workspacePath; + if (!pathIsWithin(writeRoot, candidate)) return false; + + return MANAGED_DIRECTORY_KEYS.some((key) => { + const mappedPath = mapping[key]; + return Boolean( + mappedPath && + pathIsWithin(resolveMappedPath(workspacePath, mappedPath), candidate), + ); + }); +} + /** * Selectively purge only files that were previously synced * Non-destructive: preserves user-created files @@ -611,6 +896,8 @@ export async function selectivePurgeWorkspace( workspacePath: string, state: SyncState | null, clients: ClientType[], + clientMappings: Record = CLIENT_MAPPINGS, + clientContexts?: ReadonlyMap, ): Promise { // First sync - no state, skip purge entirely (safe overlay) if (!state) { @@ -619,28 +906,30 @@ export async function selectivePurgeWorkspace( const result: PurgePaths[] = []; - // Get all clients that have files in the previous state - const previousClients = Object.keys(state.files) as ClientType[]; - // Include both current clients AND clients that were removed from config. - // Removed clients must be purged to avoid orphaned files on disk when a user - // removes a client from workspace.yaml (e.g., removes 'copilot' from clients list). + const previousClients = Object.keys(state.files) as ClientType[]; const clientsToProcess = [...new Set([...clients, ...previousClients])]; for (const client of clientsToProcess) { const previousFiles = getPreviouslySyncedFiles(state, client); + const mapping = clientMappings[client]; + if (!mapping) continue; + const context = clientContexts?.get(client); const purgedPaths: string[] = []; - // Delete each previously synced file for (const filePath of previousFiles) { - const fullPath = join(workspacePath, filePath); - - // Use lstatSync instead of existsSync — existsSync follows symlinks, - // so broken symlinks (target already deleted) return false and get skipped. - // Since we track synced files in state, if it's tracked we should remove it. - // Strip trailing slash so lstatSync checks the symlink entry itself. - const cleanPath = fullPath.replace(/\/$/, ''); - let stats: ReturnType; + if ( + !trackedPathIsAllowed( + workspacePath, + filePath, + mapping, + context, + ) + ) { + continue; + } + const cleanPath = resolveTrackedPath(workspacePath, filePath); + let stats: Stats; try { stats = lstatSync(cleanPath); } catch { @@ -648,22 +937,20 @@ export async function selectivePurgeWorkspace( } try { - // Check if it's a symlink - these need special handling - // (rm with trailing slash on a symlink fails with ENOTDIR) + await assertSafeDestination( + context?.writeRoot ?? workspacePath, + cleanPath, + { allowFinalSymlink: true }, + ); if (stats.isSymbolicLink()) { - // Remove symlink (works without trailing slash) await unlink(cleanPath); - } else if (filePath.endsWith('/')) { - // Regular directory - remove recursively - await rm(fullPath, { recursive: true, force: true }); + } else if (filePath.endsWith('/') || filePath.endsWith('\\')) { + await rm(cleanPath, { recursive: true, force: true }); } else { - // Regular file - await unlink(fullPath); + await unlink(cleanPath); } purgedPaths.push(filePath); - - // Clean up empty parent directories - await cleanupEmptyParents(workspacePath, filePath); + await cleanupEmptyParents(context?.writeRoot ?? workspacePath, cleanPath); } catch { // Best effort - continue with other files } @@ -678,29 +965,27 @@ export async function selectivePurgeWorkspace( } /** - * Clean up empty parent directories after file deletion - * Stops at workspace root + * Clean up empty parent directories after file deletion. + * The resolved write root itself is never removed. */ async function cleanupEmptyParents( - workspacePath: string, - filePath: string, + writeRoot: string, + deletedPath: string, ): Promise { - let parentPath = dirname(filePath); - - while (parentPath && parentPath !== '.' && parentPath !== '/') { - const fullParentPath = join(workspacePath, parentPath); + const root = resolve(writeRoot); + let parentPath = dirname(deletedPath); - if (!existsSync(fullParentPath)) { + while (parentPath !== root && pathIsWithin(root, parentPath)) { + if (!existsSync(parentPath)) { parentPath = dirname(parentPath); continue; } try { - // rmdir only works on empty directories - will throw if not empty - await rmdir(fullParentPath); + await assertSafeDestination(root, parentPath); + await rmdir(parentPath); parentPath = dirname(parentPath); } catch { - // Directory not empty or other error - stop climbing break; } } @@ -961,11 +1246,12 @@ export function collectSyncedPaths( clients: ClientType[], clientMappings?: Record, agentDedupeRecords?: AgentDedupeRecord[], + clientContexts?: ReadonlyMap, ): Partial> { const result: Partial> = {}; const mappings = clientMappings ?? CLIENT_MAPPINGS; + const absoluteWorkspace = resolve(workspacePath); - // Initialize arrays for each client for (const client of clients) { result[client] = []; } @@ -974,43 +1260,55 @@ export function collectSyncedPaths( if (copyResult.action !== 'copied' && copyResult.action !== 'generated') { continue; } + const destination = resolve(copyResult.destination); - // Get relative path from workspace (normalize to forward slashes for cross-platform consistency) - const relativePath = relative( - workspacePath, - copyResult.destination, - ).replace(/\\/g, '/'); - - // Track file for ALL clients whose paths match (not just the first one) - // This is important when multiple clients share the same skillsPath for (const client of clients) { const mapping = mappings[client]; + if (!mapping) continue; + const context = clientContexts?.get(client); + const agentFileDestinations = [ + mapping.agentFile, + mapping.agentFileFallback, + ] + .filter((path): path is string => path !== undefined) + .map((path) => resolveMappedPath(workspacePath, path)); + const belongsToAgentFile = agentFileDestinations.includes(destination); + if ( + context && + !pathIsWithin(context.writeRoot, destination) && + !belongsToAgentFile + ) { + continue; + } - // Check if this is a skill directory (copy results for skills point to the dir) - // e.g., relativePath = '.agents/skills/my-skill', skillsPath = '.agents/skills/' - if (mapping.skillsPath && relativePath.startsWith(mapping.skillsPath)) { - const skillName = relativePath.slice(mapping.skillsPath.length); - // If skillName has no '/', this is a skill directory (not a file inside) - if (!skillName.includes('/')) { - // Track skill directory with trailing / for efficient rm -rf - result[client]?.push(`${relativePath}/`); - continue; // Don't break - check other clients too + const trackedPath = ( + pathIsWithin(absoluteWorkspace, destination) + ? relative(absoluteWorkspace, destination) + : destination + ).replaceAll('\\', '/'); + const skillsRoot = resolveMappedPath(workspacePath, mapping.skillsPath); + if (pathIsWithin(skillsRoot, destination)) { + const skillName = relative(skillsRoot, destination); + if ( + skillName && + !skillName.includes('/') && + !skillName.includes('\\') + ) { + result[client]?.push(`${trackedPath}/`); + continue; } } - // Check if file belongs to this client's paths - if ( - (mapping.commandsPath && - relativePath.startsWith(mapping.commandsPath)) || - (mapping.skillsPath && relativePath.startsWith(mapping.skillsPath)) || - (mapping.hooksPath && relativePath.startsWith(mapping.hooksPath)) || - (mapping.agentsPath && relativePath.startsWith(mapping.agentsPath)) || - relativePath === mapping.agentFile || - (mapping.agentFileFallback && - relativePath === mapping.agentFileFallback) - ) { - result[client]?.push(relativePath); - // Don't break - continue checking other clients that might share this path + const directoryRoots = MANAGED_DIRECTORY_KEYS + .map((key) => mapping[key]) + .filter((path): path is string => path !== undefined) + .map((path) => resolveMappedPath(workspacePath, path)); + const belongsToDirectory = directoryRoots.some((root) => + pathIsWithin(root, destination), + ); + + if (belongsToDirectory || belongsToAgentFile) { + result[client]?.push(trackedPath); } } } @@ -1287,8 +1585,9 @@ export function buildPluginSyncPlans( plugins: PluginEntry[], clientEntries: ClientEntry[], scope: 'user' | 'project', -): { plans: PluginSyncPlan[]; warnings: string[] } { +): { plans: PluginSyncPlan[]; warnings: string[]; errors: string[] } { const warnings: string[] = []; + const errors: string[] = []; const workspaceClientTypes = getClientTypes(clientEntries); const plans = plugins.map((plugin, configurationIndex) => { @@ -1301,34 +1600,46 @@ export function buildPluginSyncPlans( ); } - const effectiveClients = pluginClientTypes; - - // Split into file and native clients based on resolved install mode const fileClients: ClientType[] = []; const nativeClients: ClientType[] = []; - - for (const client of effectiveClients) { + for (const client of pluginClientTypes) { const clientEntry = normalizeClientEntry( clientEntries.find( - (e) => (typeof e === 'string' ? e : e.name) === client, + (entry) => (typeof entry === 'string' ? entry : entry.name) === client, ) ?? client, ); - const mode = resolveInstallMode(plugin, clientEntry); - - // Check if this client supports native install AND the plugin is marketplace-based for this client - const nativeClient = mode === 'native' ? getNativeClient(client) : null; - if (nativeClient && nativeClient.toPluginSpec(source) !== null) { - if (nativeClient.supportsScope(scope)) { - nativeClients.push(client); - } else { - fileClients.push(client); - warnings.push( - `${client} native install only supports user scope, falling back to file copy`, - ); - } - } else { + if (resolveInstallMode(plugin, clientEntry) === 'file') { fileClients.push(client); + continue; + } + + const adapter = getNativeClient(client); + if (!adapter) { + errors.push( + `${client} does not support explicit native install for '${source}'`, + ); + continue; } + if (!adapter.supportsScope(scope)) { + errors.push( + `${client} does not support explicit native install at ${scope} scope for '${source}'`, + ); + continue; + } + const classification = adapter.resolveSource(source, { + client, + scope, + nativeScope: scope, + root: '', + }); + if (!classification.success) { + errors.push( + classification.error ?? + `${client} does not support native source '${source}'`, + ); + continue; + } + nativeClients.push(client); } const exclude = getPluginExclude(plugin); @@ -1344,7 +1655,7 @@ export function buildPluginSyncPlans( }; }); - return { plans, warnings }; + return { plans, warnings, errors: [...new Set(errors)] }; } /** @@ -1369,7 +1680,35 @@ export async function validateAllPlugins( exclude, pluginSkillsConfig, }) => { - const validated = await validatePlugin(source, workspacePath, offline); + let validated: ValidatedPlugin; + if (clients.length === 0 && nativeClients.length > 0) { + const parsed = parsePluginSpec(source); + const marketplace = parsed + ? await getMarketplace(parsed.marketplaceName, workspacePath) + : null; + const declaredMarketplaceSource = + parsed?.owner && parsed.repo + ? `${parsed.owner}/${parsed.repo}` + : undefined; + validated = { + plugin: source, + resolved: '', + success: true, + clients: [], + nativeClients: [], + ...(parsed && { pluginName: parsed.plugin }), + ...(parsed && { + registeredAs: marketplace?.name ?? parsed.marketplaceName, + }), + ...(declaredMarketplaceSource + ? { marketplaceSource: declaredMarketplaceSource } + : marketplace?.source.type === 'github' + ? { marketplaceSource: marketplace.source.location } + : {}), + }; + } else { + validated = await validatePlugin(source, workspacePath, offline); + } const result: ValidatedPlugin = { ...validated, configurationIndex, @@ -1377,8 +1716,9 @@ export async function validateAllPlugins( nativeClients, }; if (exclude) result.exclude = exclude; - if (pluginSkillsConfig !== undefined) + if (pluginSkillsConfig !== undefined) { result.pluginSkillsConfig = pluginSkillsConfig; + } return result; }, ), @@ -1416,6 +1756,7 @@ async function copyValidatedPlugin( agentOutputs: readonly AgentOutput[] = [], agentConflicts: readonly AgentOutputConflict[] = [], agentFailures: readonly AgentOutputFailure[] = [], + clientWriteRoots: Partial> = {}, ): Promise { const copyResults: CopyResult[] = []; let agentOutputsAssigned = false; @@ -1451,6 +1792,7 @@ async function copyValidatedPlugin( dryRun, ...(skillNameMap && { skillNameMap }), clientMappings: mappings, + writeRoot: clientWriteRoots[representative] ?? workspacePath, syncMode: 'copy', agentOutputs: agentOutputsAssigned ? [] : agentOutputs, ...(exclude && { exclude }), @@ -1471,6 +1813,7 @@ async function copyValidatedPlugin( dryRun, ...(skillNameMap && { skillNameMap }), clientMappings: mappings, + writeRoot: clientWriteRoots[representative] ?? workspacePath, syncMode: 'symlink', canonicalSkillsPath: CANONICAL_SKILLS_PATH, agentOutputs: agentOutputsAssigned ? [] : agentOutputs, @@ -1500,6 +1843,7 @@ async function copyValidatedPlugin( dryRun, ...(skillNameMap && { skillNameMap }), clientMappings: mappings, + writeRoot: clientWriteRoots[client] ?? workspacePath, syncMode: 'copy', agentOutputs: agentOutputsAssigned ? [] : agentOutputs, ...(exclude && { exclude }), @@ -1740,132 +2084,584 @@ function countCopyResults( return { totalCopied, totalFailed, totalSkipped, totalGenerated }; } +function nativeStateKey(resource: NativeStateResource): string { + return JSON.stringify([ + resource.client, + resource.scope, + resource.nativeScope, + resource.kind, + resource.requestedIdentity, + resource.resolvedIdentity, + resource.context, + ]); +} + +function stateFromNativeResource( + resource: NativeResource, + transition: NativeStateResource['transition'], + error?: string, +): NativeStateResource { + return { + client: resource.context.client as ClientType, + scope: resource.context.scope, + nativeScope: resource.context.nativeScope, + kind: resource.kind, + requestedIdentity: resource.requestedIdentity, + resolvedIdentity: resource.resolvedIdentity, + context: nativeContextIdentity(resource.context), + root: resolve(resource.context.root), + provenance: sanitizeNativeProvenance(resource.provenance), + transition, + ...(error && { error }), + }; +} + +function nativeResourceFromState( + state: NativeStateResource, + context: NativeOperationContext, +): NativeResource { + return { + kind: state.kind, + requestedIdentity: state.requestedIdentity, + resolvedIdentity: state.resolvedIdentity, + context: { ...context, root: state.root ?? context.root }, + provenance: state.provenance, + }; +} + async function syncNativePlugins( validPlugins: ValidatedPlugin[], previousState: SyncState | null, scope: 'project' | 'user', workspacePath: string, dryRun: boolean, - warnings: string[], - messages: string[], + contexts: Map, + selection?: SyncOptions['nativeSelection'], ): Promise { - const { - pluginsByClient: nativePluginsByClient, - marketplaceSourcesByClient: nativeMarketplaceSources, - } = collectNativePluginSources(validPlugins); + const allDesiredByClient = collectNativeResources( + validPlugins, + scope, + contexts, + ); + const desiredByClient = new Map(); + if (selection?.mode !== 'remove') { + for (const [client, resources] of allDesiredByClient) { + const selected = resources.filter((resource) => + nativeSelectionMatches( + selection, + resource.requestedIdentity, + resource.resolvedIdentity, + )); + if (selected.length > 0) desiredByClient.set(client, selected); + } + } + let stateResources = [...(previousState?.nativeResources?.resources ?? [])]; + const clients = new Set(desiredByClient.keys()); + if (selection?.mode !== 'update') { + for (const client of contexts.keys()) { + const context = contexts.get(client); + if (!context) continue; + const operationContext = nativeOperationContext(client, scope, context); + const tracked = getNativeStateResources( + previousState, + client, + scope, + nativeContextIdentity(operationContext), + ); + if ( + tracked.some((resource) => + nativeSelectionMatches( + selection, + resource.requestedIdentity, + resource.resolvedIdentity, + )) + ) { + clients.add(client); + } + } + } + const effects: NativeEffect[] = []; + if (selection?.mode !== 'update') { + for (const stateResource of stateResources) { + if ( + stateResource.scope !== scope || + !nativeSelectionMatches( + selection, + stateResource.requestedIdentity, + stateResource.resolvedIdentity, + ) + ) { + continue; + } + const resolvedContext = contexts.get(stateResource.client); + const currentContext = resolvedContext + ? nativeOperationContext(stateResource.client, scope, resolvedContext) + : { + client: stateResource.client, + scope, + nativeScope: stateResource.nativeScope, + root: stateResource.root ?? stateResource.context, + }; + if ( + resolvedContext && + nativeContextIdentity(currentContext) === stateResource.context + ) { + continue; + } + effects.push({ + action: 'unknown', + phase: 'state', + changed: false, + resource: nativeResourceFromState(stateResource, currentContext), + error: resolvedContext + ? `Recorded native context ${stateResource.context} differs from selected context ${nativeContextIdentity(currentContext)}` + : 'Recorded native resource has no resolvable client context', + }); + } + } + if (clients.size === 0 && effects.length === 0) return undefined; + const replaceStateRecord = ( + previous: NativeStateResource | undefined, + next: NativeStateResource | undefined, + ): void => { + if (previous) { + const key = nativeStateKey(previous); + stateResources = stateResources.filter( + (resource) => nativeStateKey(resource) !== key, + ); + } + if (next) { + const key = nativeStateKey(next); + stateResources = stateResources.filter( + (resource) => nativeStateKey(resource) !== key, + ); + stateResources.push(next); + } + }; + const checkpoint = async ( + previous: NativeStateResource | undefined, + next: NativeStateResource | undefined, + ): Promise => { + const snapshot = stateResources; + replaceStateRecord(previous, next); + try { + await saveNativeStateResources(workspacePath, stateResources); + } catch (error) { + stateResources = snapshot; + throw error; + } + }; - const previousNativeClients = previousState?.nativePlugins - ? (Object.keys(previousState.nativePlugins) as ClientType[]).filter( - (c) => (previousState.nativePlugins?.[c]?.length ?? 0) > 0, - ) - : []; - const hasNativeWork = - nativePluginsByClient.size > 0 || previousNativeClients.length > 0; - - if (hasNativeWork && !dryRun) { - const allClients = new Set([ - ...nativePluginsByClient.keys(), - ...previousNativeClients, - ]); - const perClientResults: NativeSyncResult[] = []; - - for (const clientType of allClients) { - const nativeClient = getNativeClient(clientType); - if (!nativeClient) { - const sources = nativePluginsByClient.get(clientType); - if (sources && sources.length > 0) { - warnings.push( - `Native install: no native client for ${clientType}, skipping`, - ); + for (const client of clients) { + const adapter = getNativeClient(client); + const resolvedContext = contexts.get(client); + if (!adapter || !resolvedContext) continue; + const context = nativeOperationContext(client, scope, resolvedContext); + const desired = desiredByClient.get(client) ?? []; + const allDesired = allDesiredByClient.get(client) ?? []; + const tracked = getNativeStateResources( + previousState, + client, + scope, + nativeContextIdentity(context), + ).filter((resource) => + selection + ? nativeSelectionMatches( + selection, + resource.requestedIdentity, + resource.resolvedIdentity, + ) + : true); + + let inspection = await adapter.inspect(context); + if (!inspection.success) { + const affected = desired.length > 0 + ? desired + : tracked.map((resource) => + nativeResourceFromState(resource, context)); + for (const resource of affected) { + effects.push({ + action: 'failed', + resource, + error: inspection.error ?? 'Native inspection failed', + }); + } + continue; + } + + for (const resource of desired) { + const exactObservation = inspection.observations?.find( + (candidate) => + candidate.resource.kind === resource.kind && + candidate.resource.resolvedIdentity === resource.resolvedIdentity, + ); + if ( + exactObservation?.status === 'disabled' || + exactObservation?.status === 'unusable' + ) { + effects.push({ + action: 'failed', + phase: 'inspection', + changed: false, + resource, + error: + exactObservation.error ?? + `Native resource is ${exactObservation.status}`, + }); + continue; + } + const exactLive = inspection.resources.find( + (candidate) => + candidate.kind === resource.kind && + candidate.resolvedIdentity === resource.resolvedIdentity, + ); + const prior = tracked.find( + (candidate) => + candidate.kind === resource.kind && + (candidate.requestedIdentity === resource.requestedIdentity || + candidate.resolvedIdentity === resource.resolvedIdentity), + ); + if (exactLive && selection?.mode !== 'update') { + effects.push({ + action: 'unchanged', + phase: 'inspection', + changed: false, + resource, + }); + if (!dryRun) { + const transition = + prior && nativeStateOwnership(prior.transition) === 'managed' + ? 'managed' + : 'referenced'; + try { + await checkpoint( + prior, + stateFromNativeResource(resource, transition), + ); + } catch (error) { + effects.push({ + action: 'failed', + phase: 'state', + changed: false, + resource, + error: `Could not checkpoint native reference: ${error instanceof Error ? error.message : String(error)}`, + }); + } } continue; } - const cliAvailable = await nativeClient.isAvailable(); - if (!cliAvailable) { - const sources = nativePluginsByClient.get(clientType); - if (sources && sources.length > 0) { - messages.push( - `Native install: ${clientType} CLI not found, skipping native plugin installation`, - ); + const priorLive = + exactLive ?? + (prior + ? inspection.resources.find( + (candidate) => + candidate.kind === prior.kind && + candidate.resolvedIdentity === prior.resolvedIdentity, + ) + : undefined); + const action = priorLive ? 'update' : 'install'; + if (dryRun) { + if (resource.provenance.marketplaceSource && !priorLive) { + effects.push({ action: 'would-register', resource }); } + effects.push({ + action: priorLive ? 'would-update' : 'would-install', + resource, + }); continue; } - const marketplaceSources = nativeMarketplaceSources.get(clientType); - if (marketplaceSources) { - for (const source of marketplaceSources) { - if (scope === 'project') { - await nativeClient.addMarketplace(source, { cwd: workspacePath }); - } else { - await nativeClient.addMarketplace(source); - } + const pendingTransition: NativeStateResource['transition'] = + priorLive && (!prior || prior.transition === 'referenced') + ? 'referenced' + : priorLive + ? 'pending-update' + : 'pending-install'; + const pending = stateFromNativeResource(resource, pendingTransition); + try { + await checkpoint(prior, pending); + } catch (error) { + effects.push({ + action: 'failed', + phase: 'state', + changed: false, + resource, + error: `Could not checkpoint native ${action}: ${error instanceof Error ? error.message : String(error)}`, + }); + continue; + } + + let mutation: NativeMutationResult; + try { + mutation = priorLive + ? await adapter.update(resource, priorLive, context) + : await adapter.install(resource, context); + } catch (error) { + mutation = { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + for (const registration of mutation.registrations ?? []) { + effects.push({ + action: 'registered', + resource: { + ...resource, + provenance: { + ...resource.provenance, + marketplaceSource: registration, + }, + }, + }); + } + if (!mutation.success) { + const retained = priorLive + ? stateFromNativeResource( + nativeResourceFromState(prior ?? pending, context), + !prior || prior.transition === 'referenced' + ? 'referenced' + : 'managed', + ) + : stateFromNativeResource(resource, 'unknown', mutation.error); + try { + await checkpoint(pending, retained); + } catch { + // The pending checkpoint already preserves retry authority. } + effects.push({ + action: 'failed', + resource, + error: mutation.error ?? `Native ${action} failed`, + }); + continue; } - const currentSources = nativePluginsByClient.get(clientType) ?? []; - const currentSpecs = currentSources - .map((s) => nativeClient.toPluginSpec(s)) - .filter((s): s is string => s !== null); - const previousPlugins = getPreviouslySyncedNativePlugins( - previousState, - clientType, + inspection = await adapter.inspect(context); + if (!inspection.success) { + const unknown = stateFromNativeResource( + resource, + 'unknown', + inspection.error, + ); + try { + await checkpoint(pending, unknown); + } catch { + // The pending checkpoint still prevents unsafe cleanup. + } + effects.push({ + action: 'unknown', + resource, + error: inspection.error ?? `Could not verify native ${action}`, + }); + continue; + } + const confirmed = inspection.resources.some( + (candidate) => + candidate.kind === resource.kind && + candidate.resolvedIdentity === resource.resolvedIdentity, ); - const removed = previousPlugins.filter((p) => !currentSpecs.includes(p)); - for (const plugin of removed) { + if (!confirmed) { + const error = `Native ${action} completed but '${resource.resolvedIdentity}' was not present in live inventory`; try { - if (scope === 'project') { - await nativeClient.uninstallPlugin(plugin, 'project', { - cwd: workspacePath, - }); - } else { - await nativeClient.uninstallPlugin(plugin, 'user'); - } - } catch (err) { - warnings.push( - `Native uninstall failed for ${plugin}: ${err instanceof Error ? err.message : String(err)}`, + await checkpoint( + pending, + stateFromNativeResource(resource, 'unknown', error), ); + } catch { + // The pending checkpoint still prevents unsafe cleanup. } + effects.push({ action: 'unknown', resource, error }); + continue; } - - if (currentSources.length > 0) { - const syncOpts = - scope === 'project' ? { cwd: workspacePath } : undefined; - perClientResults.push( - attachNativeClientContext( - await nativeClient.syncPlugins(currentSources, scope, syncOpts), - clientType, - ), + try { + const transition: NativeStateResource['transition'] = + priorLive && (!prior || prior.transition === 'referenced') + ? 'referenced' + : 'managed'; + await checkpoint( + pending, + stateFromNativeResource(resource, transition), ); + effects.push({ + action: priorLive ? 'updated' : 'installed', + resource, + }); + } catch (error) { + effects.push({ + action: 'failed', + resource, + error: `Native ${action} succeeded but state checkpoint failed: ${error instanceof Error ? error.message : String(error)}`, + }); } } - if (perClientResults.length > 0) { - return mergeNativeSyncResults(perClientResults); - } - } else if (nativePluginsByClient.size > 0 && dryRun) { - const perClientResults: NativeSyncResult[] = []; - for (const [clientType, sources] of nativePluginsByClient) { - const nativeClient = getNativeClient(clientType); - if (nativeClient && sources.length > 0) { - const syncOpts = - scope === 'project' - ? { cwd: workspacePath, dryRun: true } - : { dryRun: true }; - perClientResults.push( - attachNativeClientContext( - await nativeClient.syncPlugins(sources, scope, syncOpts), - clientType, - ), - ); + for (const prior of tracked) { + const desiredMatch = allDesired.some( + (resource) => + resource.kind === prior.kind && + (resource.requestedIdentity === prior.requestedIdentity || + resource.resolvedIdentity === prior.resolvedIdentity), + ); + if (desiredMatch) continue; + const resource = nativeResourceFromState(prior, context); + const liveObservation = inspection.observations?.find( + (candidate) => + candidate.resource.kind === prior.kind && + candidate.resource.resolvedIdentity === prior.resolvedIdentity, + ); + const live = + inspection.resources.find( + (candidate) => + candidate.kind === prior.kind && + candidate.resolvedIdentity === prior.resolvedIdentity, + ) ?? + (liveObservation?.status === 'disabled' || + liveObservation?.status === 'unusable' + ? liveObservation.resource + : undefined); + if (!live) { + if (!dryRun) { + try { + await checkpoint(prior, undefined); + } catch (error) { + effects.push({ + action: 'failed', + phase: 'state', + changed: false, + resource, + error: `Could not release absent native state: ${error instanceof Error ? error.message : String(error)}`, + }); + continue; + } + } + effects.push({ + action: dryRun ? 'would-remove' : 'removed', + phase: 'state', + resource, + }); + continue; + } + if (prior.transition === 'referenced') { + effects.push({ + action: 'retained', + phase: 'state', + changed: false, + resource, + error: 'Native resource predates AllAgents ownership', + }); + continue; + } + if (nativeStateOwnership(prior.transition) !== 'managed') { + effects.push({ + action: 'unknown', + phase: 'state', + changed: false, + resource, + error: 'Native cleanup retained because ownership is unconfirmed', + }); + continue; + } + if (dryRun) { + effects.push({ action: 'would-remove', resource }); + continue; + } + + const pending = { ...prior, transition: 'pending-remove' as const }; + try { + await checkpoint(prior, pending); + } catch (error) { + effects.push({ + action: 'failed', + resource, + error: `Could not checkpoint native removal: ${error instanceof Error ? error.message : String(error)}`, + }); + continue; + } + let removal: NativeMutationResult; + try { + removal = await adapter.remove(live, context); + } catch (error) { + removal = { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + if (!removal.success) { + const failed = { + ...prior, + transition: 'cleanup-failed' as const, + ...(removal.error && { error: removal.error }), + }; + try { + await checkpoint(pending, failed); + } catch { + // Pending removal remains durable and retryable. + } + effects.push({ + action: 'failed', + resource, + error: removal.error ?? 'Native removal failed', + }); + continue; + } + inspection = await adapter.inspect(context); + if (!inspection.success) { + const unknown = { + ...prior, + transition: 'cleanup-failed' as const, + error: inspection.error ?? 'Native removal verification failed', + }; + try { + await checkpoint(pending, unknown); + } catch { + // Pending removal remains durable and retryable. + } + effects.push({ + action: 'unknown', + resource, + error: unknown.error, + }); + continue; + } + const stillPresent = inspection.resources.some( + (candidate) => + candidate.kind === prior.kind && + candidate.resolvedIdentity === prior.resolvedIdentity, + ); + if (stillPresent) { + const error = `Native remove completed but '${prior.resolvedIdentity}' remains present`; + try { + await checkpoint(pending, { + ...prior, + transition: 'cleanup-failed', + error, + }); + } catch { + // Pending removal remains durable and retryable. + } + effects.push({ action: 'unknown', resource, error }); + continue; + } + try { + await checkpoint(pending, undefined); + effects.push({ action: 'removed', resource }); + } catch (error) { + effects.push({ + action: 'failed', + resource, + error: `Native removal succeeded but state release failed: ${error instanceof Error ? error.message : String(error)}`, + }); } - } - if (perClientResults.length > 0) { - return mergeNativeSyncResults(perClientResults); } } - return undefined; + return { + success: effects.every( + (effect) => effect.action !== 'failed' && effect.action !== 'unknown', + ), + effects, + }; } async function syncVscodeWorkspaceFile( @@ -2090,8 +2886,6 @@ async function buildSourcesProvenance( async function persistSyncState( workspacePath: string, syncedFiles: Partial>, - nativePluginsByClient: Map, - nativeResult: NativeSyncResult | undefined, extra?: { vscodeState?: { hash: string; repos: string[] }; codexHooks?: SyncState['codexHooks']; @@ -2100,28 +2894,9 @@ async function persistSyncState( sources?: Record; }, ): Promise { - // Build native plugin tracking per-client - const nativePluginsState: Partial> = {}; - const installedSet = new Set( - (nativeResult?.pluginsInstalled ?? []).map((p) => p.plugin), - ); - for (const [client, sources] of nativePluginsByClient) { - const nativeClient = getNativeClient(client); - if (!nativeClient) continue; - const clientSpecs = sources - .map((s) => nativeClient.toPluginSpec(s)) - .filter((s): s is string => s !== null && installedSet.has(s)); - if (clientSpecs.length > 0) { - nativePluginsState[client] = clientSpecs; - } - } - await saveSyncState(workspacePath, { files: syncedFiles, ...(extra?.codexHooks && { codexHooks: extra.codexHooks }), - ...(Object.keys(nativePluginsState).length > 0 && { - nativePlugins: nativePluginsState, - }), ...(extra?.vscodeState?.hash && { vscodeWorkspaceHash: extra.vscodeState.hash, }), @@ -2153,8 +2928,6 @@ export async function syncWorkspace( workspacePath: string = process.cwd(), options: SyncOptions = {}, ): Promise { - // MIGRATION: v1→v2 - remove after v3 release - await migrateWorkspaceSkillsV1toV2(workspacePath); const { offline = false, @@ -2162,6 +2935,7 @@ export async function syncWorkspace( workspaceSourceBase, skipAgentFiles = false, skipManaged = false, + nativeSelection, } = options; const sw = new Stopwatch(); const configDir = join(workspacePath, CONFIG_DIR); @@ -2197,35 +2971,40 @@ export async function syncWorkspace( ); } - // Step 0a: Process managed repositories (clone/pull) before anything else - const managedRepoResults = await sw.measure('managed-repos', () => - processManagedRepos(config.repositories ?? [], workspacePath, { - offline, - skipManaged, - dryRun, - }), - ); - const managedWarnings = managedRepoResults - .filter((r) => r.error) - .map((r) => `${r.repo}: ${r.error}`); - - // Check if repositories are configured — when empty/absent, skip agent file - // creation and WORKSPACE-RULES injection (same pattern as initWorkspace) - const hasRepositories = (config.repositories?.length ?? 0) > 0; + const { + plans: pluginPlans, + warnings: planWarnings, + errors: allPlanErrors, + } = buildPluginSyncPlans(config.plugins, config.clients, 'project'); + const planErrors = nativeSelection + ? buildPluginSyncPlans( + config.plugins.filter((plugin) => { + const source = getEffectivePluginSource(plugin); + return nativeSelectionMatches(nativeSelection, source, source); + }), + config.clients, + 'project', + ).errors + : allPlanErrors; + if (planErrors.length > 0) { + return failedSyncResult( + `Native preflight failed (workspace unchanged):\n${planErrors.map((error) => ` - ${error}`).join('\n')}`, + { totalFailed: planErrors.length, warnings: planWarnings }, + ); + } - const { plans: pluginPlans, warnings: planWarnings } = buildPluginSyncPlans( - config.plugins, - config.clients, - 'project', - ); const workspaceClients = config.clients; const filteredPlans = pluginPlans.filter( (plan) => plan.clients.length > 0 || plan.nativeClients.length > 0, ); const syncClients = collectSyncClients(workspaceClients, filteredPlans); - - // Warn when no clients are configured — the sync will succeed but create no artifacts - if (syncClients.length === 0) { + const staleNativeState = + syncClients.length === 0 + ? (await loadSyncState(workspacePath))?.nativeResources?.resources.some( + (resource) => resource.scope === 'project', + ) === true + : false; + if (syncClients.length === 0 && !staleNativeState) { return { success: true, pluginResults: [], @@ -2239,12 +3018,40 @@ export async function syncWorkspace( }; } - // Step 0: Pre-register unique marketplaces to avoid race conditions during parallel validation - const marketplaceResults = await sw.measure('marketplace-registration', () => - ensureMarketplacesRegistered(filteredPlans.map((plan) => plan.source)), + const preflightContexts = resolveClientContexts(syncClients, 'project', { + cwd: workspacePath, + homeDir: getHomeDir(), + env: process.env, + }); + const nativePreflightErrors = await preflightNativePlans( + filteredPlans, + 'project', + preflightContexts, + nativeSelection, ); + if (nativePreflightErrors.length > 0) { + return failedSyncResult( + `Native preflight failed (workspace unchanged):\n${nativePreflightErrors.map((error) => ` - ${error}`).join('\n')}`, + { + totalFailed: nativePreflightErrors.length, + warnings: planWarnings, + nativeResult: nativePreflightFailureResult( + filteredPlans, + 'project', + preflightContexts, + nativePreflightErrors, + nativeSelection, + ), + }, + ); + } + - // Seed fetchCache with marketplace paths so fetchPlugin skips redundant git pulls + // Generic marketplace registration/fetch is needed only by file targets. + const filePlans = filteredPlans.filter((plan) => plan.clients.length > 0); + const marketplaceResults = await sw.measure('marketplace-registration', () => + ensureMarketplacesRegistered(filePlans.map((plan) => plan.source)), + ); await seedFetchCacheFromMarketplaces(marketplaceResults); // Step 1: Validate all plugins before any destructive action @@ -2276,27 +3083,136 @@ export async function syncWorkspace( sw.stop('workspace-source-validation'); } - // Separate valid and failed plugins - const failedValidations = validatedPlugins.filter((v) => !v.success); - const validPlugins = validatedPlugins.filter((v) => v.success); - const warnings = [ - ...managedWarnings, + const failedValidations = validatedPlugins.filter((plugin) => !plugin.success); + const requiredNativeFailures = failedValidations.filter((plugin) => { + const plan = filteredPlans.find( + (candidate) => candidate.configurationIndex === plugin.configurationIndex, + ); + return ( + (plan?.nativeClients.length ?? 0) > 0 && + nativeSelectionMatches( + nativeSelection, + plan?.source ?? plugin.plugin, + plan?.source ?? plugin.plugin, + ) + ); + }); + const validationWarnings = [ ...planWarnings, ...workspaceSourceWarnings, - ...failedValidations.map((v) => `${v.plugin}: ${v.error} (skipped)`), + ...failedValidations.map( + (plugin) => `${plugin.plugin}: ${plugin.error} (skipped)`, + ), ]; - const messages: string[] = []; + if (requiredNativeFailures.length > 0) { + return failedSyncResult( + `Mixed native/file preflight failed (workspace unchanged):\n${requiredNativeFailures.map((plugin) => ` - ${plugin.plugin}: ${plugin.error}`).join('\n')}`, + { + totalFailed: requiredNativeFailures.length, + warnings: validationWarnings, + }, + ); + } - // If ALL plugins failed, abort + const validPlugins = validatedPlugins.filter((plugin) => plugin.success); + const filePlugins = validPlugins.filter((plugin) => plugin.clients.length > 0); if (validPlugins.length === 0 && filteredPlans.length > 0) { return failedSyncResult( - `All plugins failed validation (workspace unchanged):\n${failedValidations.map((v) => ` - ${v.plugin}: ${v.error}`).join('\n')}`, - { totalFailed: failedValidations.length, warnings }, + `All plugins failed validation (workspace unchanged):\n${failedValidations.map((plugin) => ` - ${plugin.plugin}: ${plugin.error}`).join('\n')}`, + { totalFailed: failedValidations.length, warnings: validationWarnings }, ); } + const hasRepositories = (config.repositories?.length ?? 0) > 0; + const skipWorkspaceFiles = + !!config.workspace?.source && !validatedWorkspaceSource; + const workspaceFilesSourcePath = validatedWorkspaceSource?.resolved; + const workspaceFilesToCopy = + config.workspace && !skipWorkspaceFiles + ? [...config.workspace.files] + : []; + let workspaceFilesGithubCache = new Map(); + if (config.workspace && !skipWorkspaceFiles) { + if (hasRepositories && workspaceFilesSourcePath) { + for (const agentFile of AGENT_FILES) { + const agentPath = join(workspaceFilesSourcePath, agentFile); + if ( + existsSync(agentPath) && + !workspaceFilesToCopy.includes(agentFile) + ) { + workspaceFilesToCopy.push(agentFile); + } + } + } + const fileSourceRepos = collectGitHubReposFromFiles(workspaceFilesToCopy); + if (fileSourceRepos.length > 0) { + const { cache, errors } = await fetchFileSourceRepos(fileSourceRepos); + if (errors.length > 0) { + return failedSyncResult( + `File source fetch failed (workspace unchanged):\n${errors.map((error) => ` - ${error}`).join('\n')}`, + { totalFailed: errors.length, warnings: validationWarnings }, + ); + } + workspaceFilesGithubCache = cache; + } + const fileValidationErrors = validateFileSources( + workspaceFilesToCopy, + workspaceFilesSourcePath, + workspaceFilesGithubCache, + ); + if (fileValidationErrors.length > 0) { + return failedSyncResult( + `File source validation failed (workspace unchanged):\n${fileValidationErrors.map((error) => ` - ${error}`).join('\n')}`, + { + totalFailed: fileValidationErrors.length, + warnings: validationWarnings, + }, + ); + } + } + + if (!dryRun) { + // MIGRATION: v1→v2 - remove after v3 release. + await migrateWorkspaceSkillsV1toV2(workspacePath); + } + + const managedRepoResults = await sw.measure('managed-repos', () => + processManagedRepos(config.repositories ?? [], workspacePath, { + offline, + skipManaged, + dryRun, + }), + ); + const managedWarnings = managedRepoResults + .filter((result) => result.error) + .map((result) => `${result.repo}: ${result.error}`); + const warnings = [...managedWarnings, ...validationWarnings]; + const messages: string[] = []; + // Step 2: Load previous sync state for selective purge const previousState = await loadSyncState(workspacePath); + const contextClients = [ + ...new Set([ + ...syncClients, + ...(Object.keys(previousState?.files ?? {}) as ClientType[]), + ...(previousState?.nativeResources?.resources + .filter((resource) => resource.scope === 'project') + .map((resource) => resource.client) ?? []), + ]), + ]; + const clientContexts = resolveClientContexts(contextClients, 'project', { + cwd: workspacePath, + homeDir: getHomeDir(), + env: process.env, + }); + const contextMappings = clientMappingsFromContexts( + clientContexts, + CLIENT_MAPPINGS, + ); + const resolvedMappings = resolveClientMappings( + syncClients, + contextMappings, + ); // Step 2b: Get paths that will be purged (for dry-run reporting) // In non-destructive mode, only show files from state (or nothing on first sync) @@ -2304,15 +3220,29 @@ export async function syncWorkspace( ? syncClients .map((client) => ({ client, - paths: getPreviouslySyncedFiles(previousState, client), + paths: getPreviouslySyncedFiles(previousState, client).filter( + (path) => + trackedPathIsAllowed( + workspacePath, + path, + resolvedMappings[client], + clientContexts.get(client), + ), + ), })) - .filter((p) => p.paths.length > 0) + .filter((entry) => entry.paths.length > 0) : []; // Step 3: Selective purge - only remove files we previously synced (skip in dry-run mode) if (!dryRun) { await sw.measure('selective-purge', () => - selectivePurgeWorkspace(workspacePath, previousState, syncClients), + selectivePurgeWorkspace( + workspacePath, + previousState, + syncClients, + resolvedMappings, + clientContexts, + ), ); } @@ -2329,7 +3259,7 @@ export async function syncWorkspace( : undefined; const allSkills = await sw.measure('skill-collection', () => collectAllSkills( - validPlugins, + filePlugins, disabledSkillsSet, enabledSkillsSet, warnings, @@ -2338,12 +3268,12 @@ export async function syncWorkspace( // Build per-plugin skill name maps (handles conflicts automatically) const pluginSkillMaps = buildPluginSkillNameMaps(allSkills); - const resolvedMappings = resolveClientMappings(syncClients, CLIENT_MAPPINGS); + // Context mappings preserve legacy paths and carry Pi/OMP concrete roots. const agentOutputPlan = await sw.measure('agent-output-planning', () => planValidatedPluginAgentOutputs( - validPlugins, + filePlugins, workspacePath, - CLIENT_MAPPINGS, + contextMappings, ), ); appendAgentOutputConflictWarnings(agentOutputPlan, warnings); @@ -2357,7 +3287,7 @@ export async function syncWorkspace( 'plugin-copy', () => Promise.all( - validPlugins.map(async (validatedPlugin, validIndex) => { + filePlugins.map(async (validatedPlugin, validIndex) => { const skillNameMap = pluginSkillMaps.get(validatedPlugin.resolved); const configurationIndex = validatedPlugin.configurationIndex ?? validIndex; @@ -2373,16 +3303,22 @@ export async function syncWorkspace( validatedPlugin.clients, dryRun, skillNameMap, - undefined, + contextMappings, syncMode, agentOutputs, agentConflicts, agentFailures, + Object.fromEntries( + [...clientContexts].map(([client, context]) => [ + client, + context.writeRoot, + ]), + ), ); return { ...result, scope: 'project' as const }; }), ), - `${validPlugins.length} plugin(s)`, + `${filePlugins.length} plugin(s)`, ); // Step 4b: Native CLI installations @@ -2393,8 +3329,8 @@ export async function syncWorkspace( 'project', workspacePath, dryRun, - warnings, - messages, + clientContexts, + nativeSelection, ), ); @@ -2403,7 +3339,7 @@ export async function syncWorkspace( // subset recorded in sync state. const codexHookSync = await sw.measure('codex-hooks-sync', async () => syncCodexProjectHooks( - validPlugins, + filePlugins, workspacePath, previousState?.codexHooks, { @@ -2417,7 +3353,7 @@ export async function syncWorkspace( // file. Copilot discovers project hooks only from .github/hooks/*.json; // copying a plugin's hook scripts there does not activate its root hooks.json. const copilotHookSync = await sw.measure('copilot-hooks-sync', () => - syncCopilotProjectHooks(validPlugins, workspacePath, { + syncCopilotProjectHooks(filePlugins, workspacePath, { dryRun, previouslyManaged: getPreviouslySyncedFiles( previousState, @@ -2435,52 +3371,11 @@ export async function syncWorkspace( ...copilotHookSync.copyResults, ]; let writtenSkillsIndexFiles: string[] = []; - const skipWorkspaceFiles = - !!config.workspace?.source && !validatedWorkspaceSource; if (config.workspace && !skipWorkspaceFiles) { sw.start('workspace-files'); - const sourcePath = validatedWorkspaceSource?.resolved; - const filesToCopy = [...config.workspace.files]; - - // Auto-include agent files if they exist in source and aren't already listed. - // Skip when repositories is empty — agent files contain WORKSPACE-RULES that - // reference repository paths which don't exist yet. - if (hasRepositories && sourcePath) { - for (const agentFile of AGENT_FILES) { - const agentPath = join(sourcePath, agentFile); - if (existsSync(agentPath) && !filesToCopy.includes(agentFile)) { - filesToCopy.push(agentFile); - } - } - } - - // Step 5a: Collect and fetch GitHub repos from file sources - const fileSourceRepos = collectGitHubReposFromFiles(filesToCopy); - let githubCache = new Map(); - - if (fileSourceRepos.length > 0) { - const { cache, errors } = await fetchFileSourceRepos(fileSourceRepos); - if (errors.length > 0) { - return failedSyncResult( - `File source fetch failed (workspace unchanged):\n${errors.map((e) => ` - ${e}`).join('\n')}`, - { pluginResults, totalFailed: errors.length }, - ); - } - githubCache = cache; - } - - // Step 5b: Validate all file sources exist before copying - const fileValidationErrors = validateFileSources( - filesToCopy, - sourcePath, - githubCache, - ); - if (fileValidationErrors.length > 0) { - return failedSyncResult( - `File source validation failed (workspace unchanged):\n${fileValidationErrors.map((e) => ` - ${e}`).join('\n')}`, - { pluginResults, totalFailed: fileValidationErrors.length }, - ); - } + const sourcePath = workspaceFilesSourcePath; + const filesToCopy = workspaceFilesToCopy; + const githubCache = workspaceFilesGithubCache; // Step 5c: Discover skills from workspace repositories const repoSkills = @@ -2572,7 +3467,7 @@ export async function syncWorkspace( sw.start('mcp-sync'); const mcpSyncResult = runMcpSync( workspacePath, - validPlugins, + filePlugins, config, previousState, syncClients, @@ -2588,7 +3483,7 @@ export async function syncWorkspace( // Collect all skill names from installed plugins (including disabled) so that // skills that are still available but just not synced are not reported as deleted. const availableSkillNames = await collectAvailableSkillNames( - validPlugins, + filePlugins, warnings, ); const allCopyResultsForState = [ @@ -2603,9 +3498,20 @@ export async function syncWorkspace( warnings, ); // Count results - const { totalCopied, totalFailed, totalSkipped, totalGenerated } = - countCopyResults(pluginResults, workspaceFileResults); - const hasFailures = pluginResults.some((r) => !r.success) || totalFailed > 0; + const { + totalCopied, + totalFailed: fileFailures, + totalSkipped, + totalGenerated, + } = countCopyResults(pluginResults, workspaceFileResults); + const nativeFailures = + nativeResult?.effects.filter( + (effect) => effect.action === 'failed' || effect.action === 'unknown', + ).length ?? 0; + const totalFailed = fileFailures + nativeFailures; + const hasFailures = pluginResults.some((result) => !result.success) || + totalFailed > 0 || + nativeResult?.success === false; const newStatePaths = collectSyncedPaths( allCopyResultsForState, @@ -2613,6 +3519,7 @@ export async function syncWorkspace( syncClients, resolvedMappings, agentDedupeRecords, + clientContexts, ); const deletedArtifacts = computeDeletedArtifacts( previousState, @@ -2624,16 +3531,12 @@ export async function syncWorkspace( ); // Persist sync state (skip in dry-run mode) - const { pluginsByClient: nativePluginsByClient } = - collectNativePluginSources(validPlugins); if (!dryRun) { - const sources = await buildSourcesProvenance(validPlugins, config.plugins); + const sources = await buildSourcesProvenance(filePlugins, config.plugins); await sw.measure('persist-state', () => persistSyncState( workspacePath, newStatePaths, - nativePluginsByClient, - nativeResult, { ...(vscodeState && { vscodeState }), ...(codexHookSync.managedHooks && { @@ -2726,14 +3629,17 @@ function readGitBranch(repoPath: string): string | null { * @returns Sync result */ export async function syncUserWorkspace( - options: { offline?: boolean; dryRun?: boolean; force?: boolean } = {}, + options: SyncOptions = {}, ): Promise { - // MIGRATION: v1→v2 - remove after v3 release - await migrateUserWorkspaceSkillsV1toV2(); - const sw = new Stopwatch(); const homeDir = resolve(getHomeDir()); - const config = await getUserWorkspaceConfig(); + const { + offline = false, + dryRun = false, + force = false, + nativeSelection, + } = options; + let config = await getUserWorkspaceConfig(); if (!config) { return { @@ -2746,58 +3652,153 @@ export async function syncUserWorkspace( }; } - const workspaceClients = config.clients; - const { offline = false, dryRun = false, force = false } = options; + const { + plans: allPluginPlans, + warnings: planWarnings, + errors: allPlanErrors, + } = buildPluginSyncPlans(config.plugins, config.clients, 'user'); + const planErrors = nativeSelection + ? buildPluginSyncPlans( + config.plugins.filter((plugin) => { + const source = getEffectivePluginSource(plugin); + return nativeSelectionMatches(nativeSelection, source, source); + }), + config.clients, + 'user', + ).errors + : allPlanErrors; + if (planErrors.length > 0) { + return failedSyncResult( + `Native preflight failed (user workspace unchanged):\n${planErrors.map((error) => ` - ${error}`).join('\n')}`, + { totalFailed: planErrors.length, warnings: planWarnings }, + ); + } - const { plans: allPluginPlans, warnings: planWarnings } = - buildPluginSyncPlans(config.plugins, workspaceClients, 'user'); const pluginPlans = allPluginPlans.filter( (plan) => plan.clients.length > 0 || plan.nativeClients.length > 0, ); - const syncClients = collectSyncClients(workspaceClients, pluginPlans); + const syncClients = collectSyncClients(config.clients, pluginPlans); + const preflightContexts = resolveClientContexts(syncClients, 'user', { + homeDir, + cwd: process.cwd(), + env: process.env, + }); + const nativePreflightErrors = await preflightNativePlans( + pluginPlans, + 'user', + preflightContexts, + nativeSelection, + ); + if (nativePreflightErrors.length > 0) { + return failedSyncResult( + `Native preflight failed (user workspace unchanged):\n${nativePreflightErrors.map((error) => ` - ${error}`).join('\n')}`, + { + totalFailed: nativePreflightErrors.length, + warnings: planWarnings, + nativeResult: nativePreflightFailureResult( + pluginPlans, + 'user', + preflightContexts, + nativePreflightErrors, + nativeSelection, + ), + }, + ); + } + + if (!dryRun) { + // MIGRATION: v1→v2 - remove after v3 release. + await migrateUserWorkspaceSkillsV1toV2(); + config = (await getUserWorkspaceConfig()) ?? config; + } - // Pre-register unique marketplaces to avoid race conditions during parallel validation + const filePlans = pluginPlans.filter((plan) => plan.clients.length > 0); const marketplaceResults = await sw.measure('marketplace-registration', () => - ensureMarketplacesRegistered(pluginPlans.map((plan) => plan.source)), + ensureMarketplacesRegistered(filePlans.map((plan) => plan.source)), ); - - // Seed fetchCache with marketplace paths so fetchPlugin skips redundant git pulls await seedFetchCacheFromMarketplaces(marketplaceResults); - // Validate all plugins const validatedPlugins = await sw.measure( 'plugin-validation', () => validateAllPlugins(pluginPlans, homeDir, offline), `${pluginPlans.length} plugin(s)`, ); - const failedValidations = validatedPlugins.filter((v) => !v.success); - const validPlugins = validatedPlugins.filter((v) => v.success); + const failedValidations = validatedPlugins.filter((plugin) => !plugin.success); + const requiredNativeFailures = failedValidations.filter((plugin) => { + const plan = pluginPlans.find( + (candidate) => candidate.configurationIndex === plugin.configurationIndex, + ); + return ( + (plan?.nativeClients.length ?? 0) > 0 && + nativeSelectionMatches( + nativeSelection, + plan?.source ?? plugin.plugin, + plan?.source ?? plugin.plugin, + ) + ); + }); const warnings = [ ...planWarnings, - ...failedValidations.map((v) => `${v.plugin}: ${v.error} (skipped)`), + ...failedValidations.map( + (plugin) => `${plugin.plugin}: ${plugin.error} (skipped)`, + ), ]; + if (requiredNativeFailures.length > 0) { + return failedSyncResult( + `Mixed native/file preflight failed (user workspace unchanged):\n${requiredNativeFailures.map((plugin) => ` - ${plugin.plugin}: ${plugin.error}`).join('\n')}`, + { totalFailed: requiredNativeFailures.length, warnings }, + ); + } + const validPlugins = validatedPlugins.filter((plugin) => plugin.success); + const filePlugins = validPlugins.filter((plugin) => plugin.clients.length > 0); const messages: string[] = []; - - // If ALL plugins failed, abort if (validPlugins.length === 0 && pluginPlans.length > 0) { return failedSyncResult( - `All plugins failed validation:\n${failedValidations.map((v) => ` - ${v.plugin}: ${v.error}`).join('\n')}`, + `All plugins failed validation:\n${failedValidations.map((plugin) => ` - ${plugin.plugin}: ${plugin.error}`).join('\n')}`, { totalFailed: failedValidations.length, warnings }, ); } // Load previous sync state (stored at ~/.allagents/sync-state.json) const previousState = await loadSyncState(homeDir); + const userContextClients = [ + ...new Set([ + ...syncClients, + ...(Object.keys(previousState?.files ?? {}) as ClientType[]), + ...(previousState?.nativeResources?.resources + .filter((resource) => resource.scope === 'user') + .map((resource) => resource.client) ?? []), + ]), + ]; + const userClientContexts = resolveClientContexts(userContextClients, 'user', { + homeDir, + cwd: process.cwd(), + env: process.env, + }); + const userContextMappings = clientMappingsFromContexts( + userClientContexts, + USER_CLIENT_MAPPINGS, + ); + const resolvedUserMappings = resolveClientMappings( + syncClients, + userContextMappings, + ); // Selective purge if (!dryRun) { await sw.measure('selective-purge', () => - selectivePurgeWorkspace(homeDir, previousState, syncClients), + selectivePurgeWorkspace( + homeDir, + previousState, + syncClients, + resolvedUserMappings, + userClientContexts, + ), ); const relocatedHooks = await sw.measure('legacy-copilot-hook-scan', () => findRelocatedGitHubHooks( - validPlugins + filePlugins .filter( (plugin) => plugin.clients.includes('copilot') && @@ -2809,7 +3810,7 @@ export async function syncUserWorkspace( })), homeDir, 'copilot', - { clientMappings: USER_CLIENT_MAPPINGS }, + { clientMappings: userContextMappings }, ), ); @@ -2833,22 +3834,18 @@ export async function syncUserWorkspace( : undefined; const allSkills = await sw.measure('skill-collection', () => collectAllSkills( - validPlugins, + filePlugins, disabledSkillsSet, enabledSkillsSet, warnings, ), ); const pluginSkillMaps = buildPluginSkillNameMaps(allSkills); - const resolvedUserMappings = resolveClientMappings( - syncClients, - USER_CLIENT_MAPPINGS, - ); const agentOutputPlan = await sw.measure('agent-output-planning', () => planValidatedPluginAgentOutputs( - validPlugins, + filePlugins, homeDir, - USER_CLIENT_MAPPINGS, + userContextMappings, ), ); appendAgentOutputConflictWarnings(agentOutputPlan, warnings); @@ -2861,7 +3858,7 @@ export async function syncUserWorkspace( 'plugin-copy', () => Promise.all( - validPlugins.map(async (vp, validIndex) => { + filePlugins.map(async (vp, validIndex) => { const skillNameMap = pluginSkillMaps.get(vp.resolved); const configurationIndex = vp.configurationIndex ?? validIndex; const agentOutputs = @@ -2872,7 +3869,7 @@ export async function syncUserWorkspace( indexedAgentOutputPlan.failures.get(configurationIndex) ?? []; const pluginMappings = resolveClientMappings( vp.clients, - USER_CLIENT_MAPPINGS, + userContextMappings, ); const result = await copyValidatedPlugin( vp, @@ -2885,11 +3882,17 @@ export async function syncUserWorkspace( agentOutputs, agentConflicts, agentFailures, + Object.fromEntries( + [...userClientContexts].map(([client, context]) => [ + client, + context.writeRoot, + ]), + ), ); return { ...result, scope: 'user' as const }; }), ), - `${validPlugins.length} plugin(s)`, + `${filePlugins.length} plugin(s)`, ); // MCP Proxy: prepare transform if configured (user-scoped) @@ -2900,7 +3903,7 @@ export async function syncUserWorkspace( let userCollectWarningsEmitted = false; function getUserServersForClient(client: ClientType): Map { const { servers, warnings: collectWarnings } = collectMcpServers( - validPlugins, + filePlugins, userWorkspaceMcpServers, client, ); @@ -2923,7 +3926,7 @@ export async function syncUserWorkspace( 'vscode', ); const vscodeMcpOverrides = getUserServersForClient('vscode'); - const vscodeMcp = syncVscodeMcpConfig(validPlugins, { + const vscodeMcp = syncVscodeMcpConfig(filePlugins, { dryRun, force, trackedServers: trackedMcpServers, @@ -2942,7 +3945,7 @@ export async function syncUserWorkspace( 'codex', ); const codexMcpOverrides = getUserServersForClient('codex'); - const codexMcp = await syncCodexMcpServers(validPlugins, { + const codexMcp = await syncCodexMcpServers(filePlugins, { dryRun, trackedServers: trackedMcpServers, ...(codexMcpOverrides && { serverOverrides: codexMcpOverrides }), @@ -2960,7 +3963,7 @@ export async function syncUserWorkspace( 'claude', ); const claudeMcpOverrides = getUserServersForClient('claude'); - const claudeMcp = await syncClaudeMcpServersViaCli(validPlugins, { + const claudeMcp = await syncClaudeMcpServersViaCli(filePlugins, { dryRun, trackedServers: trackedMcpServers, ...(claudeMcpOverrides && { serverOverrides: claudeMcpOverrides }), @@ -2979,7 +3982,7 @@ export async function syncUserWorkspace( ); const copilotMcpPath = getCopilotMcpConfigPath(); const copilotMcpOverrides = getUserServersForClient('copilot'); - const copilotMcp = syncClaudeMcpConfig(validPlugins, { + const copilotMcp = syncClaudeMcpConfig(filePlugins, { dryRun, force, configPath: copilotMcpPath, @@ -3003,7 +4006,7 @@ export async function syncUserWorkspace( 'universal', ]); const allUserMcpServers = collectMcpServers( - validPlugins, + filePlugins, userWorkspaceMcpServers, ).servers; if (allUserMcpServers.size > 0) { @@ -3024,14 +4027,14 @@ export async function syncUserWorkspace( 'user', homeDir, dryRun, - warnings, - messages, + userClientContexts, + nativeSelection, ), ); // Compute deleted artifacts: compare previous state vs what was just synced const availableUserSkillNames = await collectAvailableSkillNames( - validPlugins, + filePlugins, warnings, ); const allCopyResultsForState = pluginResults.flatMap((r) => r.copyResults); @@ -3043,8 +4046,17 @@ export async function syncUserWorkspace( warnings, ); // Count results - const { totalCopied, totalFailed, totalSkipped, totalGenerated } = - countCopyResults(pluginResults, []); + const { + totalCopied, + totalFailed: fileFailures, + totalSkipped, + totalGenerated, + } = countCopyResults(pluginResults, []); + const nativeFailures = + nativeResult?.effects.filter( + (effect) => effect.action === 'failed' || effect.action === 'unknown', + ).length ?? 0; + const totalFailed = fileFailures + nativeFailures; const newStatePaths = collectSyncedPaths( allCopyResultsForState, @@ -3052,6 +4064,7 @@ export async function syncUserWorkspace( syncClients, resolvedUserMappings, agentDedupeRecords, + userClientContexts, ); const deletedArtifacts = computeDeletedArtifacts( previousState, @@ -3062,16 +4075,12 @@ export async function syncUserWorkspace( agentDedupeRecords, ); - // Save sync state (including MCP servers and native plugins) + // Save sync state (including MCP servers and native resources). if (!dryRun) { - const { pluginsByClient: nativePluginsByClient } = - collectNativePluginSources(validPlugins); await sw.measure('persist-state', () => persistSyncState( homeDir, newStatePaths, - nativePluginsByClient, - nativeResult, { ...(Object.keys(mcpResults).length > 0 && { mcpTrackedServers: Object.fromEntries( @@ -3088,7 +4097,10 @@ export async function syncUserWorkspace( const uniqueWarnings = [...new Set(warnings)]; return { - success: totalFailed === 0, + success: + totalFailed === 0 && + pluginResults.every((result) => result.success) && + nativeResult?.success !== false, pluginResults, totalCopied, totalFailed, diff --git a/src/core/transform.ts b/src/core/transform.ts index 7afd5072..ec0e6487 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -35,6 +35,10 @@ import { parseFileSource } from '../utils/plugin-path.js'; import { createSymlink } from '../utils/symlink.js'; import { parseSkillMetadata } from '../validators/skill.js'; import { discoverNestedSkillEntries } from './skills.js'; +import { + assertSafeDestination, + resolveMappedPath, +} from './client-context.js'; /** * Agent instruction files that receive WORKSPACE-RULES injection @@ -82,11 +86,16 @@ export async function ensureWorkspaceRules( /** * Result of a file copy operation */ +export type CopyArtifactType = 'skill' | 'command' | 'agent' | 'hook'; + export interface CopyResult { source: string; destination: string; action: 'copied' | 'deduped' | 'skipped' | 'failed' | 'generated'; error?: string; + /** Resolved ownership supplied by the copy operation, when applicable. */ + client?: ClientType; + artifactType?: CopyArtifactType; } /** @@ -97,6 +106,8 @@ export interface CopyOptions { dryRun?: boolean; /** Override client path mappings (defaults to CLIENT_MAPPINGS) */ clientMappings?: Record; + /** Selected filesystem root that bounds this client's external writes. */ + writeRoot?: string; /** * Glob patterns of files to exclude during sync. * Paths are relative to the plugin root (e.g., ".github/instructions/file.md", @@ -239,10 +250,20 @@ export async function copyCommands( return results; } - const destDir = join(workspacePath, mapping.commandsPath); - if (!dryRun) { - await mkdir(destDir, { recursive: true }); + const destDir = resolveMappedPath(workspacePath, mapping.commandsPath); + try { + await assertSafeDestination(options.writeRoot ?? workspacePath, destDir); + } catch (error) { + return [{ + source: sourceDir, + destination: destDir, + action: 'failed', + error: error instanceof Error ? error.message : 'Unsafe destination', + client, + artifactType: 'command', + }]; } + if (!dryRun) await mkdir(destDir, { recursive: true }); const files = await readdir(sourceDir); const mdFiles = files.filter((f) => f.endsWith('.md')); @@ -383,9 +404,22 @@ export async function copySkills( return results; } - const destDir = join(workspacePath, mapping.skillsPath); + const destDir = resolveMappedPath(workspacePath, mapping.skillsPath); + const writeRoot = options.writeRoot ?? workspacePath; if (!dryRun) { - await mkdir(destDir, { recursive: true }); + try { + await assertSafeDestination(writeRoot, destDir); + await mkdir(destDir, { recursive: true }); + } catch (error) { + return skillSources.map((skill) => ({ + source: skill.sourcePath, + destination: join(destDir, skill.name), + action: 'failed', + error: error instanceof Error ? error.message : 'Unsafe destination', + client, + artifactType: 'skill', + })); + } } // Determine if we should use symlinks for this client @@ -403,6 +437,21 @@ export async function copySkills( source: skill.sourcePath, destination: skillDestPath, action: 'copied', + client, + artifactType: 'skill', + }; + } + + try { + await assertSafeDestination(writeRoot, skillDestPath); + } catch (error) { + return { + source: skill.sourcePath, + destination: skillDestPath, + action: 'failed', + error: error instanceof Error ? error.message : 'Unsafe destination', + client, + artifactType: 'skill', }; } @@ -423,6 +472,8 @@ export async function copySkills( source: canonicalSkillPath, destination: skillDestPath, action: 'copied', // Report as copied for consistency + client, + artifactType: 'skill', }; } // Symlink failed, fall back to copy @@ -450,6 +501,8 @@ export async function copySkills( source: skill.sourcePath, destination: skillDestPath, action: 'copied', + client, + artifactType: 'skill', }; } catch (error) { return { @@ -457,6 +510,8 @@ export async function copySkills( destination: skillDestPath, action: 'failed', error: error instanceof Error ? error.message : 'Unknown error', + client, + artifactType: 'skill', }; } }); @@ -630,7 +685,7 @@ export async function copyHooks( return results; } - const destDir = join(workspacePath, mapping.hooksPath); + const destDir = resolveMappedPath(workspacePath, mapping.hooksPath); // hooks/hooks.json is a plugin declaration, not a repository hook payload. // Project Copilot sync materializes it separately with COPILOT_PLUGIN_ROOT @@ -702,10 +757,20 @@ export async function copyAgents( return results; } - const destDir = join(workspacePath, mapping.agentsPath); - if (!dryRun) { - await mkdir(destDir, { recursive: true }); + const destDir = resolveMappedPath(workspacePath, mapping.agentsPath); + try { + await assertSafeDestination(options.writeRoot ?? workspacePath, destDir); + } catch (error) { + return [{ + source: sourceDir, + destination: destDir, + action: 'failed', + error: error instanceof Error ? error.message : 'Unsafe destination', + client, + artifactType: 'agent', + }]; } + if (!dryRun) await mkdir(destDir, { recursive: true }); const files = await readdir(sourceDir); const mdFiles = files.filter((f) => f.endsWith('.md')); @@ -918,7 +983,10 @@ export async function planAgentOutputs( for (const client of plugin.clients) { const agentsPath = resolvedPluginMappings[client]?.agentsPath; if (!agentsPath) continue; - const destination = join(workspacePath, agentsPath, entry.name); + const destination = join( + resolveMappedPath(workspacePath, agentsPath), + entry.name, + ); mergeAgentOutputConsumer(candidates, { configurationIndex: plugin.configurationIndex, plugin: plugin.plugin, @@ -1209,7 +1277,10 @@ export async function findRelocatedGitHubHooks( return emptyResult; } - const destDir = join(workspacePath, mapping.githubPath, 'hooks'); + const destDir = join( + resolveMappedPath(workspacePath, mapping.githubPath), + 'hooks', + ); const candidates = new Set(); await Promise.all( sources.map(async ({ pluginPath, exclude }) => { @@ -1344,6 +1415,7 @@ interface PlannedAgentCopyOptions { dryRun: boolean; clientMappings: Record; skillNameMap?: Map; + writeRoot?: string; } async function copyPlannedAgentOutputs( @@ -1352,11 +1424,29 @@ async function copyPlannedAgentOutputs( ): Promise { return Promise.all( outputs.map(async (output): Promise => { + const client = output.clients[0]; + try { + await assertSafeDestination( + options.writeRoot ?? dirname(output.destination), + output.destination, + ); + } catch (error) { + return { + source: output.source, + destination: output.destination, + action: 'failed', + error: error instanceof Error ? error.message : 'Unsafe destination', + ...(client && { client }), + artifactType: 'agent', + }; + } if (options.dryRun) { return { source: output.source, destination: output.destination, action: 'copied', + ...(client && { client }), + artifactType: 'agent', }; } @@ -1368,9 +1458,8 @@ async function copyPlannedAgentOutputs( join(output.pluginPath, '.github'), output.source, ).replaceAll('\\', '/'); - const firstClient = output.clients[0]; - const skillsPath = firstClient - ? (options.clientMappings[firstClient]?.skillsPath ?? '') + const skillsPath = client + ? (options.clientMappings[client]?.skillsPath ?? '') : ''; content = adjustLinksInContent(content, sourceRelativeToGithub, { ...(options.skillNameMap && { @@ -1384,6 +1473,8 @@ async function copyPlannedAgentOutputs( source: output.source, destination: output.destination, action: 'copied', + ...(client && { client }), + artifactType: 'agent', }; } catch (error) { return { @@ -1391,6 +1482,8 @@ async function copyPlannedAgentOutputs( destination: output.destination, action: 'failed', error: error instanceof Error ? error.message : 'Unknown error', + ...(client && { client }), + artifactType: 'agent', }; } }), @@ -1418,6 +1511,7 @@ export async function copyGitHubContent( dryRun, clientMappings: mappings, ...(skillNameMap && { skillNameMap }), + ...(options.writeRoot && { writeRoot: options.writeRoot }), }) : results; } @@ -1447,7 +1541,7 @@ export async function copyGitHubContent( planningFailures = directPlan.failures; } - const destDir = join(workspacePath, mapping.githubPath); + const destDir = resolveMappedPath(workspacePath, mapping.githubPath); const effectiveExclude = githubContentExcludes(mapping, options.exclude); let hasAggregateContent = false; try { @@ -1503,6 +1597,7 @@ export async function copyGitHubContent( dryRun, clientMappings: mappings, ...(skillNameMap && { skillNameMap }), + ...(options.writeRoot && { writeRoot: options.writeRoot }), })), ); results.push( @@ -1621,6 +1716,7 @@ export async function copyPluginToWorkspace( dryRun: baseOptions.dryRun ?? false, clientMappings: mappings, ...(skillNameMap && { skillNameMap }), + ...(baseOptions.writeRoot && { writeRoot: baseOptions.writeRoot }), }, ), ]); diff --git a/src/core/user-workspace.ts b/src/core/user-workspace.ts index 01578b4f..5e3377c2 100644 --- a/src/core/user-workspace.ts +++ b/src/core/user-workspace.ts @@ -369,6 +369,19 @@ async function addPluginToUserConfig( } } +export async function addUserPluginDeclaration( + plugin: string, + force?: boolean, +): Promise { + await ensureUserWorkspace(); + return addPluginToUserConfig( + plugin, + getUserWorkspaceConfigPath(), + undefined, + force, + ); +} + /** * Set clients in user-level workspace config. * Creates the config file if it doesn't exist. diff --git a/src/core/workspace-modify.ts b/src/core/workspace-modify.ts index 29fc682e..41c1124a 100644 --- a/src/core/workspace-modify.ts +++ b/src/core/workspace-modify.ts @@ -176,6 +176,20 @@ export async function addPlugin( return await addPluginToConfig(plugin, configPath, undefined, force); } +export async function addPluginDeclaration( + plugin: string, + workspacePath: string = process.cwd(), + force?: boolean, +): Promise { + await ensureWorkspace(workspacePath); + return addPluginToConfig( + plugin, + join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + undefined, + force, + ); +} + /** * Add plugin to .allagents/workspace.yaml config file */ diff --git a/src/models/client-mapping.ts b/src/models/client-mapping.ts index d56b447b..a04a3686 100644 --- a/src/models/client-mapping.ts +++ b/src/models/client-mapping.ts @@ -86,6 +86,24 @@ export const AGENT_HOSTS: readonly AgentHost[] = [ project: { skillsPath: '.codex/skills/', agentFile: 'AGENTS.md' }, user: { skillsPath: '.codex/skills/', agentFile: 'AGENTS.md' }, }, + { + id: 'pi', + name: 'Pi', + project: { skillsPath: '.pi/skills/', agentFile: 'AGENTS.md' }, + user: { + skillsPath: '.pi/agent/skills/', + agentFile: '.pi/agent/AGENTS.md', + }, + }, + { + id: 'omp', + name: 'OMP', + project: { skillsPath: '.omp/skills/', agentFile: 'AGENTS.md' }, + user: { + skillsPath: '.omp/agent/skills/', + agentFile: '.omp/agent/AGENTS.md', + }, + }, { id: 'cursor', name: 'Cursor', diff --git a/src/models/sync-state.ts b/src/models/sync-state.ts index ed1adb86..c97800ff 100644 --- a/src/models/sync-state.ts +++ b/src/models/sync-state.ts @@ -22,34 +22,70 @@ export const SyncStateSourceSchema = z.object({ }); export type SyncStateSource = z.infer; +export const NativeStateResourceSchema = z.object({ + client: ClientTypeSchema, + scope: z.enum(['user', 'project']), + nativeScope: z.string().min(1), + kind: z.enum(['plugin', 'package']), + requestedIdentity: z.string().min(1), + resolvedIdentity: z.string().min(1), + context: z.string().min(1), + /** Display/materialization root, separate from the durable native identity. */ + root: z.string().min(1).optional(), + provenance: z.record(z.string()), + transition: z.enum([ + 'managed', + 'referenced', + 'pending-install', + 'pending-update', + 'pending-remove', + 'cleanup-failed', + 'unknown', + ]), + error: z.string().optional(), +}); + +export type NativeStateResource = z.infer; + +export const NativeResourceStateSchema = z.object({ + version: z.literal(1), + resources: z.array(NativeStateResourceSchema), +}); + +export type NativeResourceState = z.infer; + /** * Sync state schema - tracks which files were synced per client * Used for non-destructive sync (only purge files we previously created) */ -export const SyncStateSchema = z.object({ - version: z.literal(1), - lastSync: z.string(), // ISO timestamp - files: z.record(ClientTypeSchema, z.array(z.string())), - // Project-scoped Codex hooks managed inside .codex/hooks.json. This stores - // only the allagents-owned portion so sync can preserve user hooks. - codexHooks: z - .object({ - hooks: z.record(z.string(), z.array(z.unknown())), - }) - .optional(), - // MCP servers tracked per scope (e.g., "vscode" for user-level mcp.json) - mcpServers: z.record(z.string(), z.array(z.string())).optional(), - // Native plugins tracked per client type (e.g., "claude" for claude plugin install) - nativePlugins: z.record(ClientTypeSchema, z.array(z.string())).optional(), - // Hash of last-written .code-workspace file content (for change detection) - vscodeWorkspaceHash: z.string().optional(), - // Repository paths at last sync (for detecting added/removed repos) - vscodeWorkspaceRepos: z.array(z.string()).optional(), - // Skills-index files tracked for cleanup (relative to .allagents/) - skillsIndex: z.array(z.string()).optional(), - // Per-source resolved ref + SHA + optional requested ref. - sources: z.record(z.string(), SyncStateSourceSchema).optional(), -}); +export const SyncStateSchema = z + .object({ + version: z.literal(1), + lastSync: z.string().default('1970-01-01T00:00:00.000Z'), + files: z.record(ClientTypeSchema, z.array(z.string())).default({}), + // Project-scoped Codex hooks managed inside .codex/hooks.json. This stores + // only the allagents-owned portion so sync can preserve user hooks. + codexHooks: z + .object({ + hooks: z.record(z.string(), z.array(z.unknown())), + }) + .optional(), + // MCP servers tracked per scope (e.g., "vscode" for user-level mcp.json) + mcpServers: z.record(z.string(), z.array(z.string())).optional(), + // Legacy native plugin tracking. Loaded for conservative migration only; + // string identities never authorize cleanup on their own. + nativePlugins: z.record(ClientTypeSchema, z.array(z.string())).optional(), + nativeResources: NativeResourceStateSchema.optional(), + // Hash of last-written .code-workspace file content (for change detection) + vscodeWorkspaceHash: z.string().optional(), + // Repository paths at last sync (for detecting added/removed repos) + vscodeWorkspaceRepos: z.array(z.string()).optional(), + // Skills-index files tracked for cleanup (relative to .allagents/) + skillsIndex: z.array(z.string()).optional(), + // Per-source resolved ref + SHA + optional requested ref. + sources: z.record(z.string(), SyncStateSourceSchema).optional(), + }) + .passthrough(); export type SyncState = z.infer; diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index 1963ad96..62eefe39 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -85,6 +85,8 @@ export const ClientTypeSchema = z.enum([ 'claude', 'copilot', 'codex', + 'pi', + 'omp', 'cursor', 'opencode', 'gemini', diff --git a/tests/e2e/cli-enriched-help.test.ts b/tests/e2e/cli-enriched-help.test.ts index e3847836..5e1b4906 100644 --- a/tests/e2e/cli-enriched-help.test.ts +++ b/tests/e2e/cli-enriched-help.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from 'bun:test'; import { buildDescription, type CommandMeta } from '../../src/cli/help.js'; import { initMeta, syncMeta, statusMeta } from '../../src/cli/metadata/workspace.js'; -import { marketplaceListMeta, marketplaceAddMeta, marketplaceRemoveMeta, marketplaceUpdateMeta, marketplaceBrowseMeta, pluginListMeta, pluginValidateMeta, pluginInstallMeta, pluginUninstallMeta } from '../../src/cli/metadata/plugin.js'; +import { marketplaceListMeta, marketplaceAddMeta, marketplaceRemoveMeta, marketplaceUpdateMeta, marketplaceBrowseMeta, pluginListMeta, pluginValidateMeta, pluginInstallMeta, pluginUninstallMeta, pluginUpdateMeta } from '../../src/cli/metadata/plugin.js'; import { updateMeta } from '../../src/cli/metadata/self.js'; /** @@ -13,6 +13,7 @@ const allCommandMetas: { name: string; meta: CommandMeta }[] = [ { name: 'workspace status', meta: statusMeta }, { name: 'plugin install', meta: pluginInstallMeta }, { name: 'plugin uninstall', meta: pluginUninstallMeta }, + { name: 'plugin update', meta: pluginUpdateMeta }, { name: 'plugin marketplace list', meta: marketplaceListMeta }, { name: 'plugin marketplace add', meta: marketplaceAddMeta }, { name: 'plugin marketplace remove', meta: marketplaceRemoveMeta }, diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/agent-help.test.ts index 3ea9c561..b2646dd8 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/agent-help.test.ts @@ -16,6 +16,7 @@ import { pluginValidateMeta, pluginInstallMeta, pluginUninstallMeta, + pluginUpdateMeta, } from '../../../src/cli/metadata/plugin.js'; import { updateMeta } from '../../../src/cli/metadata/self.js'; import { @@ -34,6 +35,7 @@ const allCommands: AgentCommandMeta[] = [ statusMeta, pluginInstallMeta, pluginUninstallMeta, + pluginUpdateMeta, marketplaceListMeta, marketplaceAddMeta, marketplaceRemoveMeta, @@ -76,8 +78,8 @@ describe('extractAgentHelpFlag', () => { }); describe('agent command metadata', () => { - test('contains exactly 19 commands', () => { - expect(allCommands.length).toBe(19); + test('contains exactly 20 commands', () => { + expect(allCommands.length).toBe(20); }); test('all expected commands are present', () => { @@ -92,6 +94,7 @@ describe('agent command metadata', () => { 'plugin marketplace remove', 'plugin marketplace update', 'plugin uninstall', + 'plugin update', 'plugin validate', 'self update', 'skill add', @@ -146,6 +149,22 @@ describe('agent command metadata', () => { expect(statusCmd.positionals).toBeUndefined(); expect(statusCmd.options).toBeUndefined(); }); + + test('describes ordinary Pi and OMP behavior without profile surfaces', () => { + const ordinaryMetadata = [ + syncMeta, + statusMeta, + pluginListMeta, + pluginInstallMeta, + pluginUninstallMeta, + pluginUpdateMeta, + ]; + const text = JSON.stringify(ordinaryMetadata); + expect(text).toContain('Pi'); + expect(text).toContain('OMP'); + expect(text.toLowerCase()).not.toContain('profile'); + expect(allCommands.some((command) => command.command.includes('profile'))).toBe(false); + }); }); describe('findMetaByCommand', () => { diff --git a/tests/unit/cli/format-sync.test.ts b/tests/unit/cli/format-sync.test.ts index bd24d3cc..c05608e8 100644 --- a/tests/unit/cli/format-sync.test.ts +++ b/tests/unit/cli/format-sync.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { formatMcpResult, formatNativeResult, classifyCopyResults, formatArtifactLines, formatPluginArtifacts, formatSyncSummary, formatDeletedArtifacts, formatPluginHeader } from '../../../src/cli/format-sync.js'; +import { buildSyncData, formatMcpResult, formatNativeResult, classifyCopyResults, formatArtifactLines, formatPluginArtifacts, formatSyncSummary, formatDeletedArtifacts, formatPluginHeader } from '../../../src/cli/format-sync.js'; import type { CopyResult } from '../../../src/core/transform.js'; import type { SyncResult, DeletedArtifact, PluginSyncResult } from '../../../src/core/sync.js'; import type { McpMergeResult } from '../../../src/core/vscode-mcp.js'; @@ -261,79 +261,86 @@ describe('formatSyncSummary', () => { }); describe('formatNativeResult', () => { - test('includes provider name when present on failed native installs', () => { - const result: NativeSyncResult = { - marketplacesAdded: [], - pluginsInstalled: [], - pluginsFailed: [ - { client: 'copilot', plugin: 'glow@wtg-ai-prompts', error: 'boom' }, - ], - skipped: [], - }; - - expect(formatNativeResult(result)).toEqual([ - ' ✗ [copilot] glow@wtg-ai-prompts: boom', - ]); - }); + const resource = { + kind: 'plugin' as const, + requestedIdentity: 'glow@wtg-ai-prompts', + resolvedIdentity: 'glow@wtg-ai-prompts', + context: { + client: 'copilot', + scope: 'user' as const, + nativeScope: 'user', + root: '/home/test', + }, + provenance: {}, + }; - test('shows claude client name for plugins installed via claude CLI', () => { + test('formats typed successful lifecycle effects', () => { const result: NativeSyncResult = { - marketplacesAdded: [], - pluginsInstalled: [ - { plugin: 'superpowers@claude-plugins-official', client: 'claude' }, + success: true, + effects: [ + { action: 'installed', resource }, + { action: 'unchanged', resource }, + { action: 'removed', resource }, ], - pluginsFailed: [], - skipped: [], }; - expect(formatNativeResult(result)).toEqual([ - ' + superpowers@claude-plugins-official (installed via claude CLI)', + ' + [copilot:user] kind=plugin requested="glow@wtg-ai-prompts" resolved="glow@wtg-ai-prompts" root="/home/test" action=installed phase=install changed=true', + ' = [copilot:user] kind=plugin requested="glow@wtg-ai-prompts" resolved="glow@wtg-ai-prompts" root="/home/test" action=unchanged phase=inspection changed=false', + ' - [copilot:user] kind=plugin requested="glow@wtg-ai-prompts" resolved="glow@wtg-ai-prompts" root="/home/test" action=removed phase=remove changed=true', ]); }); - test('shows copilot client name for plugins installed via copilot CLI', () => { + test('formats failures with exact client and scope', () => { const result: NativeSyncResult = { - marketplacesAdded: [], - pluginsInstalled: [ - { plugin: 'glow@wtg-ai-prompts', client: 'copilot' }, - ], - pluginsFailed: [], - skipped: [], + success: false, + effects: [{ action: 'failed', resource, error: 'boom' }], }; - expect(formatNativeResult(result)).toEqual([ - ' + glow@wtg-ai-prompts (installed via copilot CLI)', + ' ✗ [copilot:user] kind=plugin requested="glow@wtg-ai-prompts" resolved="glow@wtg-ai-prompts" root="/home/test" action=failed phase=inspection changed=false error="boom"', ]); }); - test('shows codex client name for plugins installed via codex CLI', () => { + test('uses the same sanitized typed fields for human and JSON output', () => { const result: NativeSyncResult = { - marketplacesAdded: [], - pluginsInstalled: [ - { plugin: 'my-plugin@marketplace', client: 'codex' }, - ], - pluginsFailed: [], - skipped: [], + success: false, + effects: [{ + action: 'failed', + phase: 'update', + changed: false, + resource: { + ...resource, + requestedIdentity: 'requested@repo', + }, + error: '\u001b[31mboom\u001b[0m\nsecret-free detail', + }], }; - - expect(formatNativeResult(result)).toEqual([ - ' + my-plugin@marketplace (installed via codex CLI)', - ]); - }); - - test('falls back to native CLI when client is not set', () => { - const result: NativeSyncResult = { - marketplacesAdded: [], - pluginsInstalled: [ - { plugin: 'plugin@repo' }, - ], - pluginsFailed: [], - skipped: [], + const syncResult: SyncResult = { + success: false, + pluginResults: [], + totalCopied: 0, + totalFailed: 1, + totalSkipped: 0, + totalGenerated: 0, + nativeResult: result, }; expect(formatNativeResult(result)).toEqual([ - ' + plugin@repo (installed via native CLI)', + ' ✗ [copilot:user] kind=plugin requested="requested@repo" resolved="glow@wtg-ai-prompts" root="/home/test" action=failed phase=update changed=false error="boom secret-free detail"', ]); + expect(buildSyncData(syncResult).nativeResources?.effects).toEqual([{ + action: 'failed', + phase: 'update', + changed: false, + client: 'copilot', + scope: 'user', + nativeScope: 'user', + kind: 'plugin', + requestedIdentity: 'requested@repo', + resolvedIdentity: 'glow@wtg-ai-prompts', + root: '/home/test', + provenance: {}, + error: 'boom secret-free detail', + }]); }); }); diff --git a/tests/unit/core/claude-native.test.ts b/tests/unit/core/claude-native.test.ts deleted file mode 100644 index e98fe799..00000000 --- a/tests/unit/core/claude-native.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { - toClaudePluginSpec, - extractMarketplaceSource, -} from '../../../src/core/claude-native.js'; - -describe('claude-native', () => { - describe('toClaudePluginSpec', () => { - test('converts marketplace spec with owner/repo to plugin@repo', () => { - expect(toClaudePluginSpec('superpowers@obra/superpowers-marketplace')).toBe( - 'superpowers@superpowers-marketplace', - ); - }); - - test('preserves plugin@marketplace format', () => { - expect(toClaudePluginSpec('superpowers@superpowers-marketplace')).toBe( - 'superpowers@superpowers-marketplace', - ); - }); - - test('returns null for direct GitHub paths', () => { - expect( - toClaudePluginSpec('vercel-labs/agent-browser/skills/agent-browser'), - ).toBeNull(); - }); - - test('returns null for empty string', () => { - expect(toClaudePluginSpec('')).toBeNull(); - }); - - test('returns null for trailing slash in marketplace', () => { - expect(toClaudePluginSpec('plugin@owner/')).toBeNull(); - }); - }); - - describe('extractMarketplaceSource', () => { - test('extracts owner/repo from marketplace spec', () => { - expect( - extractMarketplaceSource('superpowers@obra/superpowers-marketplace'), - ).toBe('obra/superpowers-marketplace'); - }); - - test('returns null for non-marketplace specs', () => { - expect( - extractMarketplaceSource('vercel-labs/agent-browser/skills/agent-browser'), - ).toBeNull(); - }); - - test('returns null for plain marketplace name', () => { - expect( - extractMarketplaceSource('superpowers@superpowers-marketplace'), - ).toBeNull(); - }); - }); -}); diff --git a/tests/unit/core/client-context.test.ts b/tests/unit/core/client-context.test.ts new file mode 100644 index 00000000..faa473c0 --- /dev/null +++ b/tests/unit/core/client-context.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'bun:test'; +import { resolve } from 'node:path'; +import { + resolveClientContext, + resolveClientContexts, +} from '../../../src/core/client-context.js'; +import { + CLIENT_MAPPINGS, + USER_CLIENT_MAPPINGS, +} from '../../../src/models/client-mapping.js'; +import { ClientTypeSchema } from '../../../src/models/workspace-config.js'; + +describe('resolveClientContext', () => { + const homeDir = '/users/tester'; + const cwd = '/work/repo/packages/app'; + const repoRoot = '/work/repo'; + + describe('Pi', () => { + it('uses the native default user root and shared user discovery root', () => { + const context = resolveClientContext('pi', 'user', { + homeDir, + cwd, + env: { + XDG_DATA_HOME: '/xdg/data', + XDG_STATE_HOME: '/xdg/state', + XDG_CACHE_HOME: '/xdg/cache', + }, + }); + + expect(context.writeRoot).toBe('/users/tester/.pi/agent'); + expect(context.mapping).toEqual(USER_CLIENT_MAPPINGS.pi); + expect(context.skillDiscoveryRoots).toEqual([ + '/users/tester/.pi/agent/skills', + '/users/tester/.agents/skills', + ]); + }); + + it('resolves an absolute PI_CODING_AGENT_DIR without unsafe relative mapping', () => { + const context = resolveClientContext('pi', 'user', { + homeDir, + cwd, + env: { PI_CODING_AGENT_DIR: '/external/pi-agent' }, + }); + + expect(context.writeRoot).toBe('/external/pi-agent'); + expect(context.mapping.skillsPath).toBe('/external/pi-agent/skills/'); + expect(context.mapping.skillsPath).not.toContain('..'); + }); + + it('resolves a relative PI_CODING_AGENT_DIR from the runtime cwd', () => { + const context = resolveClientContext('pi', 'user', { + homeDir, + cwd, + env: { PI_CODING_AGENT_DIR: '../selected-pi' }, + }); + + expect(context.writeRoot).toBe('/work/repo/packages/selected-pi'); + expect(context.mapping.skillsPath).toBe( + '/work/repo/packages/selected-pi/skills/', + ); + }); + + it('uses the cwd native root before shared ancestors and stops at the repository boundary', () => { + const context = resolveClientContext('pi', 'project', { + homeDir, + cwd, + repoRoot, + env: {}, + }); + + expect(context.skillDiscoveryRoots).toEqual([ + '/work/repo/packages/app/.pi/skills', + '/work/repo/packages/app/.agents/skills', + '/work/repo/packages/.agents/skills', + '/work/repo/.agents/skills', + ]); + }); + }); + + describe('OMP', () => { + it('uses the ordinary default roots and ignores ambient named profiles', () => { + const context = resolveClientContext('omp', 'user', { + homeDir, + cwd, + env: { + OMP_PROFILE: 'work', + PI_PROFILE: 'legacy', + PI_CONFIG_FILES: '/tmp/profile.yml', + }, + platform: 'linux', + pathExists: () => false, + }); + + expect(context.writeRoot).toBe('/users/tester/.omp/agent'); + expect(context.mapping).toEqual(USER_CLIENT_MAPPINGS.omp); + expect(context.commandEnv.OMP_PROFILE).toBeUndefined(); + expect(context.commandEnv.PI_PROFILE).toBeUndefined(); + expect(context.commandEnv.PI_CONFIG_FILES).toBeUndefined(); + }); + + it('honors PI_CONFIG_DIR for the default user agent root', () => { + const context = resolveClientContext('omp', 'user', { + homeDir, + cwd, + env: { PI_CONFIG_DIR: '.config/omp-custom' }, + platform: 'linux', + pathExists: () => false, + }); + + expect(context.ompRoots?.config).toBe( + '/users/tester/.config/omp-custom', + ); + expect(context.writeRoot).toBe( + '/users/tester/.config/omp-custom/agent', + ); + expect(context.mapping.skillsPath).toBe( + '.config/omp-custom/agent/skills/', + ); + }); + + it('activates each existing XDG category independently', () => { + const existing = new Set(['/xdg/data/omp', '/xdg/cache/omp']); + const context = resolveClientContext('omp', 'user', { + homeDir, + cwd, + env: { + XDG_DATA_HOME: '/xdg/data', + XDG_STATE_HOME: '/xdg/state', + XDG_CACHE_HOME: '/xdg/cache', + }, + platform: 'linux', + pathExists: (path) => existing.has(path), + }); + + expect(context.ompRoots).toEqual({ + config: '/users/tester/.omp', + agent: '/users/tester/.omp/agent', + data: '/xdg/data/omp', + state: '/users/tester/.omp', + cache: '/xdg/cache/omp', + dataAgent: '/xdg/data/omp', + stateAgent: '/users/tester/.omp/agent', + cacheAgent: '/xdg/cache/omp', + }); + }); + + it('does not activate missing XDG targets or XDG with an agent override', () => { + const missing = resolveClientContext('omp', 'user', { + homeDir, + cwd, + env: { XDG_DATA_HOME: '/xdg/data' }, + platform: 'linux', + pathExists: () => false, + }); + expect(missing.ompRoots?.data).toBe('/users/tester/.omp'); + + const overridden = resolveClientContext('omp', 'user', { + homeDir, + cwd, + env: { + PI_CODING_AGENT_DIR: '/external/omp-agent', + XDG_DATA_HOME: '/xdg/data', + }, + platform: 'linux', + pathExists: () => true, + }); + expect(overridden.ompRoots?.data).toBe('/users/tester/.omp'); + expect(overridden.writeRoot).toBe('/external/omp-agent'); + expect(overridden.mapping.skillsPath).toBe( + '/external/omp-agent/skills/', + ); + }); + + it('resolves a relative PI_CODING_AGENT_DIR from the runtime cwd', () => { + const context = resolveClientContext('omp', 'user', { + homeDir, + cwd, + env: { PI_CODING_AGENT_DIR: '../selected-omp' }, + platform: 'linux', + pathExists: () => false, + }); + + expect(context.writeRoot).toBe('/work/repo/packages/selected-omp'); + expect(context.mapping.skillsPath).toBe( + '/work/repo/packages/selected-omp/skills/', + ); + }); + + it('orders native then legacy/shared project roots within the boundary', () => { + const context = resolveClientContext('omp', 'project', { + homeDir, + cwd, + repoRoot, + env: {}, + platform: 'linux', + pathExists: () => false, + }); + + expect(context.skillDiscoveryRoots).toEqual([ + '/work/repo/packages/app/.omp/skills', + '/work/repo/packages/.omp/skills', + '/work/repo/.omp/skills', + '/work/repo/packages/app/.agent/skills', + '/work/repo/packages/app/.agents/skills', + '/work/repo/packages/.agent/skills', + '/work/repo/packages/.agents/skills', + '/work/repo/.agent/skills', + '/work/repo/.agents/skills', + ]); + }); + }); + + it('preserves every existing client mapping and root', () => { + const existingClients = ClientTypeSchema.options.filter( + (client) => client !== 'pi' && client !== 'omp', + ); + const project = resolveClientContexts(existingClients, 'project', { + homeDir, + cwd: repoRoot, + env: {}, + }); + const user = resolveClientContexts(existingClients, 'user', { + homeDir, + cwd, + env: {}, + }); + + for (const client of existingClients) { + expect(project.get(client)?.writeRoot).toBe(resolve(repoRoot)); + expect(project.get(client)?.mapping).toBe(CLIENT_MAPPINGS[client]); + expect(user.get(client)?.writeRoot).toBe(resolve(homeDir)); + expect(user.get(client)?.mapping).toBe(USER_CLIENT_MAPPINGS[client]); + expect(user.get(client)?.skillDiscoveryRoots).toEqual([ + resolve(homeDir, USER_CLIENT_MAPPINGS[client].skillsPath), + ]); + } + }); +}); diff --git a/tests/unit/core/native/native-marketplace-registration.test.ts b/tests/unit/core/native/native-marketplace-registration.test.ts index 6f3c2901..33354d1f 100644 --- a/tests/unit/core/native/native-marketplace-registration.test.ts +++ b/tests/unit/core/native/native-marketplace-registration.test.ts @@ -24,7 +24,21 @@ mock.module('../../../../src/core/native/types.js', () => ({ executeCommandCalls.push({ binary, args }); // Simulate successful responses if (args[0] === '--version') { - return { success: true, output: 'claude 1.0.0' }; + return { + success: true, + output: binary === 'omp' ? 'omp/18.1.20' : 'claude 1.0.0', + }; + } + if ( + binary === 'omp' && + args[0] === 'plugin' && + args[1] === 'list' && + args[2] === '--json' + ) { + return { + success: true, + output: JSON.stringify({ npm: [], marketplace: [] }), + }; } if (args.includes('marketplace') && args.includes('add')) { return { success: true, output: 'Marketplace added' }; @@ -34,20 +48,21 @@ mock.module('../../../../src/core/native/types.js', () => ({ } return { success: true, output: '' }; }), - mergeNativeSyncResults: (results: Array<{ marketplacesAdded: string[]; pluginsInstalled: Array<{ plugin: string; client?: string }>; pluginsFailed: unknown[]; skipped: string[] }>) => - results.reduce( - (acc, r) => ({ - marketplacesAdded: [...acc.marketplacesAdded, ...r.marketplacesAdded], - pluginsInstalled: [...acc.pluginsInstalled, ...r.pluginsInstalled], - pluginsFailed: [...acc.pluginsFailed, ...r.pluginsFailed], - skipped: [...acc.skipped, ...r.skipped], - }), - { marketplacesAdded: [] as string[], pluginsInstalled: [] as Array<{ plugin: string; client?: string }>, pluginsFailed: [] as unknown[], skipped: [] as string[] }, - ), + mergeNativeSyncResults: ( + results: Array<{ success: boolean; effects: unknown[] }>, + ) => ({ + success: results.every((result) => result.success), + effects: results.flatMap((result) => result.effects), + }), })); // Mock git operations mock.module('../../../../src/core/git.js', () => ({ + createGitEnv: () => ({ + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_LFS_SKIP_SMUDGE: '1', + }), pull: mock(() => Promise.resolve()), cloneTo: mock((_url: string, path: string) => { mkdirSync(path, { recursive: true }); @@ -235,4 +250,46 @@ describe('native marketplace registration during syncWorkspace', () => { // No addMarketplace should be called for local sources expect(addMarketplaceCalls.length).toBe(0); }); + + it('never invokes OMP mutations during AllAgents dry-run', async () => { + const mpPath = setupMarketplace('agentv', [ + { name: 'agentv-dev', source: './plugins/agentv-dev' }, + ]); + setupRegistry({ + agentv: { + name: 'agentv', + source: { type: 'github', location: 'EntityProcess/agentv' }, + path: mpPath, + lastUpdated: new Date().toISOString(), + }, + }); + setupWorkspace([ + 'repositories: []', + 'plugins:', + ' - agentv-dev@agentv', + 'clients:', + ' - name: omp', + ' install: native', + ].join('\n')); + + const result = await syncWorkspace(testDir, { dryRun: true }); + const ompCalls = executeCommandCalls.filter((call) => call.binary === 'omp'); + + expect(result.success).toBe(true); + expect(ompCalls.length).toBeGreaterThan(0); + expect( + ompCalls.every( + (call) => + call.args[0] === '--version' || + (call.args[0] === 'plugin' && call.args[1] === 'list'), + ), + ).toBe(true); + expect( + ompCalls.some((call) => + ['install', 'upgrade', 'uninstall', 'marketplace'].includes( + call.args[1] ?? '', + ), + ), + ).toBe(false); + }); }); diff --git a/tests/unit/core/native/omp.test.ts b/tests/unit/core/native/omp.test.ts new file mode 100644 index 00000000..b30f4529 --- /dev/null +++ b/tests/unit/core/native/omp.test.ts @@ -0,0 +1,630 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + OmpNativeClient, + inspectOmpMarketplaceRegistry, +} from '../../../../src/core/native/omp.js'; +import type { + NativeCommandOptions, + NativeCommandResult, + NativeOperationContext, + NativeResource, +} from '../../../../src/core/native/types.js'; + +const temporaryDirectories: string[] = []; +const timestamp = '2026-09-14T00:00:00.000Z'; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +interface OmpFixture { + home: string; + dataRoot: string; + agentRoot: string; + workspace: string; +} + +interface MarketplaceSummary { + id: string; + scope: 'user' | 'project'; + entries: Array<{ + scope: 'user' | 'project'; + installPath: string; + version: string; + installedAt: string; + lastUpdated: string; + enabled?: boolean; + }>; + shadowedBy?: 'project'; +} + +function fixture(): OmpFixture { + const home = mkdtempSync(join(tmpdir(), 'allagents-omp-')); + temporaryDirectories.push(home); + const dataRoot = join(home, 'xdg-data', 'omp'); + const agentRoot = join(home, '.omp', 'agent'); + const workspace = join(home, 'workspace'); + mkdirSync(dataRoot, { recursive: true }); + mkdirSync(agentRoot, { recursive: true }); + mkdirSync(workspace, { recursive: true }); + return { home, dataRoot, agentRoot, workspace }; +} + +function context( + scope: 'user' | 'project', + paths: OmpFixture, +): NativeOperationContext { + return { + client: 'omp', + scope, + nativeScope: scope, + root: scope === 'user' ? paths.agentRoot : paths.workspace, + cwd: paths.workspace, + env: { + HOME: paths.home, + OMP_PROFILE: 'ambient-profile', + PI_PROFILE: 'legacy-profile', + PI_CONFIG_FILES: '/tmp/overlay.yml', + SENTINEL: 'preserved', + }, + roots: { + config: join(paths.home, '.omp'), + agent: paths.agentRoot, + data: paths.dataRoot, + state: join(paths.home, '.omp'), + cache: join(paths.home, '.omp'), + dataAgent: paths.dataRoot, + stateAgent: paths.agentRoot, + cacheAgent: paths.agentRoot, + }, + }; +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function writeMarketplace( + paths: OmpFixture, + options: { + name?: string; + sourceUri?: string; + plugin?: string; + catalogName?: string; + version?: number; + } = {}, +): void { + const name = options.name ?? 'tools'; + const catalogPath = join(paths.dataRoot, 'plugins', 'cache', 'marketplaces', name, 'marketplace.json'); + writeJson(catalogPath, { + name: options.catalogName ?? name, + owner: { name: 'Example' }, + plugins: [ + { + name: options.plugin ?? 'reviewer', + source: './plugin', + version: '2.0.0', + }, + ], + }); + writeJson(join(paths.dataRoot, 'marketplaces.json'), { + version: options.version ?? 1, + marketplaces: [ + { + name, + sourceType: 'github', + sourceUri: options.sourceUri ?? 'acme/tools', + catalogPath, + addedAt: timestamp, + updatedAt: timestamp, + }, + ], + }); +} + +function summary( + paths: OmpFixture, + scope: 'user' | 'project', + options: { id?: string; enabled?: boolean; shadowed?: boolean } = {}, +): MarketplaceSummary { + const id = options.id ?? 'reviewer@tools'; + return { + id, + scope, + entries: [ + { + scope, + installPath: join(paths.dataRoot, 'plugins', `${scope}-${id.replace('@', '-')}`), + version: '1.0.0', + installedAt: timestamp, + lastUpdated: timestamp, + ...(options.enabled === false && { enabled: false }), + }, + ], + ...(options.shadowed && { shadowedBy: 'project' }), + }; +} + +function inventory(marketplace: unknown[]): string { + return JSON.stringify({ npm: [], marketplace }); +} + +function pluginResource( + client: OmpNativeClient, + operationContext: NativeOperationContext, + source = 'reviewer@tools', + marketplaceSource?: string, +): NativeResource { + const resolved = client.resolveSource( + source, + operationContext, + marketplaceSource ? { marketplaceSource } : {}, + ); + expect(resolved.success).toBe(true); + expect(resolved.resource).toBeDefined(); + return resolved.resource!; +} + +describe('native/omp version and context', () => { + test('accepts 18.1.17 and newer while rejecting old or malformed versions', async () => { + const paths = fixture(); + const operationContext = context('user', paths); + const versionClient = (output: string) => new OmpNativeClient({ + execute: async () => ({ success: true, output }), + }); + + expect(await versionClient('omp/18.1.17').isAvailable(operationContext)).toBe(true); + expect(await versionClient('18.1.20').isAvailable(operationContext)).toBe(true); + expect(await versionClient('omp/19.0.0').isAvailable(operationContext)).toBe(true); + expect(await versionClient('omp/18.1.16').isAvailable(operationContext)).toBe(false); + expect(await versionClient('omp version 18.1.20').isAvailable(operationContext)).toBe(false); + expect( + await new OmpNativeClient({ + execute: async () => ({ success: false, output: '', exitCode: 127 }), + }).isAvailable(operationContext), + ).toBe(false); + }); + + test('fails a direct mutation at version inspection before inventory or marketplace access', async () => { + const paths = fixture(); + const operationContext = context('user', paths); + const calls: string[][] = []; + let fileReads = 0; + const client = new OmpNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + return { success: true, output: 'omp/18.1.16' }; + }, + readFile: async () => { + fileReads++; + return '{}'; + }, + }); + const resource = pluginResource( + client, + operationContext, + 'reviewer@tools', + 'acme/tools', + ); + + const result = await client.install(resource, operationContext); + + expect(result.success).toBe(false); + expect(result.error).toContain('18.1.17 or newer'); + expect(calls).toEqual([['--version']]); + expect(fileReads).toBe(0); + }); + + test('uses the resolved data root and removes ambient profile/config selectors', async () => { + const paths = fixture(); + const operationContext = context('user', paths); + const calls: Array<{ args: string[]; options?: NativeCommandOptions }> = []; + const readPaths: string[] = []; + const client = new OmpNativeClient({ + execute: async (_binary, args, options) => { + calls.push({ args, ...(options && { options }) }); + return args[0] === '--version' + ? { success: true, output: 'omp/18.1.20' } + : { success: true, output: inventory([]) }; + }, + readFile: async (path) => { + readPaths.push(path); + const error = new Error('missing') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }, + }); + + const result = await client.inspect(operationContext); + + expect(result.success).toBe(true); + expect(readPaths).toEqual([join(paths.dataRoot, 'marketplaces.json')]); + expect(calls.map((call) => call.args)).toEqual([ + ['--version'], + ['plugin', 'list', '--json'], + ]); + for (const call of calls) { + expect(call.options?.cwd).toBe(paths.workspace); + expect(call.options?.env?.SENTINEL).toBe('preserved'); + expect(call.options?.env?.OMP_PROFILE).toBeUndefined(); + expect(call.options?.env?.PI_PROFILE).toBeUndefined(); + expect(call.options?.env?.PI_CONFIG_FILES).toBeUndefined(); + } + expect(calls.some((call) => call.args.includes('--profile'))).toBe(false); + }); +}); + +describe('native/omp authoritative marketplace inspection', () => { + test('correlates the versioned registry, catalog, and scoped JSON inventory', async () => { + const paths = fixture(); + writeMarketplace(paths); + const marketplace = [ + summary(paths, 'project'), + summary(paths, 'user', { shadowed: true }), + ]; + const client = new OmpNativeClient({ + execute: async (_binary, args) => args[0] === '--version' + ? { success: true, output: 'omp/18.1.20' } + : { success: true, output: inventory(marketplace) }, + }); + + const user = await client.inspect(context('user', paths)); + const project = await client.inspect(context('project', paths)); + + expect(user.success).toBe(true); + expect(user.resources.map((resource) => resource.resolvedIdentity)).toEqual([ + 'reviewer@tools', + ]); + expect(user.resources[0]?.provenance).toMatchObject({ + marketplaceSource: 'acme/tools', + installedVersion: '1.0.0', + catalogVersion: '2.0.0', + shadowedBy: 'project', + }); + expect(project.success).toBe(true); + expect(project.resources).toHaveLength(1); + }); + + test('reports a disabled plugin without crediting it as installed', async () => { + const paths = fixture(); + writeMarketplace(paths); + const client = new OmpNativeClient({ + execute: async (_binary, args) => args[0] === '--version' + ? { success: true, output: 'omp/18.1.20' } + : { + success: true, + output: inventory([summary(paths, 'user', { enabled: false })]), + }, + }); + + const result = await client.inspect(context('user', paths)); + + expect(result.success).toBe(true); + expect(result.resources).toEqual([]); + expect(result.observations?.[0]?.status).toBe('disabled'); + }); + + test('rejects missing catalogs, wrong registry versions, and conflicting identities', async () => { + const paths = fixture(); + const operationContext = context('user', paths); + writeMarketplace(paths, { version: 2 }); + let result = await inspectOmpMarketplaceRegistry(operationContext); + expect(result.success).toBe(false); + expect(result.error).toContain('unsupported or malformed version'); + + writeMarketplace(paths); + const registryPath = join(paths.dataRoot, 'marketplaces.json'); + const firstCatalog = join(paths.dataRoot, 'plugins', 'cache', 'marketplaces', 'tools', 'marketplace.json'); + writeJson(registryPath, { + version: 1, + marketplaces: [ + { + name: 'tools', + sourceType: 'github', + sourceUri: 'acme/tools', + catalogPath: firstCatalog, + addedAt: timestamp, + updatedAt: timestamp, + }, + { + name: 'other-tools', + sourceType: 'github', + sourceUri: 'https://github.com/ACME/tools.git', + catalogPath: join(paths.dataRoot, 'other-catalog.json'), + addedAt: timestamp, + updatedAt: timestamp, + }, + ], + }); + result = await inspectOmpMarketplaceRegistry(operationContext); + expect(result.success).toBe(false); + expect(result.error).toContain('ambiguous identity'); + + writeMarketplace(paths, { catalogName: 'renamed-tools' }); + result = await inspectOmpMarketplaceRegistry(operationContext); + expect(result.success).toBe(false); + expect(result.error).toContain('conflicts with catalog name'); + + rmSync(firstCatalog, { force: true }); + result = await inspectOmpMarketplaceRegistry(operationContext); + expect(result.success).toBe(false); + expect(result.error).toContain('catalog is missing'); + }); + + test('rejects malformed, ambiguous, and uncorroborated plugin inventories', async () => { + const paths = fixture(); + writeMarketplace(paths); + const valid = summary(paths, 'user'); + const malformedOutputs = [ + '', + '{not-json', + JSON.stringify({ marketplace: [] }), + inventory([{ ...valid, entries: [] }]), + inventory([{ ...valid, entries: [{ ...valid.entries[0], scope: 'project' }] }]), + inventory([{ ...valid, shadowedBy: 'project' }]), + inventory([valid, valid]), + ]; + + for (const output of malformedOutputs) { + const client = new OmpNativeClient({ + execute: async (_binary, args) => args[0] === '--version' + ? { success: true, output: 'omp/18.1.20' } + : { success: true, output }, + }); + const result = await client.inspect(context('user', paths)); + expect(result.success).toBe(false); + expect(result.resources).toEqual([]); + } + }); + + test('does not accept a nonzero inventory command even when stdout looks valid', async () => { + const paths = fixture(); + let calls = 0; + const client = new OmpNativeClient({ + execute: async (_binary, args): Promise => { + calls++; + return args[0] === '--version' + ? { success: true, output: 'omp/18.1.20' } + : { success: false, output: inventory([]), exitCode: 2 }; + }, + }); + + const result = await client.inspect(context('user', paths)); + + expect(result.success).toBe(false); + expect(result.error).toContain('exited with code 2'); + expect(calls).toBe(2); + }); +}); + +describe('native/omp ordered command effects', () => { + test('registers an absent marketplace before an explicit-scope install and verifies it', async () => { + const paths = fixture(); + const operationContext = context('user', paths); + const calls: Array<{ args: string[]; options?: NativeCommandOptions }> = []; + const marketplace: MarketplaceSummary[] = []; + const client = new OmpNativeClient({ + execute: async (_binary, args, options) => { + calls.push({ args, ...(options && { options }) }); + if (args[0] === '--version') return { success: true, output: 'omp/18.1.20' }; + if (args[0] === 'plugin' && args[1] === 'list') { + return { success: true, output: inventory(marketplace) }; + } + if (args[0] === 'plugin' && args[1] === 'marketplace') { + writeMarketplace(paths); + return { success: true, output: 'added' }; + } + if (args[0] === 'plugin' && args[1] === 'install') { + marketplace.push(summary(paths, 'user')); + return { success: true, output: 'installed' }; + } + return { success: false, output: '', exitCode: 1 }; + }, + }); + const resource = pluginResource(client, operationContext, 'reviewer@tools', 'acme/tools'); + + const result = await client.install(resource, operationContext); + + expect(result).toEqual({ success: true, registrations: ['acme/tools'] }); + expect(calls.map((call) => call.args)).toEqual([ + ['--version'], + ['plugin', 'list', '--json'], + ['plugin', 'marketplace', 'add', 'acme/tools'], + ['plugin', 'install', '--scope', 'user', 'reviewer@tools'], + ['plugin', 'list', '--json'], + ]); + }); + + test('stops on registration failure and retains a confirmed registration when install fails', async () => { + const failedPaths = fixture(); + const failedContext = context('user', failedPaths); + const failedCalls: string[][] = []; + const registrationFailure = new OmpNativeClient({ + execute: async (_binary, args) => { + failedCalls.push(args); + if (args[0] === '--version') return { success: true, output: 'omp/18.1.20' }; + if (args[1] === 'list') return { success: true, output: inventory([]) }; + return { success: false, output: '', error: 'network unavailable' }; + }, + }); + const failedResource = pluginResource( + registrationFailure, + failedContext, + 'reviewer@tools', + 'acme/tools', + ); + + const registrationResult = await registrationFailure.install( + failedResource, + failedContext, + ); + + expect(registrationResult.success).toBe(false); + expect(failedCalls.some((args) => args[1] === 'install')).toBe(false); + + const installedPaths = fixture(); + const installedContext = context('user', installedPaths); + const installCalls: string[][] = []; + const installFailure = new OmpNativeClient({ + execute: async (_binary, args) => { + installCalls.push(args); + if (args[0] === '--version') return { success: true, output: 'omp/18.1.20' }; + if (args[1] === 'list') return { success: true, output: inventory([]) }; + if (args[1] === 'marketplace') { + writeMarketplace(installedPaths); + return { success: true, output: 'added' }; + } + return { success: false, output: '', error: 'plugin install failed' }; + }, + }); + const installResource = pluginResource( + installFailure, + installedContext, + 'reviewer@tools', + 'acme/tools', + ); + + const installResult = await installFailure.install( + installResource, + installedContext, + ); + + expect(installResult.success).toBe(false); + expect(installResult.registrations).toEqual(['acme/tools']); + expect(installCalls.map((args) => args[1])).toEqual([ + undefined, + 'list', + 'marketplace', + 'install', + ]); + }); + + test('treats exit-zero install without the exact post-state as a failure', async () => { + const paths = fixture(); + writeMarketplace(paths); + const operationContext = context('user', paths); + const calls: string[][] = []; + const client = new OmpNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + if (args[0] === '--version') { + return { success: true, output: 'omp/18.1.20' }; + } + if (args[1] === 'list') { + return { success: true, output: inventory([]) }; + } + return { success: true, output: 'installed' }; + }, + }); + const resource = pluginResource( + client, + operationContext, + 'reviewer@tools', + 'acme/tools', + ); + + const result = await client.install(resource, operationContext); + + expect(result.success).toBe(false); + expect(result.error).toContain('absent from user inventory'); + expect(calls.filter((args) => args[1] === 'list')).toHaveLength(2); + }); + + test('skips an exact existing registration/plugin and rejects a source conflict without mutation', async () => { + const paths = fixture(); + writeMarketplace(paths); + const operationContext = context('user', paths); + const calls: string[][] = []; + const marketplace = [summary(paths, 'user')]; + const client = new OmpNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + return args[0] === '--version' + ? { success: true, output: 'omp/18.1.20' } + : { success: true, output: inventory(marketplace) }; + }, + }); + const existing = pluginResource(client, operationContext, 'reviewer@tools', 'acme/tools'); + + expect(await client.install(existing, operationContext)).toEqual({ success: true }); + expect(calls.some((args) => args[1] === 'install')).toBe(false); + + marketplace.splice(0); + const conflicting = pluginResource( + client, + operationContext, + 'reviewer@tools', + 'different/tools', + ); + const conflictResult = await client.install(conflicting, operationContext); + expect(conflictResult.success).toBe(false); + expect(conflictResult.error).toContain('conflicting source'); + expect(calls.some((args) => args[1] === 'marketplace')).toBe(false); + }); + + test('uses targeted explicit-scope upgrade/uninstall and preserves the other scope', async () => { + const paths = fixture(); + writeMarketplace(paths); + const userContext = context('user', paths); + const projectContext = context('project', paths); + const marketplace = [ + summary(paths, 'project'), + summary(paths, 'user', { shadowed: true }), + ]; + const calls: string[][] = []; + const client = new OmpNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + if (args[0] === '--version') return { success: true, output: 'omp/18.1.20' }; + if (args[1] === 'list') return { success: true, output: inventory(marketplace) }; + if (args[1] === 'uninstall') { + marketplace.splice( + marketplace.findIndex((entry) => entry.scope === args[3]), + 1, + ); + const user = marketplace.find((entry) => entry.scope === 'user'); + if (user) delete user.shadowedBy; + } + return { success: true, output: 'ok' }; + }, + }); + const userResource = pluginResource(client, userContext); + const projectResource = pluginResource(client, projectContext); + + expect((await client.update(userResource, userResource, userContext)).success).toBe(true); + expect((await client.remove(projectResource, projectContext)).success).toBe(true); + + expect(calls).toContainEqual([ + 'plugin', + 'upgrade', + '--scope', + 'user', + 'reviewer@tools', + ]); + expect(calls).toContainEqual([ + 'plugin', + 'uninstall', + '--scope', + 'project', + 'reviewer@tools', + ]); + expect(calls).not.toContainEqual(['plugin', 'upgrade', 'reviewer@tools']); + expect(calls.some((args) => args[1] === 'marketplace' && args[2] === 'remove')).toBe(false); + + const remaining = await client.inspect(userContext); + expect(remaining.resources.map((resource) => resource.resolvedIdentity)).toEqual([ + 'reviewer@tools', + ]); + }); +}); diff --git a/tests/unit/core/native/pi-mcp.test.ts b/tests/unit/core/native/pi-mcp.test.ts new file mode 100644 index 00000000..dce768c5 --- /dev/null +++ b/tests/unit/core/native/pi-mcp.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inspectPiMcpAdapter } from '../../../../src/core/native/pi-mcp.js'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function fixture() { + const base = mkdtempSync(join(tmpdir(), 'allagents-pi-mcp-')); + temporaryDirectories.push(base); + const selectedRoot = join(base, 'selected-agent'); + mkdirSync(selectedRoot, { recursive: true }); + return { base, selectedRoot }; +} + +function writeJson(path: string, value: unknown) { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function installAdapter( + root: string, + manifest: Record = { + name: 'pi-mcp-adapter', + version: '2.33.0', + pi: { extensions: ['./index.ts'] }, + }, +) { + const packageRoot = join( + root, + 'npm', + 'node_modules', + 'pi-mcp-adapter', + ); + mkdirSync(packageRoot, { recursive: true }); + writeJson(join(packageRoot, 'package.json'), manifest); + writeFileSync(join(packageRoot, 'index.ts'), 'export default function adapter() {}\n'); + return packageRoot; +} + +describe('native/pi-mcp', () => { + test('classifies a configured same-root package with an enabled contained extension as usable', async () => { + const { selectedRoot } = fixture(); + writeJson(join(selectedRoot, 'settings.json'), { + packages: ['npm:pi-mcp-adapter'], + }); + const packageRoot = installAdapter(selectedRoot); + + expect(await inspectPiMcpAdapter(selectedRoot)).toEqual({ + classification: 'usable', + root: selectedRoot, + packageSource: 'npm:pi-mcp-adapter', + packagePath: packageRoot, + manifestPath: join(packageRoot, 'package.json'), + version: '2.33.0', + extensionPath: join(packageRoot, 'index.ts'), + }); + }); + + test('classifies an unconfigured PATH-only adapter as absent', async () => { + const { base, selectedRoot } = fixture(); + const bin = join(base, 'bin'); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, 'pi-mcp-adapter'), '#!/bin/sh\nexit 0\n'); + writeJson(join(selectedRoot, 'settings.json'), { packages: [] }); + + expect((await inspectPiMcpAdapter(selectedRoot)).classification).toBe( + 'absent', + ); + }); + + test('does not credit a package installed and configured in another Pi root', async () => { + const { base, selectedRoot } = fixture(); + const otherRoot = join(base, 'other-agent'); + mkdirSync(otherRoot, { recursive: true }); + writeJson(join(otherRoot, 'settings.json'), { + packages: ['npm:pi-mcp-adapter'], + }); + installAdapter(otherRoot); + writeJson(join(selectedRoot, 'settings.json'), { packages: [] }); + + expect((await inspectPiMcpAdapter(selectedRoot)).classification).toBe( + 'absent', + ); + }); + + test('distinguishes configured-missing and installed-disabled packages', async () => { + const missing = fixture(); + writeJson(join(missing.selectedRoot, 'settings.json'), { + packages: ['npm:pi-mcp-adapter'], + }); + expect( + (await inspectPiMcpAdapter(missing.selectedRoot)).classification, + ).toBe('configured-missing'); + + const disabled = fixture(); + writeJson(join(disabled.selectedRoot, 'settings.json'), { + packages: [ + { source: 'npm:pi-mcp-adapter', extensions: [] }, + ], + }); + installAdapter(disabled.selectedRoot); + expect( + (await inspectPiMcpAdapter(disabled.selectedRoot)).classification, + ).toBe('installed-disabled'); + }); + + test('honors autoload false extension includes without enabling omitted resources', async () => { + const { selectedRoot } = fixture(); + writeJson(join(selectedRoot, 'settings.json'), { + packages: [ + { + source: 'npm:pi-mcp-adapter', + autoload: false, + extensions: ['+index.ts'], + }, + ], + }); + installAdapter(selectedRoot); + + expect((await inspectPiMcpAdapter(selectedRoot)).classification).toBe( + 'usable', + ); + }); + + test('fails inspection for malformed or wrong-name installed manifests', async () => { + const malformed = fixture(); + writeJson(join(malformed.selectedRoot, 'settings.json'), { + packages: ['npm:pi-mcp-adapter'], + }); + const malformedPackage = installAdapter(malformed.selectedRoot); + writeFileSync(join(malformedPackage, 'package.json'), '{'); + expect( + (await inspectPiMcpAdapter(malformed.selectedRoot)).classification, + ).toBe('inspection-failed'); + + const wrongName = fixture(); + writeJson(join(wrongName.selectedRoot, 'settings.json'), { + packages: ['npm:pi-mcp-adapter'], + }); + installAdapter(wrongName.selectedRoot, { + name: 'not-the-adapter', + version: '2.33.0', + pi: { extensions: ['./index.ts'] }, + }); + expect( + (await inspectPiMcpAdapter(wrongName.selectedRoot)).classification, + ).toBe('inspection-failed'); + }); + + test('fails inspection when the configured package or extension escapes the selected root', async () => { + const externalPackage = fixture(); + const outside = join(externalPackage.base, 'outside-package'); + mkdirSync(outside, { recursive: true }); + writeJson(join(outside, 'package.json'), { + name: 'pi-mcp-adapter', + version: '2.33.0', + pi: { extensions: ['./index.ts'] }, + }); + writeFileSync(join(outside, 'index.ts'), 'export default function adapter() {}\n'); + writeJson(join(externalPackage.selectedRoot, 'settings.json'), { + packages: [outside], + }); + expect( + (await inspectPiMcpAdapter(externalPackage.selectedRoot)).classification, + ).toBe('inspection-failed'); + + const escapingExtension = fixture(); + writeJson(join(escapingExtension.selectedRoot, 'settings.json'), { + packages: ['npm:pi-mcp-adapter'], + }); + const packageRoot = installAdapter(escapingExtension.selectedRoot); + const externalExtension = join(escapingExtension.base, 'external.ts'); + writeFileSync(externalExtension, 'export default function adapter() {}\n'); + rmSync(join(packageRoot, 'index.ts')); + symlinkSync(externalExtension, join(packageRoot, 'index.ts')); + const result = await inspectPiMcpAdapter(escapingExtension.selectedRoot); + expect(result.classification).toBe('inspection-failed'); + expect(result.error).toContain('escapes the selected Pi root'); + }); + + test('performs inspection without writing package, init, or MCP state', async () => { + const { selectedRoot } = fixture(); + const settingsPath = join(selectedRoot, 'settings.json'); + writeJson(settingsPath, { packages: ['npm:pi-mcp-adapter'] }); + installAdapter(selectedRoot); + const beforeEntries = readdirSync(selectedRoot, { recursive: true }) + .map(String) + .sort(); + const beforeSettings = readFileSync(settingsPath, 'utf8'); + + const result = await inspectPiMcpAdapter(selectedRoot); + + expect(result.classification).toBe('usable'); + expect(readdirSync(selectedRoot, { recursive: true }).map(String).sort()).toEqual( + beforeEntries, + ); + expect(readFileSync(settingsPath, 'utf8')).toBe(beforeSettings); + expect(beforeEntries.some((entry) => entry.includes('mcp.json'))).toBe(false); + }); +}); diff --git a/tests/unit/core/native/pi.test.ts b/tests/unit/core/native/pi.test.ts new file mode 100644 index 00000000..e6ba17eb --- /dev/null +++ b/tests/unit/core/native/pi.test.ts @@ -0,0 +1,483 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + PiNativeClient, + inspectPiProjectTrust, + normalizePiPackageSource, +} from '../../../../src/core/native/pi.js'; +import type { + NativeCommandOptions, + NativeCommandResult, + NativeOperationContext, + NativeResource, +} from '../../../../src/core/native/types.js'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +interface PiFixture { + home: string; + agentRoot: string; + workspace: string; +} + +function fixture(): PiFixture { + const home = mkdtempSync(join(tmpdir(), 'allagents-pi-')); + temporaryDirectories.push(home); + const agentRoot = join(home, 'pi-agent'); + const workspace = join(home, 'workspace'); + mkdirSync(agentRoot, { recursive: true }); + mkdirSync(workspace, { recursive: true }); + return { home, agentRoot, workspace }; +} + +function context( + scope: 'user' | 'project', + paths: PiFixture, +): NativeOperationContext { + return { + client: 'pi', + scope, + nativeScope: scope, + root: scope === 'user' ? paths.agentRoot : paths.workspace, + cwd: paths.workspace, + env: { + HOME: paths.home, + PI_CODING_AGENT_DIR: paths.agentRoot, + SENTINEL: 'preserved', + }, + }; +} + +function writeJson(path: string, value: unknown) { + mkdirSync(resolve(path, '..'), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function installNpmPackage( + root: string, + name: string, + manifest: Record = { name, version: '1.0.0' }, +) { + const packageRoot = join(root, 'npm', 'node_modules', ...name.split('/')); + mkdirSync(packageRoot, { recursive: true }); + writeJson(join(packageRoot, 'package.json'), manifest); + return packageRoot; +} + +function packageResource( + client: PiNativeClient, + source: string, + operationContext: NativeOperationContext, +): NativeResource { + const result = client.resolveSource(source, operationContext); + expect(result.success).toBe(true); + expect(result.resource).toBeDefined(); + return result.resource!; +} + +describe('native/pi source normalization', () => { + test('preserves requested pins while resolving npm identity', () => { + const paths = fixture(); + const operationContext = context('user', paths); + const normalized = normalizePiPackageSource( + ' npm:@scope/tool@1.2.3 ', + operationContext, + ); + + expect(normalized).toEqual({ + kind: 'npm', + requested: 'npm:@scope/tool@1.2.3', + resolvedIdentity: 'npm:@scope/tool@1.2.3', + packageIdentity: 'npm:@scope/tool', + commandSource: 'npm:@scope/tool@1.2.3', + packageName: '@scope/tool', + }); + }); + + test('normalizes git shorthand, URLs, and refs to one transport-neutral identity', () => { + const paths = fixture(); + const operationContext = context('user', paths); + + expect( + normalizePiPackageSource( + 'git:github.com/acme/pi-tool.git@v1', + operationContext, + )?.resolvedIdentity, + ).toBe('git:github.com/acme/pi-tool@v1'); + expect( + normalizePiPackageSource( + 'https://github.com/acme/pi-tool.git@v1', + operationContext, + )?.resolvedIdentity, + ).toBe('git:github.com/acme/pi-tool@v1'); + expect( + normalizePiPackageSource( + 'git:git@github.com:acme/pi-tool.git@v1', + operationContext, + )?.resolvedIdentity, + ).toBe('git:github.com/acme/pi-tool@v1'); + expect( + normalizePiPackageSource( + 'ssh://git@github.com/acme/pi-tool.git@v1', + operationContext, + )?.resolvedIdentity, + ).toBe('git:github.com/acme/pi-tool@v1'); + }); + + test('resolves absolute and relative packages against command cwd', () => { + const paths = fixture(); + const operationContext = context('project', paths); + const absolute = join(paths.home, 'packages', 'absolute'); + + expect( + normalizePiPackageSource(absolute, operationContext)?.resolvedIdentity, + ).toBe(`local:${absolute}`); + expect( + normalizePiPackageSource('./packages/relative', operationContext) + ?.resolvedIdentity, + ).toBe(`local:${join(paths.workspace, 'packages', 'relative')}`); + }); + + test('rejects bare names and malformed package sources', () => { + const paths = fixture(); + const operationContext = context('user', paths); + + expect(normalizePiPackageSource('bare-package', operationContext)).toBeNull(); + expect(normalizePiPackageSource('npm:', operationContext)).toBeNull(); + expect( + normalizePiPackageSource('https://github.com/only-one-part', operationContext), + ).toBeNull(); + }); +}); + +describe('native/pi inspection', () => { + test('credits only enabled packages with corroborated installed manifests', async () => { + const paths = fixture(); + const operationContext = context('user', paths); + writeJson(join(paths.agentRoot, 'settings.json'), { + packages: [ + 'npm:installed', + 'npm:missing', + { source: 'npm:disabled', autoload: false }, + 'npm:wrong-manifest', + ], + }); + installNpmPackage(paths.agentRoot, 'installed'); + installNpmPackage(paths.agentRoot, 'disabled'); + installNpmPackage(paths.agentRoot, 'wrong-manifest', { + name: 'different-package', + version: '1.0.0', + }); + + const result = await new PiNativeClient().inspect(operationContext); + + expect(result.success).toBe(true); + expect(result.resources.map((resource) => resource.resolvedIdentity)).toEqual([ + 'npm:installed', + ]); + expect( + result.observations?.map((observation) => [ + observation.resource.resolvedIdentity, + observation.status, + ]), + ).toEqual([ + ['npm:installed', 'installed'], + ['npm:missing', 'configured-missing'], + ['npm:disabled', 'disabled'], + ['npm:wrong-manifest', 'unusable'], + ]); + }); + + test('keeps user and project settings observations isolated', async () => { + const paths = fixture(); + const userContext = context('user', paths); + const projectContext = context('project', paths); + writeJson(join(paths.agentRoot, 'settings.json'), { + defaultProjectTrust: 'always', + packages: ['npm:user-only'], + }); + writeJson(join(paths.workspace, '.pi', 'settings.json'), { + packages: ['npm:project-only'], + }); + installNpmPackage(paths.agentRoot, 'user-only'); + installNpmPackage(join(paths.workspace, '.pi'), 'project-only'); + + const [user, project] = await Promise.all([ + new PiNativeClient().inspect(userContext), + new PiNativeClient().inspect(projectContext), + ]); + + expect(user.resources.map((resource) => resource.resolvedIdentity)).toEqual([ + 'npm:user-only', + ]); + expect(project.resources.map((resource) => resource.resolvedIdentity)).toEqual([ + 'npm:project-only', + ]); + }); + + test('fails closed on duplicate package identities', async () => { + const paths = fixture(); + const operationContext = context('user', paths); + writeJson(join(paths.agentRoot, 'settings.json'), { + packages: ['npm:duplicate@1', 'npm:duplicate@2'], + }); + + const result = await new PiNativeClient().inspect(operationContext); + + expect(result.success).toBe(false); + expect(result.error).toContain('Ambiguous duplicate Pi package identity'); + expect(result.resources).toEqual([]); + }); +}); + +describe('native/pi project trust', () => { + test('uses the nearest canonical saved ancestor decision', async () => { + const paths = fixture(); + const nested = join(paths.workspace, 'nested', 'project'); + mkdirSync(nested, { recursive: true }); + const linked = join(paths.home, 'workspace-link'); + symlinkSync(paths.workspace, linked, 'dir'); + writeJson(join(paths.agentRoot, 'settings.json'), { + defaultProjectTrust: 'never', + }); + writeJson(join(paths.agentRoot, 'trust.json'), { + [paths.home]: false, + [paths.workspace]: true, + }); + const operationContext = { + ...context('project', paths), + cwd: join(linked, 'nested', 'project'), + }; + + expect(await inspectPiProjectTrust(operationContext)).toEqual({ + status: 'allowed', + allowed: true, + source: 'saved', + matchedPath: paths.workspace, + }); + }); + + test('classifies ask, deny, unreadable, and ambiguous trust as not allowed', async () => { + const paths = fixture(); + const operationContext = context('project', paths); + writeJson(join(paths.agentRoot, 'settings.json'), { + defaultProjectTrust: 'ask', + }); + expect((await inspectPiProjectTrust(operationContext)).status).toBe('ask'); + + writeJson(join(paths.agentRoot, 'settings.json'), { + defaultProjectTrust: 'never', + }); + expect((await inspectPiProjectTrust(operationContext)).status).toBe('denied'); + + mkdirSync(join(paths.agentRoot, 'trust.json'), { recursive: true }); + expect((await inspectPiProjectTrust(operationContext)).status).toBe( + 'inspection-failed', + ); + rmSync(join(paths.agentRoot, 'trust.json'), { recursive: true, force: true }); + + writeJson(join(paths.agentRoot, 'trust.json'), { + [paths.workspace]: true, + [`${paths.workspace}/.`]: false, + }); + expect((await inspectPiProjectTrust(operationContext)).status).toBe( + 'ambiguous', + ); + }); +}); + +describe('native/pi command effects', () => { + test('checks the compatible Pi version range', async () => { + const paths = fixture(); + const operationContext = context('user', paths); + const supported = new PiNativeClient({ + execute: async () => ({ success: true, output: '0.85.1' }), + }); + const unsupported = new PiNativeClient({ + execute: async () => ({ success: true, output: '0.86.0' }), + }); + + expect(await supported.isAvailable(operationContext)).toBe(true); + expect(await unsupported.isAvailable(operationContext)).toBe(false); + }); + + test('uses exact user and trusted-project install/remove argv, cwd, and env', async () => { + const paths = fixture(); + writeJson(join(paths.agentRoot, 'settings.json'), { + defaultProjectTrust: 'always', + }); + const calls: Array<{ + binary: string; + args: string[]; + options?: NativeCommandOptions; + }> = []; + const execute = async ( + binary: string, + args: string[], + options?: NativeCommandOptions, + ): Promise => { + calls.push({ binary, args, ...(options && { options }) }); + return { success: true, output: '' }; + }; + const client = new PiNativeClient({ execute }); + const userContext = context('user', paths); + const projectContext = context('project', paths); + const userResource = packageResource(client, 'npm:user-pkg', userContext); + const projectResource = packageResource( + client, + './packages/project-pkg', + projectContext, + ); + + await client.install(userResource, userContext); + await client.install(projectResource, projectContext); + await client.remove(userResource, userContext); + await client.remove(projectResource, projectContext); + + expect(calls.map(({ binary, args }) => [binary, args])).toEqual([ + ['pi', ['install', 'npm:user-pkg', '--no-approve']], + [ + 'pi', + [ + 'install', + join(paths.workspace, 'packages', 'project-pkg'), + '-l', + '--approve', + ], + ], + ['pi', ['remove', 'npm:user-pkg', '--no-approve']], + [ + 'pi', + [ + 'remove', + join(paths.workspace, 'packages', 'project-pkg'), + '-l', + '--approve', + ], + ], + ]); + expect(calls.every((call) => call.options?.cwd === paths.workspace)).toBe( + true, + ); + expect( + calls.every((call) => call.options?.env?.SENTINEL === 'preserved'), + ).toBe(true); + }); + + test('always names a targeted update and never invokes bare pi update', async () => { + const paths = fixture(); + const operationContext = context('user', paths); + writeJson(join(paths.agentRoot, 'settings.json'), { + packages: ['npm:target'], + }); + installNpmPackage(paths.agentRoot, 'target'); + const calls: string[][] = []; + const client = new PiNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + return { success: true, output: '' }; + }, + }); + const resource = packageResource(client, 'npm:target', operationContext); + + const result = await client.update(resource, resource, operationContext); + + expect(result.success).toBe(true); + expect(calls).toEqual([['update', 'npm:target', '--no-approve']]); + expect(calls).not.toContainEqual(['update']); + }); + + test('targets a trusted project update without using user scope', async () => { + const paths = fixture(); + const operationContext = context('project', paths); + writeJson(join(paths.agentRoot, 'settings.json'), { + defaultProjectTrust: 'always', + packages: [], + }); + writeJson(join(paths.workspace, '.pi', 'settings.json'), { + packages: ['npm:project-target'], + }); + installNpmPackage(join(paths.workspace, '.pi'), 'project-target'); + const calls: string[][] = []; + const client = new PiNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + return { success: true, output: '' }; + }, + }); + const resource = packageResource( + client, + 'npm:project-target', + operationContext, + ); + + const result = await client.update(resource, resource, operationContext); + + expect(result.success).toBe(true); + expect(calls).toEqual([ + ['update', 'npm:project-target', '--approve'], + ]); + }); + + test('refuses a targeted update when Pi would affect the same identity in both scopes', async () => { + const paths = fixture(); + const operationContext = context('project', paths); + writeJson(join(paths.agentRoot, 'settings.json'), { + defaultProjectTrust: 'always', + packages: ['npm:shared'], + }); + writeJson(join(paths.workspace, '.pi', 'settings.json'), { + packages: ['npm:shared'], + }); + installNpmPackage(paths.agentRoot, 'shared'); + installNpmPackage(join(paths.workspace, '.pi'), 'shared'); + const calls: string[][] = []; + const client = new PiNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + return { success: true, output: '' }; + }, + }); + const resource = packageResource(client, 'npm:shared', operationContext); + + const result = await client.update(resource, resource, operationContext); + + expect(result.success).toBe(false); + expect(result.error).toContain('both user and project scopes'); + expect(calls).toEqual([]); + }); + + test('fails project mutations before command execution when trust is not already allowed', async () => { + const paths = fixture(); + const operationContext = context('project', paths); + writeJson(join(paths.agentRoot, 'settings.json'), { + defaultProjectTrust: 'ask', + }); + const calls: string[][] = []; + const client = new PiNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + return { success: true, output: '' }; + }, + }); + const resource = packageResource(client, 'npm:blocked', operationContext); + + expect((await client.install(resource, operationContext)).success).toBe(false); + expect((await client.remove(resource, operationContext)).success).toBe(false); + expect(calls).toEqual([]); + }); +}); diff --git a/tests/unit/core/native/registry.test.ts b/tests/unit/core/native/registry.test.ts index 88efb007..162b5892 100644 --- a/tests/unit/core/native/registry.test.ts +++ b/tests/unit/core/native/registry.test.ts @@ -1,22 +1,28 @@ import { describe, expect, test } from 'bun:test'; +import { PiNativeClient } from '../../../../src/core/native/pi.js'; +import { OmpNativeClient } from '../../../../src/core/native/omp.js'; import { getNativeClient } from '../../../../src/core/native/registry.js'; -import { ClaudeNativeClient } from '../../../../src/core/native/claude.js'; -import { CopilotNativeClient } from '../../../../src/core/native/copilot.js'; -describe('native/registry', () => { - test('returns ClaudeNativeClient for claude', () => { - expect(getNativeClient('claude')).toBeInstanceOf(ClaudeNativeClient); - }); +describe('native registry', () => { + test('registers Pi through the shared native client interface', () => { + const client = getNativeClient('pi'); - test('returns CopilotNativeClient for copilot', () => { - expect(getNativeClient('copilot')).toBeInstanceOf(CopilotNativeClient); + expect(client).toBeInstanceOf(PiNativeClient); + expect(client?.client).toBe('pi'); + expect(client?.supportsScope('user')).toBe(true); + expect(client?.supportsScope('project')).toBe(true); }); - test('returns null for unsupported client', () => { - expect(getNativeClient('cursor')).toBeNull(); + test('registers OMP through the shared native client interface', () => { + const client = getNativeClient('omp'); + + expect(client).toBeInstanceOf(OmpNativeClient); + expect(client?.client).toBe('omp'); + expect(client?.supportsScope('user')).toBe(true); + expect(client?.supportsScope('project')).toBe(true); }); - test('returns null for universal', () => { - expect(getNativeClient('universal')).toBeNull(); + test('does not register clients without native lifecycle support', () => { + expect(getNativeClient('cursor')).toBeNull(); }); }); diff --git a/tests/unit/core/native/types.test.ts b/tests/unit/core/native/types.test.ts index 66780049..7ca8006f 100644 --- a/tests/unit/core/native/types.test.ts +++ b/tests/unit/core/native/types.test.ts @@ -206,35 +206,39 @@ describe('native/types', () => { }); describe('mergeNativeSyncResults', () => { - test('merges two results', () => { + test('preserves ordered effects and aggregate failure', () => { + const resource = { + kind: 'plugin' as const, + requestedIdentity: 'p1@repo', + resolvedIdentity: 'p1@repo', + context: { + client: 'claude', + scope: 'project' as const, + nativeScope: 'project', + root: '/workspace', + }, + provenance: {}, + }; const a: NativeSyncResult = { - marketplacesAdded: ['a/repo'], - pluginsInstalled: [{ plugin: 'p1@repo', client: 'claude' }], - pluginsFailed: [], - skipped: [], + success: true, + effects: [{ action: 'installed', resource }], }; const b: NativeSyncResult = { - marketplacesAdded: ['b/repo'], - pluginsInstalled: [{ plugin: 'p2@repo', client: 'copilot' }], - pluginsFailed: [{ plugin: 'p3@repo', error: 'fail' }], - skipped: ['local-plugin'], + success: false, + effects: [{ action: 'failed', resource, error: 'fail' }], }; - const merged = mergeNativeSyncResults([a, b]); - expect(merged.marketplacesAdded).toEqual(['a/repo', 'b/repo']); - expect(merged.pluginsInstalled).toEqual([ - { plugin: 'p1@repo', client: 'claude' }, - { plugin: 'p2@repo', client: 'copilot' }, - ]); - expect(merged.pluginsFailed).toEqual([{ plugin: 'p3@repo', error: 'fail' }]); - expect(merged.skipped).toEqual(['local-plugin']); + + expect(mergeNativeSyncResults([a, b])).toEqual({ + success: false, + effects: [...a.effects, ...b.effects], + }); }); - test('returns empty result for empty array', () => { - const merged = mergeNativeSyncResults([]); - expect(merged.marketplacesAdded).toEqual([]); - expect(merged.pluginsInstalled).toEqual([]); - expect(merged.pluginsFailed).toEqual([]); - expect(merged.skipped).toEqual([]); + test('returns a successful empty result', () => { + expect(mergeNativeSyncResults([])).toEqual({ + success: true, + effects: [], + }); }); }); }); diff --git a/tests/unit/core/repo-skills.test.ts b/tests/unit/core/repo-skills.test.ts index e70832cf..fa0c0659 100644 --- a/tests/unit/core/repo-skills.test.ts +++ b/tests/unit/core/repo-skills.test.ts @@ -1,8 +1,17 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdirSync, mkdtempSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + writeFileSync, + rmSync, + symlinkSync, +} from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { discoverRepoSkills, discoverWorkspaceSkills } from '../../../src/core/repo-skills.js'; +import { + discoverRepoSkills, + discoverWorkspaceSkills, +} from '../../../src/core/repo-skills.js'; function makeSkill(dir: string, name: string, description: string) { const skillDir = join(dir, name); @@ -113,6 +122,47 @@ describe('discoverRepoSkills', () => { expect(names).toEqual(['agents-skill', 'claude-skill']); }); + it('uses Pi native-before-shared precedence without scanning above the repository', async () => { + const repoDir = join(tmpDir, 'repo'); + makeSkill(join(repoDir, '.pi', 'skills'), 'collision', 'Pi native'); + makeSkill(join(repoDir, '.agents', 'skills'), 'collision', 'Shared'); + makeSkill( + join(repoDir, '.agents', 'skills'), + 'shared-only', + 'Shared only', + ); + makeSkill(join(tmpDir, '.pi', 'skills'), 'outside-boundary', 'Outside'); + + const results = await discoverRepoSkills(repoDir, { + clients: ['pi', 'universal'], + }); + + expect(results.map((entry) => entry.name)).toEqual([ + 'collision', + 'shared-only', + ]); + expect(results[0]?.description).toBe('Pi native'); + }); + + it('uses OMP native precedence before legacy and shared skill roots', async () => { + makeSkill(join(tmpDir, '.omp', 'skills'), 'collision', 'OMP native'); + makeSkill(join(tmpDir, '.agent', 'skills'), 'collision', 'Legacy shared'); + makeSkill(join(tmpDir, '.agents', 'skills'), 'collision', 'Shared'); + makeSkill(join(tmpDir, '.agent', 'skills'), 'legacy-only', 'Legacy only'); + makeSkill(join(tmpDir, '.agents', 'skills'), 'shared-only', 'Shared only'); + + const results = await discoverRepoSkills(tmpDir, { + clients: ['omp'], + }); + + expect(results.map((entry) => entry.name)).toEqual([ + 'collision', + 'legacy-only', + 'shared-only', + ]); + expect(results[0]?.description).toBe('OMP native'); + }); + it('returns empty when skill directory does not exist', async () => { const results = await discoverRepoSkills(tmpDir, { clients: ['claude'], @@ -173,4 +223,32 @@ describe('discoverWorkspaceSkills opt-in', () => { expect(results).toHaveLength(1); expect(results[0].location).not.toContain('//'); }); + + it('keeps Pi native precedence across configured repositories', async () => { + const sharedRepo = join(tmpDir, 'shared-repo'); + const nativeRepo = join(tmpDir, 'native-repo'); + makeSkill( + join(sharedRepo, '.agents', 'skills'), + 'collision', + 'A much larger shared skill description', + ); + makeSkill( + join(nativeRepo, '.pi', 'skills'), + 'collision', + 'Pi native', + ); + + const results = await discoverWorkspaceSkills( + tmpDir, + [ + { path: './shared-repo', skills: true }, + { path: './native-repo', skills: true }, + ], + ['pi'], + ); + + expect(results).toHaveLength(1); + expect(results[0]?.description).toBe('Pi native'); + expect(results[0]?.location).toContain('/.pi/skills/'); + }); }); diff --git a/tests/unit/core/sync-dedup.test.ts b/tests/unit/core/sync-dedup.test.ts index 6ae2c92f..20d61e84 100644 --- a/tests/unit/core/sync-dedup.test.ts +++ b/tests/unit/core/sync-dedup.test.ts @@ -3,10 +3,24 @@ import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { syncWorkspace, deduplicateClientsByPath, collectSyncedPaths } from '../../../src/core/sync.js'; +import { + syncWorkspace, + deduplicateClientsByPath, + collectSyncedPaths, + selectivePurgeWorkspace, +} from '../../../src/core/sync.js'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../../src/constants.js'; -import { CLIENT_MAPPINGS, USER_CLIENT_MAPPINGS, resolveClientMappings } from '../../../src/models/client-mapping.js'; +import { + CLIENT_MAPPINGS, + USER_CLIENT_MAPPINGS, + resolveClientMappings, +} from '../../../src/models/client-mapping.js'; import type { CopyResult } from '../../../src/core/transform.js'; +import { + clientMappingsFromContexts, + resolveClientContexts, +} from '../../../src/core/client-context.js'; +import type { SyncState } from '../../../src/models/sync-state.js'; describe('deduplicateClientsByPath', () => { it('should group clients that share the same skillsPath after resolution', () => { @@ -119,6 +133,17 @@ describe('deduplicateClientsByPath', () => { }); }); + it('keeps Pi and OMP materialization distinct from shared discovery paths', () => { + const clients = ['pi', 'omp', 'universal'] as const; + const result = deduplicateClientsByPath([...clients], CLIENT_MAPPINGS); + + expect(result.representativeClients).toEqual([ + 'pi', + 'omp', + 'universal', + ]); + }); + describe('collectSyncedPaths with shared paths', () => { it('should track file for all clients sharing the same skillsPath after resolution', () => { // After resolution, copilot and vscode both use .github/skills/ @@ -166,6 +191,80 @@ describe('collectSyncedPaths with shared paths', () => { }); }); +describe('external resolved path state and purge containment', () => { + it('tracks an external Pi root as an absolute path without traversal', () => { + const contexts = resolveClientContexts(['pi'], 'user', { + homeDir: '/home/tester', + cwd: '/work/project', + env: { PI_CODING_AGENT_DIR: '/external/pi' }, + }); + const mappings = clientMappingsFromContexts( + contexts, + USER_CLIENT_MAPPINGS, + ); + const destination = '/external/pi/skills/example'; + + const result = collectSyncedPaths( + [{ source: '/plugin/skills/example', destination, action: 'copied' }], + '/home/tester', + ['pi'], + mappings, + undefined, + contexts, + ); + + expect(result.pi).toEqual(['/external/pi/skills/example/']); + expect(result.pi?.[0]).not.toContain('../'); + }); + + it('purges only tracked paths inside the resolved external write root', async () => { + const root = await mkdtemp(join(tmpdir(), 'allagents-purge-boundary-')); + const homeDir = join(root, 'home'); + const externalRoot = join(root, 'selected-pi'); + const managedSkill = join(externalRoot, 'skills', 'managed'); + const outsideSkill = join(root, 'outside', 'tampered'); + await mkdir(managedSkill, { recursive: true }); + await mkdir(outsideSkill, { recursive: true }); + await writeFile(join(managedSkill, 'SKILL.md'), 'managed'); + await writeFile(join(outsideSkill, 'SKILL.md'), 'outside'); + + try { + const contexts = resolveClientContexts(['pi'], 'user', { + homeDir, + cwd: root, + env: { PI_CODING_AGENT_DIR: externalRoot }, + }); + const mappings = clientMappingsFromContexts( + contexts, + USER_CLIENT_MAPPINGS, + ); + const managedStatePath = `${managedSkill.replaceAll('\\', '/')}/`; + const outsideStatePath = `${outsideSkill.replaceAll('\\', '/')}/`; + const state = { + version: 1, + lastSync: new Date().toISOString(), + files: { pi: [managedStatePath, outsideStatePath] }, + } as SyncState; + + const result = await selectivePurgeWorkspace( + homeDir, + state, + ['pi'], + mappings, + contexts, + ); + + expect(existsSync(managedSkill)).toBe(false); + expect(existsSync(outsideSkill)).toBe(true); + expect(result).toEqual([ + { client: 'pi', paths: [managedStatePath] }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + describe('syncWorkspace deduplication', () => { let testDir: string; @@ -260,6 +359,34 @@ clients: expect(existsSync(join(testDir, '.github', 'skills', 'test-skill', 'SKILL.md'))).toBe(true); }); + it('materializes Pi native and universal shared skills exactly once each', async () => { + const pluginDir = await createPluginWithSkill('my-plugin', 'test-skill'); + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - ${pluginDir} +clients: + - pi + - universal +syncMode: copy +`, + ); + + const result = await syncWorkspace(testDir); + + expect(result.success).toBe(true); + expect(result.totalCopied).toBe(2); + expect( + existsSync(join(testDir, '.pi', 'skills', 'test-skill', 'SKILL.md')), + ).toBe(true); + expect( + existsSync(join(testDir, '.agents', 'skills', 'test-skill', 'SKILL.md')), + ).toBe(true); + }); + it('should properly purge when a client sharing path is removed', async () => { const pluginDir = await createPluginWithSkill('my-plugin', 'test-skill'); diff --git a/tests/unit/core/sync-install-mode.test.ts b/tests/unit/core/sync-install-mode.test.ts index e0e7b979..fd17cc83 100644 --- a/tests/unit/core/sync-install-mode.test.ts +++ b/tests/unit/core/sync-install-mode.test.ts @@ -3,7 +3,11 @@ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { syncWorkspace } from '../../../src/core/sync.js'; +import { + nativeContextIdentity, + nativeIdentityMatches, + syncWorkspace, +} from '../../../src/core/sync.js'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../../src/constants.js'; async function createPlugin(baseDir: string, name: string, skillName: string): Promise { @@ -39,7 +43,7 @@ describe('syncWorkspace — install mode', () => { expect(existsSync(join(testDir, '.github', 'skills', 'test-skill'))).toBe(true); }); - it('native-only client skips file copy for that client', async () => { + it('rejects unsupported explicit native sources without file fallback', async () => { await createPlugin(testDir, 'test-plugin', 'test-skill'); await writeFile( join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), @@ -47,11 +51,9 @@ describe('syncWorkspace — install mode', () => { ); const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - // Claude native: local plugin can't install natively -> falls back to file - expect(existsSync(join(testDir, '.claude', 'skills', 'test-skill'))).toBe(true); - // Copilot file: files copied - expect(existsSync(join(testDir, '.github', 'skills', 'test-skill'))).toBe(true); + expect(result.success).toBe(false); + expect(existsSync(join(testDir, '.claude', 'skills', 'test-skill'))).toBe(false); + expect(existsSync(join(testDir, '.github', 'skills', 'test-skill'))).toBe(false); }); it('plugin-level install:file overrides client native', async () => { @@ -67,7 +69,7 @@ describe('syncWorkspace — install mode', () => { expect(existsSync(join(testDir, '.claude', 'skills', 'test-skill'))).toBe(true); }); - it('non-marketplace plugin with native client falls back to file copy', async () => { + it('does not silently copy a local source requested as native', async () => { await createPlugin(testDir, 'local-plugin', 'local-skill'); await writeFile( join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), @@ -75,12 +77,11 @@ describe('syncWorkspace — install mode', () => { ); const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - // Non-marketplace can't install natively, falls back to file - expect(existsSync(join(testDir, '.claude', 'skills', 'local-skill'))).toBe(true); + expect(result.success).toBe(false); + expect(existsSync(join(testDir, '.claude', 'skills', 'local-skill'))).toBe(false); }); - it('copilot native in project scope falls back to file copy', async () => { + it('rejects an unsupported native scope before copying', async () => { await createPlugin(testDir, 'test-plugin', 'test-skill'); await writeFile( join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), @@ -88,12 +89,11 @@ describe('syncWorkspace — install mode', () => { ); const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - // Copilot native with local plugin can't install natively -> falls back to file - expect(existsSync(join(testDir, '.github', 'skills', 'test-skill'))).toBe(true); + expect(result.success).toBe(false); + expect(existsSync(join(testDir, '.github', 'skills', 'test-skill'))).toBe(false); }); - it('colon shorthand claude:native skips file copy for marketplace plugin', async () => { + it('colon shorthand native rejects an unsupported source before file mutation', async () => { await createPlugin(testDir, 'test-plugin', 'test-skill'); await writeFile( join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), @@ -101,9 +101,60 @@ describe('syncWorkspace — install mode', () => { ); const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - // Local plugin falls back to file copy even for native clients - expect(existsSync(join(testDir, '.claude', 'skills', 'test-skill'))).toBe(true); - expect(existsSync(join(testDir, '.github', 'skills', 'test-skill'))).toBe(true); + expect(result.success).toBe(false); + expect(existsSync(join(testDir, '.claude', 'skills', 'test-skill'))).toBe(false); + expect(existsSync(join(testDir, '.github', 'skills', 'test-skill'))).toBe(false); + }); +}); + +describe('native lifecycle identity', () => { + it('matches qualified marketplace declarations exactly', () => { + expect( + nativeIdentityMatches( + 'review@market-b', + 'review@market-a', + 'review@market-a', + ), + ).toBe(false); + expect( + nativeIdentityMatches( + 'review@market-a', + 'review@market-a', + 'review@market-a', + ), + ).toBe(true); + }); + + it('matches Pi npm declarations by exact package identity', () => { + expect( + nativeIdentityMatches( + '@scope/review', + '@scope/review', + 'npm:@scope/review@2.0.0', + ), + ).toBe(true); + expect( + nativeIdentityMatches( + '@other/review', + '@scope/review', + 'npm:@scope/review@2.0.0', + ), + ).toBe(false); + }); + + it('includes every authoritative OMP root in durable context identity', () => { + const base = { + client: 'omp' as const, + scope: 'user' as const, + nativeScope: 'user' as const, + root: '/tmp/omp', + roots: { agent: '/tmp/omp', data: '/tmp/data-a' }, + }; + expect(nativeContextIdentity(base)).not.toBe( + nativeContextIdentity({ + ...base, + roots: { agent: '/tmp/omp', data: '/tmp/data-b' }, + }), + ); }); }); diff --git a/tests/unit/core/sync-merge.test.ts b/tests/unit/core/sync-merge.test.ts index fd87890d..60172589 100644 --- a/tests/unit/core/sync-merge.test.ts +++ b/tests/unit/core/sync-merge.test.ts @@ -99,7 +99,19 @@ describe('mergeSyncResults', () => { expect(merged.messages).toEqual(['msg1', 'msg2']); }); - test('merges nativeResult from both results', () => { + test('merges ordered native lifecycle effects from both scopes', () => { + const resource = { + kind: 'plugin' as const, + requestedIdentity: 'plugin@repo', + resolvedIdentity: 'plugin@repo', + context: { + client: 'claude', + scope: 'user' as const, + nativeScope: 'user', + root: '/home/test', + }, + provenance: {}, + }; const a: SyncResult = { success: true, pluginResults: [], @@ -108,35 +120,29 @@ describe('mergeSyncResults', () => { totalSkipped: 0, totalGenerated: 0, nativeResult: { - marketplacesAdded: ['org/repo-a'], - pluginsInstalled: [{ plugin: 'pluginA@repo-a', client: 'claude' }], - pluginsFailed: [], - skipped: [], + success: true, + effects: [{ action: 'installed', resource }], }, }; const b: SyncResult = { - success: true, + success: false, pluginResults: [], totalCopied: 0, - totalFailed: 0, + totalFailed: 1, totalSkipped: 0, totalGenerated: 0, nativeResult: { - marketplacesAdded: ['org/repo-b'], - pluginsInstalled: [{ plugin: 'pluginB@repo-b', client: 'copilot' }], - pluginsFailed: [{ plugin: 'pluginC@repo-c', error: 'not found' }], - skipped: ['local-plugin'], + success: false, + effects: [{ action: 'failed', resource, error: 'not found' }], }, }; const merged = mergeSyncResults(a, b); expect(merged.nativeResult).toEqual({ - marketplacesAdded: ['org/repo-a', 'org/repo-b'], - pluginsInstalled: [ - { plugin: 'pluginA@repo-a', client: 'claude' }, - { plugin: 'pluginB@repo-b', client: 'copilot' }, + success: false, + effects: [ + { action: 'installed', resource }, + { action: 'failed', resource, error: 'not found' }, ], - pluginsFailed: [{ plugin: 'pluginC@repo-c', error: 'not found' }], - skipped: ['local-plugin'], }); }); @@ -148,12 +154,7 @@ describe('mergeSyncResults', () => { totalFailed: 0, totalSkipped: 0, totalGenerated: 0, - nativeResult: { - marketplacesAdded: ['org/repo'], - pluginsInstalled: [{ plugin: 'plugin@repo', client: 'claude' }], - pluginsFailed: [], - skipped: [], - }, + nativeResult: { success: true, effects: [] }, }; const b: SyncResult = { success: true, @@ -163,8 +164,7 @@ describe('mergeSyncResults', () => { totalSkipped: 0, totalGenerated: 0, }; - const merged = mergeSyncResults(a, b); - expect(merged.nativeResult).toEqual(a.nativeResult); + expect(mergeSyncResults(a, b).nativeResult).toEqual(a.nativeResult); }); test('merges purgedPaths from both results', () => { diff --git a/tests/unit/core/sync-state-native.test.ts b/tests/unit/core/sync-state-native.test.ts index b7beb873..19db6d3a 100644 --- a/tests/unit/core/sync-state-native.test.ts +++ b/tests/unit/core/sync-state-native.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { + saveNativeStateResources, saveSyncState, loadSyncState, - getPreviouslySyncedNativePlugins, } from '../../../src/core/sync-state.js'; describe('sync-state nativePlugins', () => { @@ -29,6 +29,96 @@ describe('sync-state nativePlugins', () => { expect(state).not.toBeNull(); expect(state!.nativePlugins).toEqual({ claude: ['plugin-a', 'plugin-b'] }); }); + it('isolates exact native resource identity by client, scope, and root', async () => { + await saveSyncState(testDir, { + files: {}, + nativeResources: { + version: 1, + resources: [ + { + client: 'claude', + scope: 'project', + nativeScope: 'project', + kind: 'plugin', + requestedIdentity: 'plugin@market', + resolvedIdentity: 'plugin@market', + context: '/workspace/a', + provenance: { source: 'plugin@owner/market' }, + transition: 'managed', + }, + { + client: 'copilot', + scope: 'user', + nativeScope: 'user', + kind: 'plugin', + requestedIdentity: 'plugin@market', + resolvedIdentity: 'plugin@market', + context: '/home/test', + provenance: { source: 'plugin@owner/market' }, + transition: 'cleanup-failed', + error: 'busy', + }, + ], + }, + }); + + const state = await loadSyncState(testDir); + expect(state?.nativeResources?.resources).toHaveLength(2); + expect(state?.nativeResources?.resources[1]?.transition).toBe( + 'cleanup-failed', + ); + }); + + it('preserves unrelated legacy fields when writing valid state', async () => { + const stateDir = join(testDir, '.allagents'); + await mkdir(stateDir, { recursive: true }); + await writeFile( + join(stateDir, 'sync-state.json'), + JSON.stringify({ + version: 1, + lastSync: 'old', + files: {}, + futureField: { keep: true }, + }), + ); + await saveSyncState(testDir, { files: { claude: ['skill.md'] } }); + const raw = JSON.parse( + await readFile(join(stateDir, 'sync-state.json'), 'utf-8'), + ); + expect(raw.futureField).toEqual({ keep: true }); + }); + + it('patches malformed native state without discarding valid file ownership', async () => { + const stateDir = join(testDir, '.allagents'); + await mkdir(stateDir, { recursive: true }); + await writeFile( + join(stateDir, 'sync-state.json'), + JSON.stringify({ + version: 1, + lastSync: 'old', + files: { pi: ['.pi/skills/review/SKILL.md'] }, + nativeResources: { resources: 'malformed' }, + }), + ); + + await saveNativeStateResources(testDir, [{ + client: 'pi', + scope: 'user', + nativeScope: 'user', + kind: 'package', + requestedIdentity: 'review', + resolvedIdentity: 'npm:review@1.0.0', + context: '/tmp/pi', + provenance: {}, + transition: 'managed', + }]); + + const raw = JSON.parse( + await readFile(join(stateDir, 'sync-state.json'), 'utf-8'), + ); + expect(raw.files).toEqual({ pi: ['.pi/skills/review/SKILL.md'] }); + expect(raw.nativeResources.resources).toHaveLength(1); + }); it('omits nativePlugins when not provided', async () => { await saveSyncState(testDir, { @@ -48,29 +138,4 @@ describe('sync-state nativePlugins', () => { expect(state!.nativePlugins).toBeUndefined(); }); - it('getPreviouslySyncedNativePlugins returns empty for null state', () => { - expect(getPreviouslySyncedNativePlugins(null, 'claude')).toEqual([]); - }); - - it('getPreviouslySyncedNativePlugins returns plugins for matching client', async () => { - await saveSyncState(testDir, { - files: {}, - nativePlugins: { cursor: ['cursor-plugin-1'] }, - }); - - const state = await loadSyncState(testDir); - expect(getPreviouslySyncedNativePlugins(state, 'cursor')).toEqual([ - 'cursor-plugin-1', - ]); - }); - - it('getPreviouslySyncedNativePlugins returns empty for non-matching client', async () => { - await saveSyncState(testDir, { - files: {}, - nativePlugins: { claude: ['plugin-a'] }, - }); - - const state = await loadSyncState(testDir); - expect(getPreviouslySyncedNativePlugins(state, 'copilot')).toEqual([]); - }); }); diff --git a/tests/unit/core/sync-user.test.ts b/tests/unit/core/sync-user.test.ts index 81fab25b..da5416b5 100644 --- a/tests/unit/core/sync-user.test.ts +++ b/tests/unit/core/sync-user.test.ts @@ -394,4 +394,54 @@ describe('syncUserWorkspace', () => { "Copilot user hook '.copilot/hooks/repository.json' shares a path with a repository .github/hooks artifact. Repository hooks are no longer synced at user scope; review this file manually if an older AllAgents version installed it. A root hooks/ artifact may still manage the same path.", ); }); + + it('writes and purges Pi skills in an external selected agent root', async () => { + const externalRoot = await mkdtemp( + join(tmpdir(), 'allagents-pi-agent-test-'), + ); + const priorAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = externalRoot; + + try { + const pluginDir = await createLocalPlugin('pi-plugin', 'pi-skill'); + await writeUserConfig({ + repositories: [], + plugins: [pluginDir], + clients: ['pi'], + syncMode: 'copy', + }); + + const first = await syncUserWorkspace(); + const skillPath = join(externalRoot, 'skills', 'pi-skill'); + expect(first.success).toBe(true); + expect(existsSync(join(skillPath, 'SKILL.md'))).toBe(true); + + const state = JSON.parse( + await readFile(join(testDir, '.allagents', 'sync-state.json'), 'utf-8'), + ); + expect(state.files.pi).toContain( + `${skillPath.replaceAll('\\', '/')}/`, + ); + expect(state.files.pi.every((path: string) => !path.includes('../'))).toBe( + true, + ); + + await writeUserConfig({ + repositories: [], + plugins: [], + clients: ['pi'], + syncMode: 'copy', + }); + const second = await syncUserWorkspace(); + expect(second.success).toBe(true); + expect(existsSync(skillPath)).toBe(false); + } finally { + if (priorAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = priorAgentDir; + } + await rm(externalRoot, { recursive: true, force: true }); + } + }); }); diff --git a/tests/unit/models/client-entry-schema.test.ts b/tests/unit/models/client-entry-schema.test.ts index ad238e31..a36a2baf 100644 --- a/tests/unit/models/client-entry-schema.test.ts +++ b/tests/unit/models/client-entry-schema.test.ts @@ -30,6 +30,20 @@ describe('ClientEntrySchema', () => { }); }); + it('parses Pi and OMP in bare, colon, and object forms', () => { + for (const client of ['pi', 'omp'] as const) { + expect(ClientEntrySchema.parse(client)).toBe(client); + expect(ClientEntrySchema.parse(`${client}:native`)).toEqual({ + name: client, + install: 'native', + }); + expect(ClientEntrySchema.parse({ name: client })).toEqual({ + name: client, + install: 'file', + }); + } + }); + describe('normalizeClientEntry', () => { it('normalizes bare string to object', () => { expect(normalizeClientEntry('claude')).toEqual({ name: 'claude', install: 'file' }); diff --git a/tests/unit/models/client-mapping.test.ts b/tests/unit/models/client-mapping.test.ts index 0e41ed47..f004e0ef 100644 --- a/tests/unit/models/client-mapping.test.ts +++ b/tests/unit/models/client-mapping.test.ts @@ -14,8 +14,31 @@ import { ClientTypeSchema } from '../../../src/models/workspace-config.js'; describe('CLIENT_MAPPINGS', () => { test('defines project-level paths for all supported clients', () => { const expectedClients = [ - 'claude', 'copilot', 'codex', 'cursor', 'opencode', 'gemini', 'factory', 'ampcode', 'vscode', - 'openclaw', 'windsurf', 'cline', 'continue', 'roo', 'kilo', 'trae', 'augment', 'zencoder', 'junie', 'openhands', 'kiro', 'replit', 'kimi', + 'claude', + 'copilot', + 'codex', + 'pi', + 'omp', + 'cursor', + 'opencode', + 'gemini', + 'factory', + 'ampcode', + 'vscode', + 'openclaw', + 'windsurf', + 'cline', + 'continue', + 'roo', + 'kilo', + 'trae', + 'augment', + 'zencoder', + 'junie', + 'openhands', + 'kiro', + 'replit', + 'kimi', 'universal', ]; for (const client of expectedClients) { @@ -47,6 +70,11 @@ describe('CLIENT_MAPPINGS', () => { expect(CLIENT_MAPPINGS.codex.skillsPath).toBe('.codex/skills/'); }); + test('pi and OMP use native project skill paths', () => { + expect(CLIENT_MAPPINGS.pi.skillsPath).toBe('.pi/skills/'); + expect(CLIENT_MAPPINGS.omp.skillsPath).toBe('.omp/skills/'); + }); + test('opencode uses provider-specific .opencode/skills/ path', () => { expect(CLIENT_MAPPINGS.opencode.skillsPath).toBe('.opencode/skills/'); }); @@ -139,6 +167,11 @@ describe('USER_CLIENT_MAPPINGS', () => { expect(USER_CLIENT_MAPPINGS.codex.skillsPath).toBe('.codex/skills/'); }); + test('pi and OMP use native default user skill paths', () => { + expect(USER_CLIENT_MAPPINGS.pi.skillsPath).toBe('.pi/agent/skills/'); + expect(USER_CLIENT_MAPPINGS.omp.skillsPath).toBe('.omp/agent/skills/'); + }); + test('opencode uses provider-specific ~/.opencode/skills/ path', () => { expect(USER_CLIENT_MAPPINGS.opencode.skillsPath).toBe('.opencode/skills/'); }); From ff9c2b7c80b4a78f11b3c73cfcf2a25817e72397 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Mon, 14 Sep 2026 20:03:51 +1000 Subject: [PATCH 2/6] test(native): expect command completion metadata on Windows --- tests/unit/core/native/types.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/core/native/types.test.ts b/tests/unit/core/native/types.test.ts index 7ca8006f..0057b6ae 100644 --- a/tests/unit/core/native/types.test.ts +++ b/tests/unit/core/native/types.test.ts @@ -118,6 +118,8 @@ describe('native/types', () => { expect(JSON.parse(stdout)).toEqual({ success: true, output: JSON.stringify({ runtime: 'node', args }), + exitCode: 0, + signal: null, }); } @@ -170,6 +172,8 @@ describe('native/types', () => { ).toEqual({ success: true, output: JSON.stringify({ runtime: 'node', args }), + exitCode: 0, + signal: null, }); const cwdFallbackProc = Bun.spawnSync( From 20a7008abdf8fb108e8b6019cf86ea633897357f Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Tue, 15 Sep 2026 06:30:46 +1000 Subject: [PATCH 3/6] refactor(native): share semantic version comparison --- src/core/native/omp.ts | 13 ++----------- src/core/native/pi.ts | 15 +++------------ src/core/native/types.ts | 11 +++++++++++ 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/core/native/omp.ts b/src/core/native/omp.ts index cd8e0838..56578055 100644 --- a/src/core/native/omp.ts +++ b/src/core/native/omp.ts @@ -3,6 +3,7 @@ import { isAbsolute, join, resolve, win32 } from 'node:path'; import { z } from 'zod'; import { executeCommand, + compareNativeVersions, type NativeClient, type NativeCommandOptions, type NativeCommandResult, @@ -203,16 +204,6 @@ function parseVersion(output: string): [number, number, number] | null { return version.every(Number.isSafeInteger) ? version : null; } -function compareVersion( - left: readonly number[], - right: readonly number[], -): number { - for (let index = 0; index < 3; index++) { - const difference = (left[index] ?? 0) - (right[index] ?? 0); - if (difference !== 0) return difference; - } - return 0; -} export function parseOmpPluginId(value: string): ParsedPluginId | null { const separator = value.lastIndexOf('@'); @@ -707,7 +698,7 @@ export class OmpNativeClient implements NativeClient { }; return this.versionResult; } - if (compareVersion(version, OMP_MINIMUM_VERSION) < 0) { + if (compareNativeVersions(version, OMP_MINIMUM_VERSION) < 0) { this.versionResult = { success: false, error: `OMP ${version.join('.')} is unsupported; version 18.1.17 or newer is required`, diff --git a/src/core/native/pi.ts b/src/core/native/pi.ts index ece7240b..d7e98ce9 100644 --- a/src/core/native/pi.ts +++ b/src/core/native/pi.ts @@ -11,6 +11,7 @@ import { readFile, realpath, stat } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { executeCommand, + compareNativeVersions, type NativeClient, type NativeCommandOptions, type NativeCommandResult, @@ -779,16 +780,6 @@ function versionTuple(output: string): [number, number, number] | null { : null; } -function compareVersion( - left: readonly number[], - right: readonly number[], -): number { - for (let index = 0; index < 3; index++) { - const difference = (left[index] ?? 0) - (right[index] ?? 0); - if (difference !== 0) return difference; - } - return 0; -} function sourceFromResource(resource: NativeResource): string { return resource.provenance.commandSource ?? resource.requestedIdentity; @@ -832,8 +823,8 @@ export class PiNativeClient implements NativeClient { const version = versionTuple(result.output); return Boolean( version && - compareVersion(version, PI_MINIMUM_VERSION) >= 0 && - compareVersion(version, PI_MAXIMUM_VERSION) < 0, + compareNativeVersions(version, PI_MINIMUM_VERSION) >= 0 && + compareNativeVersions(version, PI_MAXIMUM_VERSION) < 0, ); } diff --git a/src/core/native/types.ts b/src/core/native/types.ts index 11a9ce1f..f2206eb8 100644 --- a/src/core/native/types.ts +++ b/src/core/native/types.ts @@ -35,6 +35,17 @@ export interface NativeOperationContext { roots?: Readonly>; } +export function compareNativeVersions( + left: readonly number[], + right: readonly number[], +): number { + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + export interface NativeResource { kind: NativeResourceKind; requestedIdentity: string; From 93269a8845b6f895878a71cf6bba99e1ae245425 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Tue, 15 Sep 2026 11:18:04 +1000 Subject: [PATCH 4/6] fix(clients): sync OMP hook factories --- .../content/docs/docs/reference/clients.mdx | 33 +++++++++++------ src/models/client-mapping.ts | 7 +++- tests/unit/core/omp-artifacts.test.ts | 36 +++++++++++++++++++ tests/unit/models/client-mapping.test.ts | 2 ++ 4 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 tests/unit/core/omp-artifacts.test.ts diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index bbac1d9f..941649bf 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -7,7 +7,9 @@ AllAgents supports 25 AI coding assistants, organized into universal clients (sh ## Universal Clients -These clients share the canonical `.agents/skills/` directory: +These clients primarily share the canonical `.agents/skills/` directory. The +Hooks column records artifacts AllAgents materializes; it is not a complete +inventory of each runtime's native extension APIs. | Client | Skills | Agent File | Hooks | Commands | GitHub Overrides | |--------|--------|------------|-------|----------|------------------| @@ -22,13 +24,14 @@ These clients share the canonical `.agents/skills/` directory: ## Provider-Specific Clients -These clients use their own skills directory: +These clients use their own skills directory. As above, Hooks lists +AllAgents-managed destinations rather than every native extension mechanism: | Client | Skills | Agent File | Hooks | Commands | |--------|--------|------------|-------|----------| | Claude | `.claude/skills/` | `CLAUDE.md` | `.claude/hooks/` | `.claude/commands/` | -| Pi | `.pi/skills/` | `AGENTS.md` | No | No | -| OMP | `.omp/skills/` | `AGENTS.md` | No | No | +| Pi | `.pi/skills/` | `AGENTS.md` | No—use Pi extensions | No | +| OMP | `.omp/skills/` | `AGENTS.md` | `.omp/hooks/` | No | | Cursor | `.cursor/skills/` | `AGENTS.md` | No | No | | Factory | `.factory/skills/` | `AGENTS.md` | `.factory/hooks/` | No | | OpenClaw | `skills/` | `AGENTS.md` | No | No | @@ -61,14 +64,24 @@ fail before mutation unless Pi's trusted settings outside the project boundary authorize that project. AllAgents inspects `pi-mcp-adapter` state but does not install or configure the adapter. +Pi provides lifecycle interception through extensions discovered from +`.pi/extensions/` and `~/.pi/agent/extensions/`, not through the portable +plugin `hooks/` artifact. AllAgents therefore does not map `hooks/` into Pi; +native Pi packages can supply extensions through Pi's package lifecycle. + ### OMP -File sync writes project skills to `.omp/skills/` and user skills to -`~/.omp/agent/skills/`. Native install mode requires OMP 18.1.17 or newer and a -marketplace-backed plugin spec such as -`compound-engineering@EveryInc/compound-engineering-plugin`. AllAgents registers -the required marketplace, delegates scoped install, update, and uninstall to -OMP, and preserves marketplace registrations on uninstall. +File sync writes project skills to `.omp/skills/`, user skills to +`~/.omp/agent/skills/`, and OMP JS/TS hook factories to the corresponding +`hooks/pre/` or `hooks/post/` discovery path. A factory placed directly in +`hooks/` is not discovered. + +Native install mode requires OMP 18.1.17 or newer and a marketplace-backed +plugin spec such as `compound-engineering@EveryInc/compound-engineering-plugin`. +AllAgents registers the required marketplace and delegates the complete plugin +lifecycle—including OMP-compatible hooks—to OMP. OMP accepts Claude-compatible +marketplace catalogs, but Claude Code's command-based `hooks/hooks.json` format +is not interchangeable with OMP's JS/TS hook factories. Ordinary OMP operations always address the unnamed profile: AllAgents removes inherited `OMP_PROFILE`, `PI_PROFILE`, and `PI_CONFIG_FILES` selectors while diff --git a/src/models/client-mapping.ts b/src/models/client-mapping.ts index a04a3686..6e919566 100644 --- a/src/models/client-mapping.ts +++ b/src/models/client-mapping.ts @@ -98,9 +98,14 @@ export const AGENT_HOSTS: readonly AgentHost[] = [ { id: 'omp', name: 'OMP', - project: { skillsPath: '.omp/skills/', agentFile: 'AGENTS.md' }, + project: { + skillsPath: '.omp/skills/', + hooksPath: '.omp/hooks/', + agentFile: 'AGENTS.md', + }, user: { skillsPath: '.omp/agent/skills/', + hooksPath: '.omp/agent/hooks/', agentFile: '.omp/agent/AGENTS.md', }, }, diff --git a/tests/unit/core/omp-artifacts.test.ts b/tests/unit/core/omp-artifacts.test.ts new file mode 100644 index 00000000..504c1235 --- /dev/null +++ b/tests/unit/core/omp-artifacts.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { copyHooks } from '../../../src/core/transform.js'; + +describe('OMP hook sync', () => { + let root: string | undefined; + + afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + }); + + it('copies plugin hook factories into the project discovery path', async () => { + root = await mkdtemp(join(tmpdir(), 'allagents-omp-hooks-')); + const plugin = join(root, 'plugin'); + const workspace = join(root, 'workspace'); + const source = join(plugin, 'hooks', 'pre', 'guard.ts'); + await mkdir(join(plugin, 'hooks', 'pre'), { recursive: true }); + await mkdir(workspace, { recursive: true }); + await writeFile(source, 'export default function guard() {}\n'); + + const results = await copyHooks(plugin, workspace, 'omp'); + + expect(results).toEqual([ + { + source: join(plugin, 'hooks'), + destination: join(workspace, '.omp', 'hooks'), + action: 'copied', + }, + ]); + expect( + await readFile(join(workspace, '.omp', 'hooks', 'pre', 'guard.ts'), 'utf8'), + ).toBe('export default function guard() {}\n'); + }); +}); diff --git a/tests/unit/models/client-mapping.test.ts b/tests/unit/models/client-mapping.test.ts index f004e0ef..6e7e1d7d 100644 --- a/tests/unit/models/client-mapping.test.ts +++ b/tests/unit/models/client-mapping.test.ts @@ -73,6 +73,7 @@ describe('CLIENT_MAPPINGS', () => { test('pi and OMP use native project skill paths', () => { expect(CLIENT_MAPPINGS.pi.skillsPath).toBe('.pi/skills/'); expect(CLIENT_MAPPINGS.omp.skillsPath).toBe('.omp/skills/'); + expect(CLIENT_MAPPINGS.omp.hooksPath).toBe('.omp/hooks/'); }); test('opencode uses provider-specific .opencode/skills/ path', () => { @@ -170,6 +171,7 @@ describe('USER_CLIENT_MAPPINGS', () => { test('pi and OMP use native default user skill paths', () => { expect(USER_CLIENT_MAPPINGS.pi.skillsPath).toBe('.pi/agent/skills/'); expect(USER_CLIENT_MAPPINGS.omp.skillsPath).toBe('.omp/agent/skills/'); + expect(USER_CLIENT_MAPPINGS.omp.hooksPath).toBe('.omp/agent/hooks/'); }); test('opencode uses provider-specific ~/.opencode/skills/ path', () => { From f94aa4f2347e4de1638ca7925e04a398c90c4033 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Tue, 15 Sep 2026 11:30:17 +1000 Subject: [PATCH 5/6] fix(sync): track hook factories individually --- src/core/transform.ts | 101 +++++++++++++++++++------- tests/unit/core/omp-artifacts.test.ts | 69 +++++++++++++++++- 2 files changed, 141 insertions(+), 29 deletions(-) diff --git a/src/core/transform.ts b/src/core/transform.ts index ec0e6487..ae68acd1 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -162,6 +162,43 @@ async function copyDirectoryWithExclusions( } } +interface DirectoryFile { + source: string; + relativePath: string; +} + +async function collectDirectoryFiles( + sourceRoot: string, + pluginPath: string, + exclude?: string[], + relativeDir = '', +): Promise { + const directory = join(sourceRoot, relativeDir); + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + + const files: DirectoryFile[] = []; + for (const entry of entries) { + const relativePath = join(relativeDir, entry.name); + const source = join(sourceRoot, relativePath); + if (isExcluded(pluginPath, source, exclude)) continue; + + if (entry.isDirectory()) { + files.push( + ...(await collectDirectoryFiles( + sourceRoot, + pluginPath, + exclude, + relativePath, + )), + ); + } else { + files.push({ source, relativePath }); + } + } + return files; +} + /** * Options for skill copy operations */ @@ -673,16 +710,14 @@ export async function copyHooks( ): Promise { const { dryRun = false } = options; const mapping = getMapping(client, options); - const results: CopyResult[] = []; - // Skip if client doesn't support hooks if (!mapping.hooksPath) { - return results; + return []; } const sourceDir = join(pluginPath, 'hooks'); if (!existsSync(sourceDir)) { - return results; + return []; } const destDir = resolveMappedPath(workspacePath, mapping.hooksPath); @@ -696,36 +731,50 @@ export async function copyHooks( existsSync(join(sourceDir, 'hooks.json')) ? [...(options.exclude ?? []), 'hooks/hooks.json'] : options.exclude; - - if (dryRun) { - results.push({ source: sourceDir, destination: destDir, action: 'copied' }); - return results; - } - - await mkdir(destDir, { recursive: true }); + const hookFiles = await collectDirectoryFiles( + sourceDir, + pluginPath, + effectiveExclude, + ); + const writeRoot = options.writeRoot ?? workspacePath; try { - if (effectiveExclude && effectiveExclude.length > 0) { - await copyDirectoryWithExclusions( - sourceDir, - destDir, - pluginPath, - effectiveExclude, - ); - } else { - await cp(sourceDir, destDir, { recursive: true }); - } - results.push({ source: sourceDir, destination: destDir, action: 'copied' }); + await assertSafeDestination(writeRoot, destDir); } catch (error) { - results.push({ + return [{ source: sourceDir, destination: destDir, action: 'failed', - error: error instanceof Error ? error.message : 'Unknown error', - }); + error: error instanceof Error ? error.message : 'Unsafe destination', + client, + artifactType: 'hook', + }]; } - return results; + return Promise.all( + hookFiles.map(async ({ source, relativePath }): Promise => { + const destination = join(destDir, relativePath); + if (dryRun) { + return { source, destination, action: 'copied' }; + } + + try { + await assertSafeDestination(writeRoot, destination); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination); + return { source, destination, action: 'copied' }; + } catch (error) { + return { + source, + destination, + action: 'failed', + error: error instanceof Error ? error.message : 'Unknown error', + client, + artifactType: 'hook', + }; + } + }), + ); } /** diff --git a/tests/unit/core/omp-artifacts.test.ts b/tests/unit/core/omp-artifacts.test.ts index 504c1235..c89bf740 100644 --- a/tests/unit/core/omp-artifacts.test.ts +++ b/tests/unit/core/omp-artifacts.test.ts @@ -1,8 +1,20 @@ import { afterEach, describe, expect, it } from 'bun:test'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { + collectSyncedPaths, + selectivePurgeWorkspace, +} from '../../../src/core/sync.js'; import { copyHooks } from '../../../src/core/transform.js'; +import type { SyncState } from '../../../src/models/sync-state.js'; describe('OMP hook sync', () => { let root: string | undefined; @@ -24,8 +36,8 @@ describe('OMP hook sync', () => { expect(results).toEqual([ { - source: join(plugin, 'hooks'), - destination: join(workspace, '.omp', 'hooks'), + source, + destination: join(workspace, '.omp', 'hooks', 'pre', 'guard.ts'), action: 'copied', }, ]); @@ -33,4 +45,55 @@ describe('OMP hook sync', () => { await readFile(join(workspace, '.omp', 'hooks', 'pre', 'guard.ts'), 'utf8'), ).toBe('export default function guard() {}\n'); }); + + it('purges removed factories without deleting user-owned hooks', async () => { + root = await mkdtemp(join(tmpdir(), 'allagents-omp-hooks-')); + const plugin = join(root, 'plugin'); + const workspace = join(root, 'workspace'); + const sourceDir = join(plugin, 'hooks', 'pre'); + const destinationDir = join(workspace, '.omp', 'hooks', 'pre'); + const guard = join(sourceDir, 'guard.ts'); + const stale = join(sourceDir, 'stale.ts'); + await mkdir(sourceDir, { recursive: true }); + await mkdir(workspace, { recursive: true }); + await writeFile(guard, 'export default function guard() {}\n'); + await writeFile(stale, 'export default function stale() {}\n'); + + const initialResults = await copyHooks(plugin, workspace, 'omp'); + const state: SyncState = { + version: 1, + lastSync: new Date(0).toISOString(), + files: { + omp: collectSyncedPaths(initialResults, workspace, ['omp']).omp ?? [], + }, + }; + await writeFile(join(destinationDir, 'local.ts'), 'export default function local() {}\n'); + await rm(stale); + + await selectivePurgeWorkspace(workspace, state, ['omp']); + await copyHooks(plugin, workspace, 'omp'); + + await expect(readFile(join(destinationDir, 'stale.ts'), 'utf8')).rejects.toThrow(); + expect(await readFile(join(destinationDir, 'guard.ts'), 'utf8')).toContain('guard'); + expect(await readFile(join(destinationDir, 'local.ts'), 'utf8')).toContain('local'); + }); + + it('rejects a hook destination beneath a symlinked client root', async () => { + root = await mkdtemp(join(tmpdir(), 'allagents-omp-hooks-')); + const plugin = join(root, 'plugin'); + const workspace = join(root, 'workspace'); + const outside = join(root, 'outside'); + const source = join(plugin, 'hooks', 'pre', 'guard.ts'); + await mkdir(join(plugin, 'hooks', 'pre'), { recursive: true }); + await mkdir(workspace, { recursive: true }); + await mkdir(outside, { recursive: true }); + await writeFile(source, 'export default function guard() {}\n'); + await symlink(outside, join(workspace, '.omp'), 'dir'); + + const results = await copyHooks(plugin, workspace, 'omp'); + + expect(results).toHaveLength(1); + expect(results[0]?.action).toBe('failed'); + await expect(readFile(join(outside, 'hooks', 'pre', 'guard.ts'), 'utf8')).rejects.toThrow(); + }); }); From 20be7fc62131b3d670ed9d89133a5fa5f0949f0d Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Tue, 15 Sep 2026 12:08:06 +1000 Subject: [PATCH 6/6] fix(sync): bound hook file copying --- src/core/transform.ts | 84 ++++++++++++++++----------- tests/unit/core/omp-artifacts.test.ts | 14 +++++ 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/src/core/transform.ts b/src/core/transform.ts index ae68acd1..571826e1 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -162,22 +162,17 @@ async function copyDirectoryWithExclusions( } } -interface DirectoryFile { - source: string; - relativePath: string; -} - async function collectDirectoryFiles( sourceRoot: string, pluginPath: string, exclude?: string[], relativeDir = '', -): Promise { +): Promise { const directory = join(sourceRoot, relativeDir); const entries = await readdir(directory, { withFileTypes: true }); entries.sort((a, b) => a.name.localeCompare(b.name)); - const files: DirectoryFile[] = []; + const files: string[] = []; for (const entry of entries) { const relativePath = join(relativeDir, entry.name); const source = join(sourceRoot, relativePath); @@ -193,7 +188,7 @@ async function collectDirectoryFiles( )), ); } else { - files.push({ source, relativePath }); + files.push(relativePath); } } return files; @@ -731,11 +726,23 @@ export async function copyHooks( existsSync(join(sourceDir, 'hooks.json')) ? [...(options.exclude ?? []), 'hooks/hooks.json'] : options.exclude; - const hookFiles = await collectDirectoryFiles( - sourceDir, - pluginPath, - effectiveExclude, - ); + let hookFiles: string[]; + try { + hookFiles = await collectDirectoryFiles( + sourceDir, + pluginPath, + effectiveExclude, + ); + } catch (error) { + return [{ + source: sourceDir, + destination: destDir, + action: 'failed', + error: error instanceof Error ? error.message : 'Unknown error', + client, + artifactType: 'hook', + }]; + } const writeRoot = options.writeRoot ?? workspacePath; try { @@ -751,30 +758,37 @@ export async function copyHooks( }]; } - return Promise.all( - hookFiles.map(async ({ source, relativePath }): Promise => { - const destination = join(destDir, relativePath); - if (dryRun) { - return { source, destination, action: 'copied' }; - } + const results: CopyResult[] = []; + const preparedDirectories = new Set(); + for (const relativePath of hookFiles) { + const source = join(sourceDir, relativePath); + const destination = join(destDir, relativePath); + if (dryRun) { + results.push({ source, destination, action: 'copied' }); + continue; + } - try { - await assertSafeDestination(writeRoot, destination); - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination); - return { source, destination, action: 'copied' }; - } catch (error) { - return { - source, - destination, - action: 'failed', - error: error instanceof Error ? error.message : 'Unknown error', - client, - artifactType: 'hook', - }; + try { + await assertSafeDestination(writeRoot, destination); + const destinationDirectory = dirname(destination); + if (!preparedDirectories.has(destinationDirectory)) { + await mkdir(destinationDirectory, { recursive: true }); + preparedDirectories.add(destinationDirectory); } - }), - ); + await cp(source, destination); + results.push({ source, destination, action: 'copied' }); + } catch (error) { + results.push({ + source, + destination, + action: 'failed', + error: error instanceof Error ? error.message : 'Unknown error', + client, + artifactType: 'hook', + }); + } + } + return results; } /** diff --git a/tests/unit/core/omp-artifacts.test.ts b/tests/unit/core/omp-artifacts.test.ts index c89bf740..f651b8a3 100644 --- a/tests/unit/core/omp-artifacts.test.ts +++ b/tests/unit/core/omp-artifacts.test.ts @@ -78,6 +78,20 @@ describe('OMP hook sync', () => { expect(await readFile(join(destinationDir, 'local.ts'), 'utf8')).toContain('local'); }); + it('reports an unreadable hook artifact without aborting plugin sync', async () => { + root = await mkdtemp(join(tmpdir(), 'allagents-omp-hooks-')); + const plugin = join(root, 'plugin'); + const workspace = join(root, 'workspace'); + await mkdir(plugin, { recursive: true }); + await mkdir(workspace, { recursive: true }); + await writeFile(join(plugin, 'hooks'), 'not a directory\n'); + + const results = await copyHooks(plugin, workspace, 'omp'); + + expect(results).toHaveLength(1); + expect(results[0]?.action).toBe('failed'); + }); + it('rejects a hook destination beneath a symlinked client root', async () => { root = await mkdtemp(join(tmpdir(), 'allagents-omp-hooks-')); const plugin = join(root, 'plugin');