From 46f615ee3c86466aead164ffa30c52107eafecd7 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 09:48:18 +0800 Subject: [PATCH 01/16] feat(cli): install the framework for every project with setup --global Until now `setup --scope user` only registered the framework source: it refused --plugins, refused OpenCode, and installed nothing, so every machine-wide install lived in make reload's own copies. `aidd setup --global --ide claude,codex,opencode --plugins all|recommended|` now installs: Claude Code, Codex and Copilot enable the plugins through their own CLI at user scope; OpenCode, which has no plugin system, gets each plugin's skills and agents copied into ~/.config/opencode, and Codex, whose plugins cannot carry agents, its agents into ~/.codex/agents. A profile declares those copies as `machineFiles`; they are recorded in the machine manifest as user-scope plugins and written by the existing user-scope plugin writer, so `sync --global` rebuilds them from the framework source and `clean --global` removes them without touching files aidd did not write. `--global` is the spelling of `--scope user` on setup, sync, doctor and clean, and `--ide` names every tool, AI assistants included (`--ai` stays as a hidden former spelling). --- cli/README.md | 36 ++-- .../framework-source-is-machine-scope.md | 5 +- cli/package.json | 2 +- cli/scripts/check-bundle-size.mjs | 2 + .../marketplace-sync-settings-use-case.ts | 28 ++- .../ownership/machine-plugin-files.ts | 98 +++++++++++ .../ownership/user-plugin-file-updater.ts | 40 +++-- .../ownership/user-plugin-update-use-case.ts | 12 +- .../plugin/plugin-update-use-case.ts | 3 + .../framework/application/setup-use-case.ts | 4 + .../setup/install-machine-plugins-use-case.ts | 112 ++++++++++++ .../setup/setup-machine-scope-use-case.ts | 34 +++- .../contexts/framework/domain/setup-flow.ts | 10 +- .../domain/capabilities/plugins-capability.ts | 37 +++- .../domain/profiles/codex/codex-paths.ts | 13 ++ .../tools/domain/profiles/codex/profile.ts | 2 + .../profiles/opencode/opencode-paths.ts | 18 ++ .../tools/domain/profiles/opencode/profile.ts | 5 + cli/src/contexts/tools/domain/registry.ts | 18 +- cli/src/kernel/errors.ts | 20 +-- cli/src/presentation/commands/clean.ts | 6 +- cli/src/presentation/commands/doctor.ts | 6 +- .../presentation/commands/global-options.ts | 10 ++ cli/src/presentation/commands/setup.ts | 59 ++++--- cli/src/presentation/commands/sync.ts | 28 ++- cli/src/presentation/display/setup-display.ts | 9 + cli/src/presentation/display/sync-display.ts | 13 +- cli/src/runtime/wiring/framework.ts | 35 ++-- .../orchestrator-deps.arch.test.ts | 7 + .../machine-plugin-files.unit.test.ts | 159 ++++++++++++++++++ .../application/setup-use-case.unit.test.ts | 2 + ...tall-machine-plugins-use-case.unit.test.ts | 129 ++++++++++++++ .../framework/domain/setup-flow.unit.test.ts | 24 ++- .../profiles/codex/machine-files.unit.test.ts | 30 ++++ .../opencode/machine-files.unit.test.ts | 42 +++++ cli/tests/e2e/command-matrix-help.e2e.test.ts | 7 +- cli/tests/e2e/setup-scope-user.e2e.test.ts | 80 ++++++--- cli/tests/golden/snapshots/help/surface.json | 8 +- .../golden/snapshots/phase0/snapshot.json | 4 +- cli/tests/kernel/errors.unit.test.ts | 8 +- .../commands/clean-wiring.integration.test.ts | 4 + .../doctor-wiring.integration.test.ts | 1 + .../commands/setup-options.unit.test.ts | 13 +- .../commands/setup-wiring.integration.test.ts | 75 ++++----- .../commands/sync-wiring.integration.test.ts | 29 +++- .../display/setup-display.unit.test.ts | 26 +++ .../display/sync-display.unit.test.ts | 19 +++ docs/SUBAGENTS.md | 9 +- 48 files changed, 1130 insertions(+), 211 deletions(-) create mode 100644 cli/src/contexts/framework/application/ownership/machine-plugin-files.ts create mode 100644 cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts create mode 100644 cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts create mode 100644 cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts create mode 100644 cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts diff --git a/cli/README.md b/cli/README.md index a0d93a103..de2e31675 100644 --- a/cli/README.md +++ b/cli/README.md @@ -25,13 +25,18 @@ Check: `aidd --version`. | Step | Run | | --- | --- | -| Bootstrap the project | `aidd setup --ai claude --ide vscode --plugins recommended --yes` | +| Bootstrap the project | `aidd setup --ide claude,vscode --plugins recommended --yes` | | Add a plugin | `aidd plugin install aidd-dev` | | Check the state | `aidd doctor` | | Restore owned files | `aidd sync` | | Update the CLI | `aidd update` | `setup` writes the manifest, the default marketplace, the tool configs and the plugins. +`--global` installs for the machine instead, and writes nothing under the project. + +| Command | Lands in | +| --- | --- | +| `aidd setup --ide claude` | `.claude/` and `.aidd/` in this project | ## Commands @@ -101,31 +106,34 @@ Counts the tokens of Claude Code and Codex sessions, locally. Details: [FAQ](../ ## Scope -`setup`, `doctor`, `sync` and `clean` accept `--scope `. +`setup`, `doctor`, `sync` and `clean` accept `--global`, or its former spelling `--scope user`. | Scope | Acts on | | --- | --- | -| `project` (default) | This project alone | -| `user` | The machine: registers the framework source and supported tools, writes nothing under the project | +| project (default) | This project alone | +| `--global` | Every project on the machine; writes nothing under the project | -`setup --scope user` needs: +`setup --global` needs: -- An explicit `--ai` list of supported tools (`--ai all` is refused). -- `--plugins none`, or no `--plugins` in a scripted run. -- No `--ide`. +- An explicit `--ide` list of supported tools, no editor (`--ide all` is refused). +- `--plugins all`, `recommended`, `none` or comma-separated names. -| Tool | `setup --scope user` | +| Tool | `setup --global --plugins …` | | --- | --- | -| `claude`, `codex`, `copilot` | Registers the source and drives the tool's own CLI | -| `cursor` | Records the source and tool only, no native activation | -| `opencode`, `kilo` | Unsupported: use project scope | +| `claude`, `copilot` | Registers the source and enables the plugins through the tool's own CLI | +| `codex` | The same, plus the plugins' agents in `~/.codex/agents/` | +| `opencode` | Copies the plugins' skills and agents into `~/.config/opencode/` | +| `cursor` | Records the source and tool only | +| `kilo` | Unsupported: use project scope | Native activation needs the tool's executable on `PATH`. Without it, registration succeeds with a warning and activation stays unrun. -Install it, then run `aidd sync --scope user`. +Install it, then run `aidd sync --global`. ```sh -aidd setup --scope user --ai claude,codex,copilot --plugins none --yes +aidd setup --global --ide claude,codex,opencode --plugins all --yes +aidd sync --global # rebuild the copied files and refresh the hosts from the framework source +aidd clean --global # remove what --global installed and purge the shared source ``` ## Authentication diff --git a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md index be260e1e8..f8f4be92a 100644 --- a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md +++ b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md @@ -49,13 +49,14 @@ One registration of `aidd-framework` per machine, shared by every project. Read - Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Unsupported Copilot versions refuse it. - Writes nothing under `projectRoot`: no content, no plugin prompt, no gitignore touch. +- `--plugins all|recommended|` picks plugins from the shared source's catalog. A tool with native activation gets them through `MarketplaceSyncSettingsOptions.enablePlugins`; a tool declaring `PluginsCapability.machineFiles` (opencode's skills and agents, codex's agents) gets them copied into its user directory as `scope: "user"` plugins, written by `UserPluginFileUpdater`, so `sync --scope user` rebuilds them and `clean --scope user` removes them. An interactive pick is refused (`UserScopePluginModeError`). - Its manifest: `userManifestPath(userConfigDir())`, `userConfigDir()/manifest.json`, same schema and version as the project one. - `UserManifestRepositoryAdapter` reuses `Manifest.fromJSON`/`toJSON`; its `delete()` removes that one file only. - Each AI tool gets a manifest entry with an empty file list, so `MarketplaceSyncSettingsUseCase` has something to iterate. - Records no `references.json` claim: absence is the state until `clean --scope user`. - `doctor --scope user` runs only `DoctorRegistrationUseCase`. -- `sync --scope user` resolves the same manifest. +- `sync --scope user` resolves the same manifest, rebuilds its `scope: "user"` plugin files, then drives native activation. - `--scope ` on all three, default `project`. - Refuses an `--ide` tool (`UserScopeIdeToolsError`): IDE config is project-relative. -- Refuses an AI tool without machine-wide activation (`UserScopeUnsupportedAiToolsError`; `registry.ts`'s `supportsUserScopeActivation`, false for opencode alone). +- Refuses an AI tool without machine-wide activation (`UserScopeUnsupportedAiToolsError`; `registry.ts`'s `supportsUserScopeActivation`, false for kilo alone). - Both refusals fire in `SetupFlow`'s constructor; `--ai all` is unusable at this scope. diff --git a/cli/package.json b/cli/package.json index 58d2105c7..ccf272438 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.16.1", - "bundleBudgetKB": 722, + "bundleBudgetKB": 747, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index a9deb0584..fd2d69e9e 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -55,6 +55,8 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // (+5.2 KB over its 712.9 KB skills profile); 0.3% measured headroom. // 722 KB: 720.2 KB, Antigravity portable agents and direct-local installation // (+2.1 KB over its 718.1 KB hooks build); 0.3% measured headroom. +// 747 KB: 732.4 KB, the machine-wide install of `setup --global` (+12.2 KB over the 720.2 KB +// above); 2.0% measured headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts index 458151b01..02fc810f8 100644 --- a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts @@ -95,6 +95,12 @@ export interface MarketplaceSyncSettingsOptions { * never a fallback to every one. */ marketplaceNames?: readonly string[]; + /** + * Framework plugins a tool with native activation also enables at this run's scope, on top + * of the ones its manifest records — what `setup --global --plugins` asks for, since a + * machine-scope install records no plugin a host loads by itself. + */ + enablePlugins?: readonly string[]; } interface ActivationOutcome { @@ -231,7 +237,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest, marketplaces, toolIds, - scope + scope, + options.enablePlugins ?? [] ); let declarativeChanged = false; if (scope === "project") { @@ -403,7 +410,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest: Manifest, marketplaces: readonly Marketplace[], toolIds: readonly ToolId[], - scope: MarketplaceScope + scope: MarketplaceScope, + enablePlugins: readonly string[] ): Promise { const outcomes = new Map(); const binaryMissing: { toolId: ToolId; binary: string }[] = []; @@ -426,7 +434,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest, marketplaces, warnings, - scope + scope, + enablePlugins ); outcomes.set(toolId, outcome); } catch (error) { @@ -529,7 +538,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest: Manifest, marketplaces: readonly Marketplace[], warnings: string[], - scope: MarketplaceScope + scope: MarketplaceScope, + enablePlugins: readonly string[] ): Promise { // Every known marketplace, never only the ones a plugin points at: declaring a marketplace // and installing a plugin from it are two acts. Measured against the real `claude` binary — @@ -608,9 +618,17 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); } const hostNameByAlias = new Map(stillProven.map((m) => [m.alias, m.hostName])); + const frameworkHost = hostNameByAlias.get(FRAMEWORK_MARKETPLACE_NAME); + const requested = + frameworkHost === undefined ? [] : enablePlugins.map((name) => `${name}@${frameworkHost}`); const { refsToEnable, alreadyOwnedRefs } = await this.refsThisProjectEnables( toolId, - this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias), + [ + ...new Set([ + ...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias), + ...requested, + ]), + ], manifest, projectRoot ); diff --git a/cli/src/contexts/framework/application/ownership/machine-plugin-files.ts b/cli/src/contexts/framework/application/ownership/machine-plugin-files.ts new file mode 100644 index 000000000..51903e1c2 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/machine-plugin-files.ts @@ -0,0 +1,98 @@ +import { dirname, join, posix } from "node:path"; +import { InstallationFile } from "../../../../kernel/file.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { machineFilesOf } from "../../../tools/domain/registry.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-containment.js"; + +/** + * A plugin's machine-scope files, read from the tool's built tree and keyed by their path under + * the tool's user configuration directory. Empty for a tool that copies none. + */ +export async function readMachinePluginFiles( + fs: FileReader, + hasher: Hasher, + builtDir: string, + toolId: AiToolId, + dist: PluginDistribution +): Promise { + const machine = machineFilesOf(toolId); + if (machine === null) return []; + const plugin = { + name: dist.manifest.name, + agents: new Set(dist.components.agents.map((file) => posix.basename(file.relativePath, ".md"))), + }; + const files: InstallationFile[] = []; + for (const abs of await fs.listFilesRecursive(builtDir)) { + const builtPath = posix.normalize( + abs + .slice(builtDir.length + 1) + .split("\\") + .join("/") + ); + const relativePath = machine.map(builtPath, plugin); + if (relativePath === null) continue; + const content = await fs.readFile(abs); + files.push(new InstallationFile({ relativePath, content, hash: hasher.hash(content) })); + } + return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +} + +/** + * Refuses a machine-scope write before any byte lands: every path must stay a normalized relative + * path, its nearest existing parent must resolve strictly inside the tool's user directory, and a + * file already there that this plugin does not own is never overwritten unless it already holds + * the same bytes. Creates the user directory itself when the host has not yet. + */ +export async function assertMachineFilesWritable( + fs: FileReader & FileWriter, + toolId: AiToolId, + plugin: Pick | undefined, + pluginName: string, + files: readonly InstallationFile[], + homedir: string +): Promise { + const machine = machineFilesOf(toolId); + if (machine === null) throw new Error(`${toolId}: no machine-scope directory is declared.`); + const boundary = machine.dir(homedir); + await fs.createDirectory(boundary); + const resolvedBoundary = await fs.realpath(boundary); + for (const file of files) { + const rel = file.relativePath; + if (posix.isAbsolute(rel) || rel.includes("\\") || posix.normalize(rel) !== rel) { + throw new Error(`${toolId}: '${pluginName}' path '${rel}' escapes ${boundary}.`); + } + let parent = dirname(join(boundary, rel)); + while (parent !== boundary) { + const resolved = await tryRealpath(fs, parent); + if (resolved !== null) { + if (!isStrictlyWithinUserScope(resolved, resolvedBoundary)) { + throw new Error(`${toolId}: '${pluginName}' parent '${parent}' escapes ${boundary}.`); + } + break; + } + parent = dirname(parent); + } + if (plugin?.files.has(rel) === true) continue; + const target = join(boundary, rel); + if (!(await fs.fileExists(target))) continue; + if ((await fs.readFileHash(target)).value === file.hash.value) continue; + throw new Error( + `${toolId}: '${target}' already exists and was not installed by aidd; move or delete it, then rerun.` + ); + } + return boundary; +} + +async function tryRealpath(fs: FileReader, path: string): Promise { + try { + return await fs.realpath(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } +} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index 5966418f4..260af0573 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -10,6 +10,7 @@ import type { AiToolId } from "../../../../kernel/tool.js"; import { frameworkBuildModeFor, getToolConfig, + machineFilesOf, resolvePluginsCapability, } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; @@ -28,6 +29,7 @@ import { assertUserScopeWriteBoundary, userScopeFilesSafeToDelete, } from "../shared/user-scope-plugin-files.js"; +import { assertMachineFilesWritable, readMachinePluginFiles } from "./machine-plugin-files.js"; export interface PlannedUserPluginFileUpdate { readonly plugin: InstalledPlugin; @@ -61,10 +63,11 @@ export class UserPluginFileUpdater { plugin: InstalledPlugin, toolId: AiToolId, projectRoot: string, - logger: Logger + logger: Logger, + rebuild = false ): Promise { const dist = await this.loader.loadLatest(plugin, projectRoot); - if (compareSemver(dist.manifest.version, plugin.version) <= 0) return null; + if (!rebuild && compareSemver(dist.manifest.version, plugin.version) <= 0) return null; const { files, componentPaths } = await this.materializeFiles( dist, toolId, @@ -78,9 +81,8 @@ export class UserPluginFileUpdater { `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.` ); } - await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, files, home); + await this.assertWritable(plugin, toolId, files, home); const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home); - await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); return { plugin, toolId, @@ -112,14 +114,7 @@ export class UserPluginFileUpdater { `${plan.toolId}: '${plan.plugin.name}' has unsafe recorded files since preflight; update refused before writing.` ); } - await assertUserScopeWriteBoundary( - this.fs, - plan.toolId, - plan.plugin.name, - [...plan.files], - plan.home - ); - await this.assertNewFilesDoNotCollide(plan.plugin, plan.toolId, plan.baseDir, plan.files); + await this.assertWritable(plan.plugin, plan.toolId, plan.files, plan.home); await writePluginFiles([...plan.files], plan.baseDir, this.fs); const newPaths = new Set(plan.files.map((file) => file.relativePath)); await deleteOldFiles( @@ -130,6 +125,23 @@ export class UserPluginFileUpdater { return plan.next; } + /** A tool's machine files spread across its user directory; a user-scope plugin directory + * holds one plugin per subdirectory. Each layout has its own containment proof. */ + private async assertWritable( + plugin: InstalledPlugin, + toolId: AiToolId, + files: readonly InstallationFile[], + home: string + ): Promise { + if (machineFilesOf(toolId) !== null) { + await assertMachineFilesWritable(this.fs, toolId, plugin, plugin.name, files, home); + return; + } + await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, [...files], home); + const baseDir = resolveBaseDirFromRecord("user", toolId, "", () => home); + await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); + } + private async assertNewFilesDoNotCollide( plugin: InstalledPlugin, toolId: AiToolId, @@ -165,6 +177,10 @@ export class UserPluginFileUpdater { target: toolId, mode: frameworkBuildModeFor(toolId), }); + if (machineFilesOf(toolId) !== null) { + const files = await readMachinePluginFiles(this.fs, this.hasher, builtDir, toolId, dist); + return { files, componentPaths: new Map() }; + } const built = await readBuiltUserPluginFiles(this.fs, this.hasher, builtDir, plugin.name); const files = resolvePluginsCapability(toolId)?.hooksDestination === "project" diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts index 2978e3fcf..15e05e30f 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts @@ -39,7 +39,9 @@ export class UserPluginUpdateUseCase { ) .map((plugin) => ({ toolId, plugin })) ); - const nativeTargets = toolIds.flatMap((toolId) => + // A rebuild re-materializes the files aidd wrote; a host enabling plugins itself picks the + // rebuilt tree up through its own marketplace refresh, which activation drives. + const nativeTargets = (options.rebuild === true ? [] : toolIds).flatMap((toolId) => (manifest.getNativeRegistrations(toolId)?.pluginClaims ?? []) .filter( (claim) => @@ -68,7 +70,13 @@ export class UserPluginUpdateUseCase { targets.map(async ({ toolId, plugin }) => ({ toolId, plugin, - plan: await this.fileUpdater.planUpdate(plugin, toolId, options.projectRoot, this.logger), + plan: await this.fileUpdater.planUpdate( + plugin, + toolId, + options.projectRoot, + this.logger, + options.rebuild === true + ), })) ); const nativeUpdates = await Promise.all( diff --git a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts index 2673b27cd..1af12e092 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts @@ -31,6 +31,9 @@ export interface PluginUpdateOptions { toolIds: AiToolId[] | "all"; projectRoot: string; scope?: "project" | "user"; + /** Re-materialize at the same version: the source changed, not the plugin's version. Read by + * the machine-scope update, which `sync --global` drives. */ + rebuild?: boolean; } export class PluginUpdateUseCase { diff --git a/cli/src/contexts/framework/application/setup-use-case.ts b/cli/src/contexts/framework/application/setup-use-case.ts index 1424928bc..345f8da66 100644 --- a/cli/src/contexts/framework/application/setup-use-case.ts +++ b/cli/src/contexts/framework/application/setup-use-case.ts @@ -24,12 +24,16 @@ export type SetupResult = install: SetupToolsResult; activation: MarketplaceSyncSettingsResult; context?: ProjectContext; + /** Set by a machine-scope run: the plugins it installed for every project. */ + machinePlugins?: readonly string[]; } | { kind: "up-to-date"; install: SetupToolsResult; activation: MarketplaceSyncSettingsResult; context?: ProjectContext; + /** Set by a machine-scope run: the plugins it installed for every project. */ + machinePlugins?: readonly string[]; }; export class SetupUseCase { diff --git a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts new file mode 100644 index 000000000..2f12b3f24 --- /dev/null +++ b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts @@ -0,0 +1,112 @@ +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; +import type { ResolveMarketplace } from "../../../distribution/application/resolve-marketplace-use-case.js"; +import type { PluginCatalogEntry } from "../../../distribution/domain/catalog.js"; +import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import { machineFilesOf } from "../../../tools/domain/registry.js"; +import type { Manifest } from "../../domain/manifest.js"; +import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { resolvePluginSourceFromMarketplace } from "../../domain/plugins/plugin-source-resolver.js"; +import type { PluginInstallMode } from "../../domain/setup-flow.js"; +import type { UserPluginFileUpdater } from "../ownership/user-plugin-file-updater.js"; + +export interface InstallMachinePluginsOptions { + readonly projectRoot: string; + readonly toolIds: readonly ToolId[]; + readonly mode: PluginInstallMode; + readonly pluginNames: readonly string[]; +} + +export interface InstallMachinePluginsResult { + /** The framework plugins this run chose, which a tool with native activation enables next. */ + readonly plugins: readonly string[]; +} + +/** + * `setup --global --plugins …`: picks the framework plugins from the shared source's catalog and + * copies each one's machine files (OpenCode's skills and agents, Codex's agents) into the tool's + * user configuration directory, recording them in the machine manifest as user-scope plugins so + * `plugin update --scope user`, `sync --scope user` and `clean --scope user` own them. A first + * install is a rebuild of a plugin that owns no file yet, so both go through one writer. A tool + * that enables plugins through its own CLI is handed the chosen names by the caller instead. + */ +export class InstallMachinePluginsUseCase { + constructor( + private readonly registry: Pick, + private readonly resolveMarketplace: ResolveMarketplace, + private readonly fileUpdater: Pick, + private readonly logger: Logger + ) {} + + async execute( + manifest: Manifest, + options: InstallMachinePluginsOptions + ): Promise { + if (options.mode === "none" || options.mode === "interactive") return { plugins: [] }; + const marketplace = (await this.registry.list(options.projectRoot)).find( + (entry) => entry.name === FRAMEWORK_MARKETPLACE_NAME && entry.scope === "user" + ); + if (marketplace === undefined) { + throw new Error( + "--global --plugins installs from the shared framework source, and none is registered; drop --no-default-marketplace." + ); + } + const { catalog, localPath } = await this.resolveMarketplace.execute({ + marketplace, + projectRoot: options.projectRoot, + }); + const entries = selectEntries(catalog?.plugins ?? [], options); + for (const toolId of options.toolIds) { + if (machineFilesOf(toolId) === null) continue; + for (const entry of entries) { + const owned = manifest.getPlugins(toolId).find((plugin) => plugin.name === entry.name); + const plugin = + owned ?? + InstalledPlugin.fromMetadata( + entry.name, + entry.version ?? "0.0.0", + resolvePluginSourceFromMarketplace(entry.source, marketplace, localPath), + entry.strict, + "user", + marketplace.name + ); + await this.install(manifest, toolId as AiToolId, plugin, owned !== undefined, options); + } + } + return { plugins: entries.map((entry) => entry.name) }; + } + + private async install( + manifest: Manifest, + toolId: AiToolId, + plugin: InstalledPlugin, + owned: boolean, + options: InstallMachinePluginsOptions + ): Promise { + const plan = await this.fileUpdater.planUpdate( + plugin, + toolId, + options.projectRoot, + this.logger, + true + ); + if (plan === null || (plan.files.length === 0 && !owned)) return; + const next = await this.fileUpdater.applyUpdate(plan, this.logger); + if (owned) manifest.updatePlugin(toolId, next); + else manifest.addPlugin(toolId, next); + } +} + +function selectEntries( + catalog: readonly PluginCatalogEntry[], + options: InstallMachinePluginsOptions +): readonly PluginCatalogEntry[] { + if (options.mode === "all") return catalog; + if (options.mode === "recommended") return catalog.filter((entry) => entry.recommended); + const missing = options.pluginNames.filter((name) => !catalog.some((e) => e.name === name)); + if (missing.length > 0) { + throw new Error(`Not in the framework catalog: ${missing.join(", ")}.`); + } + return catalog.filter((entry) => options.pluginNames.includes(entry.name)); +} diff --git a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts index 9ba14ac66..714147654 100644 --- a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts +++ b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts @@ -8,12 +8,14 @@ import type { } from "../flows/marketplace-sync-settings-use-case.js"; import type { SetupResult } from "../setup-use-case.js"; import type { SetupMarketplaceRegistrationUseCase } from "../shared/setup-marketplace-registration-use-case.js"; +import type { InstallMachinePluginsUseCase } from "./install-machine-plugins-use-case.js"; import type { SetupToolsResult } from "./setup-tools-use-case.js"; /** - * `--scope user`: registers the shared framework source and drives native activation machine-wide, - * writing nothing under `flow.projectRoot` at all — no tool content install, no plugin prompt, no - * project-context detection. + * `--scope user`: registers the shared framework source, installs the requested plugins in each + * tool's user configuration directory and drives native activation machine-wide, writing nothing + * under `flow.projectRoot` at all — no project tool content, no plugin prompt, no project-context + * detection. * * Records no shared-source reference: `references.json` tracks which *project* still claims the * shared source, and a `--scope user` run has no project-scope manifest for a later `clean` to read @@ -25,7 +27,8 @@ export class SetupMachineScopeUseCase { private readonly userManifestRepo: ManifestRepository, private readonly setupMarketplaceRegistration: SetupMarketplaceRegistrationUseCase, private readonly marketplaceSyncSettingsUseCase: MarketplaceSyncSettings, - private readonly currentVersionProvider: VersionReader + private readonly currentVersionProvider: VersionReader, + private readonly installMachinePluginsUseCase?: InstallMachinePluginsUseCase ) {} async execute(flow: SetupFlow): Promise { @@ -43,16 +46,33 @@ export class SetupMachineScopeUseCase { const isNew = await this.initUserManifest(); await this.setupMarketplaceRegistration.registerIfPresent(flow, source); await this.registerUserScopeTools(flow); + const plugins = await this.installMachinePlugins(flow); const activation: MarketplaceSyncSettingsResult = await this.marketplaceSyncSettingsUseCase.execute({ projectRoot: flow.projectRoot, scope: "user", manifestRepo: this.userManifestRepo, + enablePlugins: plugins, }); const install: SetupToolsResult = { results: [] }; - return isNew - ? { kind: "initialized", install, activation, context: undefined } - : { kind: "up-to-date", install, activation, context: undefined }; + const outcome = { install, activation, context: undefined, machinePlugins: plugins }; + return isNew ? { kind: "initialized", ...outcome } : { kind: "up-to-date", ...outcome }; + } + + /** Copies the chosen plugins' machine files and returns their names, which every tool with + * native activation then enables machine-wide. */ + private async installMachinePlugins(flow: SetupFlow): Promise { + if (this.installMachinePluginsUseCase === undefined) return []; + const manifest = await this.userManifestRepo.load(); + if (manifest === null) return []; + const { plugins } = await this.installMachinePluginsUseCase.execute(manifest, { + projectRoot: flow.projectRoot, + toolIds: flow.aiTools, + mode: flow.pluginMode, + pluginNames: flow.pluginNames, + }); + await this.userManifestRepo.save(manifest); + return plugins; } private async initUserManifest(): Promise { diff --git a/cli/src/contexts/framework/domain/setup-flow.ts b/cli/src/contexts/framework/domain/setup-flow.ts index c63a8bacd..ac6a0cea5 100644 --- a/cli/src/contexts/framework/domain/setup-flow.ts +++ b/cli/src/contexts/framework/domain/setup-flow.ts @@ -76,10 +76,10 @@ export class SetupFlow { } // `--scope user` writes nothing under `projectRoot`: an IDE tool's project-relative config has - // nowhere to land, an AI tool with neither native activation nor a user-scope install directory - // has nowhere to be registered, an empty `--ai` list registers the shared source for no tool at - // all, and no manifest entry exists yet to enable a plugin against. Each is refused rather than - // silently dropped. + // nowhere to land, an AI tool with neither native activation nor a user-scope directory has + // nowhere to be registered, an empty `--ai` list registers the shared source for no tool at + // all, and an interactive pick has no project to offer it in. Each is refused rather than + // silently dropped; named, recommended and all plugins install machine-wide. private validateScope( scope: SetupScope, aiTools: readonly ToolId[], @@ -91,7 +91,7 @@ export class SetupFlow { if (aiTools.length === 0) throw new UserScopeNoToolsError(); const unsupported = aiTools.filter((id) => !supportsUserScopeActivation(id)); if (unsupported.length > 0) throw new UserScopeUnsupportedAiToolsError(unsupported); - if (pluginMode !== "none") throw new UserScopePluginModeError(); + if (pluginMode === "interactive") throw new UserScopePluginModeError(); } private validatePluginMode(mode: PluginInstallMode, names: readonly string[]): void { diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index b488a508f..54757e2a3 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -70,6 +70,32 @@ export interface ProjectHooksDelivery { projectHooksFormat?: ProjectHooksFormat; } +/** + * What a machine-scope install (`setup --global`) copies out of this tool's built tree into + * the user's own configuration directory, for a host that loads those files from there rather + * than from a plugin it enables itself: OpenCode's skills and agents, Codex's agents. + */ +export interface MachineFiles { + /** The user configuration directory the files land in, given a homedir. */ + readonly dir: (homedir: string) => string; + /** A built-tree path (relative to the tool's built dir) to its path under `dir` for one + * plugin, or `null` when that file is not this plugin's or is not installed machine-wide. */ + readonly map: (builtPath: string, plugin: MachinePlugin) => string | null; +} + +/** The plugin a machine file is read for: its name, and the agents its source ships, by base + * name. A flat build prefixes an agent with its plugin, so a prefix alone cannot tell + * `aidd-dev`'s agents from those of a plugin named `aidd-dev-tools`. */ +export interface MachinePlugin { + readonly name: string; + readonly agents: ReadonlySet; +} + +/** Shared by every mode: where a tool's machine-scope files go, when it has any. */ +interface MachineFilesParams { + machineFiles?: MachineFiles; +} + export interface NativePluginsParams extends ProjectHooksDelivery { mode: "native"; pluginsDir: string; @@ -153,10 +179,12 @@ export type HooksSupport = | { acceptsHooks: true; hooksTrustNotice?: string } | { acceptsHooks: false; hooksUnsupportedReason: string }; -type PluginsParams = +type PluginsParams = ( | (NativePluginsParams & HooksSupport) | FlatPluginsParams - | UnsupportedPluginsParams; + | UnsupportedPluginsParams +) & + MachineFilesParams; export class PluginsCapability { readonly mode: PluginsMode; @@ -190,11 +218,14 @@ export class PluginsCapability { readonly nativeActivation: NativeActivation | null; readonly translationMode: PluginTranslationMode | null; readonly installScope: "project" | "user"; + /** See {@link MachineFiles}, or `null` when a machine-scope install copies no file. */ + readonly machineFiles: MachineFiles | null; private readonly _userPluginsDir?: (homedir: string) => string; constructor(params: PluginsParams) { this.mode = params.mode; + this.machineFiles = params.machineFiles ?? null; this.translationMode = PluginsCapability.resolveTranslationMode(params); this.installScope = PluginsCapability.resolveInstallScope(params); PluginsCapability.validateUserScope(params); @@ -285,7 +316,7 @@ export class PluginsCapability { * independently of this capability's own `installScope`: a caller resolving a *recorded* * scope still needs the directory that scope means. */ userPluginsBaseDir(homedir: string): string | null { - return this._userPluginsDir?.(homedir) ?? null; + return this._userPluginsDir?.(homedir) ?? this.machineFiles?.dir(homedir) ?? null; } pluginOutputDir(pluginName: string): string | null { diff --git a/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts index e36a285aa..457d41374 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts @@ -3,6 +3,8 @@ * even where the literal values coincide: a change to either side must not collapse them. */ +import type { MachinePlugin } from "../../capabilities/plugins-capability.js"; + /** Relative path for the Codex-native plugin manifest inside each plugin output directory. */ export const OUTPUT_CODEX_MANIFEST_RELATIVE = ".codex-plugin/plugin.json"; @@ -15,3 +17,14 @@ export const OUTPUT_CODEX_MARKETPLACE_RELATIVE = ".agents/plugins/marketplace.js /** Subdirectory name inside each plugin output for staged Codex agent TOML files. */ export const OUTPUT_CODEX_AGENTS_DIR = "codex-agents"; + +/** + * A Codex plugin cannot carry agents, and Codex loads them from `~/.codex/agents/` for every + * project, so a machine-scope install copies each staged TOML there, prefixed with its plugin. + */ +export function codexMachineFilePath(builtPath: string, plugin: MachinePlugin): string | null { + const staged = `plugins/${plugin.name}/${OUTPUT_CODEX_AGENTS_DIR}/`; + if (!builtPath.startsWith(staged) || !builtPath.endsWith(".toml")) return null; + const name = builtPath.slice(staged.length); + return name.includes("/") ? null : `agents/${plugin.name}-${name}`; +} diff --git a/cli/src/contexts/tools/domain/profiles/codex/profile.ts b/cli/src/contexts/tools/domain/profiles/codex/profile.ts index 320807e35..1d6e8e39c 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/profile.ts @@ -31,6 +31,7 @@ import { stripCodexSkillFrontmatter, } from "./build.js"; import { codexAgentFrontmatter, codexAgentToml } from "./codex-agent-toml.js"; +import { codexMachineFilePath } from "./codex-paths.js"; const DIRECTORY = ".codex/"; const TOOL_SUFFIX = ".codex.md"; @@ -181,6 +182,7 @@ export const codex: AiTool< mode: "native", pluginsDir: ".codex/plugins/", pluginManifestRelativePath: "plugin.json", + machineFiles: { dir: (homedir) => join(homedir, ".codex"), map: codexMachineFilePath }, acceptsMcp: true, translationMode: "marketplace", // Codex only enables plugins from its user-global config plus its own plugin cache; a diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts index 7380bcbdd..ee8311f1c 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts @@ -2,8 +2,26 @@ * contract can read the same values: the profile imports the contract, so the contract cannot * import the profile back. */ +import type { MachinePlugin } from "../../capabilities/plugins-capability.js"; + export const OPENCODE_DIRECTORY = ".opencode/"; +/** + * A machine-scope install puts a plugin's skills and agents where OpenCode loads them for every + * project: its global config directory takes the same `skills/` and `agents/` a project's + * `.opencode/` does. Hooks, commands and MCP entries stay project-scoped. + */ +export function opencodeMachineFilePath(builtPath: string, plugin: MachinePlugin): string | null { + const relative = builtPath.startsWith(OPENCODE_DIRECTORY) + ? builtPath.slice(OPENCODE_DIRECTORY.length) + : null; + if (relative === null) return null; + if (relative.startsWith(`skills/${plugin.name}/`)) return relative; + const agent = /^agents\/(.+)\.md$/.exec(relative)?.[1]; + if (agent === undefined || !agent.startsWith(`${plugin.name}-`)) return null; + return plugin.agents.has(agent.slice(plugin.name.length + 1)) ? relative : null; +} + /** * The directory OpenCode's own plugin loader scans (`{plugin,plugins}/*.{ts,js}`, one level, no * `hooks` family). Not where a plugin's hook scripts land: only a script literally named diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts index 6805756d9..e17b5a22e 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts @@ -33,6 +33,7 @@ import { OPENCODE_FLAT_HOOKS_DIR, OPENCODE_HOOKS_DIR, OPENCODE_PLUGIN_ENTRY_BASENAME, + opencodeMachineFilePath, } from "./opencode-paths.js"; const DIRECTORY = OPENCODE_DIRECTORY; @@ -106,6 +107,10 @@ export const opencode: AiTool< plugins: new PluginsCapability({ mode: "flat", flatNamespacePrefix: "aidd-", + machineFiles: { + dir: (homedir) => join(homedir, ".config", "opencode"), + map: opencodeMachineFilePath, + }, acceptsHooks: true, flatHooksDir: OPENCODE_HOOKS_DIR, flatHooksLoaderEntry: { diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index 237e60676..aad2fb652 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -16,6 +16,7 @@ import { import type { ToolBuildContract } from "./build-contract.js"; import type { EnvironmentReader, + MachineFiles, NativeActivation, PluginsCapability, } from "./capabilities/plugins-capability.js"; @@ -118,13 +119,22 @@ export function pluginEnablementIsMachineGlobal(toolId: ToolId): boolean { return nativeActivationOf(toolId)?.scopeArgs === undefined; } -/** Whether `--scope user` has anywhere to point this tool: either it drives its own CLI - * machine-wide, or it installs a plugin's files into a user-scope directory. `false` for a - * tool declaring neither — opencode today, with no such directory and no CLI to drive. */ +/** Whether `--scope user` has anywhere to point this tool: it drives its own CLI machine-wide, + * installs a plugin's files into a user-scope directory, or copies machine files into the + * user's configuration directory. `false` for a tool declaring none — kilo today. */ export function supportsUserScopeActivation(toolId: ToolId): boolean { const capability = resolvePluginsCapability(toolId); if (capability === null) return false; - return capability.nativeActivation !== null || capability.installScope === "user"; + return ( + capability.nativeActivation !== null || + capability.installScope === "user" || + capability.machineFiles !== null + ); +} + +/** See {@link MachineFiles}: `null` for a tool whose machine-scope install copies no file. */ +export function machineFilesOf(toolId: ToolId): MachineFiles | null { + return resolvePluginsCapability(toolId)?.machineFiles ?? null; } /** The tool's declared build contract for one framework-build mode, or undefined when the tool diff --git a/cli/src/kernel/errors.ts b/cli/src/kernel/errors.ts index 9b95ed1f7..1b2461350 100644 --- a/cli/src/kernel/errors.ts +++ b/cli/src/kernel/errors.ts @@ -484,9 +484,9 @@ export class UserScopeUnavailableError extends Error { export class UserScopeIdeToolsError extends Error { constructor(ideTools: readonly string[]) { super( - `--scope user installs no project files, so an IDE tool (${ideTools.join(", ")}) has ` + - "nothing to install at user scope. Drop --ide, or run `aidd setup --ide ` " + - "separately at project scope." + `--global installs no project files, so an editor (${ideTools.join(", ")}) has ` + + "nothing to install machine-wide. Drop it from --ide, or run `aidd setup --ide ` " + + "separately in the project." ); this.name = "UserScopeIdeToolsError"; } @@ -495,9 +495,8 @@ export class UserScopeIdeToolsError extends Error { export class UserScopeUnsupportedAiToolsError extends Error { constructor(aiTools: readonly string[]) { super( - `--scope user drives native activation machine-wide, and ${aiTools.join(", ")} declares ` + - "no user-scope settings this CLI can point at. Drop it from --ai, or run " + - "`aidd setup --ai ` separately at project scope." + `--global installs through an IDE's own plugin CLI or its user configuration directory, and neither exists for: ${aiTools.join(", ")}. ` + + "Drop them from --ide, or run `aidd setup --ide ` in the project instead." ); this.name = "UserScopeUnsupportedAiToolsError"; } @@ -506,8 +505,8 @@ export class UserScopeUnsupportedAiToolsError extends Error { export class UserScopeNoToolsError extends Error { constructor() { super( - "--scope user with no --ai registers the shared source for no tool at all. Pass " + - "`--ai ` naming which tool to activate machine-wide." + "--global with no --ide registers the shared source for no tool at all. Pass " + + "`--ide ` naming which tool to activate machine-wide." ); this.name = "UserScopeNoToolsError"; } @@ -516,9 +515,8 @@ export class UserScopeNoToolsError extends Error { export class UserScopePluginModeError extends Error { constructor() { super( - "--scope user has no manifest entry a plugin can be recorded against yet, so " + - "--plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` " + - "separately at project scope." + "--global cannot ask which plugins to install. Pass --plugins all, recommended, " + + "none, or comma-separated names." ); this.name = "UserScopePluginModeError"; } diff --git a/cli/src/presentation/commands/clean.ts b/cli/src/presentation/commands/clean.ts index 081434994..91e608ad4 100644 --- a/cli/src/presentation/commands/clean.ts +++ b/cli/src/presentation/commands/clean.ts @@ -3,13 +3,14 @@ import { createDeps } from "../../runtime/wiring/framework.js"; import { printProjectCleanOutcome, printUserScopeCleanOutcome } from "../display/clean-display.js"; import { ErrorHandler } from "../error-handler.js"; import type { CLIOutput } from "../output.js"; -import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; +import { GLOBAL_FLAG_HELP, parseGlobalOptions, resolveScopeFlags } from "./global-options.js"; type Deps = Awaited>; interface CleanCmdOptions { force: boolean; scope?: string; + global?: boolean; } async function runProjectScopeClean( @@ -56,10 +57,11 @@ export function registerCleanCommand(program: Command): void { "project (default) cleans this project alone; user undoes the machine-wide " + "registration setup --scope user wrote and purges the shared source itself" ) + .option("--global", `${GLOBAL_FLAG_HELP}, and purge the shared source`) .action(async (cmdOptions: CleanCmdOptions) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); - const scope = parseScopeFlag(cmdOptions.scope, output) ?? "project"; + const scope = resolveScopeFlags(cmdOptions, output) ?? "project"; try { const deps = await createDeps(projectRoot, { verbose }, output); diff --git a/cli/src/presentation/commands/doctor.ts b/cli/src/presentation/commands/doctor.ts index de8aba506..71e001b45 100644 --- a/cli/src/presentation/commands/doctor.ts +++ b/cli/src/presentation/commands/doctor.ts @@ -16,7 +16,7 @@ import { } from "../display/doctor-display.js"; import { ErrorHandler } from "../error-handler.js"; import type { CLIOutput } from "../output.js"; -import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; +import { GLOBAL_FLAG_HELP, parseGlobalOptions, resolveScopeFlags } from "./global-options.js"; type Deps = Awaited>; @@ -153,6 +153,7 @@ interface DoctorCmdOptions { tool?: string; plugin?: string; scope?: string; + global?: boolean; } export function registerDoctorCommand(program: Command): void { @@ -168,12 +169,13 @@ export function registerDoctorCommand(program: Command): void { "project (default) checks this project's own manifest; user checks the " + "machine-wide manifest --scope user setup wrote" ) + .option("--global", GLOBAL_FLAG_HELP) .action(async (cmdOptions: DoctorCmdOptions) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); try { const deps = await createDeps(projectRoot, { verbose }, output); - const scope = parseScopeFlag(cmdOptions.scope, output) ?? "project"; + const scope = resolveScopeFlags(cmdOptions, output) ?? "project"; if (scope === "user") { await runUserScopeDoctor(deps, output, projectRoot, cmdOptions); } else if (cmdOptions.tool !== undefined) { diff --git a/cli/src/presentation/commands/global-options.ts b/cli/src/presentation/commands/global-options.ts index c5f31167e..efb2091fd 100644 --- a/cli/src/presentation/commands/global-options.ts +++ b/cli/src/presentation/commands/global-options.ts @@ -20,6 +20,16 @@ export function parseGlobalOptions(program: Command): GlobalOptions { /** Returns the flag as given, `undefined` included: each caller applies its own default * rather than this validation guessing one for all of them. */ +/** `--global` is the spelling a person types; `--scope user` stays for scripts. */ +export const GLOBAL_FLAG_HELP = "act on the machine-wide install `setup --global` wrote"; + +export function resolveScopeFlags( + options: { scope?: string; global?: boolean }, + output: CLIOutput +): MarketplaceScope | undefined { + return options.global === true ? "user" : parseScopeFlag(options.scope, output); +} + export function parseScopeFlag( raw: string | undefined, output: CLIOutput diff --git a/cli/src/presentation/commands/setup.ts b/cli/src/presentation/commands/setup.ts index b789ffe52..0cb4c53b8 100644 --- a/cli/src/presentation/commands/setup.ts +++ b/cli/src/presentation/commands/setup.ts @@ -1,11 +1,13 @@ import { resolve } from "node:path"; -import type { Command } from "commander"; +import { type Command, Option } from "commander"; import { MarketplaceSourceMode } from "../../contexts/distribution/domain/marketplace-source-mode.js"; import { SetupUseCase } from "../../contexts/framework/application/setup-use-case.js"; import { SetupFlow } from "../../contexts/framework/domain/setup-flow.js"; import { assertToolIdsMatchCategory, getAllRegisteredTools, + machineFilesOf, + nativeActivationOf, supportsUserScopeActivation, } from "../../contexts/tools/domain/registry.js"; import type { ToolId } from "../../kernel/tool.js"; @@ -32,6 +34,7 @@ interface SetupCmdOptions { yes?: boolean; defaultMarketplace?: boolean; scope?: string; + global?: boolean; } export function parseSourceFlag( @@ -58,22 +61,24 @@ export function expandAllKeyword(raw: string | undefined, all: readonly ToolId[] .filter(Boolean) as ToolId[]; } +/** `--ide` takes every tool, AI assistants and editors alike; `--ai`, its former spelling, + * still takes AI tools alone. Each id lands in the list its category decides. */ export function parseToolIds( cmdOptions: SetupCmdOptions, errorHandler: ErrorHandler ): { aiTools: ToolId[]; ideTools: ToolId[] } | null { const aiIds = expandAllKeyword(cmdOptions.ai, AI_TOOL_IDS); - const ideIds = expandAllKeyword(cmdOptions.ide, IDE_TOOL_IDS); + const ids = expandAllKeyword(cmdOptions.ide, [...AI_TOOL_IDS, ...IDE_TOOL_IDS]); try { if (aiIds.length > 0 && cmdOptions.ai?.trim() !== "all") assertToolIdsMatchCategory(aiIds, "ai"); - if (ideIds.length > 0 && cmdOptions.ide?.trim() !== "all") - assertToolIdsMatchCategory(ideIds, "ide"); } catch (e) { errorHandler.handle(e); return null; } - return { aiTools: aiIds, ideTools: ideIds }; + const isIde = (id: ToolId) => (IDE_TOOL_IDS as readonly string[]).includes(id); + const unique = [...new Set([...aiIds, ...ids])]; + return { aiTools: unique.filter((id) => !isIde(id)), ideTools: unique.filter(isIde) }; } type PluginsMode = "interactive" | "all" | "recommended" | "named" | "none"; @@ -94,6 +99,24 @@ export function parsePluginsFlag( return { mode: "named", names }; } +/** What `--global` does for each registered tool, read from its profile. */ +export function globalSetupHelp(): string { + const tools = [...getAllRegisteredTools().keys()].filter(supportsUserScopeActivation).sort(); + const native = tools.filter((id) => nativeActivationOf(id) !== undefined); + const copying = tools.filter((id) => machineFilesOf(id) !== null); + const recorded = tools.filter((id) => !native.includes(id) && !copying.includes(id)); + const where = (id: ToolId) => machineFilesOf(id)?.dir("~").replaceAll("\\", "/"); + return [ + "", + "Global:", + ` Pass --ide ${tools.join(",")} (one or more, no editor) and --plugins all, recommended, none or names.`, + ` ${native.join(", ")} enable the plugins through their own CLI, which must be on PATH.`, + ` ${copying.map((id) => `${id} gets their files in ${where(id)}`).join(", ")}.`, + ` ${recorded.join(", ")} only records the framework source.`, + " `aidd sync --global` refreshes them from the framework source.", + ].join("\n"); +} + export function registerSetupCommand(program: Command): void { program .command("setup") @@ -103,8 +126,8 @@ export function registerSetupCommand(program: Command): void { .option("--source ", "Framework source: remote or local") .option("--path ", "Absolute path to local framework (required with --source local)") .option("--release ", "Marketplace release tag to fetch (e.g., v1.2.3)") - .option("--ai ", "Comma-separated AI tool IDs, or 'all' (e.g., claude,cursor or all)") - .option("--ide ", "Comma-separated IDE tool IDs, or 'all' (e.g., vscode or all)") + .option("--ide ", "Comma-separated tool IDs, or 'all' (e.g., claude,vscode or all)") + .addOption(new Option("--ai ", "Former spelling of --ide, for AI tools").hideHelp()) .option( "--plugins ", "Plugin install mode: none | all | recommended | comma-separated names" @@ -115,25 +138,18 @@ export function registerSetupCommand(program: Command): void { ) .option("--yes", "Accept defaults without prompting") .option( - "--scope ", - "project (default) installs into this project alone; user registers the shared " + - "framework source and supported tools machine-wide, writing nothing under this project; " + - "see user-scope requirements below" - ) - .addHelpText( - "after", - () => - "\nUser scope:\n" + - ` Pass --ai ${[...getAllRegisteredTools().keys()].filter(supportsUserScopeActivation).sort().join(",")} (choose one or more), --plugins none, and no --ide.\n` + - " Native activation requires the corresponding host CLI on PATH.\n" + - " Tools without native activation are registered only; setup installs no plugins or tool configuration files." + "--global", + "install for every project on this machine instead of into this project; see the " + + "requirements below" ) + .addOption(new Option("--scope ", "Former spelling: user means --global").hideHelp()) + .addHelpText("after", globalSetupHelp) .action(async (cmdOptions: SetupCmdOptions) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); const source = parseSourceFlag(cmdOptions, output); - const scope = parseScopeFlag(cmdOptions.scope, output); + const scope = cmdOptions.global === true ? "user" : parseScopeFlag(cmdOptions.scope, output); const toolIds = parseToolIds(cmdOptions, errorHandler); if (toolIds === null) return; @@ -143,7 +159,8 @@ export function registerSetupCommand(program: Command): void { cmdOptions.ai || cmdOptions.ide || cmdOptions.plugins || - cmdOptions.yes + cmdOptions.yes || + cmdOptions.global ); const interactive = process.stdout.isTTY && !hasScriptingFlags; diff --git a/cli/src/presentation/commands/sync.ts b/cli/src/presentation/commands/sync.ts index fd7faf47b..8535b7986 100644 --- a/cli/src/presentation/commands/sync.ts +++ b/cli/src/presentation/commands/sync.ts @@ -4,7 +4,7 @@ import { SyncFailedError, UserScopeFilterUnsupportedError, } from "../../kernel/errors.js"; -import type { ToolId } from "../../kernel/tool.js"; +import type { AiToolId, ToolId } from "../../kernel/tool.js"; import { createDeps } from "../../runtime/wiring/framework.js"; import { printActivationOutcome, @@ -14,17 +14,19 @@ import { } from "../display/sync-display.js"; import { ErrorHandler } from "../error-handler.js"; import type { CLIOutput } from "../output.js"; -import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; +import { GLOBAL_FLAG_HELP, parseGlobalOptions, resolveScopeFlags } from "./global-options.js"; interface SyncCmdOptions { force: boolean; tool?: string; plugin?: string; scope?: string; + global?: boolean; } -/** A user-scope install writes nothing under any project, so this skips `restoreAllUseCase` - * and drives only native activation, through `deps.userManifestRepo` and never this +/** A user-scope install writes nothing under any project, so this skips `restoreAllUseCase`: + * it rebuilds the machine files aidd copied into each tool's user directory from the shared + * source, then drives native activation, through `deps.userManifestRepo` and never this * project's own manifest. */ async function runUserScopeSync( deps: Awaited>, @@ -42,6 +44,19 @@ async function runUserScopeSync( throw new UserScopeFilterUnsupportedError("a file argument", "sync "); } const toolIds = cmdOptions.tool !== undefined ? [cmdOptions.tool as ToolId] : undefined; + const machine = await deps.userManifestRepo.load(); + const withFiles = (toolIds ?? machine?.getInstalledToolIds() ?? []).filter((toolId) => + machine?.getPlugins(toolId as AiToolId).some((plugin) => plugin.scope === "user") + ) as AiToolId[]; + const refreshed = + withFiles.length === 0 + ? [] + : await deps.userPluginUpdateUseCase.execute({ + toolIds: withFiles, + projectRoot, + scope: "user", + rebuild: true, + }); const activation = await deps.marketplaceSyncSettingsUseCase.execute({ projectRoot, scope: "user", @@ -51,7 +66,7 @@ async function runUserScopeSync( }); printActivationOutcome(output, activation); if (activation.errors.length > 0) throw new SyncFailedError(activation.errors); - printUserScopeSyncOutcome(output, activation.activated); + printUserScopeSyncOutcome(output, activation.activated, [...new Set(refreshed)]); } async function runSyncAction( @@ -63,7 +78,7 @@ async function runSyncAction( const errorHandler = new ErrorHandler(output); try { const deps = await createDeps(projectRoot, { verbose }, output); - const scope = parseScopeFlag(cmdOptions.scope, output) ?? "project"; + const scope = resolveScopeFlags(cmdOptions, output) ?? "project"; if (scope === "user") { await runUserScopeSync(deps, output, projectRoot, fileArgs, cmdOptions); @@ -145,6 +160,7 @@ export function registerSyncCommand(program: Command): void { "project (default) resolves this project's own manifest; user resolves the " + "machine-wide manifest --scope user setup wrote, restoring no project files" ) + .option("--global", `${GLOBAL_FLAG_HELP}, rebuilding its files from the framework source`) .action(async (fileArgs: string[], cmdOptions: SyncCmdOptions) => { await runSyncAction(program, fileArgs, cmdOptions); }); diff --git a/cli/src/presentation/display/setup-display.ts b/cli/src/presentation/display/setup-display.ts index d6889f5f2..5b9e03ce2 100644 --- a/cli/src/presentation/display/setup-display.ts +++ b/cli/src/presentation/display/setup-display.ts @@ -11,6 +11,7 @@ interface ToolInstallOutcome { interface SetupOutcome { readonly kind: "initialized" | "up-to-date"; readonly install: { readonly results: readonly ToolInstallOutcome[] }; + readonly machinePlugins?: readonly string[]; } function displayInstall( @@ -37,6 +38,14 @@ function displayInstall( } export function printSetupOutcome(output: CLIOutput, result: SetupOutcome, verbose: boolean): void { + if (result.machinePlugins !== undefined) { + output.success( + result.machinePlugins.length === 0 + ? "Registered for every project on this machine." + : `Installed for every project on this machine: ${result.machinePlugins.join(", ")}.` + ); + return; + } switch (result.kind) { case "initialized": { output.success("Project initialized."); diff --git a/cli/src/presentation/display/sync-display.ts b/cli/src/presentation/display/sync-display.ts index 75964f28a..e6d423f8d 100644 --- a/cli/src/presentation/display/sync-display.ts +++ b/cli/src/presentation/display/sync-display.ts @@ -67,10 +67,15 @@ export function printActivationOutcome(output: CLIOutput, activation: Activation for (const e of activation.errors) output.warn(`[${e.scope}] ${e.message}`); } -export function printUserScopeSyncOutcome(output: CLIOutput, activated: readonly string[]): void { - if (activated.length === 0) { - output.success("Nothing to sync — no tool is registered at user scope yet."); - } else { +export function printUserScopeSyncOutcome( + output: CLIOutput, + activated: readonly string[], + refreshed: readonly string[] = [] +): void { + if (refreshed.length > 0) output.success(`Refreshed machine files for: ${refreshed.join(", ")}`); + if (activated.length > 0) { output.success(`Synced native activation for: ${activated.join(", ")}`); + } else if (refreshed.length === 0) { + output.success("Nothing to sync — no tool is registered at user scope yet."); } } diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index cd97898a1..d72f7569c 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -52,6 +52,7 @@ import { PluginSearchUseCase } from "../../contexts/framework/application/plugin import { PluginUpdateUseCase } from "../../contexts/framework/application/plugin/plugin-update-use-case.js"; import { ValidateRecipesUseCase } from "../../contexts/framework/application/recipes/validate-recipes-use-case.js"; import { RestoreUseCase } from "../../contexts/framework/application/restore/restore-use-case.js"; +import { InstallMachinePluginsUseCase } from "../../contexts/framework/application/setup/install-machine-plugins-use-case.js"; import { ProjectContextDetectorUseCase } from "../../contexts/framework/application/setup/project-context-detector-use-case.js"; import { SetupMachineScopeUseCase } from "../../contexts/framework/application/setup/setup-machine-scope-use-case.js"; import { SetupMarketplaceSourceUseCase } from "../../contexts/framework/application/setup/setup-marketplace-source-use-case.js"; @@ -486,13 +487,6 @@ export async function createDeps( releaseResolver, userSourceReferences ); - const setupMachineScopeUseCase = new SetupMachineScopeUseCase( - userManifestRepo, - setupMarketplaceRegistration, - marketplaceSyncSettingsUseCase, - currentVersionProvider - ); - const statusUseCase = new StatusUseCase(fs, manifestRepo, hasher, detectPluginDriftUseCase); // Restore re-materializes through the build pipeline, matching what install wrote: // rewriting raw content instead would itself be drift. const builtMaterializationDeps = { @@ -500,6 +494,26 @@ export async function createDeps( marketplaceRegistry, homedir, }; + // One writer for a machine-scope plugin's files, whether setup installs it or update rebuilds it. + const userPluginFileUpdater = new UserPluginFileUpdater( + fs, + new PluginDistributionLoader(pluginFetcher, pluginDistributionReader), + hasher, + builtMaterializationDeps + ); + const setupMachineScopeUseCase = new SetupMachineScopeUseCase( + userManifestRepo, + setupMarketplaceRegistration, + marketplaceSyncSettingsUseCase, + currentVersionProvider, + new InstallMachinePluginsUseCase( + marketplaceRegistry, + resolveMarketplaceUseCase, + userPluginFileUpdater, + logger + ) + ); + const statusUseCase = new StatusUseCase(fs, manifestRepo, hasher, detectPluginDriftUseCase); const pluginUpdateUseCase = new PluginUpdateUseCase( fs, manifestRepo, @@ -510,12 +524,7 @@ export async function createDeps( ); const userPluginUpdateUseCase = new UserPluginUpdateUseCase( userManifestRepo, - new UserPluginFileUpdater( - fs, - new PluginDistributionLoader(pluginFetcher, pluginDistributionReader), - hasher, - builtMaterializationDeps - ), + userPluginFileUpdater, logger, nativeHostRegistrationGate ); diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index 783d3405e..c465733b7 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -20,6 +20,13 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] // `setup` brings a project from nothing to correct, so it names each stage: registration, // tool install, plugin prompt, settings sync, version, plus the machine-scope handoff. { path: "src/contexts/framework/application/setup-use-case.ts", injected: 11 }, + // Its machine-scope counterpart names the same stages for the machine: the machine manifest, + // source registration, the plugins' machine files, native activation, and the version each + // registered tool records. + { + path: "src/contexts/framework/application/setup/setup-machine-scope-use-case.ts", + injected: 5, + }, // Five section generators reached inline through a switch on section name, plus the config // generator. It resolves by registering a generator per section type, not by dropping one. { diff --git a/cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts new file mode 100644 index 000000000..34b51f4f2 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts @@ -0,0 +1,159 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { + assertMachineFilesWritable, + readMachinePluginFiles, +} from "../../../../../src/contexts/framework/application/ownership/machine-plugin-files.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const BUILT = "/cache/built/opencode"; + +function distribution(name: string, agents: string[]): PluginDistribution { + const files = agents.map((relativePath) => ({ relativePath, content: "agent" })); + return new PluginDistribution({ + manifest: { name, version: "1.0.0", strict: true }, + format: "claude", + files, + components: { skills: [], commands: [], agents: files, rules: [], hooks: [], mcp: [] }, + }); +} + +const tree = new InMemoryFileAdapter({ + [`${BUILT}/.opencode/skills/aidd-dev/01-plan/SKILL.md`]: "skill", + [`${BUILT}/.opencode/agents/aidd-dev-reviewer.md`]: "reviewer", + [`${BUILT}/.opencode/agents/aidd-dev-tools-reviewer.md`]: "another plugin's", + [`${BUILT}/.opencode/agents/aidd-pm-reviewer.md`]: "another plugin's", + [`${BUILT}/.opencode/commands/aidd-dev-plan.md`]: "command", +}); + +describe("readMachinePluginFiles", () => { + it("reads the plugin's skills and the agents its source ships, keyed under the user directory", async () => { + const files = await readMachinePluginFiles( + tree, + new DeterministicHasher(), + BUILT, + "opencode", + distribution("aidd-dev", ["agents/reviewer.md", "agents/nested/ignored.md", "skills/x.md"]) + ); + + expect(files.map((file) => [file.relativePath, file.content])).toEqual([ + ["agents/aidd-dev-reviewer.md", "reviewer"], + ["skills/aidd-dev/01-plan/SKILL.md", "skill"], + ]); + }); + + it("reads nothing for a plugin whose source ships no agent and no skill in the tree", async () => { + const files = await readMachinePluginFiles( + tree, + new DeterministicHasher(), + BUILT, + "opencode", + distribution("aidd-qa", []) + ); + + expect(files).toEqual([]); + }); + + it("reads nothing for a tool that copies no machine file, which has no directory to write either", async () => { + await expect( + assertMachineFilesWritable( + new InMemoryFileAdapter(), + "claude", + undefined, + "aidd-dev", + [], + "/h" + ) + ).rejects.toThrow("claude: no machine-scope directory is declared."); + }); + + it("reads nothing for a tool that copies no machine file", async () => { + const files = await readMachinePluginFiles( + tree, + new DeterministicHasher(), + BUILT, + "claude", + distribution("aidd-dev", ["agents/reviewer.md"]) + ); + + expect(files).toEqual([]); + }); +}); + +describe("assertMachineFilesWritable", () => { + const hasher = new DeterministicHasher(); + const HOME = "/home/me"; + const DIR = join(HOME, ".config", "opencode"); + const under = (...parts: string[]) => join(DIR, ...parts); + const file = (relativePath: string, content = "new") => + new InstallationFile({ relativePath, content, hash: hasher.hash(content) }); + + it("returns the user directory when every path stays inside it", async () => { + const fs = new InMemoryFileAdapter({}, hasher); + + await expect( + assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + ).resolves.toBe(DIR); + }); + + it.each(["/etc/passwd", "agents/../../escape.md", "agents\\a.md"])( + "refuses the path %s that is not a plain relative path", + async (path) => { + const fs = new InMemoryFileAdapter({}, hasher); + + await expect( + assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file(path)], HOME) + ).rejects.toThrow(`opencode: 'aidd-dev' path '${path}' escapes ${DIR}.`); + } + ); + + it("refuses a parent that resolves outside the user directory", async () => { + const fs = new InMemoryFileAdapter({ [under("agents", "kept.md")]: "x" }, hasher); + fs.setSymlink(under("agents"), "/elsewhere"); + + await expect( + assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + ).rejects.toThrow(`opencode: 'aidd-dev' parent '${under("agents")}' escapes ${DIR}.`); + }); + + it("refuses to overwrite a file it does not own that holds other bytes", async () => { + const fs = new InMemoryFileAdapter({ [under("agents", "a.md")]: "mine" }, hasher); + + await expect( + assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + ).rejects.toThrow( + `opencode: '${under("agents", "a.md")}' already exists and was not installed by aidd` + ); + }); + + it("adopts a file already holding the same bytes, and rewrites a file the plugin owns", async () => { + const fs = new InMemoryFileAdapter( + { [under("agents", "same.md")]: "new", [under("agents", "owned.md")]: "old" }, + hasher + ); + const owned = InstalledPlugin.fromMetadata( + "aidd-dev", + "1.0.0", + { kind: "local", path: "/fw/plugins/aidd-dev" }, + true, + "user" + ).withFiles(new Map([["agents/owned.md", "0".repeat(32)]])); + + await expect( + assertMachineFilesWritable( + fs, + "opencode", + owned, + "aidd-dev", + [file("agents/same.md"), file("agents/owned.md")], + HOME + ) + ).resolves.toBe(DIR); + }); +}); diff --git a/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts index 6291436ab..4ad72eea2 100644 --- a/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts @@ -582,6 +582,7 @@ describe("setup --scope user", () => { install: { results: [] }, activation: { updatedTools: [] }, context: undefined, + machinePlugins: [], }); }); @@ -598,6 +599,7 @@ describe("setup --scope user", () => { install: { results: [] }, activation: { updatedTools: [] }, context: undefined, + machinePlugins: [], }); }); diff --git a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts new file mode 100644 index 000000000..fd54b2fb3 --- /dev/null +++ b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import type { PluginCatalogEntry } from "../../../../../src/contexts/distribution/domain/catalog.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import type { PlannedUserPluginFileUpdate } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { InstallMachinePluginsUseCase } from "../../../../../src/contexts/framework/application/setup/install-machine-plugins-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { PluginInstallMode } from "../../../../../src/contexts/framework/domain/setup-flow.js"; +import { FileHash, InstallationFile } from "../../../../../src/kernel/file.js"; +import type { AiToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; + +const FRAMEWORK = Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/fw" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", +}); + +const CATALOG: PluginCatalogEntry[] = [ + { + name: "aidd-dev", + source: { kind: "local", path: "./plugins/aidd-dev" }, + recommended: true, + strict: true, + }, + { + name: "aidd-pm", + source: { kind: "local", path: "./plugins/aidd-pm" }, + recommended: false, + strict: true, + }, +]; + +const HASH = "0".repeat(32); + +function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addTool("claude", "1.0.0", []); + const planUpdate = vi.fn( + async (plugin: InstalledPlugin, toolId: AiToolId): Promise => { + const relativePath = `agents/${plugin.name}-reviewer.md`; + return { + plugin, + toolId, + home: "/home", + baseDir: "/home/.config/opencode", + files: [new InstallationFile({ relativePath, content: "", hash: new FileHash(HASH) })], + oldSafe: new Map(), + next: plugin.withFiles(new Map([[relativePath, HASH]])), + }; + } + ); + const useCase = new InstallMachinePluginsUseCase( + { list: async () => marketplaces }, + { + execute: async () => ({ + marketplace: FRAMEWORK, + catalog: { plugins: CATALOG }, + localPath: "/fw", + }), + }, + { planUpdate, applyUpdate: async (plan) => plan.next }, + new CapturingLogger() + ); + return { manifest, useCase, planUpdate }; +} + +function run( + useCase: InstallMachinePluginsUseCase, + manifest: Manifest, + mode: PluginInstallMode, + pluginNames: string[] = [] +) { + return useCase.execute(manifest, { + projectRoot: "/project", + toolIds: ["claude", "opencode"], + mode, + pluginNames, + }); +} + +describe("InstallMachinePluginsUseCase", () => { + it("records each chosen plugin's machine files for a tool that copies them, and names every one", async () => { + const { manifest, useCase, planUpdate } = setup(); + + const result = await run(useCase, manifest, "all"); + + expect(result.plugins).toEqual(["aidd-dev", "aidd-pm"]); + expect( + manifest.getPlugins("opencode").map((p) => [p.name, p.scope, [...p.files.keys()]]) + ).toEqual([ + ["aidd-dev", "user", ["agents/aidd-dev-reviewer.md"]], + ["aidd-pm", "user", ["agents/aidd-pm-reviewer.md"]], + ]); + expect(manifest.getPlugins("claude")).toEqual([]); + expect(planUpdate).toHaveBeenCalledTimes(2); + }); + + it("keeps the recommended plugins alone", async () => { + const { manifest, useCase } = setup(); + + expect((await run(useCase, manifest, "recommended")).plugins).toEqual(["aidd-dev"]); + }); + + it("refuses a named plugin the catalog does not carry", async () => { + const { manifest, useCase } = setup(); + + await expect(run(useCase, manifest, "named", ["aidd-nope"])).rejects.toThrow( + "Not in the framework catalog: aidd-nope." + ); + }); + + it("installs nothing for --plugins none", async () => { + const { manifest, useCase, planUpdate } = setup(); + + expect((await run(useCase, manifest, "none")).plugins).toEqual([]); + expect(planUpdate).not.toHaveBeenCalled(); + }); + + it("refuses when no shared framework source is registered", async () => { + const { manifest, useCase } = setup([]); + + await expect(run(useCase, manifest, "all")).rejects.toThrow("none is registered"); + }); +}); diff --git a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts index 44b4ca86a..49d05a0a3 100644 --- a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts +++ b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts @@ -4,6 +4,7 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { @@ -120,12 +121,16 @@ describe("SetupFlow", () => { expect(() => makeFlow({ scope: "user" })).toThrow(UserScopeNoToolsError); }); - it("refuses an AI tool with no user-scope activation at --scope user (opencode)", () => { - expect(() => makeFlow({ scope: "user", aiTools: ["opencode"] })).toThrow( + it("refuses an AI tool with no user-scope activation at --scope user (kilo)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).toThrow( UserScopeUnsupportedAiToolsError ); }); + it("accepts an AI tool that copies machine files at --scope user (opencode)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["opencode"] })).not.toThrow(); + }); + it("accepts an AI tool that installs to a user-scope directory at --scope user (cursor)", () => { expect(() => makeFlow({ scope: "user", aiTools: ["cursor"] })).not.toThrow(); }); @@ -134,10 +139,17 @@ describe("SetupFlow", () => { expect(() => makeFlow({ scope: "user", aiTools: ["claude"] })).not.toThrow(); }); - it("refuses --plugins at --scope user — no manifest entry exists yet to enable one against", () => { - expect(() => makeFlow({ scope: "user", aiTools: ["claude"], pluginMode: "all" })).toThrow( - UserScopePluginModeError - ); + it("refuses an interactive plugin pick at --scope user — there is no project to ask in", () => { + expect(() => + makeFlow({ scope: "user", aiTools: ["claude"], pluginMode: "interactive" }) + ).toThrow(UserScopePluginModeError); }); + + it.each(["all", "recommended", "none"] as const)( + "accepts --plugins %s at --scope user", + (pluginMode) => { + expect(() => makeFlow({ scope: "user", aiTools: ["opencode"], pluginMode })).not.toThrow(); + } + ); }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts new file mode 100644 index 000000000..d3b4e9741 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts @@ -0,0 +1,30 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { codexMachineFilePath } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-paths.js"; +import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; + +const ORCHESTRATOR = { name: "aidd-orchestrator", agents: new Set() }; + +describe("codexMachineFilePath — the agents a machine-scope install copies into ~/.codex", () => { + it("prefixes a staged agent with its plugin under agents/", () => { + expect( + codexMachineFilePath("plugins/aidd-orchestrator/codex-agents/reviewer.toml", ORCHESTRATOR) + ).toBe("agents/aidd-orchestrator-reviewer.toml"); + }); + + it.each([ + "plugins/aidd-dev/codex-agents/reviewer.toml", + "plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md", + "plugins/aidd-orchestrator/codex-agents/nested/reviewer.toml", + "plugins/aidd-orchestrator/codex-agents/reviewer.md", + ])("leaves %s out: another plugin's, or not a staged agent", (built) => { + expect(codexMachineFilePath(built, ORCHESTRATOR)).toBeNull(); + }); +}); + +describe("the codex profile's machine-scope directory", () => { + it("is ~/.codex under the home directory", () => { + expect(machineFilesOf("codex")?.dir("/home/me")).toBe(join("/home/me", ".codex")); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts new file mode 100644 index 000000000..fb103047d --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts @@ -0,0 +1,42 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { opencodeMachineFilePath } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-paths.js"; +import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; + +const AIDD_DEV = { name: "aidd-dev", agents: new Set(["reviewer"]) }; + +describe("opencodeMachineFilePath — what a machine-scope install copies into OpenCode's config dir", () => { + it.each([ + [".opencode/skills/aidd-dev/01-plan/SKILL.md", "skills/aidd-dev/01-plan/SKILL.md"], + [ + ".opencode/skills/aidd-dev/01-plan/actions/01-read.md", + "skills/aidd-dev/01-plan/actions/01-read.md", + ], + [".opencode/agents/aidd-dev-reviewer.md", "agents/aidd-dev-reviewer.md"], + ])("keeps the plugin's own %s as %s", (built, machine) => { + expect(opencodeMachineFilePath(built, AIDD_DEV)).toBe(machine); + }); + + it.each([ + ".opencode/skills/aidd-pm/01-epic/SKILL.md", + ".opencode/agents/aidd-pm-reviewer.md", + ".opencode/commands/aidd-dev-plan.md", + ".opencode/plugin/aidd-dev-hooks.js", + ".opencode/agents/aidd-dev-reviewer.txt", + ".opencode/agents/aidd-dev-executor.md", + ".opencode/agents/aidd-dev-tools-reviewer.md", + "skills/aidd-dev/01-plan/SKILL.md", + ])( + "leaves %s out: another plugin's, an agent it does not ship, or not loaded machine-wide", + (built) => { + expect(opencodeMachineFilePath(built, AIDD_DEV)).toBeNull(); + } + ); +}); + +describe("the opencode profile's machine-scope directory", () => { + it("is ~/.config/opencode under the home directory", () => { + expect(machineFilesOf("opencode")?.dir("/home/me")).toBe(join("/home/me", ".config/opencode")); + }); +}); diff --git a/cli/tests/e2e/command-matrix-help.e2e.test.ts b/cli/tests/e2e/command-matrix-help.e2e.test.ts index 8b0fb18a2..96d36324d 100644 --- a/cli/tests/e2e/command-matrix-help.e2e.test.ts +++ b/cli/tests/e2e/command-matrix-help.e2e.test.ts @@ -102,20 +102,21 @@ describe.concurrent("Command Matrix: Help", () => { } }); - it("aidd setup --help shows simplified flag surface (6 flags + --no-default-marketplace)", async () => { + it("aidd setup --help shows simplified flag surface, with --ai kept out of it", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("help-setup"); try { const { stdout, exitCode } = await runCli(["setup", "--help"], projectDir, fakeHome); expect(exitCode).toBe(0); expect(stdout).toContain("--source"); - expect(stdout).toContain("--ai"); + expect(stdout).not.toContain("--ai "); expect(stdout).toContain("--ide"); + expect(stdout).toContain("--global"); expect(stdout).toContain("--plugins"); expect(stdout).toContain("--release"); expect(stdout).toContain("--yes"); expect(stdout).not.toContain("--all-plugins"); expect(stdout).not.toContain("--recommended-plugins"); - // --all dropped (use --ai all --ide all) + // --all dropped (use --ide all) expect(stdout).not.toMatch(/--all\b(?! options)/); expect(stdout).not.toContain("--from"); expect(stdout).not.toContain("--switch-mode"); diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index a246cc633..ecd0fef9f 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; @@ -25,29 +25,54 @@ async function readJson(path: string): Promise> { } describe("E2E: setup --scope user writes nothing under the project", () => { - it.each(["all", "recommended", "aidd-dev"])( - "prints a clean refusal for user-scope plugin mode %s", - async (plugins) => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-plugins"); - try { - const result = await runCli( - ["setup", "--scope", "user", "--ai", "claude", "--plugins", plugins, "--yes"], - projectDir, - fakeHome - ); - - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "Error: --scope user has no manifest entry a plugin can be recorded against yet, so " + - "--plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` " + - "separately at project scope.\n" - ); - } finally { - await cleanup(); - } + it("installs OpenCode's skills and agents for the machine, refreshes them, and cleans them", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-opencode"); + try { + await gitInit(projectDir); + await execFileAsync("git", [...GIT_TEST_IDENTITY, "commit", "--allow-empty", "-m", "empty"], { + cwd: projectDir, + env: environmentWithoutGitVariables(process.env), + }); + const opencode = join(fakeHome, ".config", "opencode"); + + const setup = await runCli( + [ + "setup", + "--global", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ide", + "opencode", + "--plugins", + "aidd-dev", + "--yes", + ], + projectDir, + fakeHome + ); + expect(setup.stderr).toBe(""); + expect(setup.exitCode).toBe(0); + expect(setup.stdout).toContain("Installed for every project on this machine: aidd-dev."); + expect(await gitStatusPorcelain(projectDir)).toBe(""); + const agent = join(opencode, "agents", "aidd-dev-reviewer.md"); + expect(await readFile(agent, "utf-8")).toMatch(/^mode: '?subagent'?$/m); + const skills = await readdir(join(opencode, "skills", "aidd-dev")); + expect(skills.length).toBeGreaterThan(0); + + const sync = await runCli(["sync", "--global"], projectDir, fakeHome); + expect(sync.exitCode).toBe(0); + expect(sync.stdout).toContain("Refreshed machine files for: aidd-dev"); + + const clean = await runCli(["clean", "--global", "--force"], projectDir, fakeHome); + expect(clean.exitCode).toBe(0); + await expect(readFile(agent, "utf-8")).rejects.toThrow(); + expect(await gitStatusPorcelain(projectDir)).toBe(""); + } finally { + await cleanup(); } - ); + }); it("explains user-scope restrictions in setup help", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-help"); @@ -56,10 +81,11 @@ describe("E2E: setup --scope user writes nothing under the project", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("--ai claude,codex,copilot,cursor"); - expect(result.stdout).toContain("--plugins none"); - expect(result.stdout).toContain("no --ide"); - expect(result.stdout).toContain("host CLI on PATH"); + expect(result.stdout).toContain("--ide claude,codex,copilot,cursor,opencode"); + expect(result.stdout).toContain("--plugins all, recommended, none or names"); + expect(result.stdout).toContain("no editor"); + expect(result.stdout).toContain("must be on PATH"); + expect(result.stdout).toContain("opencode gets their files in ~/.config/opencode"); } finally { await cleanup(); } diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index d34a66563..2663670ff 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -27,12 +27,12 @@ { "invocation": "aidd clean", "exitCode": 0, - "help": "Usage: aidd clean [options]\n\nRemove all AIDD-managed files from the project — retires every part of AIDD; see\n`framework remove`, which removes the framework only\n\nOptions:\n --force Confirm file removal (skip dry-run) (default: false)\n --scope project (default) cleans this project alone; user undoes the\n machine-wide registration setup --scope user wrote and purges\n the shared source itself\n -h, --help display help for command" + "help": "Usage: aidd clean [options]\n\nRemove all AIDD-managed files from the project — retires every part of AIDD; see\n`framework remove`, which removes the framework only\n\nOptions:\n --force Confirm file removal (skip dry-run) (default: false)\n --scope project (default) cleans this project alone; user undoes the\n machine-wide registration setup --scope user wrote and purges\n the shared source itself\n --global act on the machine-wide install `setup --global` wrote, and\n purge the shared source\n -h, --help display help for command" }, { "invocation": "aidd doctor", "exitCode": 0, - "help": "Usage: aidd doctor [options]\n\nDetected and equipped tools, plugins, drift, and problems — across all tools or\none\n\nOptions:\n --tool Limit to a specific AI or IDE tool\n --plugin Limit plugin checks to a specific plugin\n --scope project (default) checks this project's own manifest; user\n checks the machine-wide manifest --scope user setup wrote\n -h, --help display help for command" + "help": "Usage: aidd doctor [options]\n\nDetected and equipped tools, plugins, drift, and problems — across all tools or\none\n\nOptions:\n --tool Limit to a specific AI or IDE tool\n --plugin Limit plugin checks to a specific plugin\n --scope project (default) checks this project's own manifest; user\n checks the machine-wide manifest --scope user setup wrote\n --global act on the machine-wide install `setup --global` wrote\n -h, --help display help for command" }, { "invocation": "aidd framework", @@ -127,12 +127,12 @@ { "invocation": "aidd setup", "exitCode": 0, - "help": "Usage: aidd setup [options]\n\nSet up or update the project to a correct state — bootstraps the whole project\n(marketplace, framework, tools, plugins); see `framework install`, which acts on\nthe framework alone\n\nOptions:\n --source Framework source: remote or local\n --path Absolute path to local framework (required with\n --source local)\n --release Marketplace release tag to fetch (e.g., v1.2.3)\n --ai Comma-separated AI tool IDs, or 'all' (e.g.,\n claude,cursor or all)\n --ide Comma-separated IDE tool IDs, or 'all' (e.g., vscode\n or all)\n --plugins Plugin install mode: none | all | recommended |\n comma-separated names\n --no-default-marketplace Skip auto-registering aidd-framework (no source\n prompt, no plugin install)\n --yes Accept defaults without prompting\n --scope project (default) installs into this project alone;\n user registers the shared framework source and\n supported tools machine-wide, writing nothing under\n this project; see user-scope requirements below\n -h, --help display help for command\n\nUser scope:\n Pass --ai claude,codex,copilot,cursor (choose one or more), --plugins none, and no --ide.\n Native activation requires the corresponding host CLI on PATH.\n Tools without native activation are registered only; setup installs no plugins or tool configuration files." + "help": "Usage: aidd setup [options]\n\nSet up or update the project to a correct state — bootstraps the whole project\n(marketplace, framework, tools, plugins); see `framework install`, which acts on\nthe framework alone\n\nOptions:\n --source Framework source: remote or local\n --path Absolute path to local framework (required with\n --source local)\n --release Marketplace release tag to fetch (e.g., v1.2.3)\n --ide Comma-separated tool IDs, or 'all' (e.g.,\n claude,vscode or all)\n --plugins Plugin install mode: none | all | recommended |\n comma-separated names\n --no-default-marketplace Skip auto-registering aidd-framework (no source\n prompt, no plugin install)\n --yes Accept defaults without prompting\n --global install for every project on this machine instead of\n into this project; see the requirements below\n -h, --help display help for command\n\nGlobal:\n Pass --ide claude,codex,copilot,cursor,opencode (one or more, no editor) and --plugins all, recommended, none or names.\n claude, codex, copilot enable the plugins through their own CLI, which must be on PATH.\n codex gets their files in ~/.codex, opencode gets their files in ~/.config/opencode.\n cursor only records the framework source.\n `aidd sync --global` refreshes them from the framework source." }, { "invocation": "aidd sync", "exitCode": 0, - "help": "Usage: aidd sync [options] [files...]\n\nRewrite owned files from what is already there — regenerate tracked files,\ndriven by the manifest (see `translate`, which converts a source without\nrecording anything)\n\nArguments:\n files Limit sync to specific tracked files\n\nOptions:\n -f, --force Sync without prompting (default: false)\n --tool Limit sync to a specific tool\n --plugin Limit sync to a specific plugin\n --scope project (default) resolves this project's own manifest; user\n resolves the machine-wide manifest --scope user setup wrote,\n restoring no project files\n -h, --help display help for command" + "help": "Usage: aidd sync [options] [files...]\n\nRewrite owned files from what is already there — regenerate tracked files,\ndriven by the manifest (see `translate`, which converts a source without\nrecording anything)\n\nArguments:\n files Limit sync to specific tracked files\n\nOptions:\n -f, --force Sync without prompting (default: false)\n --tool Limit sync to a specific tool\n --plugin Limit sync to a specific plugin\n --scope project (default) resolves this project's own manifest; user\n resolves the machine-wide manifest --scope user setup wrote,\n restoring no project files\n --global act on the machine-wide install `setup --global` wrote,\n rebuilding its files from the framework source\n -h, --help display help for command" }, { "invocation": "aidd telemetry", diff --git a/cli/tests/golden/snapshots/phase0/snapshot.json b/cli/tests/golden/snapshots/phase0/snapshot.json index a82f58127..cb5e06bef 100644 --- a/cli/tests/golden/snapshots/phase0/snapshot.json +++ b/cli/tests/golden/snapshots/phase0/snapshot.json @@ -25,7 +25,7 @@ { "command": "setup --source local --path --ai claude --plugins none --yes --scope user", "exitCode": 0, - "stdout": "Fetching marketplace 'aidd-framework'...\nProject initialized.\n", + "stdout": "Fetching marketplace 'aidd-framework'...\nRegistered for every project on this machine.\n", "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\n", "filesWritten": [], "manifest": { @@ -823,7 +823,7 @@ { "command": "[user-scope-clean] setup --source local --path --ai claude --plugins none --yes --scope user", "exitCode": 0, - "stdout": "Fetching marketplace 'aidd-framework'...\nProject initialized.\n", + "stdout": "Fetching marketplace 'aidd-framework'...\nRegistered for every project on this machine.\n", "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\n", "filesWritten": [], "manifest": null, diff --git a/cli/tests/kernel/errors.unit.test.ts b/cli/tests/kernel/errors.unit.test.ts index 06cfa7b1a..0ac15bda6 100644 --- a/cli/tests/kernel/errors.unit.test.ts +++ b/cli/tests/kernel/errors.unit.test.ts @@ -482,25 +482,25 @@ describe("every error in the catalog", () => { build: () => new errors.UserScopeIdeToolsError(["vscode", "zed"]), name: "UserScopeIdeToolsError", message: - "--scope user installs no project files, so an IDE tool (vscode, zed) has nothing to install at user scope. Drop --ide, or run `aidd setup --ide ` separately at project scope.", + "--global installs no project files, so an editor (vscode, zed) has nothing to install machine-wide. Drop it from --ide, or run `aidd setup --ide ` separately in the project.", }, { build: () => new errors.UserScopeUnsupportedAiToolsError(["cursor", "opencode"]), name: "UserScopeUnsupportedAiToolsError", message: - "--scope user drives native activation machine-wide, and cursor, opencode declares no user-scope settings this CLI can point at. Drop it from --ai, or run `aidd setup --ai ` separately at project scope.", + "--global installs through an IDE's own plugin CLI or its user configuration directory, and neither exists for: cursor, opencode. Drop them from --ide, or run `aidd setup --ide ` in the project instead.", }, { build: () => new errors.UserScopeNoToolsError(), name: "UserScopeNoToolsError", message: - "--scope user with no --ai registers the shared source for no tool at all. Pass `--ai ` naming which tool to activate machine-wide.", + "--global with no --ide registers the shared source for no tool at all. Pass `--ide ` naming which tool to activate machine-wide.", }, { build: () => new errors.UserScopePluginModeError(), name: "UserScopePluginModeError", message: - "--scope user has no manifest entry a plugin can be recorded against yet, so --plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` separately at project scope.", + "--global cannot ask which plugins to install. Pass --plugins all, recommended, none, or comma-separated names.", }, { build: () => new errors.UserScopeFilterUnsupportedError("--plugin", "sync"), diff --git a/cli/tests/presentation/commands/clean-wiring.integration.test.ts b/cli/tests/presentation/commands/clean-wiring.integration.test.ts index 5fbae0276..a1ca7d0a8 100644 --- a/cli/tests/presentation/commands/clean-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/clean-wiring.integration.test.ts @@ -216,6 +216,10 @@ describe("aidd clean — the help surface", () => { "project (default) cleans this project alone; user undoes the machine-wide " + "registration setup --scope user wrote and purges the shared source itself", ], + [ + "--global", + "act on the machine-wide install `setup --global` wrote, and purge the shared source", + ], ]); }); }); diff --git a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts index d6000ff04..f481d6fa2 100644 --- a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts @@ -537,6 +537,7 @@ describe("aidd doctor — the help surface", () => { "project (default) checks this project's own manifest; user checks the " + "machine-wide manifest --scope user setup wrote", ], + ["--global", "act on the machine-wide install `setup --global` wrote"], ]); }); }); diff --git a/cli/tests/presentation/commands/setup-options.unit.test.ts b/cli/tests/presentation/commands/setup-options.unit.test.ts index dd21fca09..65806e25d 100644 --- a/cli/tests/presentation/commands/setup-options.unit.test.ts +++ b/cli/tests/presentation/commands/setup-options.unit.test.ts @@ -97,14 +97,13 @@ describe("parseToolIds", () => { expect(output.at("error")).toHaveLength(1); }); - it("refuses an AI id given as an IDE one", () => { - const output = new CapturingOutput(false); - const exit = refuseByThrowing(); - - expect(() => parseToolIds({ ide: "claude" }, new ErrorHandler(output))).toThrow("process.exit"); + it("files every id --ide names under the list its category decides", () => { + const errorHandler = new ErrorHandler(new CapturingOutput(false)); - expect(exit).toHaveBeenCalledWith(1); - expect(output.at("error")).toHaveLength(1); + expect(parseToolIds({ ide: "claude,vscode", ai: "codex" }, errorHandler)).toStrictEqual({ + aiTools: ["codex", "claude"], + ideTools: ["vscode"], + }); }); it("accepts 'all' without checking it against the category", () => { diff --git a/cli/tests/presentation/commands/setup-wiring.integration.test.ts b/cli/tests/presentation/commands/setup-wiring.integration.test.ts index ea31fe91e..279e57499 100644 --- a/cli/tests/presentation/commands/setup-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/setup-wiring.integration.test.ts @@ -38,7 +38,9 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ })); const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); -const { registerSetupCommand } = await import("../../../src/presentation/commands/setup.js"); +const { globalSetupHelp, registerSetupCommand } = await import( + "../../../src/presentation/commands/setup.js" +); const PROJECT_ROOT = process.cwd(); @@ -258,46 +260,19 @@ describe("aidd setup — every flag that makes a run a scripted one", () => { }); describe("aidd setup — what it refuses and what it reports", () => { - it.each(["all", "recommended", "aidd-dev"])( - "refuses user-scope plugin mode %s before building dependencies", - async (plugins) => { - const exit = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("exited"); - }); - - await expect(run("--scope", "user", "--ai", "claude", "--plugins", plugins)).rejects.toThrow( - "exited" - ); - - expect(errors.join("")).toBe( - "Error: --scope user has no manifest entry a plugin can be recorded against yet, so " + - "--plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` " + - "separately at project scope.\n" - ); - expect(exit).toHaveBeenCalledWith(1); - expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); - expect(written).toEqual([]); - } - ); - it.each([ - { args: [], cause: "with no --ai", remedy: "Pass `--ai `" }, - { - args: ["--ai", "claude", "--ide", "vscode"], - cause: "an IDE tool (vscode)", - remedy: "Drop --ide", - }, + { args: [], cause: "with no --ide", remedy: "Pass `--ide `" }, { - args: ["--ai", "opencode"], - cause: "opencode declares no user-scope settings", - remedy: "Drop it from --ai", + args: ["--ide", "claude,vscode"], + cause: "an editor (vscode)", + remedy: "Drop it from --ide", }, - ])("renders user-scope validation for $cause before building dependencies", async (test) => { + ])("renders global validation for $cause before building dependencies", async (test) => { const exit = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exited"); }); - await expect(run("--scope", "user", ...test.args)).rejects.toThrow("exited"); + await expect(run("--global", ...test.args)).rejects.toThrow("exited"); expect(errors.join("")).toContain(test.cause); expect(errors.join("")).toContain(test.remedy); @@ -389,19 +364,31 @@ describe("aidd setup — the help surface", () => { return setup; } + it("says what --global does for each tool, read from its profile", () => { + expect(globalSetupHelp().split("\n")).toEqual([ + "", + "Global:", + " Pass --ide claude,codex,copilot,cursor,opencode (one or more, no editor) and --plugins all, recommended, none or names.", + " claude, codex, copilot enable the plugins through their own CLI, which must be on PATH.", + " codex gets their files in ~/.codex, opencode gets their files in ~/.config/opencode.", + " cursor only records the framework source.", + " `aidd sync --global` refreshes them from the framework source.", + ]); + }); + it("describes itself against the command that acts on the framework alone", () => { expect(setupCommand().description()).toBe( "Set up or update the project to a correct state — bootstraps the whole project (marketplace, framework, tools, plugins); see `framework install`, which acts on the framework alone" ); }); - it("offers a source, two tool lists, a plugin mode, a scope, and two switches", () => { - expect(setupCommand().options.map((option) => [option.flags, option.description])).toEqual([ + it("offers a source, one tool list, a plugin mode, a scope, a provider, and its switches", () => { + const visible = setupCommand().options.filter((option) => !option.hidden); + expect(visible.map((option) => [option.flags, option.description])).toEqual([ ["--source ", "Framework source: remote or local"], ["--path ", "Absolute path to local framework (required with --source local)"], ["--release ", "Marketplace release tag to fetch (e.g., v1.2.3)"], - ["--ai ", "Comma-separated AI tool IDs, or 'all' (e.g., claude,cursor or all)"], - ["--ide ", "Comma-separated IDE tool IDs, or 'all' (e.g., vscode or all)"], + ["--ide ", "Comma-separated tool IDs, or 'all' (e.g., claude,vscode or all)"], ["--plugins ", "Plugin install mode: none | all | recommended | comma-separated names"], [ "--no-default-marketplace", @@ -409,11 +396,15 @@ describe("aidd setup — the help surface", () => { ], ["--yes", "Accept defaults without prompting"], [ - "--scope ", - "project (default) installs into this project alone; user registers the shared " + - "framework source and supported tools machine-wide, writing nothing under this project; " + - "see user-scope requirements below", + "--global", + "install for every project on this machine instead of into this project; see the " + + "requirements below", ], ]); + expect( + setupCommand() + .options.filter((option) => option.hidden) + .map((option) => option.flags) + ).toEqual(["--ai ", "--scope "]); }); }); diff --git a/cli/tests/presentation/commands/sync-wiring.integration.test.ts b/cli/tests/presentation/commands/sync-wiring.integration.test.ts index e898d07d0..8b7bba755 100644 --- a/cli/tests/presentation/commands/sync-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/sync-wiring.integration.test.ts @@ -8,6 +8,7 @@ const loadManifest = vi.fn(); const getToolVersion = vi.fn(); const currentVersion = vi.fn(); const userManifestRepo = { load: vi.fn() }; +const userPluginUpdate = vi.fn(); vi.mock("../../../src/runtime/wiring/framework.js", () => ({ createDeps: vi.fn(async () => ({ @@ -16,6 +17,7 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ marketplaceSyncSettingsUseCase: { execute: activation }, manifestRepo: { load: loadManifest }, userManifestRepo, + userPluginUpdateUseCase: { execute: userPluginUpdate }, currentVersionProvider: { get: currentVersion }, })), createMenuDeps: vi.fn(), @@ -65,7 +67,10 @@ beforeEach(() => { getToolVersion.mockReturnValue("5.1.0"); loadManifest.mockResolvedValue({ getToolVersion }); currentVersion.mockReturnValue("5.2.2"); - userManifestRepo.load.mockResolvedValue({ getInstalledToolIds: () => ["claude"] }); + userManifestRepo.load.mockResolvedValue({ + getInstalledToolIds: () => ["claude"], + getPlugins: () => [], + }); }); afterEach(() => { @@ -231,6 +236,7 @@ describe("aidd sync --scope user", () => { toolIds: undefined, recreateFrameworkIfMissing: true, }); + expect(userPluginUpdate).not.toHaveBeenCalled(); expect(restoreAll).not.toHaveBeenCalled(); expect(restoreOne).not.toHaveBeenCalled(); }); @@ -241,6 +247,23 @@ describe("aidd sync --scope user", () => { ]); }); + it("rebuilds the machine files a tool owns before driving activation, under --global", async () => { + userManifestRepo.load.mockResolvedValue({ + getInstalledToolIds: () => ["claude", "opencode"], + getPlugins: (toolId: string) => (toolId === "opencode" ? [{ scope: "user" }] : []), + }); + userPluginUpdate.mockResolvedValue(["aidd-dev", "aidd-dev"]); + + expect(await run("--global")).toEqual(["Refreshed machine files for: aidd-dev"]); + expect(userPluginUpdate).toHaveBeenCalledWith({ + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + scope: "user", + rebuild: true, + }); + expect(activation).toHaveBeenCalledWith(expect.objectContaining({ scope: "user" })); + }); + it("narrows to the one tool named, still off the machine-wide manifest", async () => { await run("--scope", "user", "--tool", "claude"); @@ -350,6 +373,10 @@ describe("aidd sync — the help surface", () => { "project (default) resolves this project's own manifest; user resolves the " + "machine-wide manifest --scope user setup wrote, restoring no project files", ], + [ + "--global", + "act on the machine-wide install `setup --global` wrote, rebuilding its files from the framework source", + ], ]); }); }); diff --git a/cli/tests/presentation/display/setup-display.unit.test.ts b/cli/tests/presentation/display/setup-display.unit.test.ts index bdb68c3f5..868b5320d 100644 --- a/cli/tests/presentation/display/setup-display.unit.test.ts +++ b/cli/tests/presentation/display/setup-display.unit.test.ts @@ -90,6 +90,32 @@ describe("printSetupOutcome", () => { expect(output.at("success")).toEqual([]); }); + it("names the plugins a machine-wide run installed, and nothing about the project", () => { + const output = new CapturingOutput(false); + + printSetupOutcome( + output, + { kind: "initialized", install: { results: [] }, machinePlugins: ["aidd-dev", "aidd-pm"] }, + false + ); + + expect(output.lines).toEqual([ + "Installed for every project on this machine: aidd-dev, aidd-pm.", + ]); + }); + + it("says a machine-wide run with no plugin only registered the source", () => { + const output = new CapturingOutput(false); + + printSetupOutcome( + output, + { kind: "up-to-date", install: { results: [] }, machinePlugins: [] }, + false + ); + + expect(output.lines).toEqual(["Registered for every project on this machine."]); + }); + it("warns about a tool already installed rather than counting it as installed", () => { const output = new CapturingOutput(false); diff --git a/cli/tests/presentation/display/sync-display.unit.test.ts b/cli/tests/presentation/display/sync-display.unit.test.ts index 70e551aa5..7c7810857 100644 --- a/cli/tests/presentation/display/sync-display.unit.test.ts +++ b/cli/tests/presentation/display/sync-display.unit.test.ts @@ -164,6 +164,25 @@ describe("printActivationOutcome", () => { }); describe("printUserScopeSyncOutcome", () => { + it("names the plugins whose machine files were rebuilt, and nothing else when no host was driven", () => { + const output = new CapturingOutput(false); + + printUserScopeSyncOutcome(output, [], ["aidd-dev", "aidd-pm"]); + + expect(output.at("success")).toEqual(["Refreshed machine files for: aidd-dev, aidd-pm"]); + }); + + it("reports both the rebuilt files and the hosts driven", () => { + const output = new CapturingOutput(false); + + printUserScopeSyncOutcome(output, ["claude"], ["aidd-dev"]); + + expect(output.at("success")).toEqual([ + "Refreshed machine files for: aidd-dev", + "Synced native activation for: claude", + ]); + }); + it("says no tool is registered at user scope when none was activated", () => { const output = new CapturingOutput(false); diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index b62d89f9c..9a1df9cd6 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -51,6 +51,13 @@ Only `/aidd-orchestrator:01-sdlc` names an agent, by its role (`validator`), so Install the `aidd-orchestrator` plugin: `/plugin install aidd-orchestrator@aidd-framework` in Claude Code, or see the [quick start](../README.md#quick-start) for the other tools. The agents come with it, except as a Codex plugin (see [Limits](#-limits)); open a new session so the tool loads them. +For every project on the machine at once, Claude Code, Codex and OpenCode included: + +```bash +aidd setup --global --ide claude,codex,opencode --plugins all +aidd sync --global # after a framework update +``` + ## 🧠 Models No model is pinned. Each host applies its own default for subagents, usually the model of the session that called them. A model you declare in an agent reaches every host in its own format. @@ -58,7 +65,7 @@ No model is pinned. Each host applies its own default for subagents, usually the ## 🚧 Limits - **Tools:** every agent inherits the calling session's tools and permissions; what it writes is an instruction, not a restriction. -- **Codex:** a Codex plugin cannot carry agents, so only the flat distribution installs them, in `.codex/agents/`. Codex loads them only in a trusted project. +- **Codex:** a Codex plugin cannot carry agents, so only the flat distribution installs them, in `.codex/agents/`, and Codex loads them only in a trusted project; `aidd setup --global` puts them in `~/.codex/agents/` instead. - **Models:** OpenCode V1 reads the same file but not the `#` variant. - **Updates:** reinstalling replaces the agents still shipped; one removed from the plugin stays where a copy put it until you delete it. From ef31af8a14265a8a988b8673f7d3fbaff671158a Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 09:49:14 +0800 Subject: [PATCH 02/16] feat(framework): make reload installs Claude, Codex and OpenCode with setup --global The reload hands this checkout to aidd setup --global --source local --plugins all, the way a user installs machine-wide, instead of copying skills and agents by hand. Claude and Codex get their plugins afresh each run, since an edited plugin keeps its version. Copilot and Cursor are unchanged. --- scripts/__tests__/dev-sync.test.js | 293 ++++++----------------------- scripts/dev-sync.sh | 240 +++++++---------------- 2 files changed, 131 insertions(+), 402 deletions(-) diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 7d1bfb714..99a5585bf 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -208,32 +208,14 @@ test("reload fixtures run from their temporary home", () => { try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - executable(join(fakeBin, "aidd-opencode-reload"), "node -e 'process.stdout.write(process.cwd())' > \"$HOME/observed-cwd\""); + executable(join(fakeBin, "opencode"), "exit 0"); + recordingCli(fixture(home), 'fs.writeFileSync(path.join(process.env.HOME, "observed-cwd"), process.cwd());'); const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); assert.equal(realpathSync(readFileSync(join(home, "observed-cwd"), "utf8").trim()), realpathSync(home)); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("managed OpenCode reload uses the fixed-purpose helper without a local build", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - mkdirSync(home); - executable(join(fakeBin, "aidd-opencode-reload"), 'echo "managed:ok"'); - - const result = runSync(fakeBin, home); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /reload opencode\s+managed:ok/); - assert.equal(existsSync(join(home, "build")), false); - assert.match(readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"), /Use available agents when useful/); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - for (const flag of ["OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"]) { test(`OpenCode first reload respects disabled Claude fallback via ${flag}`, () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-fallback-")); @@ -243,7 +225,8 @@ for (const flag of ["OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_CLAUDE_COD mkdirSync(fakeBin); mkdirSync(join(home, ".claude"), { recursive: true }); writeFileSync(join(home, ".claude/CLAUDE.md"), "Excluded Claude personal rules.\n"); - executable(join(fakeBin, "aidd-opencode-reload"), "exit 0"); + executable(join(fakeBin, "opencode"), "exit 0"); + recordingCli(fixture(home)); const result = runSync(fakeBin, home, [], { [flag]: "1" }); assert.equal(result.status, 0, result.stderr); const installed = readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"); @@ -270,13 +253,7 @@ test("reload delivers global instructions to all three hosts while preserving pe writeFileSync(join(home, path), "Personal rules.\n"); } for (const host of ["codex", "claude", "opencode"]) executable(join(fakeBin, host), "exit 0"); - executable(join(fakeBin, "npx"), String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then shift; out=$1; fi - shift -done -mkdir -p "$out/.opencode/skills/aidd-pm-03-prd" -printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-pm-03-prd/SKILL.md"`); + executable(join(fakeBin, "npx"), "exit 0"); const first = runSync(fakeBin, home, []); assert.equal(first.status, 0, first.stderr); assert.match(first.stdout, /global instructions\s+ok/); @@ -296,156 +273,74 @@ printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-pm-03-prd/SKILL.md"`); } }); -for (const layout of ["flat", "nested"]) { - test(`local OpenCode reload refreshes the framework namespace using ${layout} output`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - mkdirSync(home); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev-stale"), { recursive: true }); - writeFileSync(join(home, ".config/opencode/skills/aidd-dev-stale/SKILL.md"), "stale\n"); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev/removed"), { recursive: true }); - writeFileSync(join(home, ".config/opencode/skills/aidd-dev/removed/SKILL.md"), "stale\n"); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev/01-plan"), { recursive: true }); - writeFileSync(join(home, ".config/opencode/skills/aidd-dev/01-plan/SKILL.md"), "old\n"); - const unrelated = join(home, ".config/opencode/skills/aidd-pm/03-prd/SKILL.md"); - mkdirSync(join(home, ".config/opencode/skills/aidd-pm/03-prd"), { recursive: true }); - writeFileSync(unrelated, "keep\n"); - const agentDir = join(home, ".config/opencode/agents"); - mkdirSync(agentDir, { recursive: true }); - writeFileSync(join(agentDir, "aidd-dev-checker.md"), "old agent\n"); - writeFileSync(join(agentDir, "aidd-dev-removed.md"), "stale\n"); - writeFileSync(join(agentDir, "personal.md"), "keep\n"); - writeFileSync(join(agentDir, "aidd-pm-review.md"), "keep\n"); - executable(join(fakeBin, "opencode"), "exit 0"); - const skillName = layout === "nested" ? "aidd-dev/01-plan" : "aidd-dev-01-plan"; - executable( - join(fakeBin, "npx"), - String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then shift; out=$1; fi - shift -done -skill="$out/.opencode/skills/${skillName}" -mkdir -p "$skill/actions" "$skill/assets" -printf '%s\n' '---' 'name: aidd-dev-01-plan' 'description: test' '---' > "$skill/SKILL.md" -printf '%s\n' 'updated action' > "$skill/actions/04-plan.md" -printf '%s\n' 'updated template' > "$skill/assets/plan-template.md" -mkdir -p "$out/.opencode/agents" -printf '%s\n' 'built executor' > "$out/.opencode/agents/aidd-dev-executor.md" -`, - ); - - const result = runSync(fakeBin, home, []); - assert.equal(result.status, 0, result.stderr); - const installed = join(home, ".config/opencode/skills", skillName); - assert.match(readFileSync(join(installed, "SKILL.md"), "utf8"), /name: aidd-dev-01-plan/); - assert.equal(readFileSync(join(installed, "actions/04-plan.md"), "utf8"), "updated action\n"); - assert.equal(readFileSync(join(installed, "assets/plan-template.md"), "utf8"), "updated template\n"); - assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev-stale")), false); - assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev/removed")), false); - assert.equal(readFileSync(unrelated, "utf8"), "keep\n"); - assert.match(result.stdout, /install opencode\s+skills:ok agents:ok/); - assert.equal(readFileSync(join(agentDir, "aidd-dev-executor.md"), "utf8"), "built executor\n"); - assert.equal(readFileSync(join(agentDir, "aidd-dev-checker.md"), "utf8"), "old agent\n"); - assert.equal(readFileSync(join(agentDir, "personal.md"), "utf8"), "keep\n"); - assert.equal(readFileSync(join(agentDir, "aidd-pm-review.md"), "utf8"), "keep\n"); +/** A checkout CLI that records each call and answers `setup` with the given shell-free body. */ +function recordingCli(source, setupBody = "") { + const binary = join(source, "cli/dist/cli.js"); + mkdirSync(dirname(binary), { recursive: true }); + writeFileSync(binary, `const fs = require("node:fs"), path = require("node:path"); +const args = process.argv.slice(2); +fs.appendFileSync(path.join(process.env.HOME, "aidd-calls"), args.join(" ") + "\\n"); +if (args[0] !== "setup") process.exit(2); +${setupBody}`); +} - } finally { - rmSync(root, { recursive: true, force: true }); +test("Claude, Codex and OpenCode take this checkout through aidd setup --global, afresh", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const source = fixture(home, ["aidd-dev", "aidd-pm"]); + recordingCli(source); + for (const host of ["claude", "codex", "opencode"]) { + executable(join(fakeBin, host), `printf '%s\\n' "${host} $*" >> "$HOME/host-calls"`); } - }); -} + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /global install\s+ok \(claude,codex,opencode\)/); + assert.equal( + readFileSync(join(home, "aidd-calls"), "utf8"), + `setup --global --source local --path ${source} --ide claude,codex,opencode --plugins all --yes\n` + ); + const hostCalls = readFileSync(join(home, "host-calls"), "utf8"); + for (const plugin of ["aidd-dev", "aidd-pm"]) { + assert.match(hostCalls, new RegExp(`claude plugin uninstall ${plugin}@aidd-framework --scope user`)); + assert.match(hostCalls, new RegExp(`codex plugin remove ${plugin}@aidd-framework`)); + } + assert.match(hostCalls, /claude plugin marketplace remove aidd-framework --scope user/); + assert.match(hostCalls, /codex plugin marketplace remove aidd-framework/); + assert.doesNotMatch(hostCalls, /opencode/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); -test("local OpenCode reload rejects missing framework skills before removing installed ones", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-missing-")); +test("a failing global install stops the reload with its own cause", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-failed-")); try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); + const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - const installed = join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev-01-plan"), { recursive: true }); - writeFileSync(installed, "keep\n"); + recordingCli(fixture(home), 'console.error("fixture refusal"); process.exit(3);'); executable(join(fakeBin, "opencode"), "exit 0"); - executable(join(fakeBin, "npx"), "exit 0"); - const result = runSync(fakeBin, home, []); assert.notEqual(result.status, 0); - assert.match(result.stderr, /no built OpenCode skill for aidd-dev-01-plan/); - assert.doesNotMatch(result.stdout, /skills:ok/); - assert.equal(readFileSync(installed, "utf8"), "keep\n"); - } finally { - rmSync(root, { recursive: true, force: true }); - } + assert.match(result.stderr, /fixture refusal/); + assert.match(result.stderr, /aidd setup --global failed for opencode/); + assert.doesNotMatch(result.stdout, /Done\./); + } finally { rmSync(root, { recursive: true, force: true }); } }); -test("default OpenCode reload installs every plugin's skills", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-all-")); +test("a global install from the published CLI is used when the checkout has none built", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-npx-")); try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); + const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - mkdirSync(home); - fixture(home, ["aidd-dev", "aidd-pm"]); - const skills = ["aidd-dev-01-plan", "aidd-pm-03-prd"]; - const buildSkills = skills.map((name) => `mkdir -p "$out/.opencode/skills/${name}"\nprintf '%s\\n' 'skill' > "$out/.opencode/skills/${name}/SKILL.md"`).join("\n"); - executable(join(fakeBin, "opencode"), "exit 0"); - executable(join(fakeBin, "npx"), `out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then shift; out=$1; fi - shift -done -${buildSkills}`); - + executable(join(fakeBin, "codex"), "exit 0"); + executable(join(fakeBin, "npx"), 'printf \'%s\\n\' "$*" > "$HOME/npx-call"'); const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - for (const skill of skills) { - assert.equal(readFileSync(join(home, ".config/opencode/skills", `${skill}/SKILL.md`), "utf8"), "skill\n"); - } - assert.equal(existsSync(join(home, ".config/opencode/agents")), false); - assert.match(result.stdout, /skills:ok/); - } finally { - rmSync(root, { recursive: true, force: true }); - } + assert.match(readFileSync(join(home, "npx-call"), "utf8"), /^--yes @ai-driven-dev\/cli@latest setup --global .* --ide codex --plugins all --yes$/m); + } finally { rmSync(root, { recursive: true, force: true }); } }); -for (const badOutput of ["missing", "empty"]) { - test(`OpenCode rejects ${badOutput} expected skill output despite another built skill`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-partial-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - const source = fixture(home); - const second = join(source, "plugins/aidd-dev/skills/02-implement"); - mkdirSync(second); - writeFileSync(join(second, "SKILL.md"), "source skill\n"); - const installed = join(home, ".config/opencode/skills/aidd-dev-02-implement"); - mkdirSync(installed, { recursive: true }); - writeFileSync(join(installed, "SKILL.md"), "keep\n"); - executable(join(fakeBin, "opencode"), "exit 0"); - executable(join(fakeBin, "npx"), String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then shift; out=$1; fi - shift -done -mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" -printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md" -${badOutput === "empty" ? 'mkdir -p "$out/.opencode/skills/aidd-dev-02-implement"; touch "$out/.opencode/skills/aidd-dev-02-implement/SKILL.md"' : ""}`); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /no built OpenCode skill for aidd-dev-02-implement/); - assert.equal(readFileSync(join(installed, "SKILL.md"), "utf8"), "keep\n"); - assert.doesNotMatch(result.stdout, /skills:ok|Done\./); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -} - -for (const host of ["opencode", "codex", "copilot", "cursor"]) { +for (const host of ["copilot", "cursor"]) { test(`reload fails when the installed ${host} host's build fails`, () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-build-failed-")); try { @@ -464,7 +359,7 @@ for (const host of ["opencode", "codex", "copilot", "cursor"]) { }); } -for (const host of ["codex", "claude", "copilot"]) { +for (const host of ["copilot"]) { test(`reload fails when the installed ${host} host rejects a plugin`, () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-install-failed-")); try { @@ -748,52 +643,7 @@ test("Copilot instructions follow its native home with task overrides taking pre } finally { rmSync(root, { recursive: true, force: true }); } }); -test("OpenCode skills follow its native home with task overrides taking precedence", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-opencode-home-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"), config = join(home, "native-config"); - mkdirSync(fakeBin); - executable(join(fakeBin, "opencode"), "exit 0"); - executable(join(fakeBin, "npx"), String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = --out ]; then shift; out=$1; fi - shift -done -mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" -printf '%s\n' 'native skill' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md" -`); - const result = runSync(fakeBin, home, [], { XDG_CONFIG_HOME: config, OPENCODE_SKILLS: "", OPENCODE_INSTRUCTIONS: "" }); - assert.equal(result.status, 0, result.stderr); - assert.equal(readFileSync(join(config, "opencode/skills/aidd-dev-01-plan/SKILL.md"), "utf8"), "native skill\n"); - assert.equal(existsSync(join(config, "opencode/agents")), false); - assert.equal(existsSync(join(home, ".config/opencode/skills")), false); - assert.equal(runSync(fakeBin, home, [], { XDG_CONFIG_HOME: config }).status, 0); - assert.equal(readFileSync(join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"), "utf8"), "native skill\n"); - } finally { rmSync(root, { recursive: true, force: true }); } -}); -for (const customPluginRoot of [false, true]) { - test(`Claude cache follows its native ${customPluginRoot ? "plugin root" : "config home"}`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-claude-home-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"), config = join(home, "native-claude"); - const plugins = customPluginRoot ? join(home, "native-plugins") : join(config, "plugins"); - mkdirSync(fakeBin); - executable(join(fakeBin, "claude"), String.raw`if [ "$1 $2" = 'plugin install' ]; then - cache="$CLAUDE_CONFIG_DIR/plugins" - [ -z "$CLAUDE_CODE_PLUGIN_CACHE_DIR" ] || cache="$CLAUDE_CODE_PLUGIN_CACHE_DIR" - cache="$cache/cache/aidd-framework/aidd-dev/2.6.0" - mkdir -p "$cache" - printf '%s\n' 'native plugin' > "$cache/plugin-installed" -fi`); - const result = runSync(fakeBin, home, [], { CLAUDE_CONFIG_DIR: config, CLAUDE_CODE_PLUGIN_CACHE_DIR: customPluginRoot ? plugins : "", CLAUDE_CACHE: "", CLAUDE_INSTRUCTIONS: "" }); - assert.equal(result.status, 0, result.stderr); - assert.equal(readFileSync(join(plugins, "cache/aidd-framework/aidd-dev/2.6.0/plugin-installed"), "utf8"), "native plugin\n"); - assert.match(readFileSync(join(config, "CLAUDE.md"), "utf8"), /Use available agents when useful/); - assert.equal(existsSync(join(home, ".claude/plugins")), false); - } finally { rmSync(root, { recursive: true, force: true }); } - }); -} for (const override of ["CLAUDE_CONFIG_DIR", "CLAUDE_INSTRUCTIONS"]) { test(`OpenCode preserves its actual Claude fallback despite ${override}`, () => { @@ -806,7 +656,8 @@ for (const override of ["CLAUDE_CONFIG_DIR", "CLAUDE_INSTRUCTIONS"]) { const custom = join(home, "custom-claude"); mkdirSync(custom); writeFileSync(join(custom, "CLAUDE.md"), "Other Claude instructions.\n"); - executable(join(fakeBin, "aidd-opencode-reload"), "exit 0"); + executable(join(fakeBin, "opencode"), "exit 0"); + recordingCli(fixture(home)); const result = runSync(fakeBin, home, [], override === "CLAUDE_CONFIG_DIR" ? { CLAUDE_CONFIG_DIR: custom, CLAUDE_INSTRUCTIONS: "" } : { CLAUDE_INSTRUCTIONS: join(custom, "CLAUDE.md") }); @@ -818,27 +669,3 @@ for (const override of ["CLAUDE_CONFIG_DIR", "CLAUDE_INSTRUCTIONS"]) { }); } -test("Codex reload installs the plugin's built agents beside personal ones", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-codex-personal-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); - const agents = join(home, ".codex/agents"); - mkdirSync(agents, { recursive: true }); - const existing = ["aidd-dev-personal.toml", "executor.toml"]; - for (const file of existing) writeFileSync(join(agents, file), `Personal ${file}\n`); - executable(join(fakeBin, "codex"), "exit 0"); - executable(join(fakeBin, "npx"), String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = --out ]; then shift; out=$1; fi - shift -done -mkdir -p "$out/plugins/aidd-dev/codex-agents" -printf '%s\n' 'built executor' > "$out/plugins/aidd-dev/codex-agents/executor.toml" -`); - const result = runSync(fakeBin, home, []); - assert.equal(result.status, 0, result.stderr); - assert.equal(readFileSync(join(agents, "aidd-dev-executor.toml"), "utf8"), "built executor\n"); - for (const file of existing) assert.equal(readFileSync(join(agents, file), "utf8"), `Personal ${file}\n`); - } finally { rmSync(root, { recursive: true, force: true }); } -}); diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 32aad997a..c5de213e2 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # (Re)install every plugin into Claude, Codex, OpenCode, Copilot and Cursor from THIS checkout. -# Claude reads the raw repo, already native; the others install from a tree the aidd CLI -# builds, so what runs locally matches what ships. Codex and OpenCode load no -# agent from a plugin, so their built agents are copied to the user's agents directory. +# Claude, Codex and OpenCode take it the way a user does: `aidd setup --global` with this +# checkout as the framework source, so the reload exercises the shipped machine-wide install. +# Copilot and Cursor install from a tree the aidd CLI builds. # # NOT live: the install copies built files, so an edit needs a re-run. Idempotent; a tool -# whose CLI is absent is skipped, and the first run needs network. A managed OpenCode host -# exposes `aidd-opencode-reload` instead, and that helper decides which checkout may load. +# whose CLI is absent is skipped, and the first run needs network. +# The agents' provider is whatever `aidd setup --global --provider` last recorded. # # Global rules: sync only the Agents section in the managed block, preserving personal # rules. First OpenCode sync keeps its Claude personal-rule fallback unless disabled. @@ -34,11 +34,6 @@ FW="${FW:-$(dirname "$SCRIPT_DIR")}" MKT="${MKT:-aidd-framework}" AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-latest}" # published fallback for sources without a built CLI BUILD="${BUILD:-$HOME/.cache/aidd-framework-dev}" # per-tool native trees the marketplaces point at -CODEX_CACHE="${CODEX_CACHE:-${CODEX_HOME:-$HOME/.codex}/plugins/cache}" -CLAUDE_CACHE="${CLAUDE_CACHE:-${CLAUDE_CODE_PLUGIN_CACHE_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins}/cache}" -CODEX_AGENTS="${CODEX_AGENTS:-${CODEX_HOME:-$HOME/.codex}/agents}" -OPENCODE_SKILLS="${OPENCODE_SKILLS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills}" -OPENCODE_AGENTS="${OPENCODE_AGENTS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/agents}" CURSOR_PLUGINS="${CURSOR_PLUGINS:-$HOME/.cursor/plugins/local}" CODEX_INSTRUCTIONS="${CODEX_INSTRUCTIONS:-${CODEX_HOME:-$HOME/.codex}/AGENTS.md}" CLAUDE_INSTRUCTIONS="${CLAUDE_INSTRUCTIONS:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md}" @@ -49,7 +44,6 @@ CURSOR_INSTRUCTIONS="${CURSOR_INSTRUCTIONS:-$HOME/.cursor/rules/aidd-agents.mdc} HAVE_CODEX=0; command -v codex >/dev/null 2>&1 && HAVE_CODEX=1 HAVE_CLAUDE=0; command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=1 HAVE_OPENCODE=0; command -v opencode >/dev/null 2>&1 && HAVE_OPENCODE=1 -HAVE_MANAGED_OPENCODE=0; command -v aidd-opencode-reload >/dev/null 2>&1 && HAVE_MANAGED_OPENCODE=1 HAVE_COPILOT=0; command -v copilot >/dev/null 2>&1 && HAVE_COPILOT=1 HAVE_CURSOR=0 if command -v cursor >/dev/null 2>&1 || command -v cursor-agent >/dev/null 2>&1; then @@ -58,33 +52,18 @@ elif command -v agent >/dev/null 2>&1 && agent --help 2>/dev/null | grep 'Start HAVE_CURSOR=1 fi -build_tool() { - local tool="$1" - rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" - local mode=marketplace - [ "$tool" != opencode ] || mode=flat +aidd_cli() { if [ -f "$FW/cli/dist/cli.js" ]; then - node "$FW/cli/dist/cli.js" translate "$FW" \ - --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + node "$FW/cli/dist/cli.js" "$@" else - npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" translate "$FW" \ - --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" "$@" fi } -validate_opencode_skills() { - # Check every expected nonempty skill before replacing plugin namespaces. - local name skill skill_name - for name in "$@"; do - for skill in "$FW/plugins/$name/skills/"*/SKILL.md; do - skill_name="$(basename "$(dirname "$skill")")" - if [ ! -s "$BUILD/opencode/.opencode/skills/$name/$skill_name/SKILL.md" ] && - [ ! -s "$BUILD/opencode/.opencode/skills/$name-$skill_name/SKILL.md" ]; then - echo "no built OpenCode skill for $name-$skill_name (missing or empty SKILL.md)" >&2 - return 1 - fi - done - done +build_tool() { + local tool="$1" + rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" + aidd_cli translate "$FW" --to "$tool" --out "$BUILD/$tool" --as marketplace --force >/dev/null 2>&1 } validate_native_plugins() { @@ -101,135 +80,68 @@ validate_native_plugins() { done } -sync_opencode_skills() { - # Accept nested and legacy flat builds; preserve personal and unselected plugin content. - local name source skill destination - local -a skills - mkdir -p "$OPENCODE_SKILLS" - source="$BUILD/opencode/.opencode/skills" - for name in "$@"; do - skills=("$source/$name/"*/ "$source/$name-"*/) - rm -rf -- "$OPENCODE_SKILLS/$name" - find "$OPENCODE_SKILLS" -mindepth 1 -maxdepth 1 -type d -name "$name-*" -exec rm -rf -- {} + - for skill in "${skills[@]}"; do - case "$skill" in - "$source/$name/"*) destination="$OPENCODE_SKILLS/$name/$(basename "$skill")" ;; - *) destination="$OPENCODE_SKILLS/$(basename "$skill")" ;; - esac - mkdir -p "$(dirname "$destination")" - cp -R "$skill" "$destination" - done - done -} - -copy_agents() { - # Overwrite only the plugin's own agents; personal agents are left untouched. - local destination="$1" prefix="$2" agent - shift 2 - for agent in "$@"; do - mkdir -p "$destination" - cp -f "$agent" "$destination/$prefix$(basename "$agent")" - done -} - -register_marketplace() { - case "$1" in - # Codex needs the native .codex-plugin tree. - codex) - codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true - codex plugin marketplace add "$BUILD/codex" >/dev/null 2>&1 ;; - # Claude installs the native repository. Scope every op to user: bare `marketplace remove` strips - # the declaration from EVERY scope, wiping the repo's own project-scoped config. - claude) - claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true - claude plugin marketplace add "$FW" --scope user >/dev/null 2>&1 ;; - copilot) - local registered_source - registered_source="$(copilot plugin marketplace list --json | node -e ' - const entries = JSON.parse(require("node:fs").readFileSync(0, "utf8")); - if (!Array.isArray(entries)) throw new Error("Invalid Copilot marketplace list"); - const entry = entries.find(item => item.name === process.argv[1]); - if (entry && typeof entry.source !== "string") throw new Error("Invalid Copilot marketplace source"); - process.stdout.write(entry?.source ?? ""); - ' "$MKT")" || return 1 - case "$registered_source" in - "") copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; - "Local: $BUILD/copilot") copilot plugin marketplace update "$MKT" >/dev/null ;; - "Local: $FW") - # Native list also includes inherited Claude registrations, which remove cannot change. - local add_result - if add_result="$(copilot plugin marketplace add "$BUILD/copilot" 2>&1)"; then - return 0 - fi - case "$add_result" in - *"already registered"*) - copilot plugin marketplace remove "$MKT" --force >/dev/null || return 1 - copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; - *) printf '%s\n' "$add_result" >&2; return 1 ;; - esac ;; - *) echo "Refusing a different Copilot marketplace source for $MKT: $registered_source" >&2; return 1 ;; +register_copilot_marketplace() { + local registered_source + registered_source="$(copilot plugin marketplace list --json | node -e ' + const entries = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + if (!Array.isArray(entries)) throw new Error("Invalid Copilot marketplace list"); + const entry = entries.find(item => item.name === process.argv[1]); + if (entry && typeof entry.source !== "string") throw new Error("Invalid Copilot marketplace source"); + process.stdout.write(entry?.source ?? ""); + ' "$MKT")" || return 1 + case "$registered_source" in + "") copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; + "Local: $BUILD/copilot") copilot plugin marketplace update "$MKT" >/dev/null ;; + "Local: $FW") + # Native list also includes inherited Claude registrations, which remove cannot change. + local add_result + if add_result="$(copilot plugin marketplace add "$BUILD/copilot" 2>&1)"; then + return 0 + fi + case "$add_result" in + *"already registered"*) + copilot plugin marketplace remove "$MKT" --force >/dev/null || return 1 + copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; + *) printf '%s\n' "$add_result" >&2; return 1 ;; esac ;; + *) echo "Refusing a different Copilot marketplace source for $MKT: $registered_source" >&2; return 1 ;; esac } -sync_one() { - local name="$1" - [ -f "$FW/plugins/$name/.claude-plugin/plugin.json" ] || { echo "skip $name (no plugin.json)"; return; } - printf '%-22s' "$name" - - if [ "$HAVE_CODEX" = 1 ]; then - codex plugin remove "$name" >/dev/null 2>&1 || true - rm -rf "$CODEX_CACHE/$MKT/$name" - if codex plugin add "$name@$MKT" >/dev/null 2>&1; then - printf ' codex:ok' - copy_agents "$CODEX_AGENTS" "$name-" "$BUILD/codex/plugins/$name/codex-agents/"*.toml - else - printf ' codex:FAIL' - echo "Codex plugin install failed for $name; rerun codex plugin add $name@$MKT for details." >&2 - return 1 - fi - fi - if [ "$HAVE_CLAUDE" = 1 ]; then - rm -rf "$CLAUDE_CACHE/$MKT/$name" - if claude plugin install "$name@$MKT" --scope user >/dev/null 2>&1; then - printf ' claude:ok' - else - printf ' claude:FAIL' - echo "Claude plugin install failed for $name; rerun claude plugin install $name@$MKT --scope user for details." >&2 - return 1 - fi - fi - if [ "$HAVE_COPILOT" = 1 ]; then - if copilot plugin install "$name@$MKT" >/dev/null; then - printf ' copilot:ok' - else - echo "Copilot plugin install failed for $name" >&2; return 1 - fi - fi - echo +# A plugin keeps its version across edits to this checkout, so Claude and Codex would keep the +# copy they cached: each reload hands them the plugins afresh, as the previous script did. +release_native_plugins() { + local name + for name in "${targets[@]}"; do + [ "$HAVE_CLAUDE" = 0 ] || claude plugin uninstall "$name@$MKT" --scope user >/dev/null 2>&1 || true + [ "$HAVE_CODEX" = 0 ] || codex plugin remove "$name@$MKT" >/dev/null 2>&1 || true + done + [ "$HAVE_CLAUDE" = 0 ] || claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true + [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true } -if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_OPENCODE" = 0 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ] && [ "$HAVE_COPILOT" = 0 ] && [ "$HAVE_CURSOR" = 0 ]; then +if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_OPENCODE" = 0 ] && [ "$HAVE_COPILOT" = 0 ] && [ "$HAVE_CURSOR" = 0 ]; then echo "No supported host installation found - nothing to install."; exit 0 fi targets=() -for d in "$FW"/plugins/*/; do targets+=("$(basename "$d")"); done - -# Codex needs a native plugin build; register against it. Claude installs from the raw -# repo (already native), so it only needs the marketplace registered - no build. -if [ "$HAVE_CODEX" = 1 ]; then - printf '%-22s' "build codex" - build_tool codex || { echo "FAIL"; echo "codex build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } - register_marketplace codex || { echo "FAIL"; echo "Codex marketplace registration failed for $BUILD/codex." >&2; exit 1; } - echo "ok" -fi -if [ "$HAVE_CLAUDE" = 1 ]; then - register_marketplace claude -fi -if [ "$HAVE_OPENCODE" = 1 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then - printf '%-22s' "build opencode" - build_tool opencode && echo "ok" || { echo "FAIL"; echo "opencode build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } +for d in "$FW"/plugins/*/; do + [ -f "$d.claude-plugin/plugin.json" ] && targets+=("$(basename "$d")") +done + +hosts=() +[ "$HAVE_CLAUDE" = 0 ] || hosts+=(claude) +[ "$HAVE_CODEX" = 0 ] || hosts+=(codex) +[ "$HAVE_OPENCODE" = 0 ] || hosts+=(opencode) +if [ "${#hosts[@]}" -gt 0 ]; then + printf '%-22s' "global install" + release_native_plugins + output="$(aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ + --plugins all --yes 2>&1)" || { + echo "FAIL"; printf '%s\n' "$output" >&2 + echo "aidd setup --global failed for ${hosts[*]}." >&2; exit 1 + } + echo "ok ($(IFS=,; echo "${hosts[*]}"))" fi if [ "$HAVE_CURSOR" = 1 ]; then printf '%-22s' "build cursor" @@ -241,23 +153,15 @@ if [ "$HAVE_COPILOT" = 1 ]; then printf '%-22s' "build copilot" build_tool copilot || { echo "FAIL"; echo "copilot build failed" >&2; exit 1; } validate_native_plugins copilot .plugin/plugin.json || { echo "FAIL"; exit 1; } - register_marketplace copilot || { echo "FAIL"; echo "Copilot marketplace registration failed" >&2; exit 1; } + register_copilot_marketplace || { echo "FAIL"; echo "Copilot marketplace registration failed" >&2; exit 1; } echo "ok" + for name in "${targets[@]}"; do + printf '%-22s' "$name" + copilot plugin install "$name@$MKT" >/dev/null || { echo "Copilot plugin install failed for $name" >&2; exit 1; } + echo " copilot:ok" + done fi -for t in "${targets[@]}"; do sync_one "$t"; done - -if [ "$HAVE_OPENCODE" = 1 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then - printf '%-22s' "install opencode" - validate_opencode_skills "${targets[@]}" || { echo "FAIL"; exit 1; } - sync_opencode_skills "${targets[@]}" - for t in "${targets[@]}"; do copy_agents "$OPENCODE_AGENTS" "" "$BUILD/opencode/.opencode/agents/$t-"*.md; done - echo "skills:ok agents:ok" -fi -if [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then - printf '%-22s' "reload opencode" - aidd-opencode-reload -fi if [ "$HAVE_CURSOR" = 1 ]; then printf '%-22s' "install cursor" mkdir -p "$CURSOR_PLUGINS" @@ -271,9 +175,7 @@ fi instructions=() [ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") [ "$HAVE_CLAUDE" = 0 ] || instructions+=(claude "$CLAUDE_INSTRUCTIONS") -if [ "$HAVE_OPENCODE" = 1 ] || [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then - instructions+=(opencode "$OPENCODE_INSTRUCTIONS") -fi +[ "$HAVE_OPENCODE" = 0 ] || instructions+=(opencode "$OPENCODE_INSTRUCTIONS") [ "$HAVE_COPILOT" = 0 ] || instructions+=(copilot "$COPILOT_INSTRUCTIONS") [ "$HAVE_CURSOR" = 0 ] || instructions+=(cursor "$CURSOR_INSTRUCTIONS") printf '%-22s' "global instructions" From 32fef3d50bfeefe8fe878cd09bcba119620798bf Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 10:09:06 +0800 Subject: [PATCH 03/16] refactor(cli): drop the agent frontmatter strippers no profile uses anymore Since the generic sub-agents shipped, every profile declares its agent frontmatter in agent-format.ts; agent-frontmatter-strip.ts and its test were left with no caller. --- .../domain/formats/agent-frontmatter-strip.ts | 59 ---------- .../agent-frontmatter-strip.unit.test.ts | 110 ------------------ 2 files changed, 169 deletions(-) delete mode 100644 cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts delete mode 100644 cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts diff --git a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts b/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts deleted file mode 100644 index a963fc32b..000000000 --- a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** Copilot-supported frontmatter keys for agent files. Order is preserved on serialization, - * for deterministic output. */ -export const COPILOT_AGENT_FRONTMATTER_KEYS: readonly [ - "name", - "description", - "model", - "reasoningEffort", - "include-custom-instructions", -] = ["name", "description", "model", "reasoningEffort", "include-custom-instructions"]; - -/** Cursor-supported frontmatter keys: Cursor documents only name, description and model — - * never tools or color. */ -export const CURSOR_AGENT_FRONTMATTER_KEYS: readonly ["name", "description", "model"] = [ - "name", - "description", - "model", -]; - -/** Keys of a host whose agent ID is its file path (OpenCode, Kilo): no `name`. */ -const PATH_ID_AGENT_FRONTMATTER_KEYS: readonly ["description", "model"] = ["description", "model"]; - -/** Pick only the given keys, preserving their order. A key whose value is undefined is - * omitted. */ -export function pickFrontmatterKeys( - fm: Record, - keys: readonly string[] -): Record { - const result: Record = {}; - for (const key of keys) { - if (fm[key] !== undefined) { - result[key] = fm[key]; - } - } - return result; -} - -/** Copilot reads Claude's `effort` as `reasoningEffort`, and gives a subagent the repository - * instructions only when `include-custom-instructions` is set. Lossy, so there is no inverse. */ -export function stripCopilotAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys( - { - ...fm, - reasoningEffort: fm.effort, - "include-custom-instructions": fm["include-custom-instructions"] ?? true, - }, - COPILOT_AGENT_FRONTMATTER_KEYS - ); -} - -/** Only the Cursor-supported keys. Tools, color and argument-hint are discarded. */ -export function stripCursorAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys(fm, CURSOR_AGENT_FRONTMATTER_KEYS); -} - -/** OpenCode and Kilo name an agent after its file and give it every tool by default; Kilo - * passes any unknown key to the model provider, so nothing else is carried. */ -export function stripPathIdAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys(fm, PATH_ID_AGENT_FRONTMATTER_KEYS); -} diff --git a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts deleted file mode 100644 index 8d9b51fc7..000000000 --- a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - COPILOT_AGENT_FRONTMATTER_KEYS, - CURSOR_AGENT_FRONTMATTER_KEYS, - stripCopilotAgentFrontmatter, - stripCursorAgentFrontmatter, - stripPathIdAgentFrontmatter, -} from "../../../../../src/contexts/tools/domain/formats/agent-frontmatter-strip.js"; - -describe("stripCopilotAgentFrontmatter", () => { - it("keeps name, description and model, and reads Claude's effort as reasoningEffort", () => { - expect( - stripCopilotAgentFrontmatter({ - name: "reviewer", - description: "Reviews code", - model: "claude-sonnet", - effort: "high", - }) - ).toEqual({ - name: "reviewer", - description: "Reviews code", - model: "claude-sonnet", - reasoningEffort: "high", - "include-custom-instructions": true, - }); - }); - - it("drops tools and every other key, so the agent keeps Copilot's default of all tools", () => { - const result = stripCopilotAgentFrontmatter({ - name: "reviewer", - tools: ["Read"], - agents: ["helper"], - "argument-hint": "[file]", - color: "red", - }); - expect(Object.keys(result)).toEqual(["name", "include-custom-instructions"]); - }); - - it("preserves an explicit repository-instruction opt-out", () => { - expect(stripCopilotAgentFrontmatter({ "include-custom-instructions": false })).toEqual({ - "include-custom-instructions": false, - }); - }); - - it("returns keys in allowlist order for deterministic serialization", () => { - const result = stripCopilotAgentFrontmatter({ - effort: "low", - model: "sonnet", - name: "reviewer", - description: "desc", - }); - expect(Object.keys(result)).toEqual(COPILOT_AGENT_FRONTMATTER_KEYS); - }); -}); - -describe("stripPathIdAgentFrontmatter", () => { - it("keeps description and model only: the file name is the ID, and no key restricts tools", () => { - expect( - stripPathIdAgentFrontmatter({ - name: "explorer", - description: "d", - model: "openai/gpt", - effort: "high", - tools: "Read", - }) - ).toEqual({ description: "d", model: "openai/gpt" }); - }); -}); - -describe("stripCursorAgentFrontmatter", () => { - it("drops tools, color, argument-hint — keeps only name/description/model", () => { - const input = { - name: "planner", - description: "Plans tasks", - model: "claude-sonnet", - tools: ["Read", "Write"], - color: "#ff0000", - "argument-hint": "[task]", - }; - const result = stripCursorAgentFrontmatter(input); - expect(result).toEqual({ - name: "planner", - description: "Plans tasks", - model: "claude-sonnet", - }); - expect(result).not.toHaveProperty("tools"); - expect(result).not.toHaveProperty("color"); - expect(result).not.toHaveProperty("argument-hint"); - }); - - it("preserves a subset: name only", () => { - const result = stripCursorAgentFrontmatter({ name: "agent" }); - expect(result).toEqual({ name: "agent" }); - }); - - it("omits model when undefined", () => { - const result = stripCursorAgentFrontmatter({ name: "agent", model: undefined }); - expect(result).not.toHaveProperty("model"); - }); - - it("returns keys in CURSOR_AGENT_FRONTMATTER_KEYS order", () => { - const input = { model: "sonnet", description: "desc", name: "agent" }; - const result = stripCursorAgentFrontmatter(input); - expect(Object.keys(result)).toEqual(CURSOR_AGENT_FRONTMATTER_KEYS.filter((k) => k in input)); - }); - - it("CURSOR_AGENT_FRONTMATTER_KEYS is exactly [name, description, model]", () => { - expect(CURSOR_AGENT_FRONTMATTER_KEYS).toEqual(["name", "description", "model"]); - }); -}); From 52a353abd44a21bc6b66a9a7cb3ce27bd1b04996 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 10:26:54 +0800 Subject: [PATCH 04/16] feat(cli): install the framework globally for Copilot, Cursor and Kilo too - Kilo copies skills and agents into ~/.config/kilo, as OpenCode does. - Cursor copies plugins into ~/.cursor/plugins/local. - Copilot 1.0.98 answers marketplace list --json: its local source is now proven, so setup --global enables the plugins through its CLI. - Plan user-scope plugin updates one at a time: plans for one tool share its built tree. --- cli/README.md | 9 ++-- .../framework-source-is-machine-scope.md | 2 +- .../ownership/user-plugin-file-updater.ts | 9 +++- .../ownership/user-plugin-update-use-case.ts | 23 ++++---- .../setup/install-machine-plugins-use-case.ts | 8 +-- .../domain/capabilities/plugins-capability.ts | 19 ++++++- .../copilot/native-marketplace-source.ts | 37 ++++++++++++- .../tools/domain/profiles/kilo/profile.ts | 7 ++- .../profiles/opencode/opencode-paths.ts | 20 ++----- cli/src/contexts/tools/domain/registry.ts | 11 +++- cli/src/presentation/commands/setup.ts | 10 ++-- ...tall-machine-plugins-use-case.unit.test.ts | 24 +++++++++ .../framework/domain/setup-flow.unit.test.ts | 21 ++++++-- .../profiles/kilo/machine-files.unit.test.ts | 30 +++++++++++ ...ketplace-source-reader.integration.test.ts | 54 +++++++++++++++++++ cli/tests/e2e/setup-scope-user.e2e.test.ts | 2 +- cli/tests/golden/snapshots/help/surface.json | 2 +- .../commands/setup-wiring.integration.test.ts | 3 +- docs/SUBAGENTS.md | 4 +- 19 files changed, 235 insertions(+), 60 deletions(-) create mode 100644 cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts diff --git a/cli/README.md b/cli/README.md index de2e31675..5bd264cb8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -122,16 +122,17 @@ Counts the tokens of Claude Code and Codex sessions, locally. Details: [FAQ](../ | --- | --- | | `claude`, `copilot` | Registers the source and enables the plugins through the tool's own CLI | | `codex` | The same, plus the plugins' agents in `~/.codex/agents/` | -| `opencode` | Copies the plugins' skills and agents into `~/.config/opencode/` | -| `cursor` | Records the source and tool only | -| `kilo` | Unsupported: use project scope | +| `opencode`, `kilo` | Copies the plugins' skills and agents into `~/.config/opencode/` or `~/.config/kilo/` | +| `cursor` | Copies the plugins into `~/.cursor/plugins/local/`; Cursor must allow local plugin imports | + +Copilot needs a CLI whose `plugin marketplace list` accepts `--json` (verified with 1.0.98; 1.0.83 refuses it). Native activation needs the tool's executable on `PATH`. Without it, registration succeeds with a warning and activation stays unrun. Install it, then run `aidd sync --global`. ```sh -aidd setup --global --ide claude,codex,opencode --plugins all --yes +aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all --yes aidd sync --global # rebuild the copied files and refresh the hosts from the framework source aidd clean --global # remove what --global installed and purge the shared source ``` diff --git a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md index f8f4be92a..6b7cb7fb7 100644 --- a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md +++ b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md @@ -49,7 +49,7 @@ One registration of `aidd-framework` per machine, shared by every project. Read - Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Unsupported Copilot versions refuse it. - Writes nothing under `projectRoot`: no content, no plugin prompt, no gitignore touch. -- `--plugins all|recommended|` picks plugins from the shared source's catalog. A tool with native activation gets them through `MarketplaceSyncSettingsOptions.enablePlugins`; a tool declaring `PluginsCapability.machineFiles` (opencode's skills and agents, codex's agents) gets them copied into its user directory as `scope: "user"` plugins, written by `UserPluginFileUpdater`, so `sync --scope user` rebuilds them and `clean --scope user` removes them. An interactive pick is refused (`UserScopePluginModeError`). +- `--plugins all|recommended|` picks plugins from the shared source's catalog. A tool with native activation gets them through `MarketplaceSyncSettingsOptions.enablePlugins`; a tool declaring `PluginsCapability.machineFiles` (opencode's and kilo's skills and agents, codex's agents) or installing into a user plugins directory (cursor) gets them copied there as `scope: "user"` plugins, written by `UserPluginFileUpdater`, so `sync --scope user` rebuilds them and `clean --scope user` removes them. An interactive pick is refused (`UserScopePluginModeError`). - Its manifest: `userManifestPath(userConfigDir())`, `userConfigDir()/manifest.json`, same schema and version as the project one. - `UserManifestRepositoryAdapter` reuses `Manifest.fromJSON`/`toJSON`; its `delete()` removes that one file only. - Each AI tool gets a manifest entry with an empty file list, so `MarketplaceSyncSettingsUseCase` has something to iterate. diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index 260af0573..6ee0a551e 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -15,7 +15,7 @@ import { } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; -import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { InstalledPlugin, PLUGIN_NAME_REGEX } from "../../domain/plugins/installed-plugin.js"; import { readBuiltUserPluginFiles, withoutHooksPrefix, @@ -137,8 +137,13 @@ export class UserPluginFileUpdater { await assertMachineFilesWritable(this.fs, toolId, plugin, plugin.name, files, home); return; } - await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, [...files], home); const baseDir = resolveBaseDirFromRecord("user", toolId, "", () => home); + // A first machine-wide install owns no file yet: its directory is created so the + // containment proof has a real path to resolve. The name is one safe path segment. + if (plugin.files.size === 0 && PLUGIN_NAME_REGEX.test(plugin.name)) { + await this.fs.createDirectory(join(baseDir, plugin.name)); + } + await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, [...files], home); await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); } diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts index 15e05e30f..567ea267c 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts @@ -66,19 +66,18 @@ export class UserPluginUpdateUseCase { "Multiple native catalogues match this plugin name; use an exact @ ref for targeted update." ); } - const filePlans = await Promise.all( - targets.map(async ({ toolId, plugin }) => ({ - toolId, + // One plan at a time: plans for one tool share its built tree, which the first one builds. + const filePlans = []; + for (const { toolId, plugin } of targets) { + const plan = await this.fileUpdater.planUpdate( plugin, - plan: await this.fileUpdater.planUpdate( - plugin, - toolId, - options.projectRoot, - this.logger, - options.rebuild === true - ), - })) - ); + toolId, + options.projectRoot, + this.logger, + options.rebuild === true + ); + filePlans.push({ toolId, plugin, plan }); + } const nativeUpdates = await Promise.all( nativeTargets.map(async ({ toolId, claim, registrations }) => ({ toolId, diff --git a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts index 2f12b3f24..3e66a6c15 100644 --- a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts +++ b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts @@ -4,7 +4,7 @@ import type { ResolveMarketplace } from "../../../distribution/application/resol import type { PluginCatalogEntry } from "../../../distribution/domain/catalog.js"; import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js"; import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; -import { machineFilesOf } from "../../../tools/domain/registry.js"; +import { copiesPluginFilesForMachine } from "../../../tools/domain/registry.js"; import type { Manifest } from "../../domain/manifest.js"; import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import { resolvePluginSourceFromMarketplace } from "../../domain/plugins/plugin-source-resolver.js"; @@ -25,8 +25,8 @@ export interface InstallMachinePluginsResult { /** * `setup --global --plugins …`: picks the framework plugins from the shared source's catalog and - * copies each one's machine files (OpenCode's skills and agents, Codex's agents) into the tool's - * user configuration directory, recording them in the machine manifest as user-scope plugins so + * copies each one's files into the tool's user configuration directory (OpenCode's and Kilo's + * skills and agents, Codex's agents) or user plugins directory (Cursor), recording them in the machine manifest as user-scope plugins so * `plugin update --scope user`, `sync --scope user` and `clean --scope user` own them. A first * install is a rebuild of a plugin that owns no file yet, so both go through one writer. A tool * that enables plugins through its own CLI is handed the chosen names by the caller instead. @@ -58,7 +58,7 @@ export class InstallMachinePluginsUseCase { }); const entries = selectEntries(catalog?.plugins ?? [], options); for (const toolId of options.toolIds) { - if (machineFilesOf(toolId) === null) continue; + if (!copiesPluginFilesForMachine(toolId)) continue; for (const entry of entries) { const owned = manifest.getPlugins(toolId).find((plugin) => plugin.name === entry.name); const plugin = diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 54757e2a3..43ebf7999 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -73,7 +73,7 @@ export interface ProjectHooksDelivery { /** * What a machine-scope install (`setup --global`) copies out of this tool's built tree into * the user's own configuration directory, for a host that loads those files from there rather - * than from a plugin it enables itself: OpenCode's skills and agents, Codex's agents. + * than from a plugin it enables itself: OpenCode's and Kilo's skills and agents, Codex's agents. */ export interface MachineFiles { /** The user configuration directory the files land in, given a homedir. */ @@ -91,6 +91,23 @@ export interface MachinePlugin { readonly agents: ReadonlySet; } +/** + * The machine-file mapping of a flat tool whose user configuration directory takes the same + * `skills/` and `agents/` its project directory does (OpenCode, Kilo): a plugin's skills and + * agents under `directory` keep their path below it. Hooks, commands and MCP entries stay + * project-scoped. + */ +export function flatMachineFilePath(directory: string): MachineFiles["map"] { + return (builtPath, plugin) => { + if (!builtPath.startsWith(directory)) return null; + const relative = builtPath.slice(directory.length); + if (relative.startsWith(`skills/${plugin.name}/`)) return relative; + const agent = /^agents\/(.+)\.md$/.exec(relative)?.[1]; + if (agent === undefined || !agent.startsWith(`${plugin.name}-`)) return null; + return plugin.agents.has(agent.slice(plugin.name.length + 1)) ? relative : null; + }; +} + /** Shared by every mode: where a tool's machine-scope files go, when it has any. */ interface MachineFilesParams { machineFiles?: MachineFiles; diff --git a/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts b/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts index f3803b181..8ee8ab9aa 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts @@ -1,9 +1,42 @@ -import type { NativeMarketplaceSourceListContract } from "../../ports/native-marketplace-source-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceListContract, +} from "../../ports/native-marketplace-source-reader.js"; + +const LOCAL_SOURCE_PREFIX = "Local: "; + +/** + * Copilot 1.0.98 answers `--json` with `[{ name, source, isDefault }]`, `source` reading + * `Local: ` or `GitHub: `. Only a local source is proven; any other stays + * named but unproven. Installed Copilot 1.0.83 rejects `--json` and stays unreadable. + */ +function parseCopilotMarketplaceSources( + output: string +): ReadonlyMap { + const parsed: unknown = JSON.parse(output); + if (!Array.isArray(parsed)) throw new Error("unexpected Copilot JSON shape"); + const entries = new Map(); + for (const item of parsed) { + const row: unknown = item; + if (row === null || typeof row !== "object") throw new Error("ambiguous Copilot JSON row"); + const { name, source } = row as Record; + if (typeof name !== "string" || name === "" || typeof source !== "string" || entries.has(name)) + throw new Error("ambiguous Copilot marketplace JSON row"); + const path = source.startsWith(LOCAL_SOURCE_PREFIX) + ? source.slice(LOCAL_SOURCE_PREFIX.length) + : ""; + entries.set( + name, + path === "" ? null : { kind: "effective-list", root: path, sourceType: "local", source: path } + ); + } + return entries; +} -/** Installed Copilot 1.0.83 rejects --json; no structured source shape is verified yet. */ export const copilotMarketplaceSourceListContract: NativeMarketplaceSourceListContract = { binary: "copilot", args: ["plugin", "marketplace", "list", "--json"], + parse: parseCopilotMarketplaceSources, unavailableMessage: "Copilot CLI marketplace list --json unavailable; upgrade to a supported binary and retry", unverifiedMessage: diff --git a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts index 708236ad2..e85dc3fab 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts @@ -1,8 +1,9 @@ +import { join } from "node:path"; import { AgentsCapability } from "../../capabilities/agents-capability.js"; import { CommandsCapability } from "../../capabilities/commands-capability.js"; import { CONFIG_MCP } from "../../capabilities/config-refs.js"; import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { flatMachineFilePath, PluginsCapability } from "../../capabilities/plugins-capability.js"; import { RulesCapability } from "../../capabilities/rules-capability.js"; import { SkillsCapability } from "../../capabilities/skills-capability.js"; import type { @@ -89,6 +90,10 @@ export const kilo: AiTool join(homedir, ".config", "kilo"), + map: flatMachineFilePath(KILO_DIRECTORY), + }, acceptsHooks: true, flatHooksDir: KILO_HOOKS_DIR, flatHooksLoaderEntry: { dir: KILO_PLUGIN_DIR, baseName: KILO_PLUGIN_ENTRY_BASENAME }, diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts index ee8311f1c..5dae58b53 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts @@ -2,25 +2,13 @@ * contract can read the same values: the profile imports the contract, so the contract cannot * import the profile back. */ -import type { MachinePlugin } from "../../capabilities/plugins-capability.js"; +import { flatMachineFilePath } from "../../capabilities/plugins-capability.js"; export const OPENCODE_DIRECTORY = ".opencode/"; -/** - * A machine-scope install puts a plugin's skills and agents where OpenCode loads them for every - * project: its global config directory takes the same `skills/` and `agents/` a project's - * `.opencode/` does. Hooks, commands and MCP entries stay project-scoped. - */ -export function opencodeMachineFilePath(builtPath: string, plugin: MachinePlugin): string | null { - const relative = builtPath.startsWith(OPENCODE_DIRECTORY) - ? builtPath.slice(OPENCODE_DIRECTORY.length) - : null; - if (relative === null) return null; - if (relative.startsWith(`skills/${plugin.name}/`)) return relative; - const agent = /^agents\/(.+)\.md$/.exec(relative)?.[1]; - if (agent === undefined || !agent.startsWith(`${plugin.name}-`)) return null; - return plugin.agents.has(agent.slice(plugin.name.length + 1)) ? relative : null; -} +/** A machine-scope install puts a plugin's skills and agents where OpenCode loads them for every + * project: its global config directory. */ +export const opencodeMachineFilePath = flatMachineFilePath(OPENCODE_DIRECTORY); /** * The directory OpenCode's own plugin loader scans (`{plugin,plugins}/*.{ts,js}`, one level, no diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index aad2fb652..11a466e18 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -121,7 +121,7 @@ export function pluginEnablementIsMachineGlobal(toolId: ToolId): boolean { /** Whether `--scope user` has anywhere to point this tool: it drives its own CLI machine-wide, * installs a plugin's files into a user-scope directory, or copies machine files into the - * user's configuration directory. `false` for a tool declaring none — kilo today. */ + * user's configuration directory. `false` for a tool declaring none. */ export function supportsUserScopeActivation(toolId: ToolId): boolean { const capability = resolvePluginsCapability(toolId); if (capability === null) return false; @@ -132,6 +132,15 @@ export function supportsUserScopeActivation(toolId: ToolId): boolean { ); } +/** Whether `setup --global` writes a plugin's files itself for this tool, into its user plugins + * directory or its user configuration directory, instead of only enabling the plugin through + * the tool's own CLI. */ +export function copiesPluginFilesForMachine(toolId: ToolId): boolean { + const capability = resolvePluginsCapability(toolId); + if (capability === null) return false; + return capability.installScope === "user" || capability.machineFiles !== null; +} + /** See {@link MachineFiles}: `null` for a tool whose machine-scope install copies no file. */ export function machineFilesOf(toolId: ToolId): MachineFiles | null { return resolvePluginsCapability(toolId)?.machineFiles ?? null; diff --git a/cli/src/presentation/commands/setup.ts b/cli/src/presentation/commands/setup.ts index 0cb4c53b8..cca55c91d 100644 --- a/cli/src/presentation/commands/setup.ts +++ b/cli/src/presentation/commands/setup.ts @@ -5,9 +5,10 @@ import { SetupUseCase } from "../../contexts/framework/application/setup-use-cas import { SetupFlow } from "../../contexts/framework/domain/setup-flow.js"; import { assertToolIdsMatchCategory, + copiesPluginFilesForMachine, getAllRegisteredTools, - machineFilesOf, nativeActivationOf, + resolvePluginsCapability, supportsUserScopeActivation, } from "../../contexts/tools/domain/registry.js"; import type { ToolId } from "../../kernel/tool.js"; @@ -103,16 +104,15 @@ export function parsePluginsFlag( export function globalSetupHelp(): string { const tools = [...getAllRegisteredTools().keys()].filter(supportsUserScopeActivation).sort(); const native = tools.filter((id) => nativeActivationOf(id) !== undefined); - const copying = tools.filter((id) => machineFilesOf(id) !== null); - const recorded = tools.filter((id) => !native.includes(id) && !copying.includes(id)); - const where = (id: ToolId) => machineFilesOf(id)?.dir("~").replaceAll("\\", "/"); + const copying = tools.filter(copiesPluginFilesForMachine); + const where = (id: ToolId) => + resolvePluginsCapability(id)?.userPluginsBaseDir("~")?.replaceAll("\\", "/"); return [ "", "Global:", ` Pass --ide ${tools.join(",")} (one or more, no editor) and --plugins all, recommended, none or names.`, ` ${native.join(", ")} enable the plugins through their own CLI, which must be on PATH.`, ` ${copying.map((id) => `${id} gets their files in ${where(id)}`).join(", ")}.`, - ` ${recorded.join(", ")} only records the framework source.`, " `aidd sync --global` refreshes them from the framework source.", ].join("\n"); } diff --git a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts index fd54b2fb3..8696ac4b6 100644 --- a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import type { PluginCatalogEntry } from "../../../../../src/contexts/distribution/domain/catalog.js"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; @@ -100,6 +101,29 @@ describe("InstallMachinePluginsUseCase", () => { expect(planUpdate).toHaveBeenCalledTimes(2); }); + it("also writes the plugins of a tool installing into a user plugins directory", async () => { + const { manifest, useCase, planUpdate } = setup(); + manifest.addTool("cursor", "1.0.0", []); + + await useCase.execute(manifest, { + projectRoot: "/project", + toolIds: ["cursor"], + mode: "recommended", + pluginNames: [], + }); + + expect(manifest.getPlugins("cursor").map((p) => [p.name, p.scope])).toEqual([ + ["aidd-dev", "user"], + ]); + expect(planUpdate).toHaveBeenCalledWith( + expect.objectContaining({ name: "aidd-dev" }), + "cursor", + "/project", + expect.anything(), + true + ); + }); + it("keeps the recommended plugins alone", async () => { const { manifest, useCase } = setup(); diff --git a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts index 49d05a0a3..d8a11be0c 100644 --- a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts +++ b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts @@ -4,7 +4,8 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import { kilo } from "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import { registerTool } from "../../../../src/contexts/tools/domain/registry.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { @@ -121,10 +122,20 @@ describe("SetupFlow", () => { expect(() => makeFlow({ scope: "user" })).toThrow(UserScopeNoToolsError); }); - it("refuses an AI tool with no user-scope activation at --scope user (kilo)", () => { - expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).toThrow( - UserScopeUnsupportedAiToolsError - ); + it("refuses an AI tool with no user-scope activation at --scope user", () => { + const { plugins: _plugins, ...withoutPlugins } = kilo.capabilities; + registerTool({ ...kilo, capabilities: withoutPlugins }); + try { + expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).toThrow( + UserScopeUnsupportedAiToolsError + ); + } finally { + registerTool(kilo); + } + }); + + it("accepts an AI tool that copies machine files at --scope user (kilo)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).not.toThrow(); }); it("accepts an AI tool that copies machine files at --scope user (opencode)", () => { diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts new file mode 100644 index 000000000..2823c2de3 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts @@ -0,0 +1,30 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; + +const AIDD_DEV = { name: "aidd-dev", agents: new Set(["reviewer"]) }; + +describe("the kilo profile's machine-scope files", () => { + const machine = machineFilesOf("kilo"); + + it("land in ~/.config/kilo under the home directory", () => { + expect(machine?.dir("/home/me")).toBe(join("/home/me", ".config/kilo")); + }); + + it.each([ + [".kilo/skills/aidd-dev/01-plan/SKILL.md", "skills/aidd-dev/01-plan/SKILL.md"], + [".kilo/agents/aidd-dev-reviewer.md", "agents/aidd-dev-reviewer.md"], + ])("keep the plugin's own %s as %s", (built, path) => { + expect(machine?.map(built, AIDD_DEV)).toBe(path); + }); + + it.each([ + ".kilo/skills/aidd-pm/01-epic/SKILL.md", + ".kilo/agents/aidd-dev-executor.md", + ".kilo/commands/aidd-dev-plan.md", + ".opencode/skills/aidd-dev/01-plan/SKILL.md", + ])("leave %s out", (built) => { + expect(machine?.map(built, AIDD_DEV)).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts index 2da2ae663..7cffc4f93 100644 --- a/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts @@ -247,4 +247,58 @@ describe("native marketplace source reader", () => { expect(reading.unreadable).toContain("marketplace list --json"); expect(reading.unreadable).toContain("upgrade"); }); + + it("proves Copilot 1.0.98's local sources and leaves any other source unproven", async () => { + spawn.mockReturnValue( + result( + JSON.stringify([ + { name: "aidd-framework", source: "Local: /home/.aidd/built/copilot", isDefault: false }, + { name: "awesome-copilot", source: "GitHub: github/awesome-copilot", isDefault: true }, + ]) + ) + ); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + copilotMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries?.get("aidd-framework")).toEqual({ + kind: "effective-list", + root: "/home/.aidd/built/copilot", + sourceType: "local", + source: "/home/.aidd/built/copilot", + }); + expect(reading.entries?.has("awesome-copilot")).toBe(true); + expect(reading.entries?.get("awesome-copilot")).toBeNull(); + }); + + it.each([ + ['{"marketplaces":[]}'], + ["[null]"], + ['[{"source":"Local: /a"}]'], + ['[{"name":"","source":"Local: /a"}]'], + ['[{"name":"x"}]'], + ['[{"name":"x","source":"Local: /a"},{"name":"x","source":"Local: /b"}]'], + ])("refuses the Copilot listing %s rather than guess a source", async (stdout) => { + spawn.mockReturnValue(result(stdout)); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + copilotMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toMatch( + /^copilot marketplace list could not prove a source: .*Copilot/ + ); + }); + + it("leaves a Copilot source reading just 'Local: ' unproven", async () => { + spawn.mockReturnValue(result('[{"name":"x","source":"Local: "}]')); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + copilotMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries?.get("x")).toBeNull(); + }); }); diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index ecd0fef9f..7d9acea52 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -81,7 +81,7 @@ describe("E2E: setup --scope user writes nothing under the project", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("--ide claude,codex,copilot,cursor,opencode"); + expect(result.stdout).toContain("--ide claude,codex,copilot,cursor,kilo,opencode"); expect(result.stdout).toContain("--plugins all, recommended, none or names"); expect(result.stdout).toContain("no editor"); expect(result.stdout).toContain("must be on PATH"); diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index 2663670ff..953e3cf99 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -127,7 +127,7 @@ { "invocation": "aidd setup", "exitCode": 0, - "help": "Usage: aidd setup [options]\n\nSet up or update the project to a correct state — bootstraps the whole project\n(marketplace, framework, tools, plugins); see `framework install`, which acts on\nthe framework alone\n\nOptions:\n --source Framework source: remote or local\n --path Absolute path to local framework (required with\n --source local)\n --release Marketplace release tag to fetch (e.g., v1.2.3)\n --ide Comma-separated tool IDs, or 'all' (e.g.,\n claude,vscode or all)\n --plugins Plugin install mode: none | all | recommended |\n comma-separated names\n --no-default-marketplace Skip auto-registering aidd-framework (no source\n prompt, no plugin install)\n --yes Accept defaults without prompting\n --global install for every project on this machine instead of\n into this project; see the requirements below\n -h, --help display help for command\n\nGlobal:\n Pass --ide claude,codex,copilot,cursor,opencode (one or more, no editor) and --plugins all, recommended, none or names.\n claude, codex, copilot enable the plugins through their own CLI, which must be on PATH.\n codex gets their files in ~/.codex, opencode gets their files in ~/.config/opencode.\n cursor only records the framework source.\n `aidd sync --global` refreshes them from the framework source." + "help": "Usage: aidd setup [options]\n\nSet up or update the project to a correct state — bootstraps the whole project\n(marketplace, framework, tools, plugins); see `framework install`, which acts on\nthe framework alone\n\nOptions:\n --source Framework source: remote or local\n --path Absolute path to local framework (required with\n --source local)\n --release Marketplace release tag to fetch (e.g., v1.2.3)\n --ide Comma-separated tool IDs, or 'all' (e.g.,\n claude,vscode or all)\n --plugins Plugin install mode: none | all | recommended |\n comma-separated names\n --no-default-marketplace Skip auto-registering aidd-framework (no source\n prompt, no plugin install)\n --yes Accept defaults without prompting\n --global install for every project on this machine instead of\n into this project; see the requirements below\n -h, --help display help for command\n\nGlobal:\n Pass --ide claude,codex,copilot,cursor,kilo,opencode (one or more, no editor) and --plugins all, recommended, none or names.\n claude, codex, copilot enable the plugins through their own CLI, which must be on PATH.\n codex gets their files in ~/.codex, cursor gets their files in ~/.cursor/plugins/local, kilo gets their files in ~/.config/kilo, opencode gets their files in ~/.config/opencode.\n `aidd sync --global` refreshes them from the framework source." }, { "invocation": "aidd sync", diff --git a/cli/tests/presentation/commands/setup-wiring.integration.test.ts b/cli/tests/presentation/commands/setup-wiring.integration.test.ts index 279e57499..84d29583b 100644 --- a/cli/tests/presentation/commands/setup-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/setup-wiring.integration.test.ts @@ -370,8 +370,7 @@ describe("aidd setup — the help surface", () => { "Global:", " Pass --ide claude,codex,copilot,cursor,opencode (one or more, no editor) and --plugins all, recommended, none or names.", " claude, codex, copilot enable the plugins through their own CLI, which must be on PATH.", - " codex gets their files in ~/.codex, opencode gets their files in ~/.config/opencode.", - " cursor only records the framework source.", + " codex gets their files in ~/.codex, cursor gets their files in ~/.cursor/plugins/local, opencode gets their files in ~/.config/opencode.", " `aidd sync --global` refreshes them from the framework source.", ]); }); diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 9a1df9cd6..e8c24281d 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -51,10 +51,10 @@ Only `/aidd-orchestrator:01-sdlc` names an agent, by its role (`validator`), so Install the `aidd-orchestrator` plugin: `/plugin install aidd-orchestrator@aidd-framework` in Claude Code, or see the [quick start](../README.md#quick-start) for the other tools. The agents come with it, except as a Codex plugin (see [Limits](#-limits)); open a new session so the tool loads them. -For every project on the machine at once, Claude Code, Codex and OpenCode included: +For every project on the machine at once: ```bash -aidd setup --global --ide claude,codex,opencode --plugins all +aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all aidd sync --global # after a framework update ``` From 1d727f26bd3faafdd45ec9d5e4a0acca2ed87425 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 10:42:33 +0800 Subject: [PATCH 05/16] feat(framework): make reload installs every tool with setup --global - Copilot and Cursor leave the script's own build and copy: setup --global installs them, with Kilo now detected too. Copilot is released afresh each reload, like Claude and Codex. - Kilo documents no global AGENTS.md, so the reload writes it no instructions. - Docs: INSTALL.md shows the machine-wide install, update and removal; the CLI README, memory and smoke-real tool list follow every tool now supporting --global. --- Makefile | 2 +- aidd_docs/memory/deployment.md | 2 +- aidd_docs/memory/project-brief.md | 2 +- cli/README.md | 3 +- .../framework-source-is-machine-scope.md | 4 +- cli/scripts/smoke-real.sh | 5 +- docs/INSTALL.md | 12 +- scripts/__tests__/dev-sync.test.js | 287 ++---------------- ...e-real-user-scope-tool-list-parity.test.js | 19 +- scripts/dev-sync.sh | 146 +++------ 10 files changed, 98 insertions(+), 384 deletions(-) diff --git a/Makefile b/Makefile index 24aa053b7..1b8b55bb5 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,6 @@ doctor: ## Check the local toolchain check: ## Run the pre-commit checks pnpm exec lefthook run pre-commit -reload: ## Dev: reload all plugins into Claude, Codex, OpenCode, Copilot, and Cursor +reload: ## Dev: reload all plugins into Claude, Codex, OpenCode, Kilo, Copilot, and Cursor via aidd setup --global pnpm --dir cli build scripts/dev-sync.sh diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 89190d715..bdfc8ea32 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -66,7 +66,7 @@ Config: `release-please-config.json`, eleven packages. Manifest: `.release-pleas ## Local reload -`make reload` syncs all plugins and generic delegation guidance to installed hosts; details in `scripts/dev-sync.sh`. +`make reload` installs every plugin into the installed hosts with `aidd setup --global` from this checkout, then syncs generic delegation guidance; details in `scripts/dev-sync.sh`. ## Monitoring diff --git a/aidd_docs/memory/project-brief.md b/aidd_docs/memory/project-brief.md index dc2bb5f41..f1afc7e3d 100644 --- a/aidd_docs/memory/project-brief.md +++ b/aidd_docs/memory/project-brief.md @@ -33,7 +33,7 @@ What this project is, the problem it solves, and its domain language. The non-de | Capability | Entry | | --- | --- | -| Install and refresh a tool's configuration | `aidd setup --ai --ide ` | +| Install and refresh a tool's configuration | `aidd setup --ide `; `--global` for every project on the machine | | Install plugins from a marketplace | `aidd plugin install`, `aidd marketplace add` | | Build a target-native distribution | `aidd translate --to --out ` | | Bootstrap and refresh project memory | `aidd-context:02-project-memory` | diff --git a/cli/README.md b/cli/README.md index 5bd264cb8..92a9a5cac 100644 --- a/cli/README.md +++ b/cli/README.md @@ -37,6 +37,7 @@ Check: `aidd --version`. | Command | Lands in | | --- | --- | | `aidd setup --ide claude` | `.claude/` and `.aidd/` in this project | +| `aidd setup --global --ide opencode --plugins all` | `~/.config/opencode/` and `~/.config/aidd/`, nothing in this project | ## Commands @@ -210,7 +211,7 @@ Under `$AIDD_USER_CONFIG_DIR`, else `$XDG_CONFIG_HOME/aidd`, else `~/.config/aid | `auth.json` | The user-level credential | | `marketplaces.json` | The user-scope registry | | `references.json` | The projects claiming it | -| `manifest.json` | What `--scope user` owns | +| `manifest.json` | What `--global` owns | | `cache/built//` | One built tree per CLI version | ### Settings written per tool diff --git a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md index 6b7cb7fb7..e26cf8b4c 100644 --- a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md +++ b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md @@ -47,7 +47,7 @@ One registration of `aidd-framework` per machine, shared by every project. Read ## `setup --scope user` -- Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Unsupported Copilot versions refuse it. +- Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Copilot proves its source through `plugin marketplace list --json` (1.0.98); a version rejecting `--json` (1.0.83) refuses it. - Writes nothing under `projectRoot`: no content, no plugin prompt, no gitignore touch. - `--plugins all|recommended|` picks plugins from the shared source's catalog. A tool with native activation gets them through `MarketplaceSyncSettingsOptions.enablePlugins`; a tool declaring `PluginsCapability.machineFiles` (opencode's and kilo's skills and agents, codex's agents) or installing into a user plugins directory (cursor) gets them copied there as `scope: "user"` plugins, written by `UserPluginFileUpdater`, so `sync --scope user` rebuilds them and `clean --scope user` removes them. An interactive pick is refused (`UserScopePluginModeError`). - Its manifest: `userManifestPath(userConfigDir())`, `userConfigDir()/manifest.json`, same schema and version as the project one. @@ -58,5 +58,5 @@ One registration of `aidd-framework` per machine, shared by every project. Read - `sync --scope user` resolves the same manifest, rebuilds its `scope: "user"` plugin files, then drives native activation. - `--scope ` on all three, default `project`. - Refuses an `--ide` tool (`UserScopeIdeToolsError`): IDE config is project-relative. -- Refuses an AI tool without machine-wide activation (`UserScopeUnsupportedAiToolsError`; `registry.ts`'s `supportsUserScopeActivation`, false for kilo alone). +- Refuses an AI tool without machine-wide activation (`UserScopeUnsupportedAiToolsError`; `registry.ts`'s `supportsUserScopeActivation`); every registered AI tool supports it today. - Both refusals fire in `SetupFlow`'s constructor; `--ai all` is unusable at this scope. diff --git a/cli/scripts/smoke-real.sh b/cli/scripts/smoke-real.sh index 51ff408b9..52def735c 100644 --- a/cli/scripts/smoke-real.sh +++ b/cli/scripts/smoke-real.sh @@ -167,11 +167,12 @@ PROJ_U=$(mktemp -d "$TMPROOT/proj-user.XXXXXX") (cd "$PROJ_U" && git init -q) # `setup --scope user` exits 1 for a tool declaring no machine-wide activation -# (`registry.ts`'s `supportsUserScopeActivation`; opencode today), before anything is +# (`registry.ts`'s `supportsUserScopeActivation`; none today), before anything is # written — so the machine-scope phases carry their own tool list, never `ai_list`. +# kilo is listed for parity but never measured: `AI_TOOLS` does not probe for it. user_ai_list() { local ids=() - for t in claude codex copilot cursor; do + for t in claude codex copilot cursor kilo opencode; do [[ -n "${PRESENT[$t]:-}" ]] && ids+=("$t") done (IFS=,; echo "${ids[*]}") diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 626a43a9f..c4bbdf74a 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -28,9 +28,17 @@ It asks for your tool and your plugins, then installs them in your project. *Example for Claude Code, without the questions:* ```sh -npx @ai-driven-dev/cli@latest setup --ai claude --plugins recommended --yes +npx @ai-driven-dev/cli@latest setup --ide claude --plugins recommended --yes ``` +### For every project + +```sh +npx @ai-driven-dev/cli@latest setup --global --ide claude,codex,opencode --plugins all --yes +``` + +It installs the plugins once for the whole machine and writes nothing in your project. It takes any of `claude`, `codex`, `copilot`, `cursor`, `kilo` and `opencode`: [what each one gets](../cli/README.md#scope). + ### Check ```sh @@ -146,6 +154,7 @@ cp -R aidd-framework--flat-/. . | Installed with | Run | | --- | --- | | CLI | `npx @ai-driven-dev/cli@latest plugin update` | +| CLI, every project | `npx @ai-driven-dev/cli@latest sync --global` | | Claude Code marketplace | `claude plugin update @aidd-framework` | | Flat files | The same `translate` command, with `--force` | @@ -157,6 +166,7 @@ For another tool's marketplace, use that tool's plugin manager. | --- | --- | | CLI, one plugin | `npx @ai-driven-dev/cli@latest plugin remove ` | | CLI, everything | `npx @ai-driven-dev/cli@latest clean` previews. Add `--force` to delete | +| CLI, every project | `npx @ai-driven-dev/cli@latest clean --global --force` | | Claude Code marketplace | `claude plugin uninstall ` | | Flat files | Delete the files you copied | diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 99a5585bf..1f4a81028 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -183,18 +183,11 @@ function runSync(fakeBin, home, args = ["all"], overrides = {}) { PATH: [fakeBin, BASE_PATH].join(delimiter), HOME: home, FW: fixture(home), - BUILD: join(home, "build"), - CODEX_CACHE: join(home, ".codex/plugins/cache"), - CLAUDE_CACHE: join(home, ".claude/plugins/cache"), - OPENCODE_SKILLS: join(home, ".config/opencode/skills"), - CODEX_AGENTS: join(home, ".codex/agents"), - OPENCODE_AGENTS: join(home, ".config/opencode/agents"), CODEX_INSTRUCTIONS: join(home, ".codex/AGENTS.md"), CLAUDE_INSTRUCTIONS: join(home, ".claude/CLAUDE.md"), OPENCODE_INSTRUCTIONS: join(home, ".config/opencode/AGENTS.md"), COPILOT_INSTRUCTIONS: join(home, ".copilot/copilot-instructions.md"), CURSOR_INSTRUCTIONS: join(home, ".cursor/rules/aidd-agents.mdc"), - CURSOR_PLUGINS: join(home, ".cursor/plugins/local"), OPENCODE_DISABLE_CLAUDE_CODE: "", OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: "", ...overrides, @@ -284,31 +277,33 @@ if (args[0] !== "setup") process.exit(2); ${setupBody}`); } -test("Claude, Codex and OpenCode take this checkout through aidd setup --global, afresh", () => { +test("every host takes this checkout through aidd setup --global, afresh", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); const source = fixture(home, ["aidd-dev", "aidd-pm"]); recordingCli(source); - for (const host of ["claude", "codex", "opencode"]) { + for (const host of ["claude", "codex", "copilot", "cursor-agent", "kilo", "opencode"]) { executable(join(fakeBin, host), `printf '%s\\n' "${host} $*" >> "$HOME/host-calls"`); } const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /global install\s+ok \(claude,codex,opencode\)/); + assert.match(result.stdout, /global install\s+ok \(claude,codex,copilot,cursor,kilo,opencode\)/); assert.equal( readFileSync(join(home, "aidd-calls"), "utf8"), - `setup --global --source local --path ${source} --ide claude,codex,opencode --plugins all --yes\n` + `setup --global --source local --path ${source} --ide claude,codex,copilot,cursor,kilo,opencode --plugins all --yes\n` ); const hostCalls = readFileSync(join(home, "host-calls"), "utf8"); for (const plugin of ["aidd-dev", "aidd-pm"]) { assert.match(hostCalls, new RegExp(`claude plugin uninstall ${plugin}@aidd-framework --scope user`)); assert.match(hostCalls, new RegExp(`codex plugin remove ${plugin}@aidd-framework`)); + assert.match(hostCalls, new RegExp(`copilot plugin uninstall ${plugin}@aidd-framework`)); } assert.match(hostCalls, /claude plugin marketplace remove aidd-framework --scope user/); assert.match(hostCalls, /codex plugin marketplace remove aidd-framework/); - assert.doesNotMatch(hostCalls, /opencode/); + assert.match(hostCalls, /copilot plugin marketplace remove aidd-framework --force/); + assert.doesNotMatch(hostCalls, /^(cursor-agent|kilo|opencode) /m); } finally { rmSync(root, { recursive: true, force: true }); } }); @@ -340,290 +335,60 @@ test("a global install from the published CLI is used when the checkout has none } finally { rmSync(root, { recursive: true, force: true }); } }); -for (const host of ["copilot", "cursor"]) { - test(`reload fails when the installed ${host} host's build fails`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-build-failed-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - executable(join(fakeBin, host), "exit 0"); - executable(join(fakeBin, "npx"), "exit 7"); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, new RegExp(`${host} build failed`)); - assert.doesNotMatch(result.stdout, /Done\./); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -} - -for (const host of ["copilot"]) { - test(`reload fails when the installed ${host} host rejects a plugin`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-install-failed-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - fixture(home, ["aidd-pm"]); - executable(join(fakeBin, host), `if [ "$1 $2" = 'plugin marketplace' ] && [ "$3" = list ]; then echo '[]'; fi\nif [ "$1" = plugin ] && [ "$2" = ${host === "codex" ? "add" : "install"} ]; then exit 8; fi\nexit 0`); - executable(join(fakeBin, "npx"), host === "copilot" ? nativeBuildStub() : "exit 0"); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /plugin install failed for aidd-pm/); - assert.doesNotMatch(result.stdout, /Done\./); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -} - -function nativeBuildStub(omitSkill = false) { - return String.raw`out=""; tool="" -while [ "$#" -gt 0 ]; do - case "$1" in --out) shift; out=$1 ;; --to) shift; tool=$1 ;; esac - shift -done -for source in "$FW/plugins/"*/; do - plugin="$(basename "$source")" - target="$out/plugins/$plugin" - manifest=".$tool-plugin" - [ "$tool" != copilot ] || manifest=".plugin" - mkdir -p "$target/$manifest" "$target/skills" - printf '{"name":"%s"}\n' "$plugin" > "$target/$manifest/plugin.json" - ${omitSkill ? ":" : 'cp -R "$FW/plugins/$plugin/skills/." "$target/skills/"'} -done`; -} - for (const binary of ["cursor-agent", "agent", "cursor"]) { - test(`Cursor reload through ${binary} installs every built plugin and preserves personal content`, () => { + test(`Cursor found through ${binary} is installed globally with its always-on rule`, () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - fixture(home, ["aidd-dev", "aidd-pm"]); - const personal = [".cursor/agents/executor.md", ".cursor/rules/personal.mdc", ".cursor/mcp.json", ".cursor/plugins/local/personal/notes.md"]; - for (const file of personal) { - mkdirSync(dirname(join(home, file)), { recursive: true }); - writeFileSync(join(home, file), "personal bytes\n"); - } - const stale = join(home, ".cursor/plugins/local/aidd-dev/agents/removed.md"); - mkdirSync(dirname(stale), { recursive: true }); - writeFileSync(stale, "retired role\n"); + recordingCli(fixture(home)); executable(join(fakeBin, binary), "echo 'Start the Cursor Agent'"); - executable(join(fakeBin, "npx"), nativeBuildStub()); const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - for (const [plugin, skill] of [["aidd-dev", "01-plan"], ["aidd-pm", "03-prd"]]) { - assert.equal(readFileSync(join(home, `.cursor/plugins/local/${plugin}/skills/${skill}/SKILL.md`), "utf8"), "source skill\n"); - } - assert.equal(existsSync(join(home, ".cursor/plugins/local/aidd-dev/agents")), false); - assert.match(readFileSync(join(home, ".cursor/rules/aidd-agents.mdc"), "utf8"), /Use available agents when useful/); - assert.equal(existsSync(stale), false); - for (const file of personal) assert.equal(readFileSync(join(home, file), "utf8"), "personal bytes\n"); - const first = readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"); - assert.equal(runSync(fakeBin, home, []).status, 0); - assert.equal(readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"), first); + assert.match(readFileSync(join(home, "aidd-calls"), "utf8"), / --ide cursor /); + assert.match(readFileSync(join(home, ".cursor/rules/aidd-agents.mdc"), "utf8"), /^---\nalwaysApply: true\n---\n/); } finally { rmSync(root, { recursive: true, force: true }); } }); } -test("Cursor reload rejects incomplete native output before changing installed plugins", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-incomplete-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); - const installed = join(home, ".cursor/plugins/local/aidd-dev/agents/executor.md"); - mkdirSync(dirname(installed), { recursive: true }); - writeFileSync(installed, "old role\n"); - executable(join(fakeBin, "cursor-agent"), "exit 0"); - executable(join(fakeBin, "npx"), nativeBuildStub(true)); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /no built cursor skill/); - assert.equal(readFileSync(installed, "utf8"), "old role\n"); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -test("another tool named agent does not trigger Cursor installation", () => { +test("another tool named agent is not taken for Cursor", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-other-agent-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); + recordingCli(fixture(home)); executable(join(fakeBin, "agent"), "echo 'Grok Build TUI'"); - executable(join(fakeBin, "npx"), "exit 9"); const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - assert.equal(existsSync(join(home, "build/cursor")), false); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -const copilotMarketplaceList = String.raw`node -e 'process.stdout.write(JSON.stringify([{name:"aidd-framework",source:"Local: "+process.argv[1]}]))'`; -const copilotStub = String.raw`printf '%s\n' "$*" >> "$HOME/copilot-calls" -case "$1 $2 $3" in - 'plugin marketplace list') - if [ -f "$HOME/copilot-source" ]; then - ${copilotMarketplaceList} "$(cat "$HOME/copilot-source")" - else echo '[]'; fi ;; - 'plugin marketplace add') - if [ -f "$HOME/copilot-source" ]; then echo 'Marketplace "aidd-framework" already registered' >&2; exit 1; fi - printf '%s' "$4" > "$HOME/copilot-source" ;; - 'plugin marketplace remove') rm "$HOME/copilot-source" ;; - 'plugin marketplace update') : ;; -esac`; - -test("Copilot fixture serializes Windows paths and quotes as JSON", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-json-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); mkdirSync(home); - const source = String.raw`D:\Work\Example "quoted"\Framework`; - writeFileSync(join(home, "copilot-source"), source); - executable(join(fakeBin, "node"), `exec '${process.execPath}' "$@"`); - const binary = join(fakeBin, "copilot"); - executable(binary, copilotStub); - const result = spawnSync("bash", [binary, "plugin", "marketplace", "list", "--json"], { - cwd: home, - env: { ...process.env, HOME: home, PATH: [fakeBin, BASE_PATH].join(delimiter) }, - encoding: "utf8", - }); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(JSON.parse(result.stdout), [{ name: "aidd-framework", source: `Local: ${source}` }]); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -test("Copilot reload builds all native plugins, migrates its own source, and preserves personal files", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); mkdirSync(home); - const source = fixture(home, ["aidd-dev", "aidd-pm"]); - writeFileSync(join(home, "copilot-source"), source); - const personal = [".copilot/agents/executor.agent.md", ".copilot/instructions/personal.instructions.md", ".copilot/settings.json"]; - for (const file of personal) { - mkdirSync(dirname(join(home, file)), { recursive: true }); - writeFileSync(join(home, file), "personal bytes\n"); - } - executable(join(fakeBin, "copilot"), copilotStub); - executable(join(fakeBin, "npx"), nativeBuildStub()); - const first = runSync(fakeBin, home, []); - assert.equal(first.status, 0, first.stderr); - assert.equal(realpathSync(readFileSync(join(home, "copilot-source"), "utf8")), realpathSync(join(home, "build/copilot"))); - for (const plugin of ["aidd-dev", "aidd-pm"]) { - assert.match(readFileSync(join(home, "copilot-calls"), "utf8"), new RegExp(`plugin install ${plugin}@aidd-framework`)); - assert.equal(JSON.parse(readFileSync(join(home, `build/copilot/plugins/${plugin}/.plugin/plugin.json`), "utf8")).name, plugin); - } - assert.match(readFileSync(join(home, ".copilot/copilot-instructions.md"), "utf8"), /Use available agents when useful/); - assert.equal(runSync(fakeBin, home, []).status, 0); - assert.match(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace update aidd-framework/); - for (const file of personal) assert.equal(readFileSync(join(home, file), "utf8"), "personal bytes\n"); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -test("Copilot refuses another source registered under the same marketplace name", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-foreign-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); mkdirSync(home); - writeFileSync(join(home, "copilot-source"), "/another/framework"); - executable(join(fakeBin, "copilot"), copilotStub); - executable(join(fakeBin, "npx"), nativeBuildStub()); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /different Copilot marketplace source/); - assert.equal(readFileSync(join(home, "copilot-source"), "utf8"), "/another/framework"); - assert.doesNotMatch(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace remove|plugin install/); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -test("Copilot rejects incomplete native output before registering or installing", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-incomplete-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); - executable(join(fakeBin, "copilot"), copilotStub); - executable(join(fakeBin, "npx"), nativeBuildStub(true)); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /no built copilot skill/); - assert.equal(existsSync(join(home, "copilot-calls")), false); + assert.match(result.stdout, /No supported host installation found/); + assert.equal(existsSync(join(home, "aidd-calls")), false); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("Copilot registers an inherited own marketplace without trying to remove it", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-inherited-")); +test("Kilo is installed globally and gets no global instructions file", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-kilo-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - executable(join(fakeBin, "copilot"), String.raw`printf '%s\n' "$*" >> "$HOME/copilot-calls" -case "$1 $2 $3" in - 'plugin marketplace list') ${copilotMarketplaceList} "$FW" ;; - 'plugin marketplace remove') echo 'Marketplace "aidd-framework" is not registered' >&2; exit 1 ;; - 'plugin marketplace add') printf '%s' "$4" > "$HOME/copilot-source" ;; -esac`); - executable(join(fakeBin, "npx"), nativeBuildStub()); + recordingCli(fixture(home)); + executable(join(fakeBin, "kilo"), "exit 0"); const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - assert.equal(realpathSync(readFileSync(join(home, "copilot-source"), "utf8")), realpathSync(join(home, "build/copilot"))); - assert.doesNotMatch(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace remove/); + assert.match(readFileSync(join(home, "aidd-calls"), "utf8"), / --ide kilo /); + assert.equal(existsSync(join(home, ".config/kilo/AGENTS.md")), false); } finally { rmSync(root, { recursive: true, force: true }); } }); -for (const command of ["add", "update", "install"]) { - test(`Copilot ${command} failure retains the native cause`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-cause-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); - executable(join(fakeBin, "copilot"), String.raw`case "$1 $2 $3" in - 'plugin marketplace list') ${command === "update" ? `${copilotMarketplaceList} "$BUILD/copilot"` : "echo '[]'"} ;; - 'plugin marketplace ${command}'${command === "install" ? "|\'plugin install aidd-dev@aidd-framework\'" : ""}) echo 'fixture native permission denied' >&2; exit 9 ;; -esac`); - executable(join(fakeBin, "npx"), nativeBuildStub()); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /fixture native permission denied/); - } finally { rmSync(root, { recursive: true, force: true }); } - }); -} - test("reload rejects plugin selection arguments before changing any host", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-filter-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); + executable(join(fakeBin, "claude"), `printf '%s\\n' "$*" >> "$HOME/host-calls"`); const result = runSync(fakeBin, home, ["aidd-dev"]); assert.notEqual(result.status, 0); assert.match(result.stderr, /every plugin/); - assert.equal(existsSync(join(home, "build")), false); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -test("reload uses the checkout's built CLI instead of a published translator", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-cli-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); - const source = fixture(home), binary = join(source, "cli/dist/cli.js"); - mkdirSync(dirname(binary), { recursive: true }); - writeFileSync(binary, String.raw`const fs = require("node:fs"), path = require("node:path"); -const args = process.argv.slice(2); -if (args[0] !== "translate") process.exit(2); -const out = args[args.indexOf("--out") + 1]; -const source = args[1]; -for (const plugin of fs.readdirSync(path.join(source, "plugins"))) { - const target = path.join(out, "plugins", plugin); - fs.mkdirSync(path.join(target, ".cursor-plugin"), { recursive: true }); - fs.writeFileSync(path.join(target, ".cursor-plugin/plugin.json"), JSON.stringify({ name: plugin })); - fs.cpSync(path.join(source, "plugins", plugin, "skills"), path.join(target, "skills"), { recursive: true }); - -}`); - executable(join(fakeBin, "cursor-agent"), "echo 'Start the Cursor Agent'"); - executable(join(fakeBin, "npx"), "exit 19"); - const result = runSync(fakeBin, home, []); - assert.equal(result.status, 0, result.stderr); - assert.equal(readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"), "source skill\n"); + assert.equal(existsSync(join(home, "host-calls")), false); } finally { rmSync(root, { recursive: true, force: true }); } }); @@ -632,8 +397,8 @@ test("Copilot instructions follow its native home with task overrides taking pre try { const fakeBin = join(root, "bin"), home = join(root, "home"), nativeHome = join(home, "native-copilot"); mkdirSync(fakeBin); - executable(join(fakeBin, "copilot"), copilotStub); - executable(join(fakeBin, "npx"), nativeBuildStub()); + recordingCli(fixture(home)); + executable(join(fakeBin, "copilot"), "exit 0"); const result = runSync(fakeBin, home, [], { COPILOT_HOME: nativeHome, COPILOT_INSTRUCTIONS: "" }); assert.equal(result.status, 0, result.stderr); assert.match(readFileSync(join(nativeHome, "copilot-instructions.md"), "utf8"), /Use available agents when useful/); @@ -643,8 +408,6 @@ test("Copilot instructions follow its native home with task overrides taking pre } finally { rmSync(root, { recursive: true, force: true }); } }); - - for (const override of ["CLAUDE_CONFIG_DIR", "CLAUDE_INSTRUCTIONS"]) { test(`OpenCode preserves its actual Claude fallback despite ${override}`, () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-fallback-override-")); diff --git a/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js index 2b88d1147..34cd16836 100644 --- a/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js +++ b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js @@ -28,7 +28,11 @@ function aiToolIds() { function declaresUserScopeActivation(toolId) { const profile = path.join(ROOT, "cli/src/contexts/tools/domain/profiles", toolId, "profile.ts"); const text = fs.readFileSync(profile, "utf8"); - return /nativeActivation:\s*\{/u.test(text) || /installScope:\s*"user"/u.test(text); + return ( + /nativeActivation:\s*\{/u.test(text) || + /installScope:\s*"user"/u.test(text) || + /machineFiles:\s*\{/u.test(text) + ); } function scriptUserAiList() { @@ -41,13 +45,20 @@ function scriptUserAiList() { describe("smoke-real.sh drives --scope user with the tools the profiles support", () => { it("names every AI tool whose profile declares machine-wide activation, and no other", () => { const supported = aiToolIds().filter(declaresUserScopeActivation); - assert.deepEqual([...supported].sort(), ["claude", "codex", "copilot", "cursor"]); + assert.deepEqual([...supported].sort(), [ + "claude", + "codex", + "copilot", + "cursor", + "kilo", + "opencode", + ]); assert.deepEqual([...scriptUserAiList()].sort(), [...supported].sort()); }); - it("leaves out AI tools that declare neither, which setup --scope user refuses", () => { + it("leaves out AI tools that declare none, which setup --scope user refuses", () => { const unsupported = aiToolIds().filter((id) => !declaresUserScopeActivation(id)); - assert.deepEqual([...unsupported].sort(), ["antigravity", "kilo", "opencode"]); + assert.deepEqual([...unsupported].sort(), ["antigravity"]); for (const toolId of unsupported) { assert.ok( !scriptUserAiList().includes(toolId), diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index c5de213e2..f55560b2f 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -1,22 +1,22 @@ #!/usr/bin/env bash -# (Re)install every plugin into Claude, Codex, OpenCode, Copilot and Cursor from THIS checkout. -# Claude, Codex and OpenCode take it the way a user does: `aidd setup --global` with this -# checkout as the framework source, so the reload exercises the shipped machine-wide install. -# Copilot and Cursor install from a tree the aidd CLI builds. +# (Re)install every plugin into Claude, Codex, OpenCode, Kilo, Copilot and Cursor from THIS +# checkout, the way a user does: `aidd setup --global` with this checkout as the framework +# source, so the reload exercises the shipped machine-wide install. # # NOT live: the install copies built files, so an edit needs a re-run. Idempotent; a tool # whose CLI is absent is skipped, and the first run needs network. # The agents' provider is whatever `aidd setup --global --provider` last recorded. # # Global rules: sync only the Agents section in the managed block, preserving personal -# rules. First OpenCode sync keeps its Claude personal-rule fallback unless disabled. +# rules. First OpenCode sync keeps its Claude personal-rule fallback unless disabled. Kilo +# documents no global AGENTS.md, only its config's `instructions` key, so it gets none. # Paths honor native home/cache overrides; explicit task paths take precedence. # Sources: # https://developers.openai.com/codex/guides/agents-md # https://code.claude.com/docs/en/memory # OpenCode fallback and disable flags: # https://opencode.ai/docs/rules/#claude-code-compatibility -# Cursor local plugins: real directories only; local imports must be allowed. +# Cursor local plugins: local imports must be allowed. # https://cursor.com/docs/plugins#test-plugins-locally # Copilot native plugin list and install: # https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference @@ -33,8 +33,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FW="${FW:-$(dirname "$SCRIPT_DIR")}" MKT="${MKT:-aidd-framework}" AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-latest}" # published fallback for sources without a built CLI -BUILD="${BUILD:-$HOME/.cache/aidd-framework-dev}" # per-tool native trees the marketplaces point at -CURSOR_PLUGINS="${CURSOR_PLUGINS:-$HOME/.cursor/plugins/local}" CODEX_INSTRUCTIONS="${CODEX_INSTRUCTIONS:-${CODEX_HOME:-$HOME/.codex}/AGENTS.md}" CLAUDE_INSTRUCTIONS="${CLAUDE_INSTRUCTIONS:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md}" OPENCODE_INSTRUCTIONS="${OPENCODE_INSTRUCTIONS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/AGENTS.md}" @@ -44,6 +42,7 @@ CURSOR_INSTRUCTIONS="${CURSOR_INSTRUCTIONS:-$HOME/.cursor/rules/aidd-agents.mdc} HAVE_CODEX=0; command -v codex >/dev/null 2>&1 && HAVE_CODEX=1 HAVE_CLAUDE=0; command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=1 HAVE_OPENCODE=0; command -v opencode >/dev/null 2>&1 && HAVE_OPENCODE=1 +HAVE_KILO=0; command -v kilo >/dev/null 2>&1 && HAVE_KILO=1 HAVE_COPILOT=0; command -v copilot >/dev/null 2>&1 && HAVE_COPILOT=1 HAVE_CURSOR=0 if command -v cursor >/dev/null 2>&1 || command -v cursor-agent >/dev/null 2>&1; then @@ -60,67 +59,28 @@ aidd_cli() { fi } -build_tool() { - local tool="$1" - rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" - aidd_cli translate "$FW" --to "$tool" --out "$BUILD/$tool" --as marketplace --force >/dev/null 2>&1 -} - -validate_native_plugins() { - local tool="$1" manifest="$2" name skill - for name in "${targets[@]}"; do - if [ ! -s "$BUILD/$tool/plugins/$name/$manifest" ]; then - echo "no built $tool manifest for $name" >&2; return 1 - fi - for skill in "$FW/plugins/$name/skills/"*/SKILL.md; do - if [ ! -s "$BUILD/$tool/plugins/$name/skills/$(basename "$(dirname "$skill")")/SKILL.md" ]; then - echo "no built $tool skill for $name-$(basename "$(dirname "$skill")")" >&2; return 1 - fi - done - done -} - -register_copilot_marketplace() { - local registered_source - registered_source="$(copilot plugin marketplace list --json | node -e ' - const entries = JSON.parse(require("node:fs").readFileSync(0, "utf8")); - if (!Array.isArray(entries)) throw new Error("Invalid Copilot marketplace list"); - const entry = entries.find(item => item.name === process.argv[1]); - if (entry && typeof entry.source !== "string") throw new Error("Invalid Copilot marketplace source"); - process.stdout.write(entry?.source ?? ""); - ' "$MKT")" || return 1 - case "$registered_source" in - "") copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; - "Local: $BUILD/copilot") copilot plugin marketplace update "$MKT" >/dev/null ;; - "Local: $FW") - # Native list also includes inherited Claude registrations, which remove cannot change. - local add_result - if add_result="$(copilot plugin marketplace add "$BUILD/copilot" 2>&1)"; then - return 0 - fi - case "$add_result" in - *"already registered"*) - copilot plugin marketplace remove "$MKT" --force >/dev/null || return 1 - copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; - *) printf '%s\n' "$add_result" >&2; return 1 ;; - esac ;; - *) echo "Refusing a different Copilot marketplace source for $MKT: $registered_source" >&2; return 1 ;; - esac -} - -# A plugin keeps its version across edits to this checkout, so Claude and Codex would keep the -# copy they cached: each reload hands them the plugins afresh, as the previous script did. +# A plugin keeps its version across edits to this checkout, so the native hosts would keep the +# copy they cached: each reload hands them the plugins afresh. release_native_plugins() { local name for name in "${targets[@]}"; do [ "$HAVE_CLAUDE" = 0 ] || claude plugin uninstall "$name@$MKT" --scope user >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin remove "$name@$MKT" >/dev/null 2>&1 || true + [ "$HAVE_COPILOT" = 0 ] || copilot plugin uninstall "$name@$MKT" >/dev/null 2>&1 || true done [ "$HAVE_CLAUDE" = 0 ] || claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true + [ "$HAVE_COPILOT" = 0 ] || copilot plugin marketplace remove "$MKT" --force >/dev/null 2>&1 || true } -if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_OPENCODE" = 0 ] && [ "$HAVE_COPILOT" = 0 ] && [ "$HAVE_CURSOR" = 0 ]; then +hosts=() +[ "$HAVE_CLAUDE" = 0 ] || hosts+=(claude) +[ "$HAVE_CODEX" = 0 ] || hosts+=(codex) +[ "$HAVE_COPILOT" = 0 ] || hosts+=(copilot) +[ "$HAVE_CURSOR" = 0 ] || hosts+=(cursor) +[ "$HAVE_KILO" = 0 ] || hosts+=(kilo) +[ "$HAVE_OPENCODE" = 0 ] || hosts+=(opencode) +if [ "${#hosts[@]}" -eq 0 ]; then echo "No supported host installation found - nothing to install."; exit 0 fi @@ -129,48 +89,14 @@ for d in "$FW"/plugins/*/; do [ -f "$d.claude-plugin/plugin.json" ] && targets+=("$(basename "$d")") done -hosts=() -[ "$HAVE_CLAUDE" = 0 ] || hosts+=(claude) -[ "$HAVE_CODEX" = 0 ] || hosts+=(codex) -[ "$HAVE_OPENCODE" = 0 ] || hosts+=(opencode) -if [ "${#hosts[@]}" -gt 0 ]; then - printf '%-22s' "global install" - release_native_plugins - output="$(aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ - --plugins all --yes 2>&1)" || { - echo "FAIL"; printf '%s\n' "$output" >&2 - echo "aidd setup --global failed for ${hosts[*]}." >&2; exit 1 - } - echo "ok ($(IFS=,; echo "${hosts[*]}"))" -fi -if [ "$HAVE_CURSOR" = 1 ]; then - printf '%-22s' "build cursor" - build_tool cursor || { echo "FAIL"; echo "cursor build failed" >&2; exit 1; } - validate_native_plugins cursor .cursor-plugin/plugin.json || { echo "FAIL"; exit 1; } - echo "ok" -fi -if [ "$HAVE_COPILOT" = 1 ]; then - printf '%-22s' "build copilot" - build_tool copilot || { echo "FAIL"; echo "copilot build failed" >&2; exit 1; } - validate_native_plugins copilot .plugin/plugin.json || { echo "FAIL"; exit 1; } - register_copilot_marketplace || { echo "FAIL"; echo "Copilot marketplace registration failed" >&2; exit 1; } - echo "ok" - for name in "${targets[@]}"; do - printf '%-22s' "$name" - copilot plugin install "$name@$MKT" >/dev/null || { echo "Copilot plugin install failed for $name" >&2; exit 1; } - echo " copilot:ok" - done -fi - -if [ "$HAVE_CURSOR" = 1 ]; then - printf '%-22s' "install cursor" - mkdir -p "$CURSOR_PLUGINS" - for t in "${targets[@]}"; do - rm -rf -- "$CURSOR_PLUGINS/$t" - cp -R "$BUILD/cursor/plugins/$t" "$CURSOR_PLUGINS/$t" - done - echo "plugins:ok (requires local plugin imports)" -fi +printf '%-22s' "global install" +release_native_plugins +output="$(aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ + --plugins all --yes 2>&1)" || { + echo "FAIL"; printf '%s\n' "$output" >&2 + echo "aidd setup --global failed for ${hosts[*]}." >&2; exit 1 +} +echo "ok ($(IFS=,; echo "${hosts[*]}"))" instructions=() [ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") @@ -178,13 +104,15 @@ instructions=() [ "$HAVE_OPENCODE" = 0 ] || instructions+=(opencode "$OPENCODE_INSTRUCTIONS") [ "$HAVE_COPILOT" = 0 ] || instructions+=(copilot "$COPILOT_INSTRUCTIONS") [ "$HAVE_CURSOR" = 0 ] || instructions+=(cursor "$CURSOR_INSTRUCTIONS") -printf '%-22s' "global instructions" -# OpenCode uses this fixed Claude fallback, independent of Claude config overrides. -instruction_fallback="$HOME/.claude/CLAUDE.md" -case "${OPENCODE_DISABLE_CLAUDE_CODE:-}" in 1|true) instruction_fallback="" ;; esac -case "${OPENCODE_DISABLE_CLAUDE_CODE_PROMPT:-}" in 1|true) instruction_fallback="" ;; esac -node "$SCRIPT_DIR/sync-global-instructions.cjs" "$FW/AGENTS.md" "$instruction_fallback" "${instructions[@]}" || - { echo "FAIL"; exit 1; } -echo "ok (delegation guidance only)" +if [ "${#instructions[@]}" -gt 0 ]; then + printf '%-22s' "global instructions" + # OpenCode uses this fixed Claude fallback, independent of Claude config overrides. + instruction_fallback="$HOME/.claude/CLAUDE.md" + case "${OPENCODE_DISABLE_CLAUDE_CODE:-}" in 1|true) instruction_fallback="" ;; esac + case "${OPENCODE_DISABLE_CLAUDE_CODE_PROMPT:-}" in 1|true) instruction_fallback="" ;; esac + node "$SCRIPT_DIR/sync-global-instructions.cjs" "$FW/AGENTS.md" "$instruction_fallback" "${instructions[@]}" || + { echo "FAIL"; exit 1; } + echo "ok (delegation guidance only)" +fi echo "Done. Restart hosts or reload their configuration for refreshed plugins and instructions." From 4acfdc5327d33f5cc75f4aabe807861250f4f4da Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 11:34:58 +0800 Subject: [PATCH 06/16] fix(framework): make reload installs from HOME and leaves Copilot plugins in place - setup --global runs from HOME: the checkout's own .claude/settings.json enables the plugins at project scope, which the hosts report and the CLI refuses as refs it does not own. - Copilot is no longer uninstalled each reload: its uninstall leaves every plugin disabled rather than gone, which the next setup --global refuses the same way. - doctor --global stops asking a project to declare the marketplace, which a machine-wide install never writes there, and its messages name setup --global. --- .../doctor/doctor-registration-use-case.ts | 4 +++- .../user-manifest-repository-adapter.ts | 2 +- cli/src/presentation/commands/doctor.ts | 7 ++++--- .../application/doctor-registration.unit.test.ts | 14 ++++++++++++-- ...anifest-repository-adapter.integration.test.ts | 8 +++----- cli/tests/e2e/setup-scope-user.e2e.test.ts | 2 +- cli/tests/golden/snapshots/phase0/snapshot.json | 2 +- .../commands/doctor-wiring.integration.test.ts | 3 ++- scripts/__tests__/dev-sync.test.js | 15 +++++++-------- scripts/dev-sync.sh | 12 +++++++----- 10 files changed, 41 insertions(+), 28 deletions(-) diff --git a/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts index 0f5ec75a3..d90a44d86 100644 --- a/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts +++ b/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts @@ -35,6 +35,8 @@ export interface DoctorRegistrationOptions { manifest: Manifest; projectRoot: string; allowedIds: Set | null; + /** A machine-wide install declares nothing in a project's settings, so it skips that check. */ + machine?: boolean; } /** One plugin `doctor` expects a native-activation tool's own registry to carry, and the ref to @@ -84,7 +86,7 @@ export class DoctorRegistrationUseCase { async execute(options: DoctorRegistrationOptions): Promise { return [ - ...(await this.checkDeclaredMarketplaces(options)), + ...(options.machine === true ? [] : await this.checkDeclaredMarketplaces(options)), ...(await this.checkNativeRegistrations(options)), ...(await this.checkMarketplaceSources(options)), ]; diff --git a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts index a41330a39..4ffca2844 100644 --- a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts +++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts @@ -29,7 +29,7 @@ export class UserManifestRepositoryAdapter implements ManifestRepository { return readManifestFile({ path: this.path, location: "for this machine", - reinstallCommand: "aidd setup --scope user", + reinstallCommand: "aidd setup --global", }); } diff --git a/cli/src/presentation/commands/doctor.ts b/cli/src/presentation/commands/doctor.ts index 71e001b45..ebd6dcd04 100644 --- a/cli/src/presentation/commands/doctor.ts +++ b/cli/src/presentation/commands/doctor.ts @@ -109,14 +109,14 @@ async function runUserScopeDoctor( projectRoot: string, cmdOptions: DoctorCmdOptions ): Promise { - // No plugin is tracked at user scope yet — there is nothing `--plugin` could narrow — - // so it is refused rather than silently read and discarded. + // At user scope doctor checks registrations, not plugin files — there is nothing `--plugin` + // could narrow — so it is refused rather than silently read and discarded. if (cmdOptions.plugin !== undefined) { throw new UserScopeFilterUnsupportedError("--plugin", "doctor --plugin "); } const manifest = await deps.userManifestRepo.load(); if (manifest === null) { - output.success("Nothing registered at user scope yet — run `aidd setup --scope user` first."); + output.success("Nothing registered at user scope yet — run `aidd setup --global` first."); return; } const toolId = cmdOptions.tool as ToolId | undefined; @@ -139,6 +139,7 @@ async function runUserScopeDoctor( manifest, projectRoot, allowedIds, + machine: true, }); printScopeIssues(output, "User scope", { issues }); const healthy = issues.every((i) => i.severity !== "error"); diff --git a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts index 43f2dcb4d..97f1a4208 100644 --- a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts @@ -32,9 +32,14 @@ function settingsDeclaring(names: string[]): string { async function issuesFor( settings: string | null, - options: { toolId?: ToolId; toolInstalled?: boolean; allowedIds?: Set | null } = {} + options: { + toolId?: ToolId; + toolInstalled?: boolean; + allowedIds?: Set | null; + machine?: boolean; + } = {} ) { - const { toolId = "claude", toolInstalled = true, allowedIds = null } = options; + const { toolId = "claude", toolInstalled = true, allowedIds = null, machine } = options; const fs = new InMemoryFileAdapter(); if (settings !== null) await fs.writeFile(LOCAL_SETTINGS, settings); const registry = new InMemoryMarketplaceRegistry(); @@ -64,6 +69,7 @@ async function issuesFor( manifest, projectRoot: PROJECT_ROOT, allowedIds, + machine, }); } @@ -81,6 +87,10 @@ describe("DoctorRegistrationUseCase", () => { expect(await issuesFor(settingsDeclaring([]))).toStrictEqual([UNDECLARED]); }); + it("asks no project to declare a machine-wide install, which writes nothing there", async () => { + expect(await issuesFor(null, { machine: true })).toStrictEqual([]); + }); + it("reports it when the whole file is gone — nothing else would notice", async () => { expect(await issuesFor(null)).toStrictEqual([UNDECLARED]); }); diff --git a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts index b093391cd..083d52754 100644 --- a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts +++ b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts @@ -37,14 +37,12 @@ describe("UserManifestRepositoryAdapter", () => { await expect(adapter.load()).rejects.toThrow(manifestPath); }); - it('rejects a refused manifest version naming the real user manifest path and `aidd setup --scope user`, never .aidd/manifest.json or "in this project"', async () => { + it('rejects a refused manifest version naming the real user manifest path and `aidd setup --global`, never .aidd/manifest.json or "in this project"', async () => { const manifestPath = join(userConfigDir, "manifest.json"); await writeFile(manifestPath, '{"version": 7, "tools": {}}'); await expect(adapter.load()).rejects.toThrow( - new RegExp( - `${manifestPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*aidd setup --scope user` - ) + new RegExp(`${manifestPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*aidd setup --global`) ); await expect(adapter.load()).rejects.not.toThrow(/\.aidd\/manifest\.json/); await expect(adapter.load()).rejects.not.toThrow(/in this project/); @@ -55,7 +53,7 @@ describe("UserManifestRepositoryAdapter", () => { await writeFile(manifestPath, '{"version": 7, "tools": {}}'); await expect(adapter.load()).rejects.toThrow( - `delete ${manifestPath} for this machine, then run \`aidd setup --scope user\` to reinstall the framework.` + `delete ${manifestPath} for this machine, then run \`aidd setup --global\` to reinstall the framework.` ); }); }); diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index 7d9acea52..e21a55145 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -240,7 +240,7 @@ describe("E2E: setup --scope user writes nothing under the project", () => { const doctorResult = await runCli(["doctor", "--scope", "user"], projectDir, fakeHome); expect(doctorResult.exitCode).toBe(0); expect(doctorResult.stdout).toContain("Nothing registered at user scope yet"); - expect(doctorResult.stdout).toContain("aidd setup --scope user"); + expect(doctorResult.stdout).toContain("aidd setup --global"); const syncResult = await runCli(["sync", "--scope", "user"], projectDir, fakeHome); expect(syncResult.exitCode).toBe(0); diff --git a/cli/tests/golden/snapshots/phase0/snapshot.json b/cli/tests/golden/snapshots/phase0/snapshot.json index cb5e06bef..829b9d71e 100644 --- a/cli/tests/golden/snapshots/phase0/snapshot.json +++ b/cli/tests/golden/snapshots/phase0/snapshot.json @@ -853,7 +853,7 @@ { "command": "[user-scope-clean] doctor --scope user", "exitCode": 0, - "stdout": "Nothing registered at user scope yet — run `aidd setup --scope user` first.\n", + "stdout": "Nothing registered at user scope yet — run `aidd setup --global` first.\n", "stderr": "", "filesWritten": [], "manifest": null diff --git a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts index f481d6fa2..7cc8912ed 100644 --- a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts @@ -402,6 +402,7 @@ describe("aidd doctor --scope user", () => { manifest: expect.anything(), projectRoot: PROJECT_ROOT, allowedIds: null, + machine: true, }); expect(doctorAll).not.toHaveBeenCalled(); }); @@ -416,7 +417,7 @@ describe("aidd doctor --scope user", () => { loadUserManifest.mockResolvedValue(null); expect(await run("--scope", "user")).toEqual([ - "Nothing registered at user scope yet — run `aidd setup --scope user` first.", + "Nothing registered at user scope yet — run `aidd setup --global` first.", ]); expect(doctorRegistration).not.toHaveBeenCalled(); }); diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 1f4a81028..08a84bb44 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -171,13 +171,13 @@ function fixture(home, plugins = ["aidd-dev"]) { const BASE_PATH = process.platform === "win32" ? (process.env.PATH ?? "") : ["/usr/bin", "/bin"].join(delimiter); -function runSync(fakeBin, home, args = ["all"], overrides = {}) { +function runSync(fakeBin, home, args = ["all"], overrides = {}, cwd = home) { executable(join(fakeBin, "node"), `exec '${process.execPath}' "$@"`); // `bash`, resolved on PATH, never the literal `/bin/bash`: that path does not exist on // Windows, where bash is the one Git for Windows ships. `spawnSync` resolves a bare name // through the platform's own rules on both. return spawnSync("bash", [join(repo, "scripts/dev-sync.sh"), ...args], { - cwd: home, + cwd, env: { ...process.env, PATH: [fakeBin, BASE_PATH].join(delimiter), @@ -196,14 +196,15 @@ function runSync(fakeBin, home, args = ["all"], overrides = {}) { }); } -test("reload fixtures run from their temporary home", () => { +test("the global install runs from HOME, never the checkout whose settings enable the plugins", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cwd-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); executable(join(fakeBin, "opencode"), "exit 0"); - recordingCli(fixture(home), 'fs.writeFileSync(path.join(process.env.HOME, "observed-cwd"), process.cwd());'); - const result = runSync(fakeBin, home, []); + const source = fixture(home); + recordingCli(source, 'fs.writeFileSync(path.join(process.env.HOME, "observed-cwd"), process.cwd());'); + const result = runSync(fakeBin, home, [], {}, source); assert.equal(result.status, 0, result.stderr); assert.equal(realpathSync(readFileSync(join(home, "observed-cwd"), "utf8").trim()), realpathSync(home)); } finally { rmSync(root, { recursive: true, force: true }); } @@ -298,12 +299,10 @@ test("every host takes this checkout through aidd setup --global, afresh", () => for (const plugin of ["aidd-dev", "aidd-pm"]) { assert.match(hostCalls, new RegExp(`claude plugin uninstall ${plugin}@aidd-framework --scope user`)); assert.match(hostCalls, new RegExp(`codex plugin remove ${plugin}@aidd-framework`)); - assert.match(hostCalls, new RegExp(`copilot plugin uninstall ${plugin}@aidd-framework`)); } assert.match(hostCalls, /claude plugin marketplace remove aidd-framework --scope user/); assert.match(hostCalls, /codex plugin marketplace remove aidd-framework/); - assert.match(hostCalls, /copilot plugin marketplace remove aidd-framework --force/); - assert.doesNotMatch(hostCalls, /^(cursor-agent|kilo|opencode) /m); + assert.doesNotMatch(hostCalls, /^(copilot|cursor-agent|kilo|opencode) /m); } finally { rmSync(root, { recursive: true, force: true }); } }); diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index f55560b2f..4c7f53f5d 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -59,18 +59,18 @@ aidd_cli() { fi } -# A plugin keeps its version across edits to this checkout, so the native hosts would keep the -# copy they cached: each reload hands them the plugins afresh. +# A plugin keeps its version across edits to this checkout, so Claude and Codex would keep the +# copy they cached: each reload hands them the plugins afresh. Copilot loads a local +# marketplace live, and its uninstall leaves each plugin disabled rather than gone, which the +# CLI then reads as a ref it does not own, so it is left alone. release_native_plugins() { local name for name in "${targets[@]}"; do [ "$HAVE_CLAUDE" = 0 ] || claude plugin uninstall "$name@$MKT" --scope user >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin remove "$name@$MKT" >/dev/null 2>&1 || true - [ "$HAVE_COPILOT" = 0 ] || copilot plugin uninstall "$name@$MKT" >/dev/null 2>&1 || true done [ "$HAVE_CLAUDE" = 0 ] || claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true - [ "$HAVE_COPILOT" = 0 ] || copilot plugin marketplace remove "$MKT" --force >/dev/null 2>&1 || true } hosts=() @@ -91,7 +91,9 @@ done printf '%-22s' "global install" release_native_plugins -output="$(aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ +# From HOME, not this checkout: its own .claude/settings.json enables the plugins at project +# scope, which the host CLIs report and setup --global would refuse as refs it does not own. +output="$(cd "$HOME" && aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ --plugins all --yes 2>&1)" || { echo "FAIL"; printf '%s\n' "$output" >&2 echo "aidd setup --global failed for ${hosts[*]}." >&2; exit 1 From 428236113b81b9685e5f41adf412fe16a78e6fcb Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 15:17:42 +0800 Subject: [PATCH 07/16] fix(cli): clean --global then setup --global holds for Claude and Copilot - Claude 2.1.312's marketplace remove --scope user drops the settings declaration and keeps the registration: a user-scope remove now names no scope, from the home directory (NativeActivation.userRemoveArgs), so clean deletes it and the next setup declares it again. - Copilot's uninstall leaves each ref disabled rather than gone: a disabled ref there is no longer taken for a plugin in use (NativeActivation.uninstallLeavesRefDisabled), so setup after clean enables the plugins again. Claude keeps refusing a ref the person disabled. - make reload removes Claude's marketplace from every scope, from HOME, for the same reason. --- .../marketplace-sync-settings-use-case.ts | 8 +++-- .../domain/capabilities/plugins-capability.ts | 8 +++++ .../tools/domain/profiles/claude/profile.ts | 1 + .../tools/domain/profiles/copilot/profile.ts | 3 ++ cli/src/contexts/tools/domain/registry.ts | 7 ++++ .../abstract-native-plugin-cli-adapter.ts | 31 +++++++++++++---- .../native-plugin-cli-adapter.ts | 5 +++ .../tools/domain/host-ref-in-use.unit.test.ts | 19 +++++++++++ .../profiles/claude/plugins.unit.test.ts | 1 + .../profiles/copilot/profile.unit.test.ts | 1 + ...gin-cli-adapter.claude.integration.test.ts | 33 +++++++++++++++++++ scripts/__tests__/dev-sync.test.js | 2 +- scripts/dev-sync.sh | 7 ++-- 13 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 cli/tests/contexts/tools/domain/host-ref-in-use.unit.test.ts diff --git a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts index 02fc810f8..99cc83182 100644 --- a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts @@ -38,6 +38,7 @@ import type { } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; import { + hostRefInUse, nativeActivationOf, pluginEnablementIsMachineGlobal, resolvePluginsCapability, @@ -840,9 +841,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ? [] : (project?.getNativeRegistrations(toolId)?.pluginRefs ?? [])), ]); - const foreignRef = [...reading.refs.keys()].find( - (ref) => ref.endsWith(`@${hostName}`) && !claimed.has(ref) - ); + const foreignRef = [...reading.refs].find( + ([ref, entry]) => + hostRefInUse(toolId, entry.enabled) && ref.endsWith(`@${hostName}`) && !claimed.has(ref) + )?.[0]; return { ...base, hostReadable: true, ...(foreignRef === undefined ? {} : { foreignRef }) }; } diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 43ebf7999..5acb16071 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -22,6 +22,14 @@ export interface NativeActivation { * Claude's *local* scope — the registration names an absolute path, wrong for everyone * else in the shared project settings. Omit where the registry has no scopes. */ scopeArgs?: Readonly>; + /** Scope arguments `plugin marketplace remove` takes at user scope instead of + * `scopeArgs.user`, run from the home directory so no project's settings are in reach. + * Measured on Claude 2.1.312: `remove --scope user` drops the settings declaration and + * keeps the registration, which only a remove naming no scope deletes. */ + userRemoveArgs?: readonly string[]; + /** The host keeps an uninstalled plugin's ref, disabled, so a disabled ref cannot be told + * from one the person disabled and is not taken for a plugin in use. */ + uninstallLeavesRefDisabled?: boolean; /** Arguments that make `plugin marketplace remove` succeed with plugins installed from it. * Permits reclaiming a name, so declare it only alongside `sourceCheckVerb`. */ forceRemoveArgs?: readonly string[]; diff --git a/cli/src/contexts/tools/domain/profiles/claude/profile.ts b/cli/src/contexts/tools/domain/profiles/claude/profile.ts index a09f3778f..7fa94ba68 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts @@ -117,6 +117,7 @@ export const claude: AiTool join(h, ".copilot", "settings.json"), diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index 11a466e18..a27d2c648 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -132,6 +132,13 @@ export function supportsUserScopeActivation(toolId: ToolId): boolean { ); } +/** Whether a ref the host's registry lists is a plugin in use: an enabled one always; a disabled + * one unless the host's uninstall itself leaves refs disabled, where the two cannot be told + * apart and the ref is taken for what an uninstall left behind. */ +export function hostRefInUse(toolId: ToolId, enabled: boolean): boolean { + return enabled || nativeActivationOf(toolId)?.uninstallLeavesRefDisabled !== true; +} + /** Whether `setup --global` writes a plugin's files itself for this tool, into its user plugins * directory or its user configuration directory, instead of only enabling the plugin through * the tool's own CLI. */ diff --git a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts index 109588375..cca4bcc01 100644 --- a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts @@ -1,4 +1,5 @@ import { type SpawnSyncReturns, spawnSync } from "node:child_process"; +import { homedir } from "node:os"; import { NativePluginCliError } from "../../../kernel/errors.js"; import type { MarketplaceScope } from "../../../kernel/scope.js"; import type { NativePluginActivator } from "../domain/ports/native-plugin-activator.js"; @@ -28,9 +29,15 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi * quoted; anything else is spawned by its bare name. */ private spawn( args: readonly string[], - stdio: ["ignore", "ignore" | "pipe", "ignore" | "pipe"] + stdio: ["ignore", "ignore" | "pipe", "ignore" | "pipe"], + cwd?: string ): SpawnSyncReturns { - const options = { timeout: COMMAND_TIMEOUT_MS, stdio, encoding: "utf-8" as const }; + const options = { + timeout: COMMAND_TIMEOUT_MS, + stdio, + encoding: "utf-8" as const, + ...(cwd === undefined ? {} : { cwd }), + }; const executable = resolveExecutableOnPath(this.binary, hostExecutableLookup()); if (executable !== undefined && runsThroughShell(executable)) { return spawnSync(windowsCommandLine(executable, args), { ...options, shell: true }); @@ -42,6 +49,9 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi protected abstract scopeArgsFor(scope: MarketplaceScope): readonly string[]; /** Arguments that force a removal past installed plugins, empty when unsupported. */ protected abstract forceRemoveArgs(): readonly string[]; + /** Scope arguments a user-scope remove takes instead, run from the home directory; + * `undefined` when the user scope's own arguments undo the add. */ + protected abstract userRemoveArgs(): readonly string[] | undefined; addMarketplace(source: string, scope: MarketplaceScope): void { this.run( @@ -52,9 +62,18 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi removeMarketplace(name: string, scope: MarketplaceScope, options?: { force?: boolean }): void { const force = options?.force === true ? this.forceRemoveArgs() : []; + const userArgs = scope === "user" ? this.userRemoveArgs() : undefined; this.run( - ["plugin", "marketplace", "remove", name, ...this.scopeArgsFor(scope), ...force], - `marketplace remove ${name}` + [ + "plugin", + "marketplace", + "remove", + name, + ...(userArgs ?? this.scopeArgsFor(scope)), + ...force, + ], + `marketplace remove ${name}`, + userArgs === undefined ? undefined : homedir() ); } @@ -70,8 +89,8 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi return result.error === undefined && result.status === 0; } - protected run(args: readonly string[], label: string): void { - const result = this.spawn(args, ["ignore", "pipe", "pipe"]); + protected run(args: readonly string[], label: string, cwd?: string): void { + const result = this.spawn(args, ["ignore", "pipe", "pipe"], cwd); if (result.error) { throw new NativePluginCliError(`${this.binary} ${label} failed: ${result.error.message}`); } diff --git a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts index dca4ee676..2fdcc63a5 100644 --- a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts @@ -4,6 +4,7 @@ import { AbstractNativePluginCliAdapter } from "./abstract-native-plugin-cli-ada /** Everything about a tool's plugin CLI that differs between tools, read off its profile. */ export interface NativePluginCliShape { readonly scopeArgs?: Readonly>; + readonly userRemoveArgs?: readonly string[]; readonly forceRemoveArgs?: readonly string[]; readonly sourceCheckVerb?: string; readonly upgradeVerb?: string; @@ -86,4 +87,8 @@ export class NativePluginCliAdapter extends AbstractNativePluginCliAdapter { protected forceRemoveArgs(): readonly string[] { return this.shape.forceRemoveArgs ?? []; } + + protected userRemoveArgs(): readonly string[] | undefined { + return this.shape.userRemoveArgs; + } } diff --git a/cli/tests/contexts/tools/domain/host-ref-in-use.unit.test.ts b/cli/tests/contexts/tools/domain/host-ref-in-use.unit.test.ts new file mode 100644 index 000000000..698ac1ea2 --- /dev/null +++ b/cli/tests/contexts/tools/domain/host-ref-in-use.unit.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { hostRefInUse } from "../../../../src/contexts/tools/domain/registry.js"; + +describe("hostRefInUse — whether a ref the host lists is a plugin in use", () => { + it("takes an enabled ref for one, at every host", () => { + expect(hostRefInUse("claude", true)).toBe(true); + expect(hostRefInUse("copilot", true)).toBe(true); + }); + + it("takes a disabled ref for one where uninstall removes the ref: the person disabled it", () => { + expect(hostRefInUse("claude", false)).toBe(true); + }); + + it("takes a disabled ref for what an uninstall left where uninstall only disables it", () => { + expect(hostRefInUse("copilot", false)).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts index aa073ea4d..4be2d754a 100644 --- a/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts @@ -48,6 +48,7 @@ describe("claude plugins", () => { expect(claude.capabilities.plugins.nativeActivation).toStrictEqual({ binary: "claude", scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + userRemoveArgs: [], enableVerb: "install", disableVerb: "uninstall", upgradeVerb: "update", diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts index 9d042e232..c57102254 100644 --- a/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts @@ -37,6 +37,7 @@ describe("copilot declarations the rest of the CLI reads", () => { disableVerb: "uninstall", sourceCheckVerb: "update", forceRemoveArgs: ["--force"], + uninstallLeavesRefDisabled: true, }); }); }); diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts index da663dfb8..61a90c8e8 100644 --- a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; import { describe, expect, it, vi } from "vitest"; import { NativePluginCliAdapter } from "../../../../src/contexts/tools/infrastructure/native-plugin-cli-adapter.js"; @@ -80,3 +81,35 @@ describe("ClaudeCliAdapter — plugin enable/uninstall carry the requested scope ); }); }); + +describe("ClaudeCliAdapter — removing a user-scope marketplace deletes its registration", () => { + const adapter = () => + new NativePluginCliAdapter("claude", { + scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + userRemoveArgs: [], + }); + + it("names no scope at user scope, from the home directory, so the registration goes too", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().removeMarketplace("aidd-framework", "user"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "claude", + ["plugin", "marketplace", "remove", "aidd-framework"], + expect.objectContaining({ cwd: homedir() }) + ); + }); + + it("keeps the project scope's own arguments, from the current directory", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().removeMarketplace("aidd-framework", "project"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "claude", + ["plugin", "marketplace", "remove", "aidd-framework", "--scope", "local"], + expect.not.objectContaining({ cwd: expect.anything() }) + ); + }); +}); diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 08a84bb44..52b4fb17a 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -300,7 +300,7 @@ test("every host takes this checkout through aidd setup --global, afresh", () => assert.match(hostCalls, new RegExp(`claude plugin uninstall ${plugin}@aidd-framework --scope user`)); assert.match(hostCalls, new RegExp(`codex plugin remove ${plugin}@aidd-framework`)); } - assert.match(hostCalls, /claude plugin marketplace remove aidd-framework --scope user/); + assert.match(hostCalls, /^claude plugin marketplace remove aidd-framework$/m); assert.match(hostCalls, /codex plugin marketplace remove aidd-framework/); assert.doesNotMatch(hostCalls, /^(copilot|cursor-agent|kilo|opencode) /m); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 4c7f53f5d..202c05502 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -62,14 +62,17 @@ aidd_cli() { # A plugin keeps its version across edits to this checkout, so Claude and Codex would keep the # copy they cached: each reload hands them the plugins afresh. Copilot loads a local # marketplace live, and its uninstall leaves each plugin disabled rather than gone, which the -# CLI then reads as a ref it does not own, so it is left alone. +# CLI then reads as a ref it does not own, so it is left alone. Claude's `remove --scope user` +# drops only the settings declaration and keeps the registration, which setup --global then +# takes as still declared: the marketplace is removed from every scope, from HOME so no +# project's settings are among them. release_native_plugins() { local name for name in "${targets[@]}"; do [ "$HAVE_CLAUDE" = 0 ] || claude plugin uninstall "$name@$MKT" --scope user >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin remove "$name@$MKT" >/dev/null 2>&1 || true done - [ "$HAVE_CLAUDE" = 0 ] || claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true + [ "$HAVE_CLAUDE" = 0 ] || (cd "$HOME" && claude plugin marketplace remove "$MKT") >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true } From 65882d752ade0b033931b4332fed97aad38862f4 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Wed, 4 Nov 2026 08:38:58 +0800 Subject: [PATCH 08/16] test(framework): compare the reload's working directory past Windows short names --- scripts/__tests__/dev-sync.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 52b4fb17a..b592dcd28 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -206,7 +206,11 @@ test("the global install runs from HOME, never the checkout whose settings enabl recordingCli(source, 'fs.writeFileSync(path.join(process.env.HOME, "observed-cwd"), process.cwd());'); const result = runSync(fakeBin, home, [], {}, source); assert.equal(result.status, 0, result.stderr); - assert.equal(realpathSync(readFileSync(join(home, "observed-cwd"), "utf8").trim()), realpathSync(home)); + // `.native` expands a Windows 8.3 short name (`RUNNER~1`), which bash's `cd "$HOME"` keeps. + assert.equal( + realpathSync.native(readFileSync(join(home, "observed-cwd"), "utf8").trim()), + realpathSync.native(home) + ); } finally { rmSync(root, { recursive: true, force: true }); } }); From 96ba7915f063837b06808d829d89800845f333b3 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Wed, 4 Nov 2026 14:40:42 +0800 Subject: [PATCH 09/16] chore(framework): say how to recover from a failed make reload --- scripts/dev-sync.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 202c05502..b60c17479 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -5,7 +5,6 @@ # # NOT live: the install copies built files, so an edit needs a re-run. Idempotent; a tool # whose CLI is absent is skipped, and the first run needs network. -# The agents' provider is whatever `aidd setup --global --provider` last recorded. # # Global rules: sync only the Agents section in the managed block, preserving personal # rules. First OpenCode sync keeps its Claude personal-rule fallback unless disabled. Kilo @@ -99,7 +98,7 @@ release_native_plugins output="$(cd "$HOME" && aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ --plugins all --yes 2>&1)" || { echo "FAIL"; printf '%s\n' "$output" >&2 - echo "aidd setup --global failed for ${hosts[*]}." >&2; exit 1 + echo "aidd setup --global failed for ${hosts[*]}; their plugins were released, so rerun make reload once fixed." >&2; exit 1 } echo "ok ($(IFS=,; echo "${hosts[*]}"))" From 1138a845c8111eb80ee2c2c749c3ede1d6324df0 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 07:27:17 +0800 Subject: [PATCH 10/16] feat(framework): make check-hosts tables what each tool really loads of the plugins Plugins enabled, global instructions, skills and agents, per installed tool: asked of the tool where it can answer, read from the files its documentation names where it cannot. Exits 1 when an installed tool misses something. --- CONTRIBUTING.md | 1 + Makefile | 5 +- scripts/__tests__/check-hosts.test.js | 91 ++++++++ scripts/check-hosts.cjs | 312 ++++++++++++++++++++++++++ 4 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/check-hosts.test.js create mode 100644 scripts/check-hosts.cjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10a253ba9..1beaa5346 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,6 +63,7 @@ Fork the repository, or branch from it if you have write access. | `make setup` | Installs dependencies and git hooks, registers the marketplace, installs the plugins into Claude Code and Codex | | `make doctor` | Checks your environment | | `make reload` | Reloads your checkout into your tools | +| `make check-hosts` | Tables what each installed tool really loads of the plugins | | `make check` | Runs the pre-commit checks | ## 3. The rules diff --git a/Makefile b/Makefile index 1b8b55bb5..ff9ff31f9 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .DEFAULT_GOAL := help -.PHONY: help setup doctor check reload +.PHONY: help setup doctor check reload check-hosts help: ## List targets @grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed 's/:.*## /\t/' | sort @@ -18,3 +18,6 @@ check: ## Run the pre-commit checks reload: ## Dev: reload all plugins into Claude, Codex, OpenCode, Kilo, Copilot, and Cursor via aidd setup --global pnpm --dir cli build scripts/dev-sync.sh + +check-hosts: ## Dev: table what each installed tool really loads of the plugins + node scripts/check-hosts.cjs diff --git a/scripts/__tests__/check-hosts.test.js b/scripts/__tests__/check-hosts.test.js new file mode 100644 index 000000000..bb20232bc --- /dev/null +++ b/scripts/__tests__/check-hosts.test.js @@ -0,0 +1,91 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { delimiter, dirname, join, resolve } = require("node:path"); +const { test } = require("node:test"); + +const script = resolve(__dirname, "../check-hosts.cjs"); +const posixOnly = { skip: process.platform === "win32" }; + +function write(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +/** A framework of one plugin, one skill and one agent, with stubs standing in for the tools. */ +function fixture(stubs) { + const dir = mkdtempSync(join(tmpdir(), "aidd-check-hosts-test-")); + const framework = join(dir, "framework"); + write(join(framework, "plugins/aidd-x/.claude-plugin/plugin.json"), "{}\n"); + write(join(framework, "plugins/aidd-x/skills/01-a/SKILL.md"), "---\nname: 01-a\n---\n"); + write(join(framework, "plugins/aidd-x/agents/reviewer.md"), "---\nname: reviewer\n---\n"); + for (const [name, body] of Object.entries(stubs)) { + write(join(dir, "bin", name), `#!/bin/sh\n[ "$1" = --version ] && exit 0\n${body}\n`); + chmodSync(join(dir, "bin", name), 0o755); + } + mkdirSync(join(dir, "bin"), { recursive: true }); + mkdirSync(join(dir, "home"), { recursive: true }); + return { dir, framework, home: join(dir, "home") }; +} + +function check({ dir, framework, home }) { + return spawnSync(process.execPath, [script, "--root", framework], { + encoding: "utf8", + env: { HOME: home, PATH: [join(dir, "bin"), "/usr/bin", "/bin"].join(delimiter) }, + }); +} + +const KILO = (location) => `case "$1 $2" in + 'debug skill') echo '[{"location":"${location}"}]' ;; + 'debug agent') [ "$3" = aidd-x-reviewer ] && echo '{}' || exit 1 ;; +esac`; + +test("a tool that is not installed is said so and fails nothing", posixOnly, () => { + const env = fixture({}); + try { + const result = check(env); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /\| Kilo \| not installed/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } +}); + +test("a tool that answers with everything passes, proved by the tool", posixOnly, () => { + const env = fixture({ kilo: KILO("/h/.config/kilo/skills/aidd-x/01-a/SKILL.md") }); + try { + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Kilo \| ✅ 1\/1 \| ➖ none documented \| ✅ 1\/1 \| ✅ 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } +}); + +test("a skill the tool does not list fails the run and is named", posixOnly, () => { + const env = fixture({ kilo: KILO("/h/.config/kilo/skills/aidd-x/99-other/SKILL.md") }); + try { + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /❌ 0\/1, missing aidd-x\/01-a/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } +}); + +test("Cursor is read from its files, and a skill named apart from its folder is missing", posixOnly, () => { + const env = fixture({ "cursor-agent": "exit 0" }); + const local = join(env.home, ".cursor/plugins/local/aidd-x"); + write(join(local, ".cursor-plugin/plugin.json"), "{}\n"); + write(join(local, "skills/01-a/SKILL.md"), "---\nname: a\n---\n"); + write(join(local, "agents/reviewer.md"), "---\n---\n"); + write(join(env.home, ".cursor/rules/aidd-agents.mdc"), "## Agents\n"); + try { + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /\| Cursor \| 🟡 1\/1 \| 🟡 ~\/\.cursor\/rules\/aidd-agents\.mdc \| ❌ 0\/1, missing aidd-x\/01-a \| 🟡 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/check-hosts.cjs b/scripts/check-hosts.cjs new file mode 100644 index 000000000..3935148d9 --- /dev/null +++ b/scripts/check-hosts.cjs @@ -0,0 +1,312 @@ +#!/usr/bin/env node +// What each installed AI tool really loads of this checkout's plugins, as a table: asked of the +// tool itself where it can answer, read from the files its documentation says it loads where it +// cannot. Exits 1 when an installed tool misses something. Reads only; a tool's session opened to +// list its agents (Claude, Copilot) is killed before any model answers. +// +// Sources for each proof: +// https://code.claude.com/docs/en/plugins/cli-reference +// https://code.claude.com/docs/en/headless +// https://developers.openai.com/codex/cli/reference +// https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference +// https://opencode.ai/v2/docs/skills +// https://kilo.ai/docs/customize/skills +// https://cursor.com/docs/plugins#test-plugins-locally +const { spawn, spawnSync } = require("node:child_process"); +const { existsSync, mkdtempSync, readdirSync, readFileSync } = require("node:fs"); +const { homedir, tmpdir } = require("node:os"); +const { basename, join, resolve } = require("node:path"); + +const MARKETPLACE = "aidd-framework"; +const SESSION_TIMEOUT_MS = 60000; +const READS = 10; +const READ_PAUSE_MS = 2000; + +const rootFlag = process.argv.indexOf("--root"); +const root = rootFlag === -1 ? resolve(__dirname, "..") : resolve(process.argv[rootFlag + 1]); +// Neutral on purpose: a project's own settings, skills and agents would add to the answer. +const cwd = mkdtempSync(join(tmpdir(), "aidd-check-hosts-")); +const home = homedir(); + +function listNames(dir) { + return existsSync(dir) ? readdirSync(dir) : []; +} + +const plugins = listNames(join(root, "plugins")).filter((name) => + existsSync(join(root, "plugins", name, ".claude-plugin/plugin.json")) +); +const skills = plugins.flatMap((plugin) => + listNames(join(root, "plugins", plugin, "skills")) + .filter((skill) => existsSync(join(root, "plugins", plugin, "skills", skill, "SKILL.md"))) + .map((skill) => ({ plugin, skill })) +); +const agents = plugins.flatMap((plugin) => + listNames(join(root, "plugins", plugin, "agents")) + .filter((file) => file.endsWith(".md")) + .map((file) => ({ plugin, agent: basename(file, ".md") })) +); + +function run(binary, args) { + const result = spawnSync(binary, args, { cwd, encoding: "utf8", timeout: SESSION_TIMEOUT_MS }); + return result.status === 0 ? result.stdout : null; +} + +function installed(binary) { + return spawnSync(binary, ["--version"], { cwd, stdio: "ignore" }).error === undefined; +} + +function json(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function fileHolds(path, text) { + return existsSync(path) && readFileSync(path, "utf8").includes(text); +} + +/** A cell: `asked` when the tool answered, the files alone otherwise; the missing names decide. */ +function count(expected, found, proof) { + const missing = expected.filter((name) => !found.has(name)); + const tally = `${expected.length - missing.length}/${expected.length}`; + if (missing.length > 0) { + const shown = missing.slice(0, 3).join(", ") + (missing.length > 3 ? ", …" : ""); + return { status: "missing", text: `${tally}, missing ${shown}` }; + } + return { status: proof, text: tally }; +} + +function instructions(path, proof = "files") { + return fileHolds(path, "## Agents") + ? { status: proof, text: path.replace(home, "~") } + : { status: "missing", text: `no Agents block in ${path.replace(home, "~")}` }; +} + +/** Reads a tool's JSON lines until one is `done`, then kills it, so no model gets to answer. */ +function firstLine(binary, args, done, input = []) { + return new Promise((settle) => { + const child = spawn(binary, args, { cwd, stdio: ["pipe", "pipe", "ignore"] }); + let buffer = ""; + const finish = (value) => { + clearTimeout(timer); + child.kill(); + settle(value); + }; + const timer = setTimeout(() => finish(null), SESSION_TIMEOUT_MS); + child.on("error", () => finish(null)); + child.on("close", () => finish(null)); + child.stdout.on("data", (chunk) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop(); + for (const line of lines) { + const parsed = json(line); + if (parsed !== null && done(parsed)) finish(parsed); + } + }); + for (const message of input) child.stdin.write(`${JSON.stringify(message)}\n`); + if (input.length === 0) child.stdin.end(); + }); +} + +const qualified = ({ plugin, agent }) => `${plugin}:${agent}`; +const flat = ({ plugin, agent }) => `${plugin}-${agent}`; +const skillPath = ({ plugin, skill }) => `${plugin}/${skill}`; + +/** Every `/` a list of paths names, whatever comes before and after it. */ +function skillsIn(paths) { + const found = new Set(); + for (const path of paths) { + const match = String(path).match(/(?:^|[\\/])([^\\/]+)[\\/](?:skills[\\/])?([^\\/]+)[\\/]SKILL\.md$/); + const tail = String(path).match(/(?:^|[\\/])([^\\/]+)[\\/]skills[\\/]([^\\/]+)(?:[\\/]SKILL\.md)?$/); + for (const hit of [match, tail]) if (hit !== null) found.add(`${hit[1]}/${hit[2]}`); + } + return found; +} + +async function claude() { + const configDir = process.env.CLAUDE_CONFIG_DIR || join(home, ".claude"); + const listed = json(run("claude", ["plugin", "list", "--json"]) ?? "") ?? []; + const enabled = new Set( + listed + .filter((p) => p.enabled && p.scope === "user" && p.id?.endsWith(`@${MARKETPLACE}`)) + .map((p) => p.id.split("@")[0]) + ); + const skillCount = plugins.reduce((sum, plugin) => { + const details = run("claude", ["plugin", "details", `${plugin}@${MARKETPLACE}`]) ?? ""; + return sum + Number(details.match(/Skills \((\d+)\)/)?.[1] ?? 0); + }, 0); + // `plugin details` prints Agents (0) for a manifest listing its agents by file; the session's + // own init event names what it really loaded. + const init = await firstLine( + "claude", + ["-p", "Reply OK", "--model", "haiku", "--max-turns", "1", "--output-format", "stream-json", "--verbose"], + (event) => event.type === "system" && event.subtype === "init" + ); + return { + plugins: count(plugins, enabled, "asked"), + instructions: instructions(join(configDir, "CLAUDE.md")), + skills: + skillCount === skills.length + ? { status: "asked", text: `${skillCount}/${skills.length}` } + : { status: "missing", text: `${skillCount}/${skills.length}` }, + agents: count(agents.map(qualified), new Set(init?.agents ?? []), "asked"), + }; +} + +function promptText(value) { + if (typeof value === "string") return ""; + if (Array.isArray(value)) return value.map(promptText).join("\n"); + if (value === null || typeof value !== "object") return ""; + return [typeof value.text === "string" ? value.text : "", promptText(value.content)].join("\n"); +} + +async function codex() { + const codexHome = process.env.CODEX_HOME || join(home, ".codex"); + const listed = json(run("codex", ["plugin", "list", "--json"]) ?? "")?.installed ?? []; + const enabled = new Set( + listed.filter((p) => p.marketplaceName === MARKETPLACE && p.installed && p.enabled).map((p) => p.name) + ); + const prompt = promptText(json(run("codex", ["debug", "prompt-input"]) ?? "") ?? []); + const available = new Set( + [...prompt.matchAll(/^- ([a-z0-9-]+):([^:\s]+)/gm)].map((hit) => `${hit[1]}/${hit[2]}`) + ); + const loaded = prompt.includes("# AGENTS.md instructions") && prompt.includes("## Agents"); + return { + plugins: count(plugins, enabled, "asked"), + instructions: loaded + ? { status: "asked", text: "in the prompt" } + : { status: "missing", text: "not in the prompt" }, + skills: count(skills.map(skillPath), available, "asked"), + agents: count( + agents.map(flat), + new Set(listNames(join(codexHome, "agents")).map((file) => basename(file, ".toml"))), + "files" + ), + }; +} + +async function copilot() { + const listed = json(run("copilot", ["plugin", "list", "--json"]) ?? "") ?? []; + const enabled = new Set( + listed.filter((p) => p.marketplace === MARKETPLACE && p.enabled).map((p) => p.name) + ); + const sources = json(run("copilot", ["instruction", "list", "--json"]) ?? "") ?? []; + const pluginSkills = (json(run("copilot", ["skill", "list", "--json"]) ?? "") ?? []).filter( + (skill) => skill.source === "plugin" && skill.enabled + ); + const session = await firstLine("copilot", ["--acp", "--no-auto-update"], (m) => m.id === 2, [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1, clientCapabilities: {} } }, + { jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd, mcpServers: [] } }, + ]); + const agentOption = session?.result?.configOptions?.find((option) => option.id === "agent"); + return { + plugins: count(plugins, enabled, "asked"), + instructions: sources.some((source) => source.sourcePath?.endsWith("copilot-instructions.md")) + ? { status: "asked", text: "listed" } + : { status: "missing", text: "not listed" }, + skills: count(skills.map(skillPath), skillsIn(pluginSkills.map((skill) => skill.path)), "asked"), + agents: count( + agents.map(qualified), + new Set(agentOption?.options?.map((option) => option.value) ?? []), + "asked" + ), + }; +} + +function pluginFolders(found) { + return new Set([...found].map((path) => path.split("/")[0])); +} + +async function opencode() { + const configDir = (run("opencode", ["debug", "paths", "config"]) ?? "").trim(); + // Not --standalone: its private server answers an empty list before it has loaded. The service + // does too while it starts cold, so a list counts once two reads in a row agree. + let listed = []; + for (let read = 0, previous = -1; read < READS; read += 1) { + listed = json(run("opencode", ["api", "get", "/api/skill"]) ?? "")?.data ?? []; + if (listed.length > 0 && listed.length === previous) break; + previous = listed.length; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, READ_PAUSE_MS); + } + const found = skillsIn(listed.map((skill) => skill.path)); + const listedAgents = json(run("opencode", ["debug", "agents", "--standalone"]) ?? "") ?? []; + return { + plugins: count(plugins, pluginFolders(found), "asked"), + instructions: instructions(join(configDir, "AGENTS.md")), + skills: count(skills.map(skillPath), found, "asked"), + agents: count(agents.map(flat), new Set(listedAgents.map((agent) => agent.id)), "asked"), + }; +} + +async function kilo() { + const listed = json(run("kilo", ["debug", "skill"]) ?? "") ?? []; + const found = skillsIn(listed.map((skill) => skill.location)); + const loaded = agents.map(flat).filter((name) => run("kilo", ["debug", "agent", name]) !== null); + return { + plugins: count(plugins, pluginFolders(found), "asked"), + // Kilo documents no global AGENTS.md, only its config's `instructions` key. + instructions: { status: "none", text: "none documented" }, + skills: count(skills.map(skillPath), found, "asked"), + agents: count(agents.map(flat), new Set(loaded), "asked"), + }; +} + +async function cursor() { + const local = join(home, ".cursor/plugins/local"); + const valid = plugins.filter( + (plugin) => json(existsSync(join(local, plugin, ".cursor-plugin/plugin.json")) + ? readFileSync(join(local, plugin, ".cursor-plugin/plugin.json"), "utf8") + : "") !== null + ); + const named = skills.filter(({ plugin, skill }) => + fileHolds(join(local, plugin, "skills", skill, "SKILL.md"), `name: ${skill}`) + || fileHolds(join(local, plugin, "skills", skill, "SKILL.md"), `name: '${skill}'`) + ); + const present = agents.filter(({ plugin, agent }) => + existsSync(join(local, plugin, "agents", `${agent}.md`)) + ); + return { + plugins: count(plugins, new Set(valid), "files"), + instructions: instructions(join(home, ".cursor/rules/aidd-agents.mdc")), + skills: count(skills.map(skillPath), new Set(named.map(skillPath)), "files"), + agents: count(agents.map(flat), new Set(present.map(flat)), "files"), + }; +} + +const TOOLS = [ + { name: "Claude Code", binaries: ["claude"], check: claude }, + { name: "Codex", binaries: ["codex"], check: codex }, + { name: "Copilot", binaries: ["copilot"], check: copilot }, + { name: "Cursor", binaries: ["cursor-agent", "cursor"], check: cursor }, + { name: "Kilo", binaries: ["kilo"], check: kilo }, + { name: "OpenCode", binaries: ["opencode"], check: opencode }, +]; + +const MARK = { asked: "✅", files: "🟡", missing: "❌", none: "➖" }; + +function show(cell) { + return `${MARK[cell.status]} ${cell.text}`; +} + +async function main() { + console.log("| Tool | Plugins enabled | Global instructions | Skills | Agents |"); + console.log("| --- | --- | --- | --- | --- |"); + let failed = false; + for (const tool of TOOLS) { + if (!tool.binaries.some(installed)) { + console.log(`| ${tool.name} | not installed | | | |`); + continue; + } + const row = await tool.check(); + const cells = [row.plugins, row.instructions, row.skills, row.agents]; + if (cells.some((cell) => cell.status === "missing")) failed = true; + console.log(`| ${tool.name} | ${cells.map(show).join(" | ")} |`); + } + console.log("\n✅ the tool says so · 🟡 the files its documentation says it loads · ❌ missing · ➖ does not apply"); + process.exit(failed ? 1 : 0); +} + +main(); From 829634a6ba76933ba0d280d4ad70774e55058872 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 08:20:01 +0800 Subject: [PATCH 11/16] fix(framework): check-hosts proves names, and fails what it cannot prove - Claude's plugins, skills and agents come from its session's init event, compared by name; the session runs without hooks or a saved transcript. - A listed skill counts only under the tool's config directory and while its file exists. - Cursor reads names from the frontmatter alone; global instructions need the managed block. - No plugin to expect, or a tool answering no config directory, is a failure, not 0/0. - OpenCode's service is read again while it starts cold, until it lists every skill. - A test per tool, and one per false positive. --- scripts/__tests__/check-hosts.test.js | 341 ++++++++++++++++++++++---- scripts/check-hosts.cjs | 302 ++++++++++++++--------- 2 files changed, 490 insertions(+), 153 deletions(-) diff --git a/scripts/__tests__/check-hosts.test.js b/scripts/__tests__/check-hosts.test.js index bb20232bc..1e923835a 100644 --- a/scripts/__tests__/check-hosts.test.js +++ b/scripts/__tests__/check-hosts.test.js @@ -1,12 +1,13 @@ const assert = require("node:assert/strict"); const { spawnSync } = require("node:child_process"); -const { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } = require("node:fs"); +const { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } = require("node:fs"); const { tmpdir } = require("node:os"); const { delimiter, dirname, join, resolve } = require("node:path"); const { test } = require("node:test"); const script = resolve(__dirname, "../check-hosts.cjs"); const posixOnly = { skip: process.platform === "win32" }; +const MARKER = ""; function write(path, content) { mkdirSync(dirname(path), { recursive: true }); @@ -15,77 +16,331 @@ function write(path, content) { /** A framework of one plugin, one skill and one agent, with stubs standing in for the tools. */ function fixture(stubs) { - const dir = mkdtempSync(join(tmpdir(), "aidd-check-hosts-test-")); + const dir = realpathSync(mkdtempSync(join(tmpdir(), "aidd-check-hosts-test-"))); const framework = join(dir, "framework"); + const home = join(dir, "home"); write(join(framework, "plugins/aidd-x/.claude-plugin/plugin.json"), "{}\n"); write(join(framework, "plugins/aidd-x/skills/01-a/SKILL.md"), "---\nname: 01-a\n---\n"); write(join(framework, "plugins/aidd-x/agents/reviewer.md"), "---\nname: reviewer\n---\n"); - for (const [name, body] of Object.entries(stubs)) { + mkdirSync(join(dir, "bin"), { recursive: true }); + mkdirSync(home, { recursive: true }); + for (const [name, body] of Object.entries(stubs(home))) { write(join(dir, "bin", name), `#!/bin/sh\n[ "$1" = --version ] && exit 0\n${body}\n`); chmodSync(join(dir, "bin", name), 0o755); } - mkdirSync(join(dir, "bin"), { recursive: true }); - mkdirSync(join(dir, "home"), { recursive: true }); - return { dir, framework, home: join(dir, "home") }; + return { dir, framework, home }; } -function check({ dir, framework, home }) { - return spawnSync(process.execPath, [script, "--root", framework], { +function check({ dir, framework, home }, root = framework) { + return spawnSync(process.execPath, [script, "--root", root], { encoding: "utf8", env: { HOME: home, PATH: [join(dir, "bin"), "/usr/bin", "/bin"].join(delimiter) }, }); } -const KILO = (location) => `case "$1 $2" in +function scenario(name, stubs, assertions) { + test(name, posixOnly, () => { + const env = fixture(stubs); + try { + assertions(env); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); +} + +const kilo = (location) => `case "$1 $2" in + 'debug paths') echo "config $HOME/.config/kilo" ;; 'debug skill') echo '[{"location":"${location}"}]' ;; 'debug agent') [ "$3" = aidd-x-reviewer ] && echo '{}' || exit 1 ;; esac`; -test("a tool that is not installed is said so and fails nothing", posixOnly, () => { - const env = fixture({}); - try { +const opencode = (paths) => `case "$1 $2" in + 'debug paths') echo "$HOME/.config/opencode" ;; + 'api get') echo '{"data":[${paths.map((path) => `{"path":"${path}"}`).join(",")}]}' ;; + 'debug agents') echo '[{"id":"aidd-x-reviewer"}]' ;; +esac`; + +const claude = ({ skills = ["aidd-x:01-a"], plugins = [{ name: "aidd-x" }] } = {}) => { + const init = JSON.stringify({ + type: "system", + subtype: "init", + plugins, + skills, + agents: ["aidd-x:reviewer"], + }); + return `case "$1 $2" in + 'plugin list') echo '[{"id":"aidd-x@aidd-framework","scope":"user","enabled":true}]' ;; + *) echo '${init}' ;; +esac`; +}; + +scenario( + "a tool that is not installed is said so and fails nothing", + () => ({}), + (env) => { const result = check(env); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /\| Kilo \| not installed/); - } finally { - rmSync(env.dir, { recursive: true, force: true }); } +); + +scenario( + "a checkout with no plugin to expect is refused, never a table of 0/0", + () => ({ kilo: "exit 0" }), + (env) => { + const result = check(env, join(env.dir, "nowhere")); + assert.equal(result.status, 2); + assert.match(result.stderr, /No plugin to expect/); + assert.doesNotMatch(result.stdout, /0\/0/); + } +); + +test("Kilo", posixOnly, async (t) => { + await t.test("passes when it answers with everything, proved by the tool", () => { + const env = fixture((home) => ({ kilo: kilo(join(home, ".config/kilo/skills/aidd-x/01-a/SKILL.md")) })); + try { + write(join(env.home, ".config/kilo/skills/aidd-x/01-a/SKILL.md"), "x"); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Kilo \| ✅ 1\/1 \| ➖ none documented \| ✅ 1\/1 \| ✅ 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails on a skill it lists from elsewhere than its config directory", () => { + const env = fixture((home) => ({ kilo: kilo(join(home, "elsewhere/aidd-x/01-a/SKILL.md")) })); + try { + write(join(env.home, "elsewhere/aidd-x/01-a/SKILL.md"), "x"); + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /❌ 0\/1, missing aidd-x\/01-a/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails on a skill it lists whose file is gone: a stale answer proves nothing", () => { + const env = fixture((home) => ({ kilo: kilo(join(home, ".config/kilo/skills/aidd-x/01-a/SKILL.md")) })); + try { + assert.equal(check(env).status, 1); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); }); -test("a tool that answers with everything passes, proved by the tool", posixOnly, () => { - const env = fixture({ kilo: KILO("/h/.config/kilo/skills/aidd-x/01-a/SKILL.md") }); - try { +test("Claude Code", posixOnly, async (t) => { + const withInstructions = (env) => write(join(env.home, ".claude/CLAUDE.md"), `${MARKER}\n`); + + await t.test("reads plugins, skills and agents from the session's own init event", () => { + const env = fixture(() => ({ claude: claude() })); + try { + withInstructions(env); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Claude Code \| ✅ 1\/1 \| 🟡 ~\/\.claude\/CLAUDE\.md \| ✅ 1\/1 \| ✅ 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("compares skills by name: as many other skills do not count", () => { + const env = fixture(() => ({ claude: claude({ skills: ["totally:other"] }) })); + try { + withInstructions(env); + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /❌ 0\/1, missing aidd-x:01-a/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails a plugin enabled in its list that the session did not load", () => { + const env = fixture(() => ({ claude: claude({ plugins: [] }) })); + try { + withInstructions(env); + assert.match(check(env).stdout, /\| Claude Code \| ❌ 0\/1, missing aidd-x/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("does not take a personal Agents heading for the managed block", () => { + const env = fixture(() => ({ claude: claude() })); + try { + write(join(env.home, ".claude/CLAUDE.md"), "## Agents\nmine\n"); + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /❌ no managed block in ~\/\.claude\/CLAUDE\.md/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); +}); + +scenario( + "Codex reads plugins, instructions and skills from what it would send the model", + () => ({ + codex: `case "$1 $2" in + 'plugin list') echo '{"installed":[{"name":"aidd-x","marketplaceName":"aidd-framework","installed":true,"enabled":true}]}' ;; + 'debug prompt-input') printf '%s' '[{"content":[{"text":"${MARKER}\\n- aidd-x:01-a: does a"}]}]' ;; +esac`, + }), + (env) => { + write( + join(env.home, ".codex/agents/aidd-x-reviewer.toml"), + 'name = "aidd-x-reviewer"\ndeveloper_instructions = "x"\n' + ); const result = check(env); assert.equal(result.status, 0, result.stdout); - assert.match(result.stdout, /\| Kilo \| ✅ 1\/1 \| ➖ none documented \| ✅ 1\/1 \| ✅ 1\/1 \|/); - } finally { - rmSync(env.dir, { recursive: true, force: true }); + assert.match(result.stdout, /\| Codex \| ✅ 1\/1 \| ✅ in the prompt \| ✅ 1\/1 \| 🟡 1\/1 \|/); } -}); +); -test("a skill the tool does not list fails the run and is named", posixOnly, () => { - const env = fixture({ kilo: KILO("/h/.config/kilo/skills/aidd-x/99-other/SKILL.md") }); - try { - const result = check(env); - assert.equal(result.status, 1); - assert.match(result.stdout, /❌ 0\/1, missing aidd-x\/01-a/); - } finally { - rmSync(env.dir, { recursive: true, force: true }); +scenario( + "Codex fails an agent file that names another agent", + () => ({ + codex: `case "$1 $2" in + 'plugin list') echo '{"installed":[]}' ;; + 'debug prompt-input') echo '[]' ;; +esac`, + }), + (env) => { + write(join(env.home, ".codex/agents/aidd-x-reviewer.toml"), 'name = "other"\n'); + assert.match(check(env).stdout, /❌ 0\/1, missing aidd-x-reviewer/); } +); + +test("Copilot", posixOnly, async (t) => { + await t.test("lists its agents through a session that sends no prompt", () => { + const env = fixture((home) => ({ + copilot: `case "$1 $2" in + 'plugin list') echo '[{"name":"aidd-x","marketplace":"aidd-framework","enabled":true}]' ;; + 'instruction list') echo '[{"sourcePath":"${home}/.copilot/copilot-instructions.md"}]' ;; + 'skill list') echo '[{"source":"plugin","enabled":true,"path":"${home}/built/plugins/aidd-x/skills/01-a"}]' ;; + --acp*) echo '{"id":2,"result":{"configOptions":[{"id":"agent","options":[{"value":"aidd-x:reviewer"}]}]}}' ;; +esac`, + })); + try { + write(join(env.home, ".copilot/copilot-instructions.md"), `${MARKER}\n`); + write(join(env.home, "built/plugins/aidd-x/skills/01-a/SKILL.md"), "x"); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Copilot \| ✅ 1\/1 \| ✅ listed \| ✅ 1\/1 \| ✅ 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails its agents when the session dies without answering", () => { + const env = fixture(() => ({ copilot: "exit 0" })); + try { + assert.match(check(env).stdout, /\| Copilot \| ❌ .*❌ 0\/1, missing aidd-x:reviewer \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); }); -test("Cursor is read from its files, and a skill named apart from its folder is missing", posixOnly, () => { - const env = fixture({ "cursor-agent": "exit 0" }); - const local = join(env.home, ".cursor/plugins/local/aidd-x"); - write(join(local, ".cursor-plugin/plugin.json"), "{}\n"); - write(join(local, "skills/01-a/SKILL.md"), "---\nname: a\n---\n"); - write(join(local, "agents/reviewer.md"), "---\n---\n"); - write(join(env.home, ".cursor/rules/aidd-agents.mdc"), "## Agents\n"); - try { - const result = check(env); - assert.equal(result.status, 1); - assert.match(result.stdout, /\| Cursor \| 🟡 1\/1 \| 🟡 ~\/\.cursor\/rules\/aidd-agents\.mdc \| ❌ 0\/1, missing aidd-x\/01-a \| 🟡 1\/1 \|/); - } finally { - rmSync(env.dir, { recursive: true, force: true }); - } +test("OpenCode", posixOnly, async (t) => { + const present = (env) => { + write(join(env.home, ".config/opencode/skills/aidd-x/01-a/SKILL.md"), "x"); + write(join(env.home, ".config/opencode/AGENTS.md"), `${MARKER}\n`); + }; + + await t.test("passes when its service lists every skill", () => { + const env = fixture((home) => ({ + opencode: opencode([join(home, ".config/opencode/skills/aidd-x/01-a/SKILL.md")]), + })); + try { + present(env); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| OpenCode \| ✅ 1\/1 \| 🟡 ~\/\.config\/opencode\/AGENTS\.md \| ✅ 1\/1 \| ✅ 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("reads again while its service starts cold, and gets the whole list", () => { + const env = fixture((home) => ({ + opencode: `case "$1 $2" in + 'debug paths') echo "$HOME/.config/opencode" ;; + 'api get') + if [ -f "$HOME/warm" ]; then + echo '{"data":[{"path":"${home}/.config/opencode/skills/aidd-x/01-a/SKILL.md"}]}' + else + : > "$HOME/warm"; echo '{"data":[]}' + fi ;; + 'debug agents') echo '[{"id":"aidd-x-reviewer"}]' ;; +esac`, + })); + try { + present(env); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails every cell when it answers no config directory, never the checkout's file", () => { + const env = fixture(() => ({ opencode: "exit 1" })); + try { + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /\| OpenCode \| ❌ no config directory answered \| ❌/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); +}); + +test("Cursor is read from its files", posixOnly, async (t) => { + const install = (env, skill) => { + const local = join(env.home, ".cursor/plugins/local/aidd-x"); + write(join(local, ".cursor-plugin/plugin.json"), "{}\n"); + write(join(local, "skills/01-a/SKILL.md"), skill); + write(join(local, "agents/reviewer.md"), "---\ndescription: reviews\n---\nBody\n"); + write(join(env.home, ".cursor/rules/aidd-agents.mdc"), `${MARKER}\n`); + }; + const failsOnSkill = (skill) => () => { + const env = fixture(() => ({ "cursor-agent": "exit 0" })); + try { + install(env, skill); + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /❌ 0\/1, missing aidd-x\/01-a/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }; + + await t.test("and passes a conforming skill", () => { + const env = fixture(() => ({ "cursor-agent": "exit 0" })); + try { + install(env, "---\nname: 01-a\ndescription: does a\n---\n"); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Cursor \| 🟡 1\/1 \| 🟡 ~\/\.cursor\/rules\/aidd-agents\.mdc \| 🟡 1\/1 \| 🟡 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("and fails a name apart from its folder", failsOnSkill("---\nname: a\ndescription: d\n---\n")); + await t.test("and fails a name that only starts like it", failsOnSkill("---\nname: 01-abc\ndescription: d\n---\n")); + await t.test("and fails a name found only in the body", failsOnSkill("---\ndescription: d\n---\nname: 01-a\n")); + await t.test("and fails a skill with no description", failsOnSkill("---\nname: 01-a\n---\n")); }); + +scenario( + "a tool named agent counts as Cursor only when its help says so", + () => ({ agent: 'echo "Grok Build TUI"' }), + (env) => { + assert.match(check(env).stdout, /\| Cursor \| not installed/); + } +); diff --git a/scripts/check-hosts.cjs b/scripts/check-hosts.cjs index 3935148d9..79f0a944d 100644 --- a/scripts/check-hosts.cjs +++ b/scripts/check-hosts.cjs @@ -1,8 +1,12 @@ #!/usr/bin/env node // What each installed AI tool really loads of this checkout's plugins, as a table: asked of the // tool itself where it can answer, read from the files its documentation says it loads where it -// cannot. Exits 1 when an installed tool misses something. Reads only; a tool's session opened to -// list its agents (Claude, Copilot) is killed before any model answers. +// cannot. Compares names, not contents. Exits 1 when an installed tool misses something, 2 when +// the checkout has no plugin to expect. +// +// Writes no setting. Listing agents opens a session that is killed as soon as it answers: Claude's +// (no hooks, no saved transcript) may have started one model request, whose answer nobody reads; +// Copilot's sends no prompt and leaves an entry in its session state. // // Sources for each proof: // https://code.claude.com/docs/en/plugins/cli-reference @@ -13,19 +17,18 @@ // https://kilo.ai/docs/customize/skills // https://cursor.com/docs/plugins#test-plugins-locally const { spawn, spawnSync } = require("node:child_process"); -const { existsSync, mkdtempSync, readdirSync, readFileSync } = require("node:fs"); +const { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } = require("node:fs"); const { homedir, tmpdir } = require("node:os"); -const { basename, join, resolve } = require("node:path"); +const { basename, join, resolve, sep } = require("node:path"); const MARKETPLACE = "aidd-framework"; +const INSTRUCTIONS_MARKER = ""; const SESSION_TIMEOUT_MS = 60000; -const READS = 10; +const READS = 15; const READ_PAUSE_MS = 2000; const rootFlag = process.argv.indexOf("--root"); -const root = rootFlag === -1 ? resolve(__dirname, "..") : resolve(process.argv[rootFlag + 1]); -// Neutral on purpose: a project's own settings, skills and agents would add to the answer. -const cwd = mkdtempSync(join(tmpdir(), "aidd-check-hosts-")); +const root = resolve(rootFlag === -1 ? join(__dirname, "..") : (process.argv[rootFlag + 1] ?? ".")); const home = homedir(); function listNames(dir) { @@ -46,8 +49,16 @@ const agents = plugins.flatMap((plugin) => .map((file) => ({ plugin, agent: basename(file, ".md") })) ); +// Neutral on purpose: a project's own settings, skills and agents would add to the answer. +let cwd; + function run(binary, args) { - const result = spawnSync(binary, args, { cwd, encoding: "utf8", timeout: SESSION_TIMEOUT_MS }); + const result = spawnSync(binary, args, { + cwd, + encoding: "utf8", + timeout: SESSION_TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, + }); return result.status === 0 ? result.stdout : null; } @@ -63,11 +74,25 @@ function json(text) { } } +function pause(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + function fileHolds(path, text) { return existsSync(path) && readFileSync(path, "utf8").includes(text); } -/** A cell: `asked` when the tool answered, the files alone otherwise; the missing names decide. */ +/** A markdown file's frontmatter alone, so a line of its body never counts. */ +function frontmatter(path) { + if (!existsSync(path)) return null; + return readFileSync(path, "utf8").match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? null; +} + +function field(block, key) { + return block?.match(new RegExp(`^${key}:[ \\t]*["']?([^"'\\r\\n]*)`, "m"))?.[1].trim(); +} + +/** A cell: the proof it was given, unless a name is missing. */ function count(expected, found, proof) { const missing = expected.filter((name) => !found.has(name)); const tally = `${expected.length - missing.length}/${expected.length}`; @@ -78,13 +103,14 @@ function count(expected, found, proof) { return { status: proof, text: tally }; } -function instructions(path, proof = "files") { - return fileHolds(path, "## Agents") - ? { status: proof, text: path.replace(home, "~") } - : { status: "missing", text: `no Agents block in ${path.replace(home, "~")}` }; +function instructions(path) { + const shown = path.replace(home, "~"); + return fileHolds(path, INSTRUCTIONS_MARKER) + ? { status: "files", text: shown } + : { status: "missing", text: `no managed block in ${shown}` }; } -/** Reads a tool's JSON lines until one is `done`, then kills it, so no model gets to answer. */ +/** Reads a tool's JSON lines until one is `done`, then kills it. */ function firstLine(binary, args, done, input = []) { return new Promise((settle) => { const child = spawn(binary, args, { cwd, stdio: ["pipe", "pipe", "ignore"] }); @@ -97,6 +123,7 @@ function firstLine(binary, args, done, input = []) { const timer = setTimeout(() => finish(null), SESSION_TIMEOUT_MS); child.on("error", () => finish(null)); child.on("close", () => finish(null)); + child.stdin.on("error", () => {}); child.stdout.on("data", (chunk) => { buffer += chunk; const lines = buffer.split("\n"); @@ -111,53 +138,70 @@ function firstLine(binary, args, done, input = []) { }); } -const qualified = ({ plugin, agent }) => `${plugin}:${agent}`; -const flat = ({ plugin, agent }) => `${plugin}-${agent}`; -const skillPath = ({ plugin, skill }) => `${plugin}/${skill}`; - -/** Every `/` a list of paths names, whatever comes before and after it. */ -function skillsIn(paths) { - const found = new Set(); - for (const path of paths) { - const match = String(path).match(/(?:^|[\\/])([^\\/]+)[\\/](?:skills[\\/])?([^\\/]+)[\\/]SKILL\.md$/); - const tail = String(path).match(/(?:^|[\\/])([^\\/]+)[\\/]skills[\\/]([^\\/]+)(?:[\\/]SKILL\.md)?$/); - for (const hit of [match, tail]) if (hit !== null) found.add(`${hit[1]}/${hit[2]}`); - } - return found; +const qualifiedAgent = ({ plugin, agent }) => `${plugin}:${agent}`; +const flatAgent = ({ plugin, agent }) => `${plugin}-${agent}`; +const skillId = ({ plugin, skill }) => `${plugin}/${skill}`; +const qualifiedSkill = ({ plugin, skill }) => `${plugin}:${skill}`; + +/** The `/` of a skill's path, `...//[skills/][/SKILL.md]`. */ +function skillIdOf(path) { + const parts = String(path).split(/[\\/]/).filter((part) => part !== "SKILL.md"); + const skill = parts.pop(); + const parent = parts.pop(); + return `${parent === "skills" ? parts.pop() : parent}/${skill}`; +} + +/** The skills a tool lists that are really there, under the directory it was installed in: a + * stale list or a same-named skill elsewhere proves nothing. */ +function skillsUnder(paths, dir) { + return new Set( + paths + .map(String) + .filter((path) => path.startsWith(dir + sep) && existsSync(path)) + .map(skillIdOf) + ); +} + +function pluginsOf(found) { + return new Set([...found].map((id) => id.split("/")[0])); } async function claude() { const configDir = process.env.CLAUDE_CONFIG_DIR || join(home, ".claude"); const listed = json(run("claude", ["plugin", "list", "--json"]) ?? "") ?? []; - const enabled = new Set( - listed - .filter((p) => p.enabled && p.scope === "user" && p.id?.endsWith(`@${MARKETPLACE}`)) - .map((p) => p.id.split("@")[0]) - ); - const skillCount = plugins.reduce((sum, plugin) => { - const details = run("claude", ["plugin", "details", `${plugin}@${MARKETPLACE}`]) ?? ""; - return sum + Number(details.match(/Skills \((\d+)\)/)?.[1] ?? 0); - }, 0); + const enabled = listed + .filter((p) => p.enabled && p.scope === "user" && p.id?.endsWith(`@${MARKETPLACE}`)) + .map((p) => p.id.split("@")[0]); // `plugin details` prints Agents (0) for a manifest listing its agents by file; the session's // own init event names what it really loaded. const init = await firstLine( "claude", - ["-p", "Reply OK", "--model", "haiku", "--max-turns", "1", "--output-format", "stream-json", "--verbose"], + [ + "-p", + "Reply OK", + "--model", + "haiku", + "--max-turns", + "1", + "--no-session-persistence", + "--settings", + '{"disableAllHooks":true}', + "--output-format", + "stream-json", + "--verbose", + ], (event) => event.type === "system" && event.subtype === "init" ); + const loaded = new Set((init?.plugins ?? []).map((plugin) => plugin.name)); return { - plugins: count(plugins, enabled, "asked"), + plugins: count(plugins, new Set(enabled.filter((name) => loaded.has(name))), "asked"), instructions: instructions(join(configDir, "CLAUDE.md")), - skills: - skillCount === skills.length - ? { status: "asked", text: `${skillCount}/${skills.length}` } - : { status: "missing", text: `${skillCount}/${skills.length}` }, - agents: count(agents.map(qualified), new Set(init?.agents ?? []), "asked"), + skills: count(skills.map(qualifiedSkill), new Set(init?.skills ?? []), "asked"), + agents: count(agents.map(qualifiedAgent), new Set(init?.agents ?? []), "asked"), }; } function promptText(value) { - if (typeof value === "string") return ""; if (Array.isArray(value)) return value.map(promptText).join("\n"); if (value === null || typeof value !== "object") return ""; return [typeof value.text === "string" ? value.text : "", promptText(value.content)].join("\n"); @@ -166,123 +210,151 @@ function promptText(value) { async function codex() { const codexHome = process.env.CODEX_HOME || join(home, ".codex"); const listed = json(run("codex", ["plugin", "list", "--json"]) ?? "")?.installed ?? []; - const enabled = new Set( - listed.filter((p) => p.marketplaceName === MARKETPLACE && p.installed && p.enabled).map((p) => p.name) - ); + const enabled = listed + .filter((p) => p.marketplaceName === MARKETPLACE && p.installed && p.enabled) + .map((p) => p.name); const prompt = promptText(json(run("codex", ["debug", "prompt-input"]) ?? "") ?? []); - const available = new Set( - [...prompt.matchAll(/^- ([a-z0-9-]+):([^:\s]+)/gm)].map((hit) => `${hit[1]}/${hit[2]}`) + const available = [...prompt.matchAll(/^- ([a-z0-9-]+):([^:\s]+)/gm)].map( + (hit) => `${hit[1]}/${hit[2]}` ); - const loaded = prompt.includes("# AGENTS.md instructions") && prompt.includes("## Agents"); + const agentFiles = agents.map(flatAgent).filter((name) => { + const path = join(codexHome, "agents", `${name}.toml`); + return fileHolds(path, `name = "${name}"`) && fileHolds(path, "developer_instructions"); + }); return { - plugins: count(plugins, enabled, "asked"), - instructions: loaded + plugins: count(plugins, new Set(enabled), "asked"), + instructions: prompt.includes(INSTRUCTIONS_MARKER) ? { status: "asked", text: "in the prompt" } : { status: "missing", text: "not in the prompt" }, - skills: count(skills.map(skillPath), available, "asked"), - agents: count( - agents.map(flat), - new Set(listNames(join(codexHome, "agents")).map((file) => basename(file, ".toml"))), - "files" - ), + skills: count(skills.map(skillId), new Set(available), "asked"), + agents: count(agents.map(flatAgent), new Set(agentFiles), "files"), }; } async function copilot() { const listed = json(run("copilot", ["plugin", "list", "--json"]) ?? "") ?? []; - const enabled = new Set( - listed.filter((p) => p.marketplace === MARKETPLACE && p.enabled).map((p) => p.name) - ); + const enabled = listed + .filter((p) => p.marketplace === MARKETPLACE && p.enabled) + .map((p) => p.name); const sources = json(run("copilot", ["instruction", "list", "--json"]) ?? "") ?? []; + const managed = sources.some( + (source) => + source.sourcePath?.endsWith("copilot-instructions.md") && + fileHolds(source.sourcePath, INSTRUCTIONS_MARKER) + ); const pluginSkills = (json(run("copilot", ["skill", "list", "--json"]) ?? "") ?? []).filter( (skill) => skill.source === "plugin" && skill.enabled ); const session = await firstLine("copilot", ["--acp", "--no-auto-update"], (m) => m.id === 2, [ - { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1, clientCapabilities: {} } }, + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1, clientCapabilities: {} }, + }, { jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd, mcpServers: [] } }, ]); const agentOption = session?.result?.configOptions?.find((option) => option.id === "agent"); return { - plugins: count(plugins, enabled, "asked"), - instructions: sources.some((source) => source.sourcePath?.endsWith("copilot-instructions.md")) + plugins: count(plugins, new Set(enabled), "asked"), + instructions: managed ? { status: "asked", text: "listed" } - : { status: "missing", text: "not listed" }, - skills: count(skills.map(skillPath), skillsIn(pluginSkills.map((skill) => skill.path)), "asked"), + : { status: "missing", text: "no managed copilot-instructions.md listed" }, + skills: count( + skills.map(skillId), + new Set( + pluginSkills.filter((skill) => existsSync(String(skill.path))).map((s) => skillIdOf(s.path)) + ), + "asked" + ), agents: count( - agents.map(qualified), + agents.map(qualifiedAgent), new Set(agentOption?.options?.map((option) => option.value) ?? []), "asked" ), }; } -function pluginFolders(found) { - return new Set([...found].map((path) => path.split("/")[0])); -} - async function opencode() { const configDir = (run("opencode", ["debug", "paths", "config"]) ?? "").trim(); + if (configDir === "") { + const silent = { status: "missing", text: "no config directory answered" }; + return { plugins: silent, instructions: silent, skills: silent, agents: silent }; + } // Not --standalone: its private server answers an empty list before it has loaded. The service - // does too while it starts cold, so a list counts once two reads in a row agree. - let listed = []; - for (let read = 0, previous = -1; read < READS; read += 1) { - listed = json(run("opencode", ["api", "get", "/api/skill"]) ?? "")?.data ?? []; - if (listed.length > 0 && listed.length === previous) break; - previous = listed.length; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, READ_PAUSE_MS); + // does too while it starts cold, so the list is read again until it holds every skill or the + // reads run out: a late answer costs a pause, never a wrong cell. + const expected = skills.map(skillId); + let found = new Set(); + for (let read = 0; read < READS; read += 1) { + const listed = json(run("opencode", ["api", "get", "/api/skill"]) ?? "")?.data ?? []; + found = skillsUnder( + listed.map((skill) => skill.path), + join(configDir, "skills") + ); + if (expected.every((id) => found.has(id))) break; + if (read < READS - 1) pause(READ_PAUSE_MS); } - const found = skillsIn(listed.map((skill) => skill.path)); const listedAgents = json(run("opencode", ["debug", "agents", "--standalone"]) ?? "") ?? []; return { - plugins: count(plugins, pluginFolders(found), "asked"), + plugins: count(plugins, pluginsOf(found), "asked"), instructions: instructions(join(configDir, "AGENTS.md")), - skills: count(skills.map(skillPath), found, "asked"), - agents: count(agents.map(flat), new Set(listedAgents.map((agent) => agent.id)), "asked"), + skills: count(expected, found, "asked"), + agents: count(agents.map(flatAgent), new Set(listedAgents.map((agent) => agent.id)), "asked"), }; } async function kilo() { + const configDir = (run("kilo", ["debug", "paths"]) ?? "").match(/^config\s+(.+)$/m)?.[1]?.trim(); const listed = json(run("kilo", ["debug", "skill"]) ?? "") ?? []; - const found = skillsIn(listed.map((skill) => skill.location)); - const loaded = agents.map(flat).filter((name) => run("kilo", ["debug", "agent", name]) !== null); + const found = skillsUnder( + listed.map((skill) => skill.location), + join(configDir ?? join(home, ".config/kilo"), "skills") + ); + const loaded = agents.map(flatAgent).filter((name) => run("kilo", ["debug", "agent", name]) !== null); return { - plugins: count(plugins, pluginFolders(found), "asked"), + plugins: count(plugins, pluginsOf(found), "asked"), // Kilo documents no global AGENTS.md, only its config's `instructions` key. instructions: { status: "none", text: "none documented" }, - skills: count(skills.map(skillPath), found, "asked"), - agents: count(agents.map(flat), new Set(loaded), "asked"), + skills: count(skills.map(skillId), found, "asked"), + agents: count(agents.map(flatAgent), new Set(loaded), "asked"), }; } async function cursor() { const local = join(home, ".cursor/plugins/local"); - const valid = plugins.filter( - (plugin) => json(existsSync(join(local, plugin, ".cursor-plugin/plugin.json")) - ? readFileSync(join(local, plugin, ".cursor-plugin/plugin.json"), "utf8") - : "") !== null - ); - const named = skills.filter(({ plugin, skill }) => - fileHolds(join(local, plugin, "skills", skill, "SKILL.md"), `name: ${skill}`) - || fileHolds(join(local, plugin, "skills", skill, "SKILL.md"), `name: '${skill}'`) - ); + const valid = plugins.filter((plugin) => { + const path = join(local, plugin, ".cursor-plugin/plugin.json"); + return existsSync(path) && json(readFileSync(path, "utf8")) !== null; + }); + const conforming = skills.filter(({ plugin, skill }) => { + const block = frontmatter(join(local, plugin, "skills", skill, "SKILL.md")); + return field(block, "name") === skill && Boolean(field(block, "description")); + }); const present = agents.filter(({ plugin, agent }) => - existsSync(join(local, plugin, "agents", `${agent}.md`)) + Boolean(field(frontmatter(join(local, plugin, "agents", `${agent}.md`)), "description")) ); return { plugins: count(plugins, new Set(valid), "files"), instructions: instructions(join(home, ".cursor/rules/aidd-agents.mdc")), - skills: count(skills.map(skillPath), new Set(named.map(skillPath)), "files"), - agents: count(agents.map(flat), new Set(present.map(flat)), "files"), + skills: count(skills.map(skillId), new Set(conforming.map(skillId)), "files"), + agents: count(agents.map(flatAgent), new Set(present.map(flatAgent)), "files"), }; } +/** `agent` is Cursor's only when its help says so: other tools take that name. */ +function cursorInstalled() { + if (installed("cursor-agent") || installed("cursor")) return true; + return (run("agent", ["--help"]) ?? "").includes("Start the Cursor Agent"); +} + const TOOLS = [ - { name: "Claude Code", binaries: ["claude"], check: claude }, - { name: "Codex", binaries: ["codex"], check: codex }, - { name: "Copilot", binaries: ["copilot"], check: copilot }, - { name: "Cursor", binaries: ["cursor-agent", "cursor"], check: cursor }, - { name: "Kilo", binaries: ["kilo"], check: kilo }, - { name: "OpenCode", binaries: ["opencode"], check: opencode }, + { name: "Claude Code", present: () => installed("claude"), check: claude }, + { name: "Codex", present: () => installed("codex"), check: codex }, + { name: "Copilot", present: () => installed("copilot"), check: copilot }, + { name: "Cursor", present: cursorInstalled, check: cursor }, + { name: "Kilo", present: () => installed("kilo"), check: kilo }, + { name: "OpenCode", present: () => installed("opencode"), check: opencode }, ]; const MARK = { asked: "✅", files: "🟡", missing: "❌", none: "➖" }; @@ -292,11 +364,15 @@ function show(cell) { } async function main() { + if (plugins.length === 0) { + console.error(`No plugin to expect under ${root}.`); + return 2; + } console.log("| Tool | Plugins enabled | Global instructions | Skills | Agents |"); console.log("| --- | --- | --- | --- | --- |"); let failed = false; for (const tool of TOOLS) { - if (!tool.binaries.some(installed)) { + if (!tool.present()) { console.log(`| ${tool.name} | not installed | | | |`); continue; } @@ -305,8 +381,14 @@ async function main() { if (cells.some((cell) => cell.status === "missing")) failed = true; console.log(`| ${tool.name} | ${cells.map(show).join(" | ")} |`); } - console.log("\n✅ the tool says so · 🟡 the files its documentation says it loads · ❌ missing · ➖ does not apply"); - process.exit(failed ? 1 : 0); + console.log( + "\n✅ the tool says so · 🟡 the files its documentation says it loads · ❌ missing · ➖ does not apply" + ); + return failed ? 1 : 0; } -main(); +cwd = mkdtempSync(join(tmpdir(), "aidd-check-hosts-")); +main().then((code) => { + rmSync(cwd, { recursive: true, force: true }); + process.exit(code); +}); From c241e53eaca5567716e3a6839d822239aa2b6114 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 09:10:27 +0800 Subject: [PATCH 12/16] fix(cli): keep a project's plugins when removing the user's marketplace Removing Claude's marketplace from every scope also uninstalls what a project installed from it, with its saved options. clean --global and make reload now do that only when no project uses the marketplace, and stay at user scope otherwise, or when the list cannot be read. The README's clean --global example names --force, without which it previews or asks. --- cli/README.md | 2 +- .../domain/capabilities/plugins-capability.ts | 7 +-- .../abstract-native-plugin-cli-adapter.ts | 24 +++++++++- ...gin-cli-adapter.claude.integration.test.ts | 48 +++++++++++++++++-- scripts/__tests__/dev-sync.test.js | 22 ++++++++- scripts/dev-sync.sh | 21 ++++++-- 6 files changed, 111 insertions(+), 13 deletions(-) diff --git a/cli/README.md b/cli/README.md index 92a9a5cac..a0c36bb99 100644 --- a/cli/README.md +++ b/cli/README.md @@ -135,7 +135,7 @@ Install it, then run `aidd sync --global`. ```sh aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all --yes aidd sync --global # rebuild the copied files and refresh the hosts from the framework source -aidd clean --global # remove what --global installed and purge the shared source +aidd clean --global --force # remove what --global installed and purge the shared source; without --force it previews or asks ``` ## Authentication diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 5acb16071..67c390327 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -23,9 +23,10 @@ export interface NativeActivation { * else in the shared project settings. Omit where the registry has no scopes. */ scopeArgs?: Readonly>; /** Scope arguments `plugin marketplace remove` takes at user scope instead of - * `scopeArgs.user`, run from the home directory so no project's settings are in reach. - * Measured on Claude 2.1.312: `remove --scope user` drops the settings declaration and - * keeps the registration, which only a remove naming no scope deletes. */ + * `scopeArgs.user` when no project uses the marketplace, run from the home directory. + * Measured on Claude 2.1.318: `remove --scope user` drops the settings declaration and keeps + * the registration and a project's install; a remove naming no scope deletes the registration + * and uninstalls that project's plugins too, with their saved options. */ userRemoveArgs?: readonly string[]; /** The host keeps an uninstalled plugin's ref, disabled, so a disabled ref cannot be told * from one the person disabled and is not taken for a plugin in use. */ diff --git a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts index cca4bcc01..4c58e922a 100644 --- a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts @@ -63,20 +63,40 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi removeMarketplace(name: string, scope: MarketplaceScope, options?: { force?: boolean }): void { const force = options?.force === true ? this.forceRemoveArgs() : []; const userArgs = scope === "user" ? this.userRemoveArgs() : undefined; + // Removing from every scope also uninstalls what a project installed from this marketplace, + // with its saved options: only a marketplace no project uses goes that far. + const everywhere = userArgs !== undefined && !this.usedAtAnotherScope(name); this.run( [ "plugin", "marketplace", "remove", name, - ...(userArgs ?? this.scopeArgsFor(scope)), + ...(everywhere ? userArgs : this.scopeArgsFor(scope)), ...force, ], `marketplace remove ${name}`, - userArgs === undefined ? undefined : homedir() + everywhere ? homedir() : undefined ); } + /** Whether a plugin of this marketplace is installed at a scope other than the user's, in any + * project. A list it cannot read counts as yes, since that keeps the narrower removal. */ + private usedAtAnotherScope(name: string): boolean { + const result = this.spawn( + ["plugin", "list", "--json"], + ["ignore", "pipe", "ignore"], + homedir() + ); + if (result.error !== undefined || result.status !== 0) return true; + try { + const plugins = JSON.parse(result.stdout) as { id?: string; scope?: string }[]; + return plugins.some((plugin) => plugin.id?.endsWith(`@${name}`) && plugin.scope !== "user"); + } catch { + return true; + } + } + abstract enablesPlugins(): boolean; abstract registrationState(name: string): "live" | "dead" | "unknown"; abstract upgradeMarketplaces(name: string): void; diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts index 61a90c8e8..ec5d3798c 100644 --- a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts @@ -89,18 +89,60 @@ describe("ClaudeCliAdapter — removing a user-scope marketplace deletes its reg userRemoveArgs: [], }); - it("names no scope at user scope, from the home directory, so the registration goes too", () => { - mockSpawnSync.mockReturnValue(makeResult({})); + function listing(stdout: string, status = 0) { + mockSpawnSync.mockReset(); + mockSpawnSync.mockImplementation((_binary, args) => + (args as string[])[1] === "list" ? makeResult({ stdout, status }) : makeResult({}) + ); + } + + const removeArgs = () => mockSpawnSync.mock.calls.at(-1)?.[1]; + + it("names no scope when no project uses it, from the home directory, so the registration goes too", () => { + listing('[{"id":"aidd-dev@aidd-framework","scope":"user"},{"id":"x@other","scope":"local"}]'); adapter().removeMarketplace("aidd-framework", "user"); - expect(mockSpawnSync).toHaveBeenCalledWith( + expect(mockSpawnSync).toHaveBeenLastCalledWith( "claude", ["plugin", "marketplace", "remove", "aidd-framework"], expect.objectContaining({ cwd: homedir() }) ); }); + it("stays at user scope when a project installed from it, so that project keeps its plugins", () => { + listing('[{"id":"aidd-dev@aidd-framework","scope":"local","projectPath":"/p"}]'); + + adapter().removeMarketplace("aidd-framework", "user"); + + expect(removeArgs()).toEqual([ + "plugin", + "marketplace", + "remove", + "aidd-framework", + "--scope", + "user", + ]); + }); + + it.each([ + ["fails", "", 1], + ["answers no JSON", "not json", 0], + ])("stays at user scope when the list %s, never the wider removal", (_case, stdout, status) => { + listing(stdout, status); + + adapter().removeMarketplace("aidd-framework", "user"); + + expect(removeArgs()).toEqual([ + "plugin", + "marketplace", + "remove", + "aidd-framework", + "--scope", + "user", + ]); + }); + it("keeps the project scope's own arguments, from the current directory", () => { mockSpawnSync.mockReturnValue(makeResult({})); diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index b592dcd28..d18f46ccf 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -290,7 +290,7 @@ test("every host takes this checkout through aidd setup --global, afresh", () => const source = fixture(home, ["aidd-dev", "aidd-pm"]); recordingCli(source); for (const host of ["claude", "codex", "copilot", "cursor-agent", "kilo", "opencode"]) { - executable(join(fakeBin, host), `printf '%s\\n' "${host} $*" >> "$HOME/host-calls"`); + executable(join(fakeBin, host), `printf '%s\\n' "${host} $*" >> "$HOME/host-calls"\n[ "$*" = "plugin list --json" ] && echo '[]'; exit 0`); } const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); @@ -310,6 +310,26 @@ test("every host takes this checkout through aidd setup --global, afresh", () => } finally { rmSync(root, { recursive: true, force: true }); } }); +for (const [name, listing] of [ + ["a project installed from it", `echo '[{"id":"aidd-dev@aidd-framework","scope":"local","projectPath":"/p"}]'`], + ["its list cannot be read", "exit 1"], +]) { + test(`Claude's marketplace stays at user scope when ${name}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + recordingCli(fixture(home)); + executable(join(fakeBin, "claude"), `printf '%s\\n' "$*" >> "$HOME/claude-calls"\n[ "$*" = "plugin list --json" ] && { ${listing}; }\nexit 0`); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + const calls = readFileSync(join(home, "claude-calls"), "utf8"); + assert.match(calls, /^plugin marketplace remove aidd-framework --scope user$/m); + assert.doesNotMatch(calls, /^plugin marketplace remove aidd-framework$/m); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + test("a failing global install stops the reload with its own cause", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-failed-")); try { diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index b60c17479..bc1bfcd95 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -63,15 +63,30 @@ aidd_cli() { # marketplace live, and its uninstall leaves each plugin disabled rather than gone, which the # CLI then reads as a ref it does not own, so it is left alone. Claude's `remove --scope user` # drops only the settings declaration and keeps the registration, which setup --global then -# takes as still declared: the marketplace is removed from every scope, from HOME so no -# project's settings are among them. +# takes as still declared, so a marketplace no project uses is removed from every scope. That +# removal also uninstalls what a project installed from it, so one in use stays at user scope. +claude_marketplace_used_by_a_project() { + { (cd "$HOME" && claude plugin list --json 2>/dev/null) || true; } | node -e ' + try { + const plugins = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + process.exit(plugins.some((p) => p.id?.endsWith(`@${process.argv[1]}`) && p.scope !== "user") ? 0 : 1); + } catch { process.exit(0); } + ' "$MKT" +} + release_native_plugins() { local name for name in "${targets[@]}"; do [ "$HAVE_CLAUDE" = 0 ] || claude plugin uninstall "$name@$MKT" --scope user >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin remove "$name@$MKT" >/dev/null 2>&1 || true done - [ "$HAVE_CLAUDE" = 0 ] || (cd "$HOME" && claude plugin marketplace remove "$MKT") >/dev/null 2>&1 || true + if [ "$HAVE_CLAUDE" = 1 ]; then + if claude_marketplace_used_by_a_project; then + claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true + else + (cd "$HOME" && claude plugin marketplace remove "$MKT") >/dev/null 2>&1 || true + fi + fi [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true } From b702522dc26ac24058baaf0467705d4645f95fa2 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 09:39:44 +0800 Subject: [PATCH 13/16] fix(cli): a stopped setup --global keeps what it wrote and blocks nothing later Every plugin's plan is checked before the first write, the manifest is saved after each plugin, and a rerun takes over identical files on Cursor as it already did on the flat tools. --- .../ownership/user-plugin-file-updater.ts | 4 +- .../setup/install-machine-plugins-use-case.ts | 48 +++++++------- .../setup/setup-machine-scope-use-case.ts | 17 +++-- .../plugin-update-built-tree.unit.test.ts | 20 ++++++ ...tall-machine-plugins-use-case.unit.test.ts | 66 +++++++++++++++---- 5 files changed, 110 insertions(+), 45 deletions(-) diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index 6ee0a551e..fed4d8d4b 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -155,7 +155,9 @@ export class UserPluginFileUpdater { ): Promise { for (const file of files) { if (plugin.files.has(file.relativePath)) continue; - if (await this.fs.fileExists(join(baseDir, file.relativePath))) { + const target = join(baseDir, file.relativePath); + if (!(await this.fs.fileExists(target))) continue; + if ((await this.fs.readFileHash(target)).value !== file.hash.value) { throw new Error( `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` ); diff --git a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts index 3e66a6c15..69ee23212 100644 --- a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts +++ b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts @@ -9,7 +9,10 @@ import type { Manifest } from "../../domain/manifest.js"; import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import { resolvePluginSourceFromMarketplace } from "../../domain/plugins/plugin-source-resolver.js"; import type { PluginInstallMode } from "../../domain/setup-flow.js"; -import type { UserPluginFileUpdater } from "../ownership/user-plugin-file-updater.js"; +import type { + PlannedUserPluginFileUpdate, + UserPluginFileUpdater, +} from "../ownership/user-plugin-file-updater.js"; export interface InstallMachinePluginsOptions { readonly projectRoot: string; @@ -30,6 +33,9 @@ export interface InstallMachinePluginsResult { * `plugin update --scope user`, `sync --scope user` and `clean --scope user` own them. A first * install is a rebuild of a plugin that owns no file yet, so both go through one writer. A tool * that enables plugins through its own CLI is handed the chosen names by the caller instead. + * + * Every plan is checked before the first write, and `record` runs after each plugin, so a run that + * stops part way still owns what it wrote and a later update can replace it. */ export class InstallMachinePluginsUseCase { constructor( @@ -41,7 +47,8 @@ export class InstallMachinePluginsUseCase { async execute( manifest: Manifest, - options: InstallMachinePluginsOptions + options: InstallMachinePluginsOptions, + record: (manifest: Manifest) => Promise ): Promise { if (options.mode === "none" || options.mode === "interactive") return { plugins: [] }; const marketplace = (await this.registry.list(options.projectRoot)).find( @@ -57,6 +64,7 @@ export class InstallMachinePluginsUseCase { projectRoot: options.projectRoot, }); const entries = selectEntries(catalog?.plugins ?? [], options); + const plans: { readonly plan: PlannedUserPluginFileUpdate; readonly owned: boolean }[] = []; for (const toolId of options.toolIds) { if (!copiesPluginFilesForMachine(toolId)) continue; for (const entry of entries) { @@ -71,31 +79,25 @@ export class InstallMachinePluginsUseCase { "user", marketplace.name ); - await this.install(manifest, toolId as AiToolId, plugin, owned !== undefined, options); + const plan = await this.fileUpdater.planUpdate( + plugin, + toolId as AiToolId, + options.projectRoot, + this.logger, + true + ); + if (plan === null || (plan.files.length === 0 && owned === undefined)) continue; + plans.push({ plan, owned: owned !== undefined }); } } + for (const { plan, owned } of plans) { + const next = await this.fileUpdater.applyUpdate(plan, this.logger); + if (owned) manifest.updatePlugin(plan.toolId, next); + else manifest.addPlugin(plan.toolId, next); + await record(manifest); + } return { plugins: entries.map((entry) => entry.name) }; } - - private async install( - manifest: Manifest, - toolId: AiToolId, - plugin: InstalledPlugin, - owned: boolean, - options: InstallMachinePluginsOptions - ): Promise { - const plan = await this.fileUpdater.planUpdate( - plugin, - toolId, - options.projectRoot, - this.logger, - true - ); - if (plan === null || (plan.files.length === 0 && !owned)) return; - const next = await this.fileUpdater.applyUpdate(plan, this.logger); - if (owned) manifest.updatePlugin(toolId, next); - else manifest.addPlugin(toolId, next); - } } function selectEntries( diff --git a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts index 714147654..8dae84af3 100644 --- a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts +++ b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts @@ -65,13 +65,16 @@ export class SetupMachineScopeUseCase { if (this.installMachinePluginsUseCase === undefined) return []; const manifest = await this.userManifestRepo.load(); if (manifest === null) return []; - const { plugins } = await this.installMachinePluginsUseCase.execute(manifest, { - projectRoot: flow.projectRoot, - toolIds: flow.aiTools, - mode: flow.pluginMode, - pluginNames: flow.pluginNames, - }); - await this.userManifestRepo.save(manifest); + const { plugins } = await this.installMachinePluginsUseCase.execute( + manifest, + { + projectRoot: flow.projectRoot, + toolIds: flow.aiTools, + mode: flow.pluginMode, + pluginNames: flow.pluginNames, + }, + (updated) => this.userManifestRepo.save(updated) + ); return plugins; } diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts index 37a7d7073..222f835cc 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts @@ -598,6 +598,26 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]).toEqual(machineBefore); }); + it("takes over a new built file an interrupted run already wrote, byte for byte", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const newRelativePath = "sample-plugin/skills/demo/new.md"; + deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); + deps.fs.setFile(join(USER_PLUGINS_DIR, newRelativePath), "# Built v2\n"); + + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }); + + expect( + deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.has(newRelativePath) + ).toBe(true); + }); + it("preflights a Cursor user-file collision before any selected native host update", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "cursor"); diff --git a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts index 8696ac4b6..7b47e1f8d 100644 --- a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts @@ -55,6 +55,7 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { }; } ); + const applyUpdate = vi.fn(async (plan: PlannedUserPluginFileUpdate) => plan.next); const useCase = new InstallMachinePluginsUseCase( { list: async () => marketplaces }, { @@ -64,10 +65,10 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { localPath: "/fw", }), }, - { planUpdate, applyUpdate: async (plan) => plan.next }, + { planUpdate, applyUpdate }, new CapturingLogger() ); - return { manifest, useCase, planUpdate }; + return { manifest, useCase, planUpdate, applyUpdate }; } function run( @@ -76,12 +77,16 @@ function run( mode: PluginInstallMode, pluginNames: string[] = [] ) { - return useCase.execute(manifest, { - projectRoot: "/project", - toolIds: ["claude", "opencode"], - mode, - pluginNames, - }); + return useCase.execute( + manifest, + { + projectRoot: "/project", + toolIds: ["claude", "opencode"], + mode, + pluginNames, + }, + async () => {} + ); } describe("InstallMachinePluginsUseCase", () => { @@ -105,12 +110,16 @@ describe("InstallMachinePluginsUseCase", () => { const { manifest, useCase, planUpdate } = setup(); manifest.addTool("cursor", "1.0.0", []); - await useCase.execute(manifest, { - projectRoot: "/project", - toolIds: ["cursor"], - mode: "recommended", - pluginNames: [], - }); + await useCase.execute( + manifest, + { + projectRoot: "/project", + toolIds: ["cursor"], + mode: "recommended", + pluginNames: [], + }, + async () => {} + ); expect(manifest.getPlugins("cursor").map((p) => [p.name, p.scope])).toEqual([ ["aidd-dev", "user"], @@ -124,6 +133,35 @@ describe("InstallMachinePluginsUseCase", () => { ); }); + it("writes nothing when a later plugin's plan is refused", async () => { + const { manifest, useCase, planUpdate, applyUpdate } = setup(); + planUpdate.mockImplementationOnce(planUpdate.getMockImplementation()!); + planUpdate.mockRejectedValueOnce(new Error("already exists")); + + await expect(run(useCase, manifest, "all")).rejects.toThrow("already exists"); + + expect(applyUpdate).not.toHaveBeenCalled(); + }); + + it("has recorded the plugins already written when a later write fails", async () => { + const { manifest, useCase, applyUpdate } = setup(); + applyUpdate.mockImplementationOnce(async (plan) => plan.next); + applyUpdate.mockRejectedValueOnce(new Error("disk full")); + const recorded: string[][] = []; + + await expect( + useCase.execute( + manifest, + { projectRoot: "/project", toolIds: ["opencode"], mode: "all", pluginNames: [] }, + async (saved) => { + recorded.push(saved.getPlugins("opencode").map((plugin) => plugin.name)); + } + ) + ).rejects.toThrow("disk full"); + + expect(recorded).toEqual([["aidd-dev"]]); + }); + it("keeps the recommended plugins alone", async () => { const { manifest, useCase } = setup(); From e3c2cfef052ffed02af890f4952ffd14e3f90c16 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 11:30:16 +0800 Subject: [PATCH 14/16] fix(cli): make a global install restore, stay out of the project and touch only what it names sync --global writes back a recorded file that was deleted. A remote source's clone and plugin fetches live in the user's cache, never the current directory. Only the tools a run names get the chosen plugins. A rerun takes the source it resolved. Without the shared source, global files are refused rather than rebuilt in a project's layout. make reload shows what setup skipped. --- .../marketplace-refresh-use-case.ts | 3 +- .../resolve-marketplace-use-case.ts | 11 +++++-- .../marketplace-sync-settings-use-case.ts | 6 +++- .../ownership/user-plugin-file-updater.ts | 23 +++++++++----- .../plugin/plugin-distribution-loader.ts | 7 +++-- .../setup/install-machine-plugins-use-case.ts | 20 ++++++------ .../setup/setup-machine-scope-use-case.ts | 1 + .../shared/user-scope-plugin-files.ts | 17 ++++++++++ cli/src/kernel/paths.ts | 12 +++++++ cli/src/runtime/wiring/distribution.ts | 4 ++- cli/src/runtime/wiring/framework.ts | 5 ++- .../resolve-marketplace-use-case.unit.test.ts | 21 +++++++++++++ .../doctor-registration.unit.test.ts | 2 +- .../user-plugin-file-updater.unit.test.ts | 20 ++++++++++++ .../plugin-distribution-loader.unit.test.ts | 14 +++++++++ ...tall-machine-plugins-use-case.unit.test.ts | 31 +++++++++++++++++-- scripts/dev-sync.sh | 2 ++ 17 files changed, 170 insertions(+), 29 deletions(-) diff --git a/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts b/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts index e3c4f4ae2..ef07fe329 100644 --- a/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts +++ b/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts @@ -1,5 +1,4 @@ import { join, resolve } from "node:path"; -import { marketplaceCacheDir } from "../../../kernel/paths.js"; import type { FileReader } from "../../../kernel/ports/file-reader.js"; import type { Logger } from "../../../kernel/ports/logger.js"; import { @@ -61,7 +60,7 @@ export class MarketplaceRefreshUseCase implements MarketplaceRefresh { // through the result, never thrown. private async refreshOne(projectRoot: string, m: Marketplace): Promise { try { - const cacheDir = marketplaceCacheDir(projectRoot, m.name); + const cacheDir = this.resolveMarketplace.cacheDirOf(m, projectRoot); await this.warnIfStale(m.name, cacheDir); this.logger?.info(`Fetching marketplace '${m.name}'...`); const { catalog } = await this.resolveMarketplace.execute({ diff --git a/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts b/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts index 9089e0c0d..296060165 100644 --- a/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts +++ b/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts @@ -1,4 +1,4 @@ -import { marketplaceCacheDir } from "../../../kernel/paths.js"; +import { fetchedMarketplaceDir } from "../../../kernel/paths.js"; import type { PluginCatalog } from "../domain/catalog.js"; import type { Marketplace } from "../domain/marketplace.js"; import type { PluginCatalogRepository } from "../domain/ports/plugin-catalog-repository.js"; @@ -24,11 +24,12 @@ export interface ResolveMarketplace { export class ResolveMarketplaceUseCase implements ResolveMarketplace { constructor( private readonly fetchMarketplaceSource: FetchMarketplaceSourceUseCase, - private readonly catalogRepo: PluginCatalogRepository + private readonly catalogRepo: PluginCatalogRepository, + private readonly userConfigDir?: () => string ) {} async execute(options: ResolveMarketplaceOptions): Promise { - const cacheDir = marketplaceCacheDir(options.projectRoot, options.marketplace.name); + const cacheDir = this.cacheDirOf(options.marketplace, options.projectRoot); const localPath = await this.fetchMarketplaceSource.execute({ marketplace: options.marketplace, cacheDir, @@ -37,4 +38,8 @@ export class ResolveMarketplaceUseCase implements ResolveMarketplace { const catalog = await this.catalogRepo.load(localPath); return { marketplace: options.marketplace, localPath, catalog }; } + + cacheDirOf(marketplace: Marketplace, projectRoot: string): string { + return fetchedMarketplaceDir(marketplace, projectRoot, this.userConfigDir?.()); + } } diff --git a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts index 99cc83182..a0fb48410 100644 --- a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts @@ -423,7 +423,11 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { const activator = binary === undefined ? undefined : this.activators.get(binary); if (binary === undefined || activator === undefined) continue; if (!activator.isAvailable()) { - this.logger.warn(`${binary} CLI not found on PATH — skipping native plugin activation.`); + this.logger.warn( + `${binary} CLI not found on PATH — skipping native plugin activation.${ + enablePlugins.length > 0 ? " Rerun `aidd setup --global` once it is installed." : "" + }` + ); binaryMissing.push({ toolId, binary }); continue; } diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index fed4d8d4b..9588e5d23 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -28,6 +28,7 @@ import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use- import { assertUserScopeWriteBoundary, userScopeFilesSafeToDelete, + withoutMissingFiles, } from "../shared/user-scope-plugin-files.js"; import { assertMachineFilesWritable, readMachinePluginFiles } from "./machine-plugin-files.js"; @@ -75,8 +76,9 @@ export class UserPluginFileUpdater { projectRoot ); const home = (this.builtDeps?.homedir ?? nodeHomedir)(); - const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, plugin, toolId, home); - if (oldSafe.size !== plugin.files.size) { + const present = await withoutMissingFiles(this.fs, plugin, toolId, home); + const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, present, toolId, home); + if (oldSafe.size !== present.files.size) { throw new Error( `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.` ); @@ -102,14 +104,15 @@ export class UserPluginFileUpdater { } async applyUpdate(plan: PlannedUserPluginFileUpdate, logger: Logger): Promise { + const present = await withoutMissingFiles(this.fs, plan.plugin, plan.toolId, plan.home); const oldSafe = await userScopeFilesSafeToDelete( this.fs, logger, - plan.plugin, + present, plan.toolId, plan.home ); - if (oldSafe.size !== plan.plugin.files.size || oldSafe.size !== plan.oldSafe.size) { + if (oldSafe.size !== present.files.size || oldSafe.size !== plan.oldSafe.size) { throw new Error( `${plan.toolId}: '${plan.plugin.name}' has unsafe recorded files since preflight; update refused before writing.` ); @@ -138,9 +141,9 @@ export class UserPluginFileUpdater { return; } const baseDir = resolveBaseDirFromRecord("user", toolId, "", () => home); - // A first machine-wide install owns no file yet: its directory is created so the - // containment proof has a real path to resolve. The name is one safe path segment. - if (plugin.files.size === 0 && PLUGIN_NAME_REGEX.test(plugin.name)) { + // A first install owns no file yet, and a restore may have lost the directory: it is created + // so the containment proof has a real path to resolve. The name is one safe path segment. + if (PLUGIN_NAME_REGEX.test(plugin.name)) { await this.fs.createDirectory(join(baseDir, plugin.name)); } await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, [...files], home); @@ -195,6 +198,12 @@ export class UserPluginFileUpdater { : built; return { files, componentPaths: new Map() }; } + // Without the built tree, translation would write a project's layout in the user directory. + if (machineFilesOf(toolId) !== null) { + throw new Error( + `${toolId}: the shared framework source is not registered, so '${plugin.name}' cannot be rebuilt; run \`aidd setup --global\`.` + ); + } const toolConfig = getToolConfig(toolId); const forGlobalFiles = resolvePluginsCapability(toolId)?.hooksDestination === "project" ? withoutHooks(dist) : dist; diff --git a/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts b/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts index d02888ea9..97576fa08 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts @@ -12,7 +12,9 @@ import type { PluginDistributionReader } from "../../domain/ports/plugin-distrib export class PluginDistributionLoader { constructor( private readonly fetcher: PluginFetcher, - private readonly reader: PluginDistributionReader + private readonly reader: PluginDistributionReader, + /** A global install's fetches: it writes nothing in the project. */ + private readonly globalCacheDir?: () => string ) {} async load( @@ -25,6 +27,7 @@ export class PluginDistributionLoader { } loadLatest(plugin: InstalledPlugin, projectRoot: string): Promise { - return this.load(plugin.source, join(projectRoot, PLUGIN_CACHE_SUBDIR), { forceRefresh: true }); + const cacheDir = this.globalCacheDir?.() ?? join(projectRoot, PLUGIN_CACHE_SUBDIR); + return this.load(plugin.source, cacheDir, { forceRefresh: true }); } } diff --git a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts index 69ee23212..c7528b706 100644 --- a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts +++ b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts @@ -69,16 +69,18 @@ export class InstallMachinePluginsUseCase { if (!copiesPluginFilesForMachine(toolId)) continue; for (const entry of entries) { const owned = manifest.getPlugins(toolId).find((plugin) => plugin.name === entry.name); + const resolved = InstalledPlugin.fromMetadata( + entry.name, + entry.version ?? "0.0.0", + resolvePluginSourceFromMarketplace(entry.source, marketplace, localPath), + entry.strict, + "user", + marketplace.name + ); const plugin = - owned ?? - InstalledPlugin.fromMetadata( - entry.name, - entry.version ?? "0.0.0", - resolvePluginSourceFromMarketplace(entry.source, marketplace, localPath), - entry.strict, - "user", - marketplace.name - ); + owned === undefined + ? resolved + : resolved.withFiles(owned.files).withDependents(owned.dependents); const plan = await this.fileUpdater.planUpdate( plugin, toolId as AiToolId, diff --git a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts index 8dae84af3..92ea9c14c 100644 --- a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts +++ b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts @@ -52,6 +52,7 @@ export class SetupMachineScopeUseCase { projectRoot: flow.projectRoot, scope: "user", manifestRepo: this.userManifestRepo, + toolIds: flow.aiTools, enablePlugins: plugins, }); const install: SetupToolsResult = { results: [] }; diff --git a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts index 8a75f4d26..4efd14ae3 100644 --- a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts +++ b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts @@ -52,6 +52,23 @@ export async function userScopeFilesSafeToDelete( return allowed; } +/** The plugin without the recorded files nothing stands at any more, which an update writes back. + * A dangling symlink counts as gone: the atomic write replaces the link, and never follows it. */ +export async function withoutMissingFiles( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + homedir: string +): Promise { + const boundary = resolvePluginsCapability(toolId)?.userPluginsBaseDir(homedir); + if (boundary === null || boundary === undefined) return plugin; + const present = new Map(); + for (const [relativePath, hash] of plugin.files) { + if (await fs.fileExists(join(boundary, relativePath))) present.set(relativePath, hash); + } + return plugin.withFiles(present); +} + /** Refuse a machine update if a new path, existing parent, or plugin directory escapes its declared base. */ export async function assertUserScopeWriteBoundary( fs: FileReader, diff --git a/cli/src/kernel/paths.ts b/cli/src/kernel/paths.ts index 43107d5ac..6dcd7b439 100644 --- a/cli/src/kernel/paths.ts +++ b/cli/src/kernel/paths.ts @@ -18,6 +18,18 @@ export function marketplaceCacheDir(projectRoot: string, marketplaceName: string return join(projectRoot, MARKETPLACE_CACHE_SUBDIR, marketplaceName); } +/** Where a marketplace's fetched source lives: a user-scope one belongs to the whole machine, so a + * run from any directory writes nothing there. */ +export function fetchedMarketplaceDir( + marketplace: { readonly name: string; readonly scope: string }, + projectRoot: string, + userConfigDir: string | undefined +): string { + return marketplace.scope === "user" && userConfigDir !== undefined + ? join(userConfigDir, "cache", "marketplaces", marketplace.name) + : marketplaceCacheDir(projectRoot, marketplace.name); +} + export function builtMarketplaceDir( projectRoot: string, marketplaceName: string, diff --git a/cli/src/runtime/wiring/distribution.ts b/cli/src/runtime/wiring/distribution.ts index eaf9f87a4..4222369bb 100644 --- a/cli/src/runtime/wiring/distribution.ts +++ b/cli/src/runtime/wiring/distribution.ts @@ -20,6 +20,7 @@ import type { Hasher } from "../../kernel/ports/hasher.js"; import type { Logger } from "../../kernel/ports/logger.js"; import type { AuthReaderAdapter } from "../auth/auth-reader-adapter.js"; import type { HttpClient } from "../http/http-client.js"; +import { userConfigDir } from "../user-config-dir.js"; export interface DistributionWiringShared { fs: FileReader & FileWriter & FileMerger; @@ -59,7 +60,8 @@ export function wireDistribution(shared: DistributionWiringShared): Distribution ); const resolveMarketplaceUseCase = new ResolveMarketplaceUseCase( fetchMarketplaceSource, - pluginCatalogRepository + pluginCatalogRepository, + userConfigDir ); const marketplaceListUseCase = new MarketplaceListUseCase( marketplaceRegistry, diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index d72f7569c..04ca99252 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import "../../contexts/tools/domain/profiles/antigravity/profile.js"; +import { join } from "node:path"; import "../../contexts/tools/domain/profiles/claude/profile.js"; import "../../contexts/tools/domain/profiles/codex/profile.js"; import "../../contexts/tools/domain/profiles/copilot/profile.js"; @@ -497,7 +498,9 @@ export async function createDeps( // One writer for a machine-scope plugin's files, whether setup installs it or update rebuilds it. const userPluginFileUpdater = new UserPluginFileUpdater( fs, - new PluginDistributionLoader(pluginFetcher, pluginDistributionReader), + new PluginDistributionLoader(pluginFetcher, pluginDistributionReader, () => + join(userConfigDir(), "cache", "plugin-cache") + ), hasher, builtMaterializationDeps ); diff --git a/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts index 76e64c2b4..951bb0ab3 100644 --- a/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts @@ -37,6 +37,27 @@ function makeMarketplace(name: string): Marketplace { } describe("ResolveMarketplaceUseCase", () => { + it("keeps a user-scope source's clone out of the project, and a project-scope one in it", () => { + const useCase = new ResolveMarketplaceUseCase( + new FetchMarketplaceSourceUseCase(new FixturePluginFetcher()), + new PluginCatalogRepositoryAdapter(new InMemoryFileAdapter({}, new DeterministicHasher())), + () => "/config/aidd" + ); + const shared = Marketplace.create({ + name: "shared", + source: { kind: "local", path: MARKETPLACE_DIR }, + scope: "user", + addedAt: "2026-05-01T10:00:00.000Z", + }); + + expect(useCase.cacheDirOf(shared, PROJECT_ROOT)).toBe( + join("/config/aidd", "cache", "marketplaces", "shared") + ); + expect(useCase.cacheDirOf(makeMarketplace("own"), PROJECT_ROOT)).toBe( + join(PROJECT_ROOT, ".aidd", "cache", "marketplaces", "own") + ); + }); + it("returns the parsed catalog for a local marketplace", async () => { const hasher = new DeterministicHasher(); const fs = new InMemoryFileAdapter({}, hasher); diff --git a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts index 97f1a4208..2859cd236 100644 --- a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts @@ -69,7 +69,7 @@ async function issuesFor( manifest, projectRoot: PROJECT_ROOT, allowedIds, - machine, + machine: machine, }); } diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts index e20db6c2b..f26f3aab3 100644 --- a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts @@ -1,4 +1,5 @@ import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -76,6 +77,25 @@ describe("UserPluginFileUpdater public update contract", () => { expect(await f.fs.fileExists(join(f.base, "sample-plugin/skills/demo/SKILL.md"))).toBe(false); }); + it("writes back a recorded file that was deleted", async () => { + const f = fixture(); + const path = "sample-plugin/skills/demo/SKILL.md"; + const owned = f.plugin.withFiles(new Map([[path, f.hasher.hash(f.content).value]])); + + const result = await f.updater.update(owned, "cursor", "/A", f.logger); + + expect(await f.fs.readFile(join(f.base, path))).toBe(f.content); + expect(result?.files.has(path)).toBe(true); + expect(f.logger.warnMessages.filter((m) => m.includes("left in place"))).toEqual([]); + }); + + it("refuses to rebuild machine files without the shared source, rather than write a project's layout", async () => { + const f = fixture(); + await expect(f.updater.update(f.plugin, "opencode", "/A", f.logger)).rejects.toThrow( + "run `aidd setup --global`" + ); + }); + it("rechecks planned new-file containment when a parent becomes a symlink", async () => { const f = fixture(); const plan = await f.updater.planUpdate(f.plugin, "cursor", "/A", f.logger); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts index d0ac34647..fa2c28e02 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts @@ -49,4 +49,18 @@ describe("PluginDistributionLoader", () => { forceRefresh: true, }); }); + + it("fetches a global install's plugin in the given cache, outside the project", async () => { + const fetch = vi.fn().mockResolvedValue("/cache/fetched"); + const loader = new PluginDistributionLoader( + { fetch }, + { read: async () => dist }, + () => "/config/cache" + ); + const plugin = InstalledPlugin.fromMetadata("sample", "0.0.1", source, false, "user"); + + await loader.loadLatest(plugin, "/project"); + + expect(fetch).toHaveBeenCalledWith(source, "/config/cache", { forceRefresh: true }); + }); }); diff --git a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts index 7b47e1f8d..6dc540cc9 100644 --- a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts @@ -7,7 +7,7 @@ import { Marketplace } from "../../../../../src/contexts/distribution/domain/mar import type { PlannedUserPluginFileUpdate } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; import { InstallMachinePluginsUseCase } from "../../../../../src/contexts/framework/application/setup/install-machine-plugins-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; -import type { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import type { PluginInstallMode } from "../../../../../src/contexts/framework/domain/setup-flow.js"; import { FileHash, InstallationFile } from "../../../../../src/kernel/file.js"; import type { AiToolId } from "../../../../../src/kernel/tool.js"; @@ -133,9 +133,36 @@ describe("InstallMachinePluginsUseCase", () => { ); }); + it("takes the source this run resolved for a plugin it already owns, and keeps its files", async () => { + const { manifest, useCase, planUpdate } = setup(); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromMetadata( + "aidd-dev", + "1.0.0", + { kind: "local", path: "/moved-away" }, + true, + "user", + "aidd-framework" + ).withFiles(new Map([["agents/old.md", HASH]])) + ); + + await useCase.execute( + manifest, + { projectRoot: "/project", toolIds: ["opencode"], mode: "recommended", pluginNames: [] }, + async () => {} + ); + + const planned = planUpdate.mock.calls[0][0]; + expect(planned.source).not.toEqual({ kind: "local", path: "/moved-away" }); + expect([...planned.files.keys()]).toEqual(["agents/old.md"]); + }); + it("writes nothing when a later plugin's plan is refused", async () => { const { manifest, useCase, planUpdate, applyUpdate } = setup(); - planUpdate.mockImplementationOnce(planUpdate.getMockImplementation()!); + const accepted = planUpdate.getMockImplementation(); + if (accepted === undefined) throw new Error("fixture has no plan"); + planUpdate.mockImplementationOnce(accepted); planUpdate.mockRejectedValueOnce(new Error("already exists")); await expect(run(useCase, manifest, "all")).rejects.toThrow("already exists"); diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index bc1bfcd95..e14d3b8d7 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -116,6 +116,8 @@ output="$(cd "$HOME" && aidd_cli setup --global --source local --path "$FW" --id echo "aidd setup --global failed for ${hosts[*]}; their plugins were released, so rerun make reload once fixed." >&2; exit 1 } echo "ok ($(IFS=,; echo "${hosts[*]}"))" +# A warning leaves setup at exit 0: what it skipped must stay visible. +printf '%s\n' "$output" | sed 's/^/ /' instructions=() [ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") From 51f046d5db87b6d5a566584f7c9fd42b4e0d3b63 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 11:31:44 +0800 Subject: [PATCH 15/16] refactor(cli): name what this change added global, not machine --global is the word people know. The names already on next stay for their own change. --- .../framework-source-is-machine-scope.md | 2 +- .../doctor/doctor-registration-use-case.ts | 6 ++-- ...plugin-files.ts => global-plugin-files.ts} | 10 +++--- .../ownership/user-plugin-file-updater.ts | 16 +++++----- .../ownership/user-plugin-ownership.ts | 2 +- .../framework/application/setup-use-case.ts | 8 ++--- ....ts => install-global-plugins-use-case.ts} | 16 +++++----- .../setup/setup-machine-scope-use-case.ts | 16 +++++----- .../domain/capabilities/plugins-capability.ts | 26 +++++++-------- .../domain/profiles/codex/codex-paths.ts | 4 +-- .../tools/domain/profiles/codex/profile.ts | 4 +-- .../tools/domain/profiles/kilo/profile.ts | 6 ++-- .../profiles/opencode/opencode-paths.ts | 4 +-- .../tools/domain/profiles/opencode/profile.ts | 6 ++-- cli/src/contexts/tools/domain/registry.ts | 16 +++++----- cli/src/presentation/commands/doctor.ts | 2 +- cli/src/presentation/commands/setup.ts | 4 +-- cli/src/presentation/commands/sync.ts | 2 +- cli/src/presentation/display/setup-display.ts | 8 ++--- cli/src/presentation/display/sync-display.ts | 2 +- cli/src/runtime/wiring/framework.ts | 4 +-- .../orchestrator-deps.arch.test.ts | 2 +- .../doctor-registration.unit.test.ts | 2 +- ...st.ts => global-plugin-files.unit.test.ts} | 32 +++++++++---------- .../user-plugin-file-updater.unit.test.ts | 2 +- ...lugin-remove-selection.integration.test.ts | 4 +-- .../application/setup-use-case.unit.test.ts | 4 +-- ...tall-global-plugins-use-case.unit.test.ts} | 10 +++--- .../framework/domain/setup-flow.unit.test.ts | 4 +-- ...unit.test.ts => global-files.unit.test.ts} | 12 +++---- ...unit.test.ts => global-files.unit.test.ts} | 4 +-- ...unit.test.ts => global-files.unit.test.ts} | 12 +++---- cli/tests/e2e/setup-scope-user.e2e.test.ts | 2 +- .../doctor-wiring.integration.test.ts | 2 +- .../commands/sync-wiring.integration.test.ts | 4 +-- .../display/setup-display.unit.test.ts | 4 +-- .../display/sync-display.unit.test.ts | 6 ++-- ...e-real-user-scope-tool-list-parity.test.js | 2 +- 38 files changed, 136 insertions(+), 136 deletions(-) rename cli/src/contexts/framework/application/ownership/{machine-plugin-files.ts => global-plugin-files.ts} (93%) rename cli/src/contexts/framework/application/setup/{install-machine-plugins-use-case.ts => install-global-plugins-use-case.ts} (92%) rename cli/tests/contexts/framework/application/ownership/{machine-plugin-files.unit.test.ts => global-plugin-files.unit.test.ts} (83%) rename cli/tests/contexts/framework/application/setup/{install-machine-plugins-use-case.unit.test.ts => install-global-plugins-use-case.unit.test.ts} (94%) rename cli/tests/contexts/tools/domain/profiles/codex/{machine-files.unit.test.ts => global-files.unit.test.ts} (60%) rename cli/tests/contexts/tools/domain/profiles/kilo/{machine-files.unit.test.ts => global-files.unit.test.ts} (88%) rename cli/tests/contexts/tools/domain/profiles/opencode/{machine-files.unit.test.ts => global-files.unit.test.ts} (69%) diff --git a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md index e26cf8b4c..336952902 100644 --- a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md +++ b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md @@ -49,7 +49,7 @@ One registration of `aidd-framework` per machine, shared by every project. Read - Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Copilot proves its source through `plugin marketplace list --json` (1.0.98); a version rejecting `--json` (1.0.83) refuses it. - Writes nothing under `projectRoot`: no content, no plugin prompt, no gitignore touch. -- `--plugins all|recommended|` picks plugins from the shared source's catalog. A tool with native activation gets them through `MarketplaceSyncSettingsOptions.enablePlugins`; a tool declaring `PluginsCapability.machineFiles` (opencode's and kilo's skills and agents, codex's agents) or installing into a user plugins directory (cursor) gets them copied there as `scope: "user"` plugins, written by `UserPluginFileUpdater`, so `sync --scope user` rebuilds them and `clean --scope user` removes them. An interactive pick is refused (`UserScopePluginModeError`). +- `--plugins all|recommended|` picks plugins from the shared source's catalog. A tool with native activation gets them through `MarketplaceSyncSettingsOptions.enablePlugins`; a tool declaring `PluginsCapability.globalFiles` (opencode's and kilo's skills and agents, codex's agents) or installing into a user plugins directory (cursor) gets them copied there as `scope: "user"` plugins, written by `UserPluginFileUpdater`, so `sync --scope user` rebuilds them and `clean --scope user` removes them. An interactive pick is refused (`UserScopePluginModeError`). - Its manifest: `userManifestPath(userConfigDir())`, `userConfigDir()/manifest.json`, same schema and version as the project one. - `UserManifestRepositoryAdapter` reuses `Manifest.fromJSON`/`toJSON`; its `delete()` removes that one file only. - Each AI tool gets a manifest entry with an empty file list, so `MarketplaceSyncSettingsUseCase` has something to iterate. diff --git a/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts index d90a44d86..66f1e6754 100644 --- a/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts +++ b/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts @@ -35,8 +35,8 @@ export interface DoctorRegistrationOptions { manifest: Manifest; projectRoot: string; allowedIds: Set | null; - /** A machine-wide install declares nothing in a project's settings, so it skips that check. */ - machine?: boolean; + /** A global install declares nothing in a project's settings, so it skips that check. */ + globalInstall?: boolean; } /** One plugin `doctor` expects a native-activation tool's own registry to carry, and the ref to @@ -86,7 +86,7 @@ export class DoctorRegistrationUseCase { async execute(options: DoctorRegistrationOptions): Promise { return [ - ...(options.machine === true ? [] : await this.checkDeclaredMarketplaces(options)), + ...(options.globalInstall === true ? [] : await this.checkDeclaredMarketplaces(options)), ...(await this.checkNativeRegistrations(options)), ...(await this.checkMarketplaceSources(options)), ]; diff --git a/cli/src/contexts/framework/application/ownership/machine-plugin-files.ts b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts similarity index 93% rename from cli/src/contexts/framework/application/ownership/machine-plugin-files.ts rename to cli/src/contexts/framework/application/ownership/global-plugin-files.ts index 51903e1c2..501b50933 100644 --- a/cli/src/contexts/framework/application/ownership/machine-plugin-files.ts +++ b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts @@ -4,7 +4,7 @@ import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Hasher } from "../../../../kernel/ports/hasher.js"; import type { AiToolId } from "../../../../kernel/tool.js"; -import { machineFilesOf } from "../../../tools/domain/registry.js"; +import { globalFilesOf } from "../../../tools/domain/registry.js"; import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-containment.js"; @@ -13,14 +13,14 @@ import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-conta * A plugin's machine-scope files, read from the tool's built tree and keyed by their path under * the tool's user configuration directory. Empty for a tool that copies none. */ -export async function readMachinePluginFiles( +export async function readGlobalPluginFiles( fs: FileReader, hasher: Hasher, builtDir: string, toolId: AiToolId, dist: PluginDistribution ): Promise { - const machine = machineFilesOf(toolId); + const machine = globalFilesOf(toolId); if (machine === null) return []; const plugin = { name: dist.manifest.name, @@ -48,7 +48,7 @@ export async function readMachinePluginFiles( * file already there that this plugin does not own is never overwritten unless it already holds * the same bytes. Creates the user directory itself when the host has not yet. */ -export async function assertMachineFilesWritable( +export async function assertGlobalFilesWritable( fs: FileReader & FileWriter, toolId: AiToolId, plugin: Pick | undefined, @@ -56,7 +56,7 @@ export async function assertMachineFilesWritable( files: readonly InstallationFile[], homedir: string ): Promise { - const machine = machineFilesOf(toolId); + const machine = globalFilesOf(toolId); if (machine === null) throw new Error(`${toolId}: no machine-scope directory is declared.`); const boundary = machine.dir(homedir); await fs.createDirectory(boundary); diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index 9588e5d23..e6d4df6f9 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -10,7 +10,7 @@ import type { AiToolId } from "../../../../kernel/tool.js"; import { frameworkBuildModeFor, getToolConfig, - machineFilesOf, + globalFilesOf, resolvePluginsCapability, } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; @@ -30,7 +30,7 @@ import { userScopeFilesSafeToDelete, withoutMissingFiles, } from "../shared/user-scope-plugin-files.js"; -import { assertMachineFilesWritable, readMachinePluginFiles } from "./machine-plugin-files.js"; +import { assertGlobalFilesWritable, readGlobalPluginFiles } from "./global-plugin-files.js"; export interface PlannedUserPluginFileUpdate { readonly plugin: InstalledPlugin; @@ -128,7 +128,7 @@ export class UserPluginFileUpdater { return plan.next; } - /** A tool's machine files spread across its user directory; a user-scope plugin directory + /** A tool's global files spread across its user directory; a user-scope plugin directory * holds one plugin per subdirectory. Each layout has its own containment proof. */ private async assertWritable( plugin: InstalledPlugin, @@ -136,8 +136,8 @@ export class UserPluginFileUpdater { files: readonly InstallationFile[], home: string ): Promise { - if (machineFilesOf(toolId) !== null) { - await assertMachineFilesWritable(this.fs, toolId, plugin, plugin.name, files, home); + if (globalFilesOf(toolId) !== null) { + await assertGlobalFilesWritable(this.fs, toolId, plugin, plugin.name, files, home); return; } const baseDir = resolveBaseDirFromRecord("user", toolId, "", () => home); @@ -187,8 +187,8 @@ export class UserPluginFileUpdater { target: toolId, mode: frameworkBuildModeFor(toolId), }); - if (machineFilesOf(toolId) !== null) { - const files = await readMachinePluginFiles(this.fs, this.hasher, builtDir, toolId, dist); + if (globalFilesOf(toolId) !== null) { + const files = await readGlobalPluginFiles(this.fs, this.hasher, builtDir, toolId, dist); return { files, componentPaths: new Map() }; } const built = await readBuiltUserPluginFiles(this.fs, this.hasher, builtDir, plugin.name); @@ -199,7 +199,7 @@ export class UserPluginFileUpdater { return { files, componentPaths: new Map() }; } // Without the built tree, translation would write a project's layout in the user directory. - if (machineFilesOf(toolId) !== null) { + if (globalFilesOf(toolId) !== null) { throw new Error( `${toolId}: the shared framework source is not registered, so '${plugin.name}' cannot be rebuilt; run \`aidd setup --global\`.` ); diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts index 88ab22c8c..75f25ceeb 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts @@ -2,7 +2,7 @@ import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { AiToolId } from "../../../../kernel/tool.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -/** Release only this project's claim; machine files remain until explicit user cleanup. */ +/** Release only this project's claim; global files remain until explicit user cleanup. */ export async function detachUserPlugin( repo: ManifestRepository | undefined, fs: FileReader, diff --git a/cli/src/contexts/framework/application/setup-use-case.ts b/cli/src/contexts/framework/application/setup-use-case.ts index 345f8da66..3604b9762 100644 --- a/cli/src/contexts/framework/application/setup-use-case.ts +++ b/cli/src/contexts/framework/application/setup-use-case.ts @@ -24,16 +24,16 @@ export type SetupResult = install: SetupToolsResult; activation: MarketplaceSyncSettingsResult; context?: ProjectContext; - /** Set by a machine-scope run: the plugins it installed for every project. */ - machinePlugins?: readonly string[]; + /** Set by a global run: the plugins it installed for every project. */ + globalPlugins?: readonly string[]; } | { kind: "up-to-date"; install: SetupToolsResult; activation: MarketplaceSyncSettingsResult; context?: ProjectContext; - /** Set by a machine-scope run: the plugins it installed for every project. */ - machinePlugins?: readonly string[]; + /** Set by a global run: the plugins it installed for every project. */ + globalPlugins?: readonly string[]; }; export class SetupUseCase { diff --git a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts similarity index 92% rename from cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts rename to cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts index c7528b706..d382c7d50 100644 --- a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts +++ b/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts @@ -4,7 +4,7 @@ import type { ResolveMarketplace } from "../../../distribution/application/resol import type { PluginCatalogEntry } from "../../../distribution/domain/catalog.js"; import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js"; import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; -import { copiesPluginFilesForMachine } from "../../../tools/domain/registry.js"; +import { copiesPluginFilesGlobally } from "../../../tools/domain/registry.js"; import type { Manifest } from "../../domain/manifest.js"; import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import { resolvePluginSourceFromMarketplace } from "../../domain/plugins/plugin-source-resolver.js"; @@ -14,14 +14,14 @@ import type { UserPluginFileUpdater, } from "../ownership/user-plugin-file-updater.js"; -export interface InstallMachinePluginsOptions { +export interface InstallGlobalPluginsOptions { readonly projectRoot: string; readonly toolIds: readonly ToolId[]; readonly mode: PluginInstallMode; readonly pluginNames: readonly string[]; } -export interface InstallMachinePluginsResult { +export interface InstallGlobalPluginsResult { /** The framework plugins this run chose, which a tool with native activation enables next. */ readonly plugins: readonly string[]; } @@ -37,7 +37,7 @@ export interface InstallMachinePluginsResult { * Every plan is checked before the first write, and `record` runs after each plugin, so a run that * stops part way still owns what it wrote and a later update can replace it. */ -export class InstallMachinePluginsUseCase { +export class InstallGlobalPluginsUseCase { constructor( private readonly registry: Pick, private readonly resolveMarketplace: ResolveMarketplace, @@ -47,9 +47,9 @@ export class InstallMachinePluginsUseCase { async execute( manifest: Manifest, - options: InstallMachinePluginsOptions, + options: InstallGlobalPluginsOptions, record: (manifest: Manifest) => Promise - ): Promise { + ): Promise { if (options.mode === "none" || options.mode === "interactive") return { plugins: [] }; const marketplace = (await this.registry.list(options.projectRoot)).find( (entry) => entry.name === FRAMEWORK_MARKETPLACE_NAME && entry.scope === "user" @@ -66,7 +66,7 @@ export class InstallMachinePluginsUseCase { const entries = selectEntries(catalog?.plugins ?? [], options); const plans: { readonly plan: PlannedUserPluginFileUpdate; readonly owned: boolean }[] = []; for (const toolId of options.toolIds) { - if (!copiesPluginFilesForMachine(toolId)) continue; + if (!copiesPluginFilesGlobally(toolId)) continue; for (const entry of entries) { const owned = manifest.getPlugins(toolId).find((plugin) => plugin.name === entry.name); const resolved = InstalledPlugin.fromMetadata( @@ -104,7 +104,7 @@ export class InstallMachinePluginsUseCase { function selectEntries( catalog: readonly PluginCatalogEntry[], - options: InstallMachinePluginsOptions + options: InstallGlobalPluginsOptions ): readonly PluginCatalogEntry[] { if (options.mode === "all") return catalog; if (options.mode === "recommended") return catalog.filter((entry) => entry.recommended); diff --git a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts index 92ea9c14c..5f2459bc3 100644 --- a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts +++ b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts @@ -8,7 +8,7 @@ import type { } from "../flows/marketplace-sync-settings-use-case.js"; import type { SetupResult } from "../setup-use-case.js"; import type { SetupMarketplaceRegistrationUseCase } from "../shared/setup-marketplace-registration-use-case.js"; -import type { InstallMachinePluginsUseCase } from "./install-machine-plugins-use-case.js"; +import type { InstallGlobalPluginsUseCase } from "./install-global-plugins-use-case.js"; import type { SetupToolsResult } from "./setup-tools-use-case.js"; /** @@ -28,7 +28,7 @@ export class SetupMachineScopeUseCase { private readonly setupMarketplaceRegistration: SetupMarketplaceRegistrationUseCase, private readonly marketplaceSyncSettingsUseCase: MarketplaceSyncSettings, private readonly currentVersionProvider: VersionReader, - private readonly installMachinePluginsUseCase?: InstallMachinePluginsUseCase + private readonly installGlobalPluginsUseCase?: InstallGlobalPluginsUseCase ) {} async execute(flow: SetupFlow): Promise { @@ -46,7 +46,7 @@ export class SetupMachineScopeUseCase { const isNew = await this.initUserManifest(); await this.setupMarketplaceRegistration.registerIfPresent(flow, source); await this.registerUserScopeTools(flow); - const plugins = await this.installMachinePlugins(flow); + const plugins = await this.installGlobalPlugins(flow); const activation: MarketplaceSyncSettingsResult = await this.marketplaceSyncSettingsUseCase.execute({ projectRoot: flow.projectRoot, @@ -56,17 +56,17 @@ export class SetupMachineScopeUseCase { enablePlugins: plugins, }); const install: SetupToolsResult = { results: [] }; - const outcome = { install, activation, context: undefined, machinePlugins: plugins }; + const outcome = { install, activation, context: undefined, globalPlugins: plugins }; return isNew ? { kind: "initialized", ...outcome } : { kind: "up-to-date", ...outcome }; } - /** Copies the chosen plugins' machine files and returns their names, which every tool with + /** Copies the chosen plugins' global files and returns their names, which every tool with * native activation then enables machine-wide. */ - private async installMachinePlugins(flow: SetupFlow): Promise { - if (this.installMachinePluginsUseCase === undefined) return []; + private async installGlobalPlugins(flow: SetupFlow): Promise { + if (this.installGlobalPluginsUseCase === undefined) return []; const manifest = await this.userManifestRepo.load(); if (manifest === null) return []; - const { plugins } = await this.installMachinePluginsUseCase.execute( + const { plugins } = await this.installGlobalPluginsUseCase.execute( manifest, { projectRoot: flow.projectRoot, diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 67c390327..42d0dfdf7 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -84,29 +84,29 @@ export interface ProjectHooksDelivery { * the user's own configuration directory, for a host that loads those files from there rather * than from a plugin it enables itself: OpenCode's and Kilo's skills and agents, Codex's agents. */ -export interface MachineFiles { +export interface GlobalFiles { /** The user configuration directory the files land in, given a homedir. */ readonly dir: (homedir: string) => string; /** A built-tree path (relative to the tool's built dir) to its path under `dir` for one * plugin, or `null` when that file is not this plugin's or is not installed machine-wide. */ - readonly map: (builtPath: string, plugin: MachinePlugin) => string | null; + readonly map: (builtPath: string, plugin: GlobalPlugin) => string | null; } -/** The plugin a machine file is read for: its name, and the agents its source ships, by base +/** The plugin a global file is read for: its name, and the agents its source ships, by base * name. A flat build prefixes an agent with its plugin, so a prefix alone cannot tell * `aidd-dev`'s agents from those of a plugin named `aidd-dev-tools`. */ -export interface MachinePlugin { +export interface GlobalPlugin { readonly name: string; readonly agents: ReadonlySet; } /** - * The machine-file mapping of a flat tool whose user configuration directory takes the same + * The global-file mapping of a flat tool whose user configuration directory takes the same * `skills/` and `agents/` its project directory does (OpenCode, Kilo): a plugin's skills and * agents under `directory` keep their path below it. Hooks, commands and MCP entries stay * project-scoped. */ -export function flatMachineFilePath(directory: string): MachineFiles["map"] { +export function flatGlobalFilePath(directory: string): GlobalFiles["map"] { return (builtPath, plugin) => { if (!builtPath.startsWith(directory)) return null; const relative = builtPath.slice(directory.length); @@ -118,8 +118,8 @@ export function flatMachineFilePath(directory: string): MachineFiles["map"] { } /** Shared by every mode: where a tool's machine-scope files go, when it has any. */ -interface MachineFilesParams { - machineFiles?: MachineFiles; +interface GlobalFilesParams { + globalFiles?: GlobalFiles; } export interface NativePluginsParams extends ProjectHooksDelivery { @@ -210,7 +210,7 @@ type PluginsParams = ( | FlatPluginsParams | UnsupportedPluginsParams ) & - MachineFilesParams; + GlobalFilesParams; export class PluginsCapability { readonly mode: PluginsMode; @@ -244,14 +244,14 @@ export class PluginsCapability { readonly nativeActivation: NativeActivation | null; readonly translationMode: PluginTranslationMode | null; readonly installScope: "project" | "user"; - /** See {@link MachineFiles}, or `null` when a machine-scope install copies no file. */ - readonly machineFiles: MachineFiles | null; + /** See {@link GlobalFiles}, or `null` when a machine-scope install copies no file. */ + readonly globalFiles: GlobalFiles | null; private readonly _userPluginsDir?: (homedir: string) => string; constructor(params: PluginsParams) { this.mode = params.mode; - this.machineFiles = params.machineFiles ?? null; + this.globalFiles = params.globalFiles ?? null; this.translationMode = PluginsCapability.resolveTranslationMode(params); this.installScope = PluginsCapability.resolveInstallScope(params); PluginsCapability.validateUserScope(params); @@ -342,7 +342,7 @@ export class PluginsCapability { * independently of this capability's own `installScope`: a caller resolving a *recorded* * scope still needs the directory that scope means. */ userPluginsBaseDir(homedir: string): string | null { - return this._userPluginsDir?.(homedir) ?? this.machineFiles?.dir(homedir) ?? null; + return this._userPluginsDir?.(homedir) ?? this.globalFiles?.dir(homedir) ?? null; } pluginOutputDir(pluginName: string): string | null { diff --git a/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts index 457d41374..a08b38efe 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts @@ -3,7 +3,7 @@ * even where the literal values coincide: a change to either side must not collapse them. */ -import type { MachinePlugin } from "../../capabilities/plugins-capability.js"; +import type { GlobalPlugin } from "../../capabilities/plugins-capability.js"; /** Relative path for the Codex-native plugin manifest inside each plugin output directory. */ export const OUTPUT_CODEX_MANIFEST_RELATIVE = ".codex-plugin/plugin.json"; @@ -22,7 +22,7 @@ export const OUTPUT_CODEX_AGENTS_DIR = "codex-agents"; * A Codex plugin cannot carry agents, and Codex loads them from `~/.codex/agents/` for every * project, so a machine-scope install copies each staged TOML there, prefixed with its plugin. */ -export function codexMachineFilePath(builtPath: string, plugin: MachinePlugin): string | null { +export function codexGlobalFilePath(builtPath: string, plugin: GlobalPlugin): string | null { const staged = `plugins/${plugin.name}/${OUTPUT_CODEX_AGENTS_DIR}/`; if (!builtPath.startsWith(staged) || !builtPath.endsWith(".toml")) return null; const name = builtPath.slice(staged.length); diff --git a/cli/src/contexts/tools/domain/profiles/codex/profile.ts b/cli/src/contexts/tools/domain/profiles/codex/profile.ts index 1d6e8e39c..294b9255b 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/profile.ts @@ -31,7 +31,7 @@ import { stripCodexSkillFrontmatter, } from "./build.js"; import { codexAgentFrontmatter, codexAgentToml } from "./codex-agent-toml.js"; -import { codexMachineFilePath } from "./codex-paths.js"; +import { codexGlobalFilePath } from "./codex-paths.js"; const DIRECTORY = ".codex/"; const TOOL_SUFFIX = ".codex.md"; @@ -182,7 +182,7 @@ export const codex: AiTool< mode: "native", pluginsDir: ".codex/plugins/", pluginManifestRelativePath: "plugin.json", - machineFiles: { dir: (homedir) => join(homedir, ".codex"), map: codexMachineFilePath }, + globalFiles: { dir: (homedir) => join(homedir, ".codex"), map: codexGlobalFilePath }, acceptsMcp: true, translationMode: "marketplace", // Codex only enables plugins from its user-global config plus its own plugin cache; a diff --git a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts index e85dc3fab..202d3b40b 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts @@ -3,7 +3,7 @@ import { AgentsCapability } from "../../capabilities/agents-capability.js"; import { CommandsCapability } from "../../capabilities/commands-capability.js"; import { CONFIG_MCP } from "../../capabilities/config-refs.js"; import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { flatMachineFilePath, PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { flatGlobalFilePath, PluginsCapability } from "../../capabilities/plugins-capability.js"; import { RulesCapability } from "../../capabilities/rules-capability.js"; import { SkillsCapability } from "../../capabilities/skills-capability.js"; import type { @@ -90,9 +90,9 @@ export const kilo: AiTool join(homedir, ".config", "kilo"), - map: flatMachineFilePath(KILO_DIRECTORY), + map: flatGlobalFilePath(KILO_DIRECTORY), }, acceptsHooks: true, flatHooksDir: KILO_HOOKS_DIR, diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts index 5dae58b53..d35cb9285 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts @@ -2,13 +2,13 @@ * contract can read the same values: the profile imports the contract, so the contract cannot * import the profile back. */ -import { flatMachineFilePath } from "../../capabilities/plugins-capability.js"; +import { flatGlobalFilePath } from "../../capabilities/plugins-capability.js"; export const OPENCODE_DIRECTORY = ".opencode/"; /** A machine-scope install puts a plugin's skills and agents where OpenCode loads them for every * project: its global config directory. */ -export const opencodeMachineFilePath = flatMachineFilePath(OPENCODE_DIRECTORY); +export const opencodeGlobalFilePath = flatGlobalFilePath(OPENCODE_DIRECTORY); /** * The directory OpenCode's own plugin loader scans (`{plugin,plugins}/*.{ts,js}`, one level, no diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts index e17b5a22e..372e368e1 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts @@ -33,7 +33,7 @@ import { OPENCODE_FLAT_HOOKS_DIR, OPENCODE_HOOKS_DIR, OPENCODE_PLUGIN_ENTRY_BASENAME, - opencodeMachineFilePath, + opencodeGlobalFilePath, } from "./opencode-paths.js"; const DIRECTORY = OPENCODE_DIRECTORY; @@ -107,9 +107,9 @@ export const opencode: AiTool< plugins: new PluginsCapability({ mode: "flat", flatNamespacePrefix: "aidd-", - machineFiles: { + globalFiles: { dir: (homedir) => join(homedir, ".config", "opencode"), - map: opencodeMachineFilePath, + map: opencodeGlobalFilePath, }, acceptsHooks: true, flatHooksDir: OPENCODE_HOOKS_DIR, diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index a27d2c648..ef9e47d09 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -16,7 +16,7 @@ import { import type { ToolBuildContract } from "./build-contract.js"; import type { EnvironmentReader, - MachineFiles, + GlobalFiles, NativeActivation, PluginsCapability, } from "./capabilities/plugins-capability.js"; @@ -120,7 +120,7 @@ export function pluginEnablementIsMachineGlobal(toolId: ToolId): boolean { } /** Whether `--scope user` has anywhere to point this tool: it drives its own CLI machine-wide, - * installs a plugin's files into a user-scope directory, or copies machine files into the + * installs a plugin's files into a user-scope directory, or copies global files into the * user's configuration directory. `false` for a tool declaring none. */ export function supportsUserScopeActivation(toolId: ToolId): boolean { const capability = resolvePluginsCapability(toolId); @@ -128,7 +128,7 @@ export function supportsUserScopeActivation(toolId: ToolId): boolean { return ( capability.nativeActivation !== null || capability.installScope === "user" || - capability.machineFiles !== null + capability.globalFiles !== null ); } @@ -142,15 +142,15 @@ export function hostRefInUse(toolId: ToolId, enabled: boolean): boolean { /** Whether `setup --global` writes a plugin's files itself for this tool, into its user plugins * directory or its user configuration directory, instead of only enabling the plugin through * the tool's own CLI. */ -export function copiesPluginFilesForMachine(toolId: ToolId): boolean { +export function copiesPluginFilesGlobally(toolId: ToolId): boolean { const capability = resolvePluginsCapability(toolId); if (capability === null) return false; - return capability.installScope === "user" || capability.machineFiles !== null; + return capability.installScope === "user" || capability.globalFiles !== null; } -/** See {@link MachineFiles}: `null` for a tool whose machine-scope install copies no file. */ -export function machineFilesOf(toolId: ToolId): MachineFiles | null { - return resolvePluginsCapability(toolId)?.machineFiles ?? null; +/** See {@link GlobalFiles}: `null` for a tool whose machine-scope install copies no file. */ +export function globalFilesOf(toolId: ToolId): GlobalFiles | null { + return resolvePluginsCapability(toolId)?.globalFiles ?? null; } /** The tool's declared build contract for one framework-build mode, or undefined when the tool diff --git a/cli/src/presentation/commands/doctor.ts b/cli/src/presentation/commands/doctor.ts index ebd6dcd04..c3c493b9f 100644 --- a/cli/src/presentation/commands/doctor.ts +++ b/cli/src/presentation/commands/doctor.ts @@ -139,7 +139,7 @@ async function runUserScopeDoctor( manifest, projectRoot, allowedIds, - machine: true, + globalInstall: true, }); printScopeIssues(output, "User scope", { issues }); const healthy = issues.every((i) => i.severity !== "error"); diff --git a/cli/src/presentation/commands/setup.ts b/cli/src/presentation/commands/setup.ts index cca55c91d..bcd93efc1 100644 --- a/cli/src/presentation/commands/setup.ts +++ b/cli/src/presentation/commands/setup.ts @@ -5,7 +5,7 @@ import { SetupUseCase } from "../../contexts/framework/application/setup-use-cas import { SetupFlow } from "../../contexts/framework/domain/setup-flow.js"; import { assertToolIdsMatchCategory, - copiesPluginFilesForMachine, + copiesPluginFilesGlobally, getAllRegisteredTools, nativeActivationOf, resolvePluginsCapability, @@ -104,7 +104,7 @@ export function parsePluginsFlag( export function globalSetupHelp(): string { const tools = [...getAllRegisteredTools().keys()].filter(supportsUserScopeActivation).sort(); const native = tools.filter((id) => nativeActivationOf(id) !== undefined); - const copying = tools.filter(copiesPluginFilesForMachine); + const copying = tools.filter(copiesPluginFilesGlobally); const where = (id: ToolId) => resolvePluginsCapability(id)?.userPluginsBaseDir("~")?.replaceAll("\\", "/"); return [ diff --git a/cli/src/presentation/commands/sync.ts b/cli/src/presentation/commands/sync.ts index 8535b7986..6dc713e22 100644 --- a/cli/src/presentation/commands/sync.ts +++ b/cli/src/presentation/commands/sync.ts @@ -25,7 +25,7 @@ interface SyncCmdOptions { } /** A user-scope install writes nothing under any project, so this skips `restoreAllUseCase`: - * it rebuilds the machine files aidd copied into each tool's user directory from the shared + * it rebuilds the global files aidd copied into each tool's user directory from the shared * source, then drives native activation, through `deps.userManifestRepo` and never this * project's own manifest. */ async function runUserScopeSync( diff --git a/cli/src/presentation/display/setup-display.ts b/cli/src/presentation/display/setup-display.ts index 5b9e03ce2..05ff05d5a 100644 --- a/cli/src/presentation/display/setup-display.ts +++ b/cli/src/presentation/display/setup-display.ts @@ -11,7 +11,7 @@ interface ToolInstallOutcome { interface SetupOutcome { readonly kind: "initialized" | "up-to-date"; readonly install: { readonly results: readonly ToolInstallOutcome[] }; - readonly machinePlugins?: readonly string[]; + readonly globalPlugins?: readonly string[]; } function displayInstall( @@ -38,11 +38,11 @@ function displayInstall( } export function printSetupOutcome(output: CLIOutput, result: SetupOutcome, verbose: boolean): void { - if (result.machinePlugins !== undefined) { + if (result.globalPlugins !== undefined) { output.success( - result.machinePlugins.length === 0 + result.globalPlugins.length === 0 ? "Registered for every project on this machine." - : `Installed for every project on this machine: ${result.machinePlugins.join(", ")}.` + : `Installed for every project on this machine: ${result.globalPlugins.join(", ")}.` ); return; } diff --git a/cli/src/presentation/display/sync-display.ts b/cli/src/presentation/display/sync-display.ts index e6d423f8d..5ba4453ba 100644 --- a/cli/src/presentation/display/sync-display.ts +++ b/cli/src/presentation/display/sync-display.ts @@ -72,7 +72,7 @@ export function printUserScopeSyncOutcome( activated: readonly string[], refreshed: readonly string[] = [] ): void { - if (refreshed.length > 0) output.success(`Refreshed machine files for: ${refreshed.join(", ")}`); + if (refreshed.length > 0) output.success(`Refreshed global files for: ${refreshed.join(", ")}`); if (activated.length > 0) { output.success(`Synced native activation for: ${activated.join(", ")}`); } else if (refreshed.length === 0) { diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 04ca99252..0ffe39907 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -53,7 +53,7 @@ import { PluginSearchUseCase } from "../../contexts/framework/application/plugin import { PluginUpdateUseCase } from "../../contexts/framework/application/plugin/plugin-update-use-case.js"; import { ValidateRecipesUseCase } from "../../contexts/framework/application/recipes/validate-recipes-use-case.js"; import { RestoreUseCase } from "../../contexts/framework/application/restore/restore-use-case.js"; -import { InstallMachinePluginsUseCase } from "../../contexts/framework/application/setup/install-machine-plugins-use-case.js"; +import { InstallGlobalPluginsUseCase } from "../../contexts/framework/application/setup/install-global-plugins-use-case.js"; import { ProjectContextDetectorUseCase } from "../../contexts/framework/application/setup/project-context-detector-use-case.js"; import { SetupMachineScopeUseCase } from "../../contexts/framework/application/setup/setup-machine-scope-use-case.js"; import { SetupMarketplaceSourceUseCase } from "../../contexts/framework/application/setup/setup-marketplace-source-use-case.js"; @@ -509,7 +509,7 @@ export async function createDeps( setupMarketplaceRegistration, marketplaceSyncSettingsUseCase, currentVersionProvider, - new InstallMachinePluginsUseCase( + new InstallGlobalPluginsUseCase( marketplaceRegistry, resolveMarketplaceUseCase, userPluginFileUpdater, diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index c465733b7..5c901470e 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -21,7 +21,7 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] // tool install, plugin prompt, settings sync, version, plus the machine-scope handoff. { path: "src/contexts/framework/application/setup-use-case.ts", injected: 11 }, // Its machine-scope counterpart names the same stages for the machine: the machine manifest, - // source registration, the plugins' machine files, native activation, and the version each + // source registration, the plugins' global files, native activation, and the version each // registered tool records. { path: "src/contexts/framework/application/setup/setup-machine-scope-use-case.ts", diff --git a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts index 2859cd236..c9f6ce142 100644 --- a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts @@ -69,7 +69,7 @@ async function issuesFor( manifest, projectRoot: PROJECT_ROOT, allowedIds, - machine: machine, + globalInstall: machine, }); } diff --git a/cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts similarity index 83% rename from cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts rename to cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts index 34b51f4f2..3aefe2ad1 100644 --- a/cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts +++ b/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { - assertMachineFilesWritable, - readMachinePluginFiles, -} from "../../../../../src/contexts/framework/application/ownership/machine-plugin-files.js"; + assertGlobalFilesWritable, + readGlobalPluginFiles, +} from "../../../../../src/contexts/framework/application/ownership/global-plugin-files.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { InstallationFile } from "../../../../../src/kernel/file.js"; @@ -32,9 +32,9 @@ const tree = new InMemoryFileAdapter({ [`${BUILT}/.opencode/commands/aidd-dev-plan.md`]: "command", }); -describe("readMachinePluginFiles", () => { +describe("readGlobalPluginFiles", () => { it("reads the plugin's skills and the agents its source ships, keyed under the user directory", async () => { - const files = await readMachinePluginFiles( + const files = await readGlobalPluginFiles( tree, new DeterministicHasher(), BUILT, @@ -49,7 +49,7 @@ describe("readMachinePluginFiles", () => { }); it("reads nothing for a plugin whose source ships no agent and no skill in the tree", async () => { - const files = await readMachinePluginFiles( + const files = await readGlobalPluginFiles( tree, new DeterministicHasher(), BUILT, @@ -60,9 +60,9 @@ describe("readMachinePluginFiles", () => { expect(files).toEqual([]); }); - it("reads nothing for a tool that copies no machine file, which has no directory to write either", async () => { + it("reads nothing for a tool that copies no global file, which has no directory to write either", async () => { await expect( - assertMachineFilesWritable( + assertGlobalFilesWritable( new InMemoryFileAdapter(), "claude", undefined, @@ -73,8 +73,8 @@ describe("readMachinePluginFiles", () => { ).rejects.toThrow("claude: no machine-scope directory is declared."); }); - it("reads nothing for a tool that copies no machine file", async () => { - const files = await readMachinePluginFiles( + it("reads nothing for a tool that copies no global file", async () => { + const files = await readGlobalPluginFiles( tree, new DeterministicHasher(), BUILT, @@ -86,7 +86,7 @@ describe("readMachinePluginFiles", () => { }); }); -describe("assertMachineFilesWritable", () => { +describe("assertGlobalFilesWritable", () => { const hasher = new DeterministicHasher(); const HOME = "/home/me"; const DIR = join(HOME, ".config", "opencode"); @@ -98,7 +98,7 @@ describe("assertMachineFilesWritable", () => { const fs = new InMemoryFileAdapter({}, hasher); await expect( - assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) ).resolves.toBe(DIR); }); @@ -108,7 +108,7 @@ describe("assertMachineFilesWritable", () => { const fs = new InMemoryFileAdapter({}, hasher); await expect( - assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file(path)], HOME) + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file(path)], HOME) ).rejects.toThrow(`opencode: 'aidd-dev' path '${path}' escapes ${DIR}.`); } ); @@ -118,7 +118,7 @@ describe("assertMachineFilesWritable", () => { fs.setSymlink(under("agents"), "/elsewhere"); await expect( - assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) ).rejects.toThrow(`opencode: 'aidd-dev' parent '${under("agents")}' escapes ${DIR}.`); }); @@ -126,7 +126,7 @@ describe("assertMachineFilesWritable", () => { const fs = new InMemoryFileAdapter({ [under("agents", "a.md")]: "mine" }, hasher); await expect( - assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) ).rejects.toThrow( `opencode: '${under("agents", "a.md")}' already exists and was not installed by aidd` ); @@ -146,7 +146,7 @@ describe("assertMachineFilesWritable", () => { ).withFiles(new Map([["agents/owned.md", "0".repeat(32)]])); await expect( - assertMachineFilesWritable( + assertGlobalFilesWritable( fs, "opencode", owned, diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts index f26f3aab3..b57b50c32 100644 --- a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts @@ -89,7 +89,7 @@ describe("UserPluginFileUpdater public update contract", () => { expect(f.logger.warnMessages.filter((m) => m.includes("left in place"))).toEqual([]); }); - it("refuses to rebuild machine files without the shared source, rather than write a project's layout", async () => { + it("refuses to rebuild global files without the shared source, rather than write a project's layout", async () => { const f = fixture(); await expect(f.updater.update(f.plugin, "opencode", "/A", f.logger)).rejects.toThrow( "run `aidd setup --global`" diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-selection.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-selection.integration.test.ts index ec2bb8f6a..e39c5743e 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-selection.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-selection.integration.test.ts @@ -42,7 +42,7 @@ function manifest(): Manifest { } describe("plugin removal selects the recorded owner, name and tool", () => { - it("detaches only the selected user plugin's project claim while preserving machine files", async () => { + it("detaches only the selected user plugin's project claim while preserving global files", async () => { const project = manifest(); const machine = manifest(); for (const toolId of ["cursor", "claude"] as const) { @@ -190,7 +190,7 @@ describe("plugin removal selects the recorded owner, name and tool", () => { expect(projectRepo.saveCount).toBe(0); }); - it("explicit user removal deletes the proven machine file without touching the project copy", async () => { + it("explicit user removal deletes the proven global file without touching the project copy", async () => { const relative = `${NAME}/commands/owned.md`; const content = "installed machine bytes"; const machine = manifest(); diff --git a/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts index 4ad72eea2..491fd6b0d 100644 --- a/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts @@ -582,7 +582,7 @@ describe("setup --scope user", () => { install: { results: [] }, activation: { updatedTools: [] }, context: undefined, - machinePlugins: [], + globalPlugins: [], }); }); @@ -599,7 +599,7 @@ describe("setup --scope user", () => { install: { results: [] }, activation: { updatedTools: [] }, context: undefined, - machinePlugins: [], + globalPlugins: [], }); }); diff --git a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-global-plugins-use-case.unit.test.ts similarity index 94% rename from cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/setup/install-global-plugins-use-case.unit.test.ts index 6dc540cc9..0b90964d2 100644 --- a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/install-global-plugins-use-case.unit.test.ts @@ -5,7 +5,7 @@ import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import type { PluginCatalogEntry } from "../../../../../src/contexts/distribution/domain/catalog.js"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import type { PlannedUserPluginFileUpdate } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; -import { InstallMachinePluginsUseCase } from "../../../../../src/contexts/framework/application/setup/install-machine-plugins-use-case.js"; +import { InstallGlobalPluginsUseCase } from "../../../../../src/contexts/framework/application/setup/install-global-plugins-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import type { PluginInstallMode } from "../../../../../src/contexts/framework/domain/setup-flow.js"; @@ -56,7 +56,7 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { } ); const applyUpdate = vi.fn(async (plan: PlannedUserPluginFileUpdate) => plan.next); - const useCase = new InstallMachinePluginsUseCase( + const useCase = new InstallGlobalPluginsUseCase( { list: async () => marketplaces }, { execute: async () => ({ @@ -72,7 +72,7 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { } function run( - useCase: InstallMachinePluginsUseCase, + useCase: InstallGlobalPluginsUseCase, manifest: Manifest, mode: PluginInstallMode, pluginNames: string[] = [] @@ -89,8 +89,8 @@ function run( ); } -describe("InstallMachinePluginsUseCase", () => { - it("records each chosen plugin's machine files for a tool that copies them, and names every one", async () => { +describe("InstallGlobalPluginsUseCase", () => { + it("records each chosen plugin's global files for a tool that copies them, and names every one", async () => { const { manifest, useCase, planUpdate } = setup(); const result = await run(useCase, manifest, "all"); diff --git a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts index d8a11be0c..bee79dbd5 100644 --- a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts +++ b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts @@ -134,11 +134,11 @@ describe("SetupFlow", () => { } }); - it("accepts an AI tool that copies machine files at --scope user (kilo)", () => { + it("accepts an AI tool that copies global files at --scope user (kilo)", () => { expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).not.toThrow(); }); - it("accepts an AI tool that copies machine files at --scope user (opencode)", () => { + it("accepts an AI tool that copies global files at --scope user (opencode)", () => { expect(() => makeFlow({ scope: "user", aiTools: ["opencode"] })).not.toThrow(); }); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/global-files.unit.test.ts similarity index 60% rename from cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/codex/global-files.unit.test.ts index d3b4e9741..d213657b6 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/global-files.unit.test.ts @@ -1,15 +1,15 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import { codexMachineFilePath } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-paths.js"; -import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; +import { codexGlobalFilePath } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-paths.js"; +import { globalFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; const ORCHESTRATOR = { name: "aidd-orchestrator", agents: new Set() }; -describe("codexMachineFilePath — the agents a machine-scope install copies into ~/.codex", () => { +describe("codexGlobalFilePath — the agents a machine-scope install copies into ~/.codex", () => { it("prefixes a staged agent with its plugin under agents/", () => { expect( - codexMachineFilePath("plugins/aidd-orchestrator/codex-agents/reviewer.toml", ORCHESTRATOR) + codexGlobalFilePath("plugins/aidd-orchestrator/codex-agents/reviewer.toml", ORCHESTRATOR) ).toBe("agents/aidd-orchestrator-reviewer.toml"); }); @@ -19,12 +19,12 @@ describe("codexMachineFilePath — the agents a machine-scope install copies int "plugins/aidd-orchestrator/codex-agents/nested/reviewer.toml", "plugins/aidd-orchestrator/codex-agents/reviewer.md", ])("leaves %s out: another plugin's, or not a staged agent", (built) => { - expect(codexMachineFilePath(built, ORCHESTRATOR)).toBeNull(); + expect(codexGlobalFilePath(built, ORCHESTRATOR)).toBeNull(); }); }); describe("the codex profile's machine-scope directory", () => { it("is ~/.codex under the home directory", () => { - expect(machineFilesOf("codex")?.dir("/home/me")).toBe(join("/home/me", ".codex")); + expect(globalFilesOf("codex")?.dir("/home/me")).toBe(join("/home/me", ".codex")); }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts similarity index 88% rename from cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts index 2823c2de3..928c43d7b 100644 --- a/cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts @@ -1,12 +1,12 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; +import { globalFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; const AIDD_DEV = { name: "aidd-dev", agents: new Set(["reviewer"]) }; describe("the kilo profile's machine-scope files", () => { - const machine = machineFilesOf("kilo"); + const machine = globalFilesOf("kilo"); it("land in ~/.config/kilo under the home directory", () => { expect(machine?.dir("/home/me")).toBe(join("/home/me", ".config/kilo")); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/global-files.unit.test.ts similarity index 69% rename from cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/opencode/global-files.unit.test.ts index fb103047d..56a63575f 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/global-files.unit.test.ts @@ -1,12 +1,12 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import { opencodeMachineFilePath } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-paths.js"; -import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; +import { opencodeGlobalFilePath } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-paths.js"; +import { globalFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; const AIDD_DEV = { name: "aidd-dev", agents: new Set(["reviewer"]) }; -describe("opencodeMachineFilePath — what a machine-scope install copies into OpenCode's config dir", () => { +describe("opencodeGlobalFilePath — what a machine-scope install copies into OpenCode's config dir", () => { it.each([ [".opencode/skills/aidd-dev/01-plan/SKILL.md", "skills/aidd-dev/01-plan/SKILL.md"], [ @@ -15,7 +15,7 @@ describe("opencodeMachineFilePath — what a machine-scope install copies into O ], [".opencode/agents/aidd-dev-reviewer.md", "agents/aidd-dev-reviewer.md"], ])("keeps the plugin's own %s as %s", (built, machine) => { - expect(opencodeMachineFilePath(built, AIDD_DEV)).toBe(machine); + expect(opencodeGlobalFilePath(built, AIDD_DEV)).toBe(machine); }); it.each([ @@ -30,13 +30,13 @@ describe("opencodeMachineFilePath — what a machine-scope install copies into O ])( "leaves %s out: another plugin's, an agent it does not ship, or not loaded machine-wide", (built) => { - expect(opencodeMachineFilePath(built, AIDD_DEV)).toBeNull(); + expect(opencodeGlobalFilePath(built, AIDD_DEV)).toBeNull(); } ); }); describe("the opencode profile's machine-scope directory", () => { it("is ~/.config/opencode under the home directory", () => { - expect(machineFilesOf("opencode")?.dir("/home/me")).toBe(join("/home/me", ".config/opencode")); + expect(globalFilesOf("opencode")?.dir("/home/me")).toBe(join("/home/me", ".config/opencode")); }); }); diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index e21a55145..8e3eadd20 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -63,7 +63,7 @@ describe("E2E: setup --scope user writes nothing under the project", () => { const sync = await runCli(["sync", "--global"], projectDir, fakeHome); expect(sync.exitCode).toBe(0); - expect(sync.stdout).toContain("Refreshed machine files for: aidd-dev"); + expect(sync.stdout).toContain("Refreshed global files for: aidd-dev"); const clean = await runCli(["clean", "--global", "--force"], projectDir, fakeHome); expect(clean.exitCode).toBe(0); diff --git a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts index 7cc8912ed..5f59d0ad8 100644 --- a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts @@ -402,7 +402,7 @@ describe("aidd doctor --scope user", () => { manifest: expect.anything(), projectRoot: PROJECT_ROOT, allowedIds: null, - machine: true, + globalInstall: true, }); expect(doctorAll).not.toHaveBeenCalled(); }); diff --git a/cli/tests/presentation/commands/sync-wiring.integration.test.ts b/cli/tests/presentation/commands/sync-wiring.integration.test.ts index 8b7bba755..f17c4b141 100644 --- a/cli/tests/presentation/commands/sync-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/sync-wiring.integration.test.ts @@ -247,14 +247,14 @@ describe("aidd sync --scope user", () => { ]); }); - it("rebuilds the machine files a tool owns before driving activation, under --global", async () => { + it("rebuilds the global files a tool owns before driving activation, under --global", async () => { userManifestRepo.load.mockResolvedValue({ getInstalledToolIds: () => ["claude", "opencode"], getPlugins: (toolId: string) => (toolId === "opencode" ? [{ scope: "user" }] : []), }); userPluginUpdate.mockResolvedValue(["aidd-dev", "aidd-dev"]); - expect(await run("--global")).toEqual(["Refreshed machine files for: aidd-dev"]); + expect(await run("--global")).toEqual(["Refreshed global files for: aidd-dev"]); expect(userPluginUpdate).toHaveBeenCalledWith({ toolIds: ["opencode"], projectRoot: PROJECT_ROOT, diff --git a/cli/tests/presentation/display/setup-display.unit.test.ts b/cli/tests/presentation/display/setup-display.unit.test.ts index 868b5320d..daffbb3d0 100644 --- a/cli/tests/presentation/display/setup-display.unit.test.ts +++ b/cli/tests/presentation/display/setup-display.unit.test.ts @@ -95,7 +95,7 @@ describe("printSetupOutcome", () => { printSetupOutcome( output, - { kind: "initialized", install: { results: [] }, machinePlugins: ["aidd-dev", "aidd-pm"] }, + { kind: "initialized", install: { results: [] }, globalPlugins: ["aidd-dev", "aidd-pm"] }, false ); @@ -109,7 +109,7 @@ describe("printSetupOutcome", () => { printSetupOutcome( output, - { kind: "up-to-date", install: { results: [] }, machinePlugins: [] }, + { kind: "up-to-date", install: { results: [] }, globalPlugins: [] }, false ); diff --git a/cli/tests/presentation/display/sync-display.unit.test.ts b/cli/tests/presentation/display/sync-display.unit.test.ts index 7c7810857..2b9b760f4 100644 --- a/cli/tests/presentation/display/sync-display.unit.test.ts +++ b/cli/tests/presentation/display/sync-display.unit.test.ts @@ -164,12 +164,12 @@ describe("printActivationOutcome", () => { }); describe("printUserScopeSyncOutcome", () => { - it("names the plugins whose machine files were rebuilt, and nothing else when no host was driven", () => { + it("names the plugins whose global files were rebuilt, and nothing else when no host was driven", () => { const output = new CapturingOutput(false); printUserScopeSyncOutcome(output, [], ["aidd-dev", "aidd-pm"]); - expect(output.at("success")).toEqual(["Refreshed machine files for: aidd-dev, aidd-pm"]); + expect(output.at("success")).toEqual(["Refreshed global files for: aidd-dev, aidd-pm"]); }); it("reports both the rebuilt files and the hosts driven", () => { @@ -178,7 +178,7 @@ describe("printUserScopeSyncOutcome", () => { printUserScopeSyncOutcome(output, ["claude"], ["aidd-dev"]); expect(output.at("success")).toEqual([ - "Refreshed machine files for: aidd-dev", + "Refreshed global files for: aidd-dev", "Synced native activation for: claude", ]); }); diff --git a/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js index 34cd16836..cda8871f4 100644 --- a/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js +++ b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js @@ -31,7 +31,7 @@ function declaresUserScopeActivation(toolId) { return ( /nativeActivation:\s*\{/u.test(text) || /installScope:\s*"user"/u.test(text) || - /machineFiles:\s*\{/u.test(text) + /globalFiles:\s*\{/u.test(text) ); } From 9fbcf5601dc51db3a44f7c77f5883fb8a85fb04a Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 6 Nov 2026 05:04:33 +0100 Subject: [PATCH 16/16] fix(cli): preserve global install ownership and recovery Keep successful native plugin selections recoverable, protect unowned files, and complete global cleanup without removing other marketplace sources. Restore managed OpenCode reload compatibility. Refs: #1142 --- cli/README.md | 6 +- .../framework/application/clean-use-case.ts | 6 +- .../clean/clean-user-scope-use-case.ts | 26 ++- .../marketplace-sync-settings-use-case.ts | 17 +- .../ownership/global-plugin-files.ts | 5 +- .../native-host-registration-gate.ts | 3 +- .../native-marketplace-source-proof.ts | 12 +- .../ownership/user-plugin-file-updater.ts | 8 +- ...an-user-scope-use-case.integration.test.ts | 173 +++++++++++++++++- ...ce-sync-global-plugins.integration.test.ts | 124 +++++++++++++ .../global-plugin-files.unit.test.ts | 20 +- ...tive-marketplace-source-proof.unit.test.ts | 41 ++++- .../plugin-update-built-tree.unit.test.ts | 22 ++- cli/tests/e2e/setup-scope-user.e2e.test.ts | 41 ++++- scripts/__tests__/dev-sync.test.js | 56 ++++++ scripts/dev-sync.sh | 41 +++-- 16 files changed, 526 insertions(+), 75 deletions(-) create mode 100644 cli/tests/contexts/framework/application/flows/marketplace-sync-global-plugins.integration.test.ts diff --git a/cli/README.md b/cli/README.md index a0c36bb99..f4bfb2ef9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -126,11 +126,15 @@ Counts the tokens of Claude Code and Codex sessions, locally. Details: [FAQ](../ | `opencode`, `kilo` | Copies the plugins' skills and agents into `~/.config/opencode/` or `~/.config/kilo/` | | `cursor` | Copies the plugins into `~/.cursor/plugins/local/`; Cursor must allow local plugin imports | +Files without recorded AIDD ownership are refused even when their content matches. This includes +copies from an older `make reload`, or files left by an interrupted install before ownership was +saved. Move the named files aside, then rerun the original setup or reload command. + Copilot needs a CLI whose `plugin marketplace list` accepts `--json` (verified with 1.0.98; 1.0.83 refuses it). Native activation needs the tool's executable on `PATH`. Without it, registration succeeds with a warning and activation stays unrun. -Install it, then run `aidd sync --global`. +Install it, then rerun the original `aidd setup --global` command with its `--ide` and `--plugins` selections. ```sh aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all --yes diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts index 22e1b3826..19e1ae5d8 100644 --- a/cli/src/contexts/framework/application/clean-use-case.ts +++ b/cli/src/contexts/framework/application/clean-use-case.ts @@ -272,7 +272,8 @@ export class CleanUseCase { this.hostPluginRegistries.get(toolId), registration.hostName, new Set([...registrations.pluginRefs, ...machineRefs]), - projectRoot + projectRoot, + toolId ); } } @@ -604,7 +605,8 @@ export class CleanUseCase { reader, sourceRegistration.hostName, new Set([...registrations.pluginRefs, ...machineRefs]), - projectRoot + projectRoot, + toolId ); } catch (error) { this.logger.warn(`${binary}: '${ref}' left enabled; ${String(error)}`); diff --git a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts index e02784dd0..320e0261e 100644 --- a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts +++ b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts @@ -327,7 +327,8 @@ export class CleanUserScopeUseCase { this.hostPluginRegistries.get(toolId), registration.hostName, owned, - projectRoot + projectRoot, + toolId ); for (const ref of refsByHost.get(registration.hostName) ?? []) { if (hostRefs.get(ref)?.enabled !== true) @@ -419,6 +420,15 @@ export class CleanUserScopeUseCase { await this.purgeWhitelistedPath("cache/built", "cache/built", (candidate) => this.fs.deleteDirectory(candidate) ); + // Other user marketplaces keep their registered sources. Plugin fetches belong to the + // machine manifest being removed, whose active dependents were checked before any purge. + const frameworkSource = `cache/marketplaces/${FRAMEWORK_MARKETPLACE_NAME}`; + for (const path of [frameworkSource, "cache/plugin-cache"]) { + await this.purgeWhitelistedPath(path, path, (candidate) => + this.fs.deleteDirectory(candidate) + ); + } + await this.purgeEmptyCacheShell("cache/marketplaces"); // The self-update check cache, written into the same `cache/` directory by any online // command: left behind it keeps the shell below non-empty, the one occupant that made // "leaves nothing of aidd's on the machine" false in the ordinary case. @@ -444,17 +454,15 @@ export class CleanUserScopeUseCase { await this.userManifestRepo.delete(); } - /** `cache/built/` and `cache/update-check.json` are this whitelist's only occupants of - * `userConfigDir()/cache/`. The shell around them is removed only once proven empty, under the - * same containment as every other candidate — never assumed from having just purged its only - * children, since a future writer under `cache/` would make that assumption stale silently. */ - private async purgeEmptyCacheShell(): Promise { + /** Remove a cache directory only once proven empty, with the same containment as every + * whitelist candidate: another marketplace or a future cache writer may still use it. */ + private async purgeEmptyCacheShell(path = "cache"): Promise { const candidate = await resolveCacheCandidate( this.fs, this.logger, this.userConfigDir(), - "cache", - "user scope: cache" + path, + `user scope: ${path}` ); if (candidate === null) return; let entries: string[]; @@ -466,7 +474,7 @@ export class CleanUserScopeUseCase { } if (entries.length > 0) return; await this.fs.deleteDirectory(candidate); - this.logger.info(`user scope: cache purged: ${candidate}`); + this.logger.info(`user scope: ${path} purged: ${candidate}`); } /** Never `userConfigDir()` itself, and never on a manifest's word: every fixed whitelist entry diff --git a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts index a0fb48410..ca620c3ac 100644 --- a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts @@ -630,7 +630,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId, [ ...new Set([ - ...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias), + ...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias, scope), ...requested, ]), ], @@ -782,7 +782,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId: ToolId, manifest: Manifest, marketplaces: readonly Marketplace[], - hostNameByAlias: ReadonlyMap + hostNameByAlias: ReadonlyMap, + scope: MarketplaceScope ): string[] { const byName = new Map(marketplaces.map((m) => [m.name, m])); const refs: string[] = []; @@ -793,6 +794,18 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { if (hostName === undefined) continue; refs.push(`${plugin.name}@${hostName}`); } + // Native-only global installs record successful activations, not copied plugin files. + // Claims also recover a selection an older sync dropped from its projection. + if (scope === "user") { + const native = manifest.getNativeRegistrations(toolId); + const provenHosts = [...hostNameByAlias.values()]; + for (const ref of [ + ...(native?.pluginRefs ?? []), + ...(native?.pluginClaims ?? []).map((claim) => claim.ref), + ]) { + if (provenHosts.some((hostName) => ref.endsWith(`@${hostName}`))) refs.push(ref); + } + } return refs; } diff --git a/cli/src/contexts/framework/application/ownership/global-plugin-files.ts b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts index 501b50933..42665e7c6 100644 --- a/cli/src/contexts/framework/application/ownership/global-plugin-files.ts +++ b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts @@ -45,8 +45,8 @@ export async function readGlobalPluginFiles( /** * Refuses a machine-scope write before any byte lands: every path must stay a normalized relative * path, its nearest existing parent must resolve strictly inside the tool's user directory, and a - * file already there that this plugin does not own is never overwritten unless it already holds - * the same bytes. Creates the user directory itself when the host has not yet. + * file already there that this plugin does not own is never overwritten. Creates the user + * directory itself when the host has not yet. */ export async function assertGlobalFilesWritable( fs: FileReader & FileWriter, @@ -80,7 +80,6 @@ export async function assertGlobalFilesWritable( if (plugin?.files.has(rel) === true) continue; const target = join(boundary, rel); if (!(await fs.fileExists(target))) continue; - if ((await fs.readFileHash(target)).value === file.hash.value) continue; throw new Error( `${toolId}: '${target}' already exists and was not installed by aidd; move or delete it, then rerun.` ); diff --git a/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts index 5c36dce71..4c98c0f61 100644 --- a/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts +++ b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts @@ -95,7 +95,8 @@ export class NativeHostRegistrationGate { reader, registration.hostName, owned, - projectRoot + projectRoot, + toolId ); const refs = (registrations.pluginClaims ?? []).filter((claim) => claim.ref.endsWith(`@${registration.hostName}`) diff --git a/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts index 126784df5..0f0014b34 100644 --- a/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts +++ b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts @@ -1,3 +1,4 @@ +import type { ToolId } from "../../../../kernel/tool.js"; import type { HostPluginRegistryEntry, HostPluginRegistryReader, @@ -8,6 +9,7 @@ import type { NativeMarketplaceSourceReader, NativeMarketplaceSourceReading, } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; +import { hostRefInUse } from "../../../tools/domain/registry.js"; import type { NativeMarketplaceRegistration } from "../../domain/manifest/native-registrations.js"; export interface NativeMarketplaceSourceInspection { @@ -80,7 +82,8 @@ export async function assertNoForeignNativeRefs( reader: HostPluginRegistryReader | undefined, hostName: string, ownedRefs: ReadonlySet, - projectRoot: string + projectRoot: string, + toolId: ToolId ): Promise> { if (reader === undefined) throw new Error( @@ -99,9 +102,10 @@ export async function assertNoForeignNativeRefs( `Catalogue '${hostName}': host plugin registry unreadable (${reading.unreadable ?? reading.location}); no host mutation made.` ); const refs = reading.refs ?? new Map(); - const foreign = [...refs.keys()].find( - (ref) => ref.endsWith(`@${hostName}`) && !ownedRefs.has(ref) - ); + const foreign = [...refs].find( + ([ref, entry]) => + hostRefInUse(toolId, entry.enabled) && ref.endsWith(`@${hostName}`) && !ownedRefs.has(ref) + )?.[0]; if (foreign !== undefined) throw new Error( `Catalogue '${hostName}' includes foreign host ref '${foreign}'; no host mutation made.` diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index e6d4df6f9..497c6574c 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -160,11 +160,9 @@ export class UserPluginFileUpdater { if (plugin.files.has(file.relativePath)) continue; const target = join(baseDir, file.relativePath); if (!(await this.fs.fileExists(target))) continue; - if ((await this.fs.readFileHash(target)).value !== file.hash.value) { - throw new Error( - `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` - ); - } + throw new Error( + `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` + ); } } diff --git a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts index 100c0232c..44fd30054 100644 --- a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts @@ -33,6 +33,9 @@ const CLAUDE_CACHE = join(HOME, ".claude", "plugins", "cache"); const CODEX_CACHE = join(HOME, ".codex", "plugins", "cache"); const WHITELIST_PURGE_MESSAGES = [ `user scope: cache/built purged: ${join(USER_CONFIG_DIR, "cache", "built")}`, + `user scope: cache/marketplaces/aidd-framework purged: ${join(USER_CONFIG_DIR, "cache", "marketplaces", FRAMEWORK_MARKETPLACE_NAME)}`, + `user scope: cache/plugin-cache purged: ${join(USER_CONFIG_DIR, "cache", "plugin-cache")}`, + `user scope: cache/marketplaces purged: ${join(USER_CONFIG_DIR, "cache", "marketplaces")}`, `user scope: cache/update-check.json purged: ${join(USER_CONFIG_DIR, "cache", "update-check.json")}`, `user scope: cache purged: ${join(USER_CONFIG_DIR, "cache")}`, `user scope: update-check.json purged: ${join(USER_CONFIG_DIR, "update-check.json")}`, @@ -176,6 +179,72 @@ function seedClaudeCache(fs: InMemoryFileAdapter, hostName = "aidd-framework"): } describe("clean --scope user", () => { + it.each([false, true])( + "Copilot cleans past an unclaimed uninstall tombstone, but protects a foreign enabled=%s ref", + async (foreignEnabled) => { + const manifest = Manifest.create(); + manifest.addTool("copilot", "1.0.0", []); + const source = { + kind: "effective-list" as const, + root: "/built/copilot", + sourceType: "local", + source: "/built/copilot", + }; + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework", provenance: source }], + pluginRefs: ["aidd-dev@aidd-framework"], + pluginClaims: [{ ref: "aidd-dev@aidd-framework", dependents: [] }], + }); + const repo = new InMemoryManifestRepository(manifest); + const hostRefs = new Map([ + ["aidd-dev@aidd-framework", { enabled: true }], + ["aidd-pm@aidd-framework", { enabled: foreignEnabled }], + ]); + const activator = new FakeNativePluginActivator({ available: true }); + const clean = new CleanUserScopeUseCase( + new InMemoryFileAdapter(), + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["copilot", activator]]), + new Map(), + () => HOME, + undefined, + undefined, + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "marketplace list", + entries: new Map([["aidd-framework", source]]), + }), + }, + ], + ]), + new Map([ + ["copilot", new FakeHostPluginRegistryReader({ location: "plugins", refs: hostRefs })], + ]) + ); + + const result = clean.execute({ projectRoot: "/project", force: true }); + + if (foreignEnabled) { + await expect(result).rejects.toThrow(/foreign host ref 'aidd-pm@aidd-framework'/); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(repo.getCurrent()).not.toBeNull(); + } else { + await expect(result).resolves.toMatchObject({ dryRun: false }); + expect(activator.uninstalledPlugins).toEqual(["aidd-dev@aidd-framework"]); + expect(activator.removedMarketplaces).toEqual(["aidd-framework"]); + expect(repo.getCurrent()).toBeNull(); + } + } + ); + it("reads canonical dependents after acquiring the machine lock", async () => { const machine = manifestWithClaude(); class LockedRepository extends InMemoryManifestRepository { @@ -195,6 +264,16 @@ describe("clean --scope user", () => { const cacheMarker = join(seedClaudeCache(fs), "marker.json"); const sourceMarker = join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "catalogue.json"); fs.setFile(sourceMarker, "source"); + const fetched = join( + USER_CONFIG_DIR, + "cache", + "marketplaces", + FRAMEWORK_MARKETPLACE_NAME, + "source" + ); + const plugins = join(USER_CONFIG_DIR, "cache", "plugin-cache", "source"); + fs.setFile(fetched, "framework clone"); + fs.setFile(plugins, "plugin clone"); const activator = new FakeNativePluginActivator({ available: true }); const useCase = new CleanUserScopeUseCase( fs, @@ -219,6 +298,8 @@ describe("clean --scope user", () => { expect(activator.removedMarketplaces).toEqual([]); expect(fs.getFile(sourceMarker)).toBe("source"); expect(fs.getFile(cacheMarker)).toBe("{}"); + expect(fs.getFile(fetched)).toBe("framework clone"); + expect(fs.getFile(plugins)).toBe("plugin clone"); expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ { ref: "aidd-context@aidd-framework", dependents: ["/project-b"] }, ]); @@ -611,7 +692,94 @@ describe("clean --scope user", () => { }); describe("whitelist", () => { - it("deletes exactly cache/built, manifest.json, references.json and the aidd-framework marketplaces.json entry — nothing else", async () => { + it("purges fetched framework and plugin caches while preserving another marketplace's source", async () => { + const fs = new RecordingFileAdapter(); + const framework = join(USER_CONFIG_DIR, "cache", "marketplaces", FRAMEWORK_MARKETPLACE_NAME); + const plugins = join(USER_CONFIG_DIR, "cache", "plugin-cache"); + const other = join(USER_CONFIG_DIR, "cache", "marketplaces", "other-marketplace", "source"); + fs.setFile(join(framework, "source"), "framework clone"); + fs.setFile(join(plugins, "source"), "plugin clone"); + fs.setFile(other, "another marketplace's clone"); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/wherever", + Marketplace.create({ + name: "other-marketplace", + source: { kind: "local", path: other }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + registry, + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(await fs.fileExists(framework)).toBe(false); + expect(await fs.fileExists(plugins)).toBe(false); + expect(fs.getFile(other)).toBe("another marketplace's clone"); + expect(registry.getAll("/wherever").map((marketplace) => marketplace.name)).toEqual([ + "other-marketplace", + ]); + }); + + it("removes the cache shell once the fetched framework and plugin caches are gone", async () => { + const fs = new RecordingFileAdapter(); + fs.setFile( + join(USER_CONFIG_DIR, "cache", "marketplaces", FRAMEWORK_MARKETPLACE_NAME, "source"), + "framework clone" + ); + fs.setFile(join(USER_CONFIG_DIR, "cache", "plugin-cache", "source"), "plugin clone"); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(await fs.fileExists(join(USER_CONFIG_DIR, "cache"))).toBe(false); + expect(fs.order).toContain( + `deleteDirectory:${join(USER_CONFIG_DIR, "cache", "marketplaces")}` + ); + expect(fs.order).toContain(`deleteDirectory:${join(USER_CONFIG_DIR, "cache")}`); + }); + + it.each(["cache/marketplaces/aidd-framework", "cache/plugin-cache"])( + "preserves a fetched source resolving outside user config through %s", + async (relativePath) => { + const fs = new RecordingFileAdapter(); + const cache = join(USER_CONFIG_DIR, relativePath); + const marker = join(cache, "source"); + fs.setFile(marker, "external clone"); + fs.setSymlink(cache, "/elsewhere/clone"); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.getFile(marker)).toBe("external clone"); + expect(fs.order).not.toContain(`deleteDirectory:${cache}`); + expect(logger.warnMessages).toContain( + `user scope: ${relativePath} does not resolve inside ${USER_CONFIG_DIR}; left in place: ${cache}` + ); + } + ); + + it("removes framework state and keeps other marketplace registrations", async () => { const fs = new RecordingFileAdapter(); fs.setFile( join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "aidd-framework", "claude", "x"), @@ -1799,6 +1967,9 @@ describe("clean --scope user", () => { expect(fs.has(join(cacheDir, "built", "1.0.0", "aidd-framework", "x"))).toBe(true); expect(logger.warnMessages).toStrictEqual([ `user scope: cache/built does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "built")}`, + `user scope: cache/marketplaces/aidd-framework does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "marketplaces", FRAMEWORK_MARKETPLACE_NAME)}`, + `user scope: cache/plugin-cache does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "plugin-cache")}`, + `user scope: cache/marketplaces does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "marketplaces")}`, `user scope: cache/update-check.json does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "update-check.json")}`, `user scope: cache does not resolve inside ${USER_CONFIG_DIR}; left in place: ${cacheDir}`, ]); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-global-plugins.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-global-plugins.integration.test.ts new file mode 100644 index 000000000..e93f835d5 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-global-plugins.integration.test.ts @@ -0,0 +1,124 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { HostPluginRegistryEntry } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const REF = "test-plugin@aidd-framework"; + +async function globalActivation(toolId: "claude" | "copilot") { + const refs = new Map(); + class StatefulActivator extends FakeNativePluginActivator { + override enablePlugin(ref: string, scope: MarketplaceScope = "project"): void { + super.enablePlugin(ref, scope); + refs.set(ref, { enabled: true, scope }); + } + } + const activator = new StatefulActivator({ available: true }); + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + const userRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/project", + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/source" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const builtDir = `/built/${toolId}`; + const catalogDir = toolId === "claude" ? ".claude-plugin" : ".plugin"; + const fs = new InMemoryFileAdapter({ + [`${builtDir}/${catalogDir}/marketplace.json`]: JSON.stringify({ + name: "aidd-framework", + plugins: [{ name: "test-plugin" }], + }), + }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([[toolId, activator]]), + fakeEnsureBuiltMarketplace(), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ location: "catalogues", entries: new Map() }), + ], + ]), + () => "", + undefined, + undefined, + undefined, + new Map([[toolId, new FakeHostPluginRegistryReader({ location: "plugins", refs })]]), + userRepo, + new Map([ + [ + toolId, + new FakeNativeMarketplaceSourceReader( + activator, + toolId === "claude" ? "registry" : "effective-list", + (path) => (path === builtDir ? "aidd-framework" : undefined), + new Map() + ), + ], + ]) + ); + const options = { projectRoot: "/project", scope: "user" as const, manifestRepo: userRepo }; + const setup = await sync.execute({ ...options, enablePlugins: ["test-plugin"] }); + expect(setup.errors).toEqual([]); + expect(setup.warnings).toEqual([]); + expect(userRepo.getCurrent()?.getPlugins(toolId)).toEqual([]); + expect(userRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([REF]); + return { refs, activator, userRepo, sync, options }; +} + +describe.each(["claude", "copilot"] as const)("global %s native plugins", (toolId) => { + it.each(["enabled", "missing", "disabled", "claims only"] as const)( + "sync retains and restores a successful selection when the ref is %s", + async (state) => { + const { refs, activator, userRepo, sync, options } = await globalActivation(toolId); + if (state === "missing") refs.delete(REF); + if (state === "disabled") refs.set(REF, { enabled: false, scope: "user" }); + if (state === "claims only") { + const manifest = await userRepo.load(); + const registrations = manifest?.getNativeRegistrations(toolId); + if (manifest === null || registrations === undefined) + throw new Error("missing setup record"); + manifest.setNativeRegistrations(toolId, { ...registrations, pluginRefs: [] }); + await userRepo.save(manifest); + refs.delete(REF); + } + + const result = await sync.execute(options); + + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(refs.get(REF)?.enabled).toBe(true); + expect(userRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([REF]); + expect(userRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect(activator.enabledPluginScopes).toEqual( + state === "enabled" ? ["user"] : ["user", "user"] + ); + } + ); +}); diff --git a/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts index 3aefe2ad1..a4018c3b8 100644 --- a/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts +++ b/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts @@ -122,8 +122,8 @@ describe("assertGlobalFilesWritable", () => { ).rejects.toThrow(`opencode: 'aidd-dev' parent '${under("agents")}' escapes ${DIR}.`); }); - it("refuses to overwrite a file it does not own that holds other bytes", async () => { - const fs = new InMemoryFileAdapter({ [under("agents", "a.md")]: "mine" }, hasher); + it.each(["mine", "new"])("refuses an unowned file holding %s bytes", async (content) => { + const fs = new InMemoryFileAdapter({ [under("agents", "a.md")]: content }, hasher); await expect( assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) @@ -132,11 +132,8 @@ describe("assertGlobalFilesWritable", () => { ); }); - it("adopts a file already holding the same bytes, and rewrites a file the plugin owns", async () => { - const fs = new InMemoryFileAdapter( - { [under("agents", "same.md")]: "new", [under("agents", "owned.md")]: "old" }, - hasher - ); + it("allows rewriting a file the plugin owns", async () => { + const fs = new InMemoryFileAdapter({ [under("agents", "owned.md")]: "old" }, hasher); const owned = InstalledPlugin.fromMetadata( "aidd-dev", "1.0.0", @@ -146,14 +143,7 @@ describe("assertGlobalFilesWritable", () => { ).withFiles(new Map([["agents/owned.md", "0".repeat(32)]])); await expect( - assertGlobalFilesWritable( - fs, - "opencode", - owned, - "aidd-dev", - [file("agents/same.md"), file("agents/owned.md")], - HOME - ) + assertGlobalFilesWritable(fs, "opencode", owned, "aidd-dev", [file("agents/owned.md")], HOME) ).resolves.toBe(DIR); }); }); diff --git a/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts b/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts index 64e82c173..6dfb9d01f 100644 --- a/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts +++ b/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { assertNoForeignNativeRefs, inspectNativeMarketplaceSource, @@ -29,6 +32,22 @@ function reader( } describe("current native marketplace source proof", () => { + it.each(["claude", "codex", "copilot"] as const)( + "%s protects a foreign disabled ref unless uninstall leaves that tombstone", + async (toolId) => { + const refs = new Map([["foreign@real-catalog", { enabled: false }]]); + const result = assertNoForeignNativeRefs( + { read: async () => ({ location: "host plugins", refs }) }, + "real-catalog", + new Set(), + "/project-b", + toolId + ); + if (toolId === "copilot") await expect(result).resolves.toEqual(refs); + else await expect(result).rejects.toThrow(/foreign host ref/); + } + ); + it.each([new Error("source permission denied"), "source permission denied"])( "reports host source read failures as unproven without throwing", async (error) => { @@ -80,21 +99,28 @@ describe("current native marketplace source proof", () => { }, }; await expect( - assertNoForeignNativeRefs(host, "real-catalog", new Set(["mine@real-catalog"]), "/project-b") + assertNoForeignNativeRefs( + host, + "real-catalog", + new Set(["mine@real-catalog"]), + "/project-b", + "claude" + ) ).rejects.toThrow(/foreign host ref 'foreign@real-catalog'/); expect(requestedRoot).toBe("/project-b"); }); it("fails closed on unreadable or missing host plugin readers, but accepts explicit absence", async () => { await expect( - assertNoForeignNativeRefs(undefined, "real-catalog", new Set(), "/project-b") + assertNoForeignNativeRefs(undefined, "real-catalog", new Set(), "/project-b", "claude") ).rejects.toThrow(/reader unavailable/); await expect( assertNoForeignNativeRefs( { read: async () => ({ location: "host plugins", unreadable: "EACCES" }) }, "real-catalog", new Set(), - "/project-b" + "/project-b", + "claude" ) ).rejects.toThrow(/EACCES/); await expect( @@ -106,7 +132,8 @@ describe("current native marketplace source proof", () => { }, "real-catalog", new Set(), - "/project-b" + "/project-b", + "claude" ) ).rejects.toThrow(/real-catalog.*host plugin registry read failed.*EACCES/); expect( @@ -114,7 +141,8 @@ describe("current native marketplace source proof", () => { { read: async () => ({ location: "host plugins", absent: true }) }, "real-catalog", new Set(), - "/project-b" + "/project-b", + "claude" ) ).toEqual(new Map()); }); @@ -129,7 +157,8 @@ describe("current native marketplace source proof", () => { { read: async () => ({ location: "host plugins", refs }) }, "real-catalog", new Set(["mine@real-catalog"]), - "/project-b" + "/project-b", + "claude" ) ).toEqual(refs); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts index 222f835cc..c820e1ddf 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts @@ -598,7 +598,7 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]).toEqual(machineBefore); }); - it("takes over a new built file an interrupted run already wrote, byte for byte", async () => { + it("refuses an untracked identical file without changing its bytes or claiming it", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "cursor"); const registry = await makeRegistry(); @@ -607,15 +607,19 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); deps.fs.setFile(join(USER_PLUGINS_DIR, newRelativePath), "# Built v2\n"); - await makeUpdateUseCase(deps, registry).execute({ - toolIds: ["cursor"], - projectRoot: PROJECT_ROOT, - scope: "user", - }); + const before = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + const savesBefore = deps.userManifestRepo.saveCount; + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/new.md.*untracked|untracked.*new.md/); - expect( - deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.has(newRelativePath) - ).toBe(true); + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, newRelativePath))).toBe("# Built v2\n"); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]).toEqual(before); }); it("preflights a Cursor user-file collision before any selected native host update", async () => { diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index 8e3eadd20..1d9044348 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; -import { readdir, readFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; @@ -74,6 +74,43 @@ describe("E2E: setup --scope user writes nothing under the project", () => { } }); + it("preserves a pre-existing identical agent through refused setup and global clean", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-unowned-agent"); + try { + const args = [ + "setup", + "--global", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ide", + "opencode", + "--plugins", + "aidd-dev", + "--yes", + ]; + expect((await runCli(args, projectDir, fakeHome)).exitCode).toBe(0); + const agent = join(fakeHome, ".config", "opencode", "agents", "aidd-dev-reviewer.md"); + const content = await readFile(agent, "utf-8"); + expect((await runCli(["clean", "--global", "--force"], projectDir, fakeHome)).exitCode).toBe( + 0 + ); + await mkdir(dirname(agent), { recursive: true }); + await writeFile(agent, content); + + const refused = await runCli(args, projectDir, fakeHome); + expect(refused.exitCode).toBe(1); + expect(refused.stderr).toContain("already exists and was not installed by aidd"); + expect((await runCli(["clean", "--global", "--force"], projectDir, fakeHome)).exitCode).toBe( + 0 + ); + expect(await readFile(agent, "utf-8")).toBe(content); + } finally { + await cleanup(); + } + }); + it("explains user-scope restrictions in setup help", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-help"); try { diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index d18f46ccf..2156e3d77 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -282,6 +282,62 @@ if (args[0] !== "setup") process.exit(2); ${setupBody}`); } +for (const localBinary of [false, true]) { + test(`managed OpenCode reload uses its helper ${localBinary ? "instead of the local binary" : "without a local binary or build"}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "aidd-opencode-reload"), 'printf \'%s\\n\' "$*" > "$HOME/managed-call"; echo managed:ok'); + if (localBinary) executable(join(fakeBin, "opencode"), "exit 99"); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(home, "managed-call"), "utf8"), "\n"); + assert.match(result.stdout, /reload opencode\s+managed:ok/); + assert.doesNotMatch(result.stdout, /global install|nothing to install/); + assert.match(readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"), /Use available agents when useful/); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +test("managed OpenCode reload leaves other hosts on the global install path", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-mixed-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const source = fixture(home); + recordingCli(source); + executable(join(fakeBin, "codex"), "exit 0"); + executable(join(fakeBin, "opencode"), "exit 99"); + executable(join(fakeBin, "aidd-opencode-reload"), 'echo managed:ok'); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /global install\s+ok \(codex\)/); + assert.match(result.stdout, /reload opencode\s+managed:ok/); + assert.match(readFileSync(join(home, "aidd-calls"), "utf8"), / --ide codex --plugins all --yes\n$/); + assert.match(readFileSync(join(home, ".codex/AGENTS.md"), "utf8"), /Use available agents when useful/); + assert.match(readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"), /Use available agents when useful/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("a failed managed OpenCode reload stops before changing other hosts or instructions", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-failed-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + recordingCli(fixture(home)); + executable(join(fakeBin, "codex"), 'echo changed > "$HOME/host-calls"'); + executable(join(fakeBin, "aidd-opencode-reload"), 'echo "managed source refused" >&2; exit 7'); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 7, result.stderr); + assert.match(result.stderr, /managed source refused/); + assert.doesNotMatch(result.stdout, /Done\.|global instructions|nothing to install/); + assert.equal(existsSync(join(home, "host-calls")), false); + assert.equal(existsSync(join(home, "aidd-calls")), false); + assert.equal(existsSync(join(home, ".config/opencode/AGENTS.md")), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("every host takes this checkout through aidd setup --global, afresh", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-")); try { diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index e14d3b8d7..0cc0561eb 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -2,6 +2,7 @@ # (Re)install every plugin into Claude, Codex, OpenCode, Kilo, Copilot and Cursor from THIS # checkout, the way a user does: `aidd setup --global` with this checkout as the framework # source, so the reload exercises the shipped machine-wide install. +# A managed OpenCode host delegates to its fixed-purpose reload helper instead. # # NOT live: the install copies built files, so an edit needs a re-run. Idempotent; a tool # whose CLI is absent is skipped, and the first run needs network. @@ -41,6 +42,7 @@ CURSOR_INSTRUCTIONS="${CURSOR_INSTRUCTIONS:-$HOME/.cursor/rules/aidd-agents.mdc} HAVE_CODEX=0; command -v codex >/dev/null 2>&1 && HAVE_CODEX=1 HAVE_CLAUDE=0; command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=1 HAVE_OPENCODE=0; command -v opencode >/dev/null 2>&1 && HAVE_OPENCODE=1 +HAVE_MANAGED_OPENCODE=0; command -v aidd-opencode-reload >/dev/null 2>&1 && HAVE_MANAGED_OPENCODE=1 HAVE_KILO=0; command -v kilo >/dev/null 2>&1 && HAVE_KILO=1 HAVE_COPILOT=0; command -v copilot >/dev/null 2>&1 && HAVE_COPILOT=1 HAVE_CURSOR=0 @@ -96,8 +98,8 @@ hosts=() [ "$HAVE_COPILOT" = 0 ] || hosts+=(copilot) [ "$HAVE_CURSOR" = 0 ] || hosts+=(cursor) [ "$HAVE_KILO" = 0 ] || hosts+=(kilo) -[ "$HAVE_OPENCODE" = 0 ] || hosts+=(opencode) -if [ "${#hosts[@]}" -eq 0 ]; then +[ "$HAVE_OPENCODE" = 0 ] || [ "$HAVE_MANAGED_OPENCODE" = 1 ] || hosts+=(opencode) +if [ "${#hosts[@]}" -eq 0 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then echo "No supported host installation found - nothing to install."; exit 0 fi @@ -106,23 +108,32 @@ for d in "$FW"/plugins/*/; do [ -f "$d.claude-plugin/plugin.json" ] && targets+=("$(basename "$d")") done -printf '%-22s' "global install" -release_native_plugins -# From HOME, not this checkout: its own .claude/settings.json enables the plugins at project -# scope, which the host CLIs report and setup --global would refuse as refs it does not own. -output="$(cd "$HOME" && aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ - --plugins all --yes 2>&1)" || { - echo "FAIL"; printf '%s\n' "$output" >&2 - echo "aidd setup --global failed for ${hosts[*]}; their plugins were released, so rerun make reload once fixed." >&2; exit 1 -} -echo "ok ($(IFS=,; echo "${hosts[*]}"))" -# A warning leaves setup at exit 0: what it skipped must stay visible. -printf '%s\n' "$output" | sed 's/^/ /' +if [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then + printf '%-22s' "reload opencode" + aidd-opencode-reload +fi + +if [ "${#hosts[@]}" -gt 0 ]; then + printf '%-22s' "global install" + release_native_plugins + # From HOME, not this checkout: its own .claude/settings.json enables the plugins at project + # scope, which the host CLIs report and setup --global would refuse as refs it does not own. + output="$(cd "$HOME" && aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ + --plugins all --yes 2>&1)" || { + echo "FAIL"; printf '%s\n' "$output" >&2 + echo "aidd setup --global failed for ${hosts[*]}; their plugins were released, so rerun make reload once fixed." >&2; exit 1 + } + echo "ok ($(IFS=,; echo "${hosts[*]}"))" + # A warning leaves setup at exit 0: what it skipped must stay visible. + printf '%s\n' "$output" | sed 's/^/ /' +fi instructions=() [ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") [ "$HAVE_CLAUDE" = 0 ] || instructions+=(claude "$CLAUDE_INSTRUCTIONS") -[ "$HAVE_OPENCODE" = 0 ] || instructions+=(opencode "$OPENCODE_INSTRUCTIONS") +if [ "$HAVE_OPENCODE" = 1 ] || [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then + instructions+=(opencode "$OPENCODE_INSTRUCTIONS") +fi [ "$HAVE_COPILOT" = 0 ] || instructions+=(copilot "$COPILOT_INSTRUCTIONS") [ "$HAVE_CURSOR" = 0 ] || instructions+=(cursor "$CURSOR_INSTRUCTIONS") if [ "${#instructions[@]}" -gt 0 ]; then