diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 70c42f4db..1b00ccfe4 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -87,6 +87,15 @@
"metadata": {
"recommended": false
}
+ },
+ {
+ "name": "aidd-maintenance",
+ "source": "./plugins/aidd-maintenance",
+ "description": "Framework maintenance: show the installed marketplace version and update available installations.",
+ "strict": true,
+ "metadata": {
+ "recommended": false
+ }
}
]
}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 07278c741..188a167cb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -258,6 +258,7 @@ jobs:
aidd-ui,
aidd-telemetry,
aidd-qa,
+ aidd-maintenance,
]
steps:
- name: Check if this plugin was released
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 2335bfd65..9b75dcf1d 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -9,5 +9,6 @@
"plugins/aidd-ui": "0.2.1-alpha.0",
"plugins/aidd-telemetry": "0.4.0",
"plugins/aidd-qa": "1.0.0",
+ "plugins/aidd-maintenance": "0.1.0",
"cli": "5.6.0"
}
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 24aa053b7..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
@@ -15,6 +15,9 @@ 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
+
+check-hosts: ## Dev: table what each installed tool really loads of the plugins
+ node scripts/check-hosts.cjs
diff --git a/README.md b/README.md
index 3ea77aa35..baf642f17 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Unify **engineering teams** around **standardized workflows** and **shared best
🧱 **IDE agnostic** · 🏗️ **Legacy systems** · 🌱 **Token-optimized** · 🇫🇷 **Made in France**
- 9 plugins · 52 skills · 4 agents
+ 10 plugins · 53 skills · 4 agents
[](https://opensource.org/)
@@ -207,6 +207,18 @@ Acceptance QA with video evidence
+
+
+
+
+🔄 aidd-maintenance
+1 skill · 1 hook
+Framework version and updates
+
+ |
+ |
+ |
+
diff --git a/aidd_docs/memory/architecture.md b/aidd_docs/memory/architecture.md
index e8ae0d0aa..7ded30853 100644
--- a/aidd_docs/memory/architecture.md
+++ b/aidd_docs/memory/architecture.md
@@ -10,13 +10,13 @@ The macro technical shape: the stack, how the pieces fit, and the decisions behi
| --- | --- |
| Product | markdown — skills, agents, rules, templates. No framework runtime; an LLM interprets them. |
| Delivery | Node `>=22.12`, pnpm. `cli/` is the `aidd` binary; `kanban/` is a private package. |
-| Manifest | `.claude-plugin/marketplace.json`, the plugin manifest: 9 plugins, no version among them. Versions are release-please's, in `deployment.md`. |
+| Manifest | `.claude-plugin/marketplace.json`, the plugin manifest: 10 plugins, no version among them. Versions are release-please's, in `deployment.md`. |
## How it fits together
```mermaid
flowchart LR
- Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 9"]
+ Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 10"]
Plugins -->|ships| Surfaces["skills · agents · commands · hooks · rules"]
CLI["cli/ · aidd"] -->|reads| Manifest
CLI -->|installs| Target["a project's AI tool dir"]
@@ -40,6 +40,6 @@ The concern-to-plugin taxonomy is canonical in [`docs/ARCHITECTURE.md`](../../do
## Gotchas
-- 9 plugins ship, 3 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in, `aidd-qa` new and unproven outside this repository.
+- 10 plugins ship, 3 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in, `aidd-qa` new and unproven outside this repository.
- A skill never links outside itself: the tree ships both flat and as a marketplace, so no relative path survives both.
- Bundled hooks run Node. No `node` on `PATH`, no memory refresh and no question about the task.
diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md
index 89190d715..4b95a2a54 100644
--- a/aidd_docs/memory/deployment.md
+++ b/aidd_docs/memory/deployment.md
@@ -58,7 +58,7 @@ flowchart TD
- The umbrella stays a draft until every asset is attached; its publication is the event n8n listens to.
- A failed video build keeps the release a draft, so nothing is announced without its video. A failed YouTube upload does not stop Discord and Skool: they post without the video link. Their text is the video's programme, one section per theme, so the announcements say what the video shows.
-Config: `release-please-config.json`, eleven packages. Manifest: `.release-please-manifest.json`.
+Config: `release-please-config.json`, twelve packages. Manifest: `.release-please-manifest.json`.
## Gotchas
@@ -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 a0d93a103..1c4de4420 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -25,13 +25,19 @@ 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 |
+| `aidd setup --global --ide opencode --plugins all` | `~/.config/opencode/` and `~/.config/aidd/`, nothing in this project |
## Commands
@@ -46,13 +52,14 @@ Every flag: `aidd --help`, then a group's own `--help`.
| `aidd sync` | Rewrite owned files from the manifest, then re-drive native activation |
| `aidd clean` | Remove every AIDD-managed file from the project |
| `aidd update` | Update the CLI itself (alias `upgrade`) |
+| `aidd run opencode -- ` / `aidd run kilo -- ` | Run headless with the installed maintenance notice on stderr |
### Framework
| Command | Does |
| --- | --- |
| `aidd framework install` | Write one tool's runtime configuration from the bundled assets |
-| `aidd framework update` | Move installed tools to this CLI's assets (all of them without `--tool`) |
+| `aidd framework update` | Update tool configs from this CLI; `--release ` updates project plugins after confirmation, or global flat copies with `--scope user` |
| `aidd framework remove` | Delete the generated configuration files of one tool |
| `aidd framework rules` | List the rules installed across every AI tool (`--json` for a machine) |
@@ -101,31 +108,39 @@ 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`, `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 --scope user`.
+Install it, then rerun the original `aidd setup --global` command with its `--ide` and `--plugins` selections.
```sh
-aidd setup --scope user --ai claude,codex,copilot --plugins none --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 --force # remove what --global installed and purge the shared source; without --force it previews or asks
```
## Authentication
@@ -137,6 +152,8 @@ Needed for a private marketplace only.
| `stored` | The token itself | `--token ` |
| `external` | The provider name. The token is asked from it at fetch time | `--gh` |
+For Git clones, AIDD sends these tokens only to `github.com`. Configure a [Git credential helper](https://git-scm.com/docs/gitcredentials) for GitLab, Bitbucket or other hosts (documentation read 2026-11-06).
+
The token resolves from the first hit:
1. `AIDD_TOKEN`
@@ -175,7 +192,7 @@ The token resolves from the first hit:
| Variable | Effect |
| --- | --- |
-| `AIDD_TOKEN` | Token for every fetch, ahead of any stored credential |
+| `AIDD_TOKEN` | GitHub token, ahead of any stored credential |
| `AIDD_USER_CONFIG_DIR` | Relocates the user config directory, credentials included |
| `XDG_CONFIG_HOME` | Names the config root when `AIDD_USER_CONFIG_DIR` is unset |
| `AIDD_SKIP_UPDATE_CHECK` | `1` skips the self-update check before a command |
@@ -201,7 +218,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/architecture.md b/cli/aidd_docs/memory/architecture.md
index 091081633..a8447bb59 100644
--- a/cli/aidd_docs/memory/architecture.md
+++ b/cli/aidd_docs/memory/architecture.md
@@ -51,3 +51,4 @@ flowchart LR
- Configs are inlined at build time, schemas are not: five JSON files ship beside the binary. Drop them from `files` and the CLI breaks.
- The build empties its output directory; `AIDD_BUILD_OUT_DIR` accepts two shapes only.
- `git` exports `GIT_*` into everything it spawns. Strip them before reading a repository.
+- User-lock recovery requires the same boot and process namespace. References read 2026-11-05: [Linux kernel identity](https://kernel.org/doc/html/v6.6/admin-guide/sysctl/kernel.html), [Darwin boot session](https://github.com/apple/darwin-xnu/blob/main/iokit/IOKit/pwr_mgt/IOPM.h). Windows recovery is not verified and refuses to reap an owner.
diff --git a/cli/aidd_docs/memory/cli.md b/cli/aidd_docs/memory/cli.md
index 600aebf2e..0d269d157 100644
--- a/cli/aidd_docs/memory/cli.md
+++ b/cli/aidd_docs/memory/cli.md
@@ -8,6 +8,7 @@ Thirty leaf commands. Read them live: `aidd --help`, then each group's. `scripts
- `setup`, `doctor`, `sync`, `clean`: bring a project to a correct state, keep it there.
- `framework install | update | remove | rules`, chosen by `--tool `.
+- `framework update --release ` lists project plugin/tool pairs and asks before updating their source; `--yes` accepts non-interactively, and `--allow-shared` separately approves a shared marketplace.
- `plugin install | list | remove | search | update`.
- `marketplace add | list | remove | refresh | check`.
- `auth login | logout | status`.
diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md
index 752a48546..4f83c9c3f 100644
--- a/cli/aidd_docs/memory/codebase-map.md
+++ b/cli/aidd_docs/memory/codebase-map.md
@@ -22,6 +22,8 @@ src/
│ │ │ ├── install/
│ │ │ │ └── content/
│ │ │ ├── ownership/ # machine plugin claims and safe project detach
+│ │ │ │ ├── release/ # recoverable machine release updates
+│ │ │ │ └── tui/ # owned startup TUI configuration
│ │ │ ├── plugin/
│ │ │ ├── recipes/
│ │ │ ├── restore/
@@ -91,7 +93,8 @@ src/
│ ├── ports/ # a port two or more contexts both need
│ └── reading/ # getting at a file's location and its content safely
├── presentation/ # everything that talks to a human — depends on contexts, never the reverse
-│ ├── commands/ # one file per command, wiring only
+│ ├── commands/ # command wiring
+│ │ └── run/ # headless tool launcher
│ ├── display/ # rendering a result
│ │ └── telemetry/ # the telemetry report, which outgrew one file beside the others
│ └── prompts/ # asking the user; the decision stays in the context
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..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
@@ -47,15 +47,16 @@ 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.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.
- 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`); 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/aidd_docs/memory/testing.md b/cli/aidd_docs/memory/testing.md
index 227bd6445..e638e049f 100644
--- a/cli/aidd_docs/memory/testing.md
+++ b/cli/aidd_docs/memory/testing.md
@@ -22,7 +22,7 @@ How this package is tested: the layers, the tools, the conventions.
- Doubles from `tests/helpers/ports/`. Substitute at the seam; never mock functional behaviour.
- Fixtures in `tests/fixtures/`, read-only, excluded from JSON and link checks. Mutate a copy in a temp directory.
- `.gitattributes`'s `tests/fixtures/** text eol=lf` keeps byte-for-byte comparisons (`manifest-round-trip.unit.test.ts`) passing on Windows.
-- A temp directory: `await mkdtemp(join(tmpdir(), "aidd--"))`, never a fixed name (a plantable symlink). The prefix is what `scripts/sweep-stale-test-dirs.cjs` reclaims.
+- A temp directory: `await mkdtemp(join(tmpdir(), "aidd--"))`, never a fixed name (a plantable symlink). Clean up only the directory the test created.
- A test name is a phrase of observable behaviour; nested `describe` reads as a sentence. `E2E:` is a recurring legacy prefix, not one to copy.
- A golden snapshot is machine-independent, never derived from an absolute path: [`golden-machine-independence.md`](../../.claude/skills/test/references/golden-machine-independence.md).
- `describe.concurrent` appears in no unit or integration file; in e2e the golden and command-matrix suites use it. An observed split, not a rule.
diff --git a/cli/package.json b/cli/package.json
index 58d2105c7..98cc2a640 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -36,7 +36,7 @@
"node": ">=22.12"
},
"packageManager": "pnpm@12.16.1",
- "bundleBudgetKB": 722,
+ "bundleBudgetKB": 826,
"scripts": {
"build": "tsup && node scripts/check-bundle-size.mjs",
"build:check-size": "node scripts/check-bundle-size.mjs",
@@ -86,6 +86,8 @@
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"commander": "^15.0.0",
+ "cross-spawn": "7.0.6",
+ "jsonc-parser": "^3.3.1",
"simple-git": "^4.0.2",
"smol-toml": "^1.6.1"
},
@@ -95,6 +97,7 @@
"@commitlint/config-conventional": "^21.2.0",
"@stryker-mutator/core": "^10.0.0",
"@stryker-mutator/vitest-runner": "^10.0.0",
+ "@types/cross-spawn": "6.0.6",
"@types/node": "^26.1.1",
"@vitest/coverage-v8": "^5.0.0",
"fast-check": "^4.7.0",
diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml
index 1a3402328..43ff0fe24 100644
--- a/cli/pnpm-lock.yaml
+++ b/cli/pnpm-lock.yaml
@@ -193,6 +193,12 @@ importers:
commander:
specifier: ^15.0.0
version: 15.0.0
+ cross-spawn:
+ specifier: 7.0.6
+ version: 7.0.6
+ jsonc-parser:
+ specifier: ^3.3.1
+ version: 3.3.1
simple-git:
specifier: ^4.0.2
version: 4.0.2(supports-color@7.2.0)
@@ -215,6 +221,9 @@ importers:
'@stryker-mutator/vitest-runner':
specifier: ^10.0.0
version: 10.0.0(patch_hash=9e824f46a81c33f801b2120a27ca4232020bd90800df64696bdec8980afc567f)(@stryker-mutator/core@10.0.0(@types/node@26.6.5))(vitest@5.0.5)
+ '@types/cross-spawn':
+ specifier: 6.0.6
+ version: 6.0.6
'@types/node':
specifier: ^26.1.1
version: 26.6.5
@@ -232,13 +241,13 @@ importers:
version: 6.41.0
tsup:
specifier: ^9.0.0
- version: 9.0.0(jiti@2.7.0)(postcss@8.5.28)(typescript@7.0.2)(yaml@2.9.0)
+ version: 9.0.0(jiti@2.7.0)(postcss@8.5.28)(typescript@7.0.2)(yaml@2.9.1)
typescript:
specifier: ^7.0.2
version: 7.0.2
vitest:
specifier: ^5.0.0
- version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
+ version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
packages:
@@ -1585,6 +1594,9 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+ '@types/cross-spawn@6.0.6':
+ resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==}
+
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -2234,6 +2246,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ jsonc-parser@3.3.1:
+ resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
+
knip@6.41.0:
resolution: {integrity: sha512-DrhQlIuV6xScd5Q+WTF1znd1w41QzxFGCtAHXCajsH/ndGMotHoNYbdXbuFtzW3mIOmniHtZ7zCtvnQkUmJtQQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2815,11 +2830,6 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
- yaml@2.9.0:
- resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
- engines: {node: '>= 14.6'}
- hasBin: true
-
yaml@2.9.1:
resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==}
engines: {node: '>= 14.6'}
@@ -3893,7 +3903,7 @@ snapshots:
'@stryker-mutator/util': 10.0.0
semver: 7.8.5
tslib: 2.8.1
- vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
+ vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
'@tybys/wasm-util@0.10.3':
dependencies:
@@ -3905,6 +3915,10 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
+ '@types/cross-spawn@6.0.6':
+ dependencies:
+ '@types/node': 26.6.5
+
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
@@ -3989,7 +4003,7 @@ snapshots:
obug: 3.0.0
std-env: 4.3.0
tinyrainbow: 3.2.0
- vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
+ vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
'@vitest/istanbul-lib-coverage@1.0.2': {}
@@ -3997,14 +4011,14 @@ snapshots:
dependencies:
'@vitest/istanbul-lib-coverage': 1.0.2
- '@vitest/mocker@5.0.5(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))':
+ '@vitest/mocker@5.0.5(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.5
estree-walker: 3.0.3
magic-string: 1.4.3
optionalDependencies:
- vite: 8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)
+ vite: 8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)
'@vitest/spy@5.0.5': {}
@@ -4424,6 +4438,8 @@ snapshots:
json5@2.2.3: {}
+ jsonc-parser@3.3.1: {}
+
knip@6.41.0:
dependencies:
formatly: 0.11.4
@@ -4643,13 +4659,13 @@ snapshots:
mlly: 1.8.0
pathe: 2.0.3
- postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.0):
+ postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.1):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 2.7.0
postcss: 8.5.28
- yaml: 2.9.0
+ yaml: 2.9.1
postcss@8.5.28:
dependencies:
@@ -4887,7 +4903,7 @@ snapshots:
tslib@2.8.1: {}
- tsup@9.0.0(jiti@2.7.0)(postcss@8.5.28)(typescript@7.0.2)(yaml@2.9.0):
+ tsup@9.0.0(jiti@2.7.0)(postcss@8.5.28)(typescript@7.0.2)(yaml@2.9.1):
dependencies:
bundle-require: 5.1.0(esbuild@0.28.2)
cac: 7.1.0
@@ -4898,7 +4914,7 @@ snapshots:
joycon: 3.1.1
obug: 3.0.0
picocolors: 1.1.1
- postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.0)
+ postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.1)
resolve-from: 5.0.0
rollup: 4.64.7
source-map-js: 1.2.2
@@ -4967,7 +4983,7 @@ snapshots:
verkit@0.3.2: {}
- vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0):
+ vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -4979,12 +4995,12 @@ snapshots:
esbuild: 0.28.2
fsevents: 2.3.3
jiti: 2.7.0
- yaml: 2.9.0
+ yaml: 2.9.1
- vitest@5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)):
+ vitest@5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
- '@vitest/mocker': 5.0.5(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
+ '@vitest/mocker': 5.0.5(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -4995,7 +5011,7 @@ snapshots:
tinybench: 6.2.2
tinyexec: 1.3.1
tinyglobby: 0.2.17
- vite: 8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)
+ vite: 8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 26.6.5
@@ -5021,9 +5037,6 @@ snapshots:
y18n@5.0.8: {}
- yaml@2.9.0:
- optional: true
-
yaml@2.9.1: {}
yargs-parser@22.0.0: {}
diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs
index a9deb0584..33bce2da5 100644
--- a/cli/scripts/check-bundle-size.mjs
+++ b/cli/scripts/check-bundle-size.mjs
@@ -55,6 +55,21 @@ 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.
+// Maintenance branch before integration with Antigravity:
+// 734 KB: 718.8 KB, the machine-wide install of `setup --global` (+8.0 KB over 710.8 KB);
+// 2.1% measured headroom.
+// 730 KB: 721.5 KB, maintenance release upgrades and session notifications; 1.2% headroom.
+// 770 KB: 755.6 KB, global ownership, maintenance launchers and Kilo TUI; 1.9% headroom.
+// 793 KB: 777.5 KB, pinned machine releases and recoverable file updates; 2.0% headroom.
+// 796 KB: 794.9 KB, shared Claude update preview and source revalidation; 0.1% headroom.
+// 798 KB: 797.3 KB, release titles and recorded global installations; 0.1% headroom.
+// 801 KB: 799.4 KB, authenticated Git clones without credentials in remotes or logs; 0.2% headroom.
+// 826 KB: 810.3 KB, combined Antigravity support and maintenance; 1.9% measured headroom.
+// 826 KB: 811.3 KB, project hook directory permission preflight; 1.8% measured headroom.
+// 826 KB: 811.4 KB, handling a hook closing stdin after success; 1.8% measured headroom.
+// 826 KB: 811.7 KB, safe headless hook failure diagnostics; 1.7% measured headroom.
const budgetKB = pkg.bundleBudgetKB ?? 500;
const budgetBytes = budgetKB * 1024;
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/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh
index fe1cb498c..88075dc01 100755
--- a/cli/scripts/smoke-tools.sh
+++ b/cli/scripts/smoke-tools.sh
@@ -22,7 +22,7 @@ IDE_TOOLS=(vscode)
# Canonical leaf-command surface. Coverage = exercised / total.
ALL_COMMANDS=(
- "setup" "doctor" "sync" "translate" "update" "clean"
+ "setup" "doctor" "sync" "translate" "update" "clean" "run"
"framework install" "framework update" "framework remove" "framework rules"
"framework validate-recipes"
"plugin remove" "plugin list" "plugin install" "plugin search" "plugin update"
@@ -232,6 +232,7 @@ if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "update"; ok "update --ch
# Compare exact file paths and contents before/after: `--dry-run` must write nothing.
P_DRY=$(new_project)
(cd "$P_DRY" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1)
+run "run rejects unsupported tools" 1 "Supported tools" "$P_DRY" -- run unsupported
before_dry=$(project_tree_hash "$P_DRY"); before_dry_rc=$?
run "update --dry-run" "0|1" "" "$P_DRY" -- update --dry-run
after_dry=$(project_tree_hash "$P_DRY"); after_dry_rc=$?
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index 3c2ce3bb3..f231e1696 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -7,6 +7,7 @@ import { registerFrameworkCommand } from "./presentation/commands/framework.js";
import { registerMarketplaceCommand } from "./presentation/commands/marketplace.js";
import { runMenuLoop } from "./presentation/commands/menu.js";
import { registerPluginCommand } from "./presentation/commands/plugin.js";
+import { registerRunCommand } from "./presentation/commands/run/command.js";
import { registerSetupCommand } from "./presentation/commands/setup.js";
import { registerSyncCommand } from "./presentation/commands/sync.js";
import { registerTelemetryCommand } from "./presentation/commands/telemetry.js";
@@ -41,6 +42,7 @@ registerUpdateCommand(program);
registerDoctorCommand(program);
registerCleanCommand(program);
registerTelemetryCommand(program);
+registerRunCommand(program);
// Commands already paying for network I/O, so the update-check refresh rides one of them.
// `marketplace remove` is offline and `update` already resolves the latest version itself.
@@ -54,6 +56,7 @@ const ONLINE_COMMAND_PATHS = new Set([
program.hook("preAction", async (_thisCommand, actionCommand) => {
if (process.env.AIDD_SKIP_UPDATE_CHECK === "1") return;
+ if (resolveCommandPath(actionCommand) === "run") return;
// A hook runs `telemetry ingest --quiet`; an update notice would break its silence.
if (resolveCommandPath(actionCommand).startsWith("telemetry")) return;
const opts = program.opts<{ verbose?: boolean }>();
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/distribution/domain/ports/plugin-fetcher.ts b/cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts
index 8a87e6f0d..fb94f176c 100644
--- a/cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts
+++ b/cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts
@@ -2,6 +2,8 @@ import type { PluginSource } from "../../../../kernel/source.js";
export interface PluginFetchOptions {
forceRefresh?: boolean;
+ /** Use consistent Git line-ending settings for this isolated checkout. */
+ canonicalCheckout?: boolean;
}
export interface PluginFetcher {
diff --git a/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts b/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts
index b00db5dae..2d7ccafd3 100644
--- a/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts
+++ b/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts
@@ -5,19 +5,46 @@ import { simpleGit } from "simple-git";
import { PluginFetchError } from "../../../kernel/errors.js";
import type { FileReader } from "../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../kernel/ports/file-writer.js";
-import type {
- PluginSource,
- PluginSourceGitHub,
- PluginSourceGitSubdir,
- PluginSourceNpm,
- PluginSourceUrl,
+import {
+ type PluginSource,
+ type PluginSourceGitHub,
+ type PluginSourceGitSubdir,
+ type PluginSourceNpm,
+ type PluginSourceUrl,
+ SHA_REGEX,
} from "../../../kernel/source.js";
import type { TokenProvider } from "../../../runtime/auth/ports/token-provider.js";
-import { injectTokenIntoUrl, withoutCredentials } from "../../../runtime/git/inject-token.js";
+import { gitHttpAuthentication, withoutCredentials } from "../../../runtime/git/inject-token.js";
import type { PluginFetcher, PluginFetchOptions } from "../domain/ports/plugin-fetcher.js";
const execFile = promisify(execFileCb);
+// The credential goes to the wrapper on stdin, never in the parent process argv or env.
+const AUTHENTICATED_GIT = `let input="";process.stdin.setEncoding("utf8");process.stdin.on("data",x=>input+=x);process.stdin.on("end",()=>{const child=require("node:child_process").spawn("git",process.argv.slice(1),{env:JSON.parse(input),stdio:["ignore","inherit","inherit"]});child.on("error",()=>{process.stderr.write("Unable to start git.");process.exitCode=1});child.on("close",code=>{process.exitCode=code??1})})`;
+
+function gitEnvironment(): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = { GIT_TERMINAL_PROMPT: "0" };
+ for (const key of [
+ "PATH",
+ "HOME",
+ "USERPROFILE",
+ "HOMEDRIVE",
+ "HOMEPATH",
+ "SYSTEMROOT",
+ "TMP",
+ "TEMP",
+ "TMPDIR",
+ "XDG_CONFIG_HOME",
+ "SSH_AUTH_SOCK",
+ "LANG",
+ "LC_ALL",
+ "LC_CTYPE",
+ ]) {
+ if (process.env[key] !== undefined) env[key] = process.env[key];
+ }
+ return env;
+}
+
const AUTH_ERROR_PATTERN =
/Authentication failed|403|could not read Username|Permission denied \(publickey\)|Repository not found/i;
@@ -33,15 +60,16 @@ export class PluginFetcherAdapter implements PluginFetcher {
options?: PluginFetchOptions
): Promise {
const forceRefresh = options?.forceRefresh ?? false;
+ const canonicalCheckout = options?.canonicalCheckout ?? false;
switch (source.kind) {
case "local":
return this.fetchLocal(source.path);
case "github":
- return this.fetchGitHub(source, cacheDir, forceRefresh);
+ return this.fetchGitHub(source, cacheDir, forceRefresh, canonicalCheckout);
case "url":
- return this.fetchUrl(source, cacheDir, forceRefresh);
+ return this.fetchUrl(source, cacheDir, forceRefresh, canonicalCheckout);
case "git-subdir":
- return this.fetchGitSubdir(source, cacheDir, forceRefresh);
+ return this.fetchGitSubdir(source, cacheDir, forceRefresh, canonicalCheckout);
case "npm":
return this.fetchNpm(source, cacheDir, forceRefresh);
}
@@ -58,60 +86,169 @@ export class PluginFetcherAdapter implements PluginFetcher {
private async fetchGitHub(
source: PluginSourceGitHub,
cacheDir: string,
- forceRefresh: boolean
+ forceRefresh: boolean,
+ canonicalCheckout: boolean
): Promise {
const baseUrl = `https://github.com/${source.repo}.git`;
const key = `github-${source.repo.replace("/", "-")}-${source.ref ?? "HEAD"}`;
const targetDir = join(cacheDir, key);
- await this.bustCacheIfNeeded(targetDir, forceRefresh);
+ await this.prepareGitCache(targetDir, forceRefresh, source.sha);
if (!(await this.fs.fileExists(targetDir))) {
const token = (await this.tokenProvider?.resolve()) ?? undefined;
- await this.cloneShallow(injectTokenIntoUrl(baseUrl, token), baseUrl, targetDir, source.ref);
+ await this.cloneShallow(
+ gitHttpAuthentication(baseUrl, token),
+ baseUrl,
+ targetDir,
+ source.ref,
+ canonicalCheckout || source.sha !== undefined
+ );
}
+ await this.verifyPinnedCheckout(targetDir, source.sha);
return targetDir;
}
private async fetchUrl(
source: PluginSourceUrl,
cacheDir: string,
- forceRefresh: boolean
+ forceRefresh: boolean,
+ canonicalCheckout: boolean
): Promise {
const key = `${encodeKey(withoutCredentials(source.url))}${source.ref ? `-${source.ref}` : "-HEAD"}`;
const targetDir = join(cacheDir, key);
- await this.bustCacheIfNeeded(targetDir, forceRefresh);
+ await this.prepareGitCache(targetDir, forceRefresh, source.sha);
if (!(await this.fs.fileExists(targetDir))) {
const token = (await this.tokenProvider?.resolve()) ?? undefined;
- const authUrl = source.url.startsWith("git@")
- ? source.url
- : injectTokenIntoUrl(source.url, token);
- await this.cloneShallow(authUrl, source.url, targetDir, source.ref);
+ await this.cloneShallow(
+ gitHttpAuthentication(source.url, token),
+ source.url,
+ targetDir,
+ source.ref,
+ canonicalCheckout || source.sha !== undefined
+ );
}
+ await this.verifyPinnedCheckout(targetDir, source.sha);
return targetDir;
}
private async fetchGitSubdir(
source: PluginSourceGitSubdir,
cacheDir: string,
- forceRefresh: boolean
+ forceRefresh: boolean,
+ canonicalCheckout: boolean
): Promise {
const { url, path: subpath, ref } = source;
const key = `${encodeKey(withoutCredentials(url))}-subdir-${subpath.replace(/\//g, "_")}-${ref ?? "HEAD"}`;
const targetDir = join(cacheDir, key);
- await this.bustCacheIfNeeded(targetDir, forceRefresh);
+ await this.prepareGitCache(targetDir, forceRefresh, source.sha, subpath);
if (!(await this.fs.fileExists(targetDir))) {
const token = (await this.tokenProvider?.resolve()) ?? undefined;
- const authUrl = url.startsWith("git@") ? url : injectTokenIntoUrl(url, token);
- await this.cloneSparse(authUrl, url, targetDir, subpath, ref);
+ await this.cloneSparse(
+ gitHttpAuthentication(url, token),
+ url,
+ targetDir,
+ subpath,
+ ref,
+ canonicalCheckout || source.sha !== undefined
+ );
}
+ await this.verifyPinnedCheckout(targetDir, source.sha, subpath);
return join(targetDir, subpath);
}
+ /** A pinned cache is usable only while both its commit and its working tree match the pin. */
+ private async verifyPinnedCheckout(
+ directory: string,
+ sha: string | undefined,
+ subpath?: string
+ ): Promise {
+ if (sha === undefined) return;
+ if (!SHA_REGEX.test(sha)) throw new PluginFetchError("invalid pinned Git SHA.");
+ let commit: string;
+ let changes: string;
+ let index: string;
+ try {
+ const env: NodeJS.ProcessEnv = {
+ ...gitEnvironment(),
+ GIT_CONFIG_NOSYSTEM: "1",
+ // Git documents /dev/null for this variable, including Git for Windows; Node's
+ // Windows devNull is \\.\nul, which Git for Windows cannot open as a config file.
+ GIT_CONFIG_GLOBAL: "/dev/null",
+ };
+ const run = (args: string[]) =>
+ execFile(
+ "git",
+ [
+ "-c",
+ "core.fsmonitor=false",
+ "-c",
+ "core.untrackedCache=false",
+ "-c",
+ "core.trustctime=true",
+ "-c",
+ "core.checkstat=default",
+ "-c",
+ "core.autocrlf=false",
+ "-c",
+ "core.eol=lf",
+ "--literal-pathspecs",
+ ...args,
+ ],
+ { cwd: directory, env }
+ );
+ commit = (await run(["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim();
+ changes = (
+ await run([
+ "status",
+ "--porcelain=v1",
+ "--untracked-files=all",
+ "--ignored=matching",
+ "--ignore-submodules=none",
+ "--",
+ ])
+ ).stdout;
+ index = (
+ await run(["ls-files", "-v", "-z", "--", ...(subpath === undefined ? [] : [subpath])])
+ ).stdout;
+ } catch {
+ // A Git error can contain credentials from remote configuration. The path itself may
+ // also have been supplied by the user, so neither is needed in this diagnostic.
+ throw new PluginFetchError("unable to verify the pinned Git checkout.");
+ }
+ if (commit !== sha)
+ throw new PluginFetchError("the checked-out Git commit does not match the pinned SHA.");
+ if (changes.trim())
+ throw new PluginFetchError(
+ "the pinned Git cache cannot be verified: it may contain modified, untracked or ignored files, or noncanonical line endings. Inspect the cache manually before retrying."
+ );
+ if (
+ index
+ .split("\0")
+ .some((line) => line && (line[0] === "S" || line[0] === line[0]?.toLowerCase()))
+ )
+ throw new PluginFetchError("the pinned Git checkout hides tracked file changes.");
+ }
+
private async bustCacheIfNeeded(targetDir: string, forceRefresh: boolean): Promise {
if (forceRefresh && (await this.fs.fileExists(targetDir))) {
await this.fs.deleteDirectory(targetDir);
}
}
+ /** Do not discard edits made to a pinned checkout even on an explicit refresh. */
+ private async prepareGitCache(
+ targetDir: string,
+ forceRefresh: boolean,
+ sha: string | undefined,
+ subpath?: string
+ ): Promise {
+ if (sha !== undefined && !SHA_REGEX.test(sha))
+ throw new PluginFetchError("invalid pinned Git SHA.");
+ if (forceRefresh && sha !== undefined && (await this.fs.fileExists(targetDir))) {
+ await this.verifyPinnedCheckout(targetDir, sha, subpath);
+ }
+ await this.bustCacheIfNeeded(targetDir, forceRefresh);
+ }
+
private async fetchNpm(
source: PluginSourceNpm,
cacheDir: string,
@@ -133,43 +270,112 @@ export class PluginFetcherAdapter implements PluginFetcher {
private gitWithNoPrompt(baseDir?: string): ReturnType {
const git = simpleGit({ baseDir, allowEnvironment: ["GIT_TERMINAL_PROMPT"] });
- return git.env("GIT_TERMINAL_PROMPT", "0");
+ for (const [key, value] of Object.entries(gitEnvironment())) {
+ if (value !== undefined) git.env(key, value);
+ }
+ return git;
+ }
+
+ private async authenticatedGit(
+ auth: { url: string; authorization: string },
+ args: string[],
+ baseDir?: string
+ ): Promise {
+ const env = gitEnvironment();
+ const input = JSON.stringify({
+ ...env,
+ GIT_CONFIG_COUNT: "1",
+ GIT_CONFIG_KEY_0: `http.${auth.url}.extraheader`,
+ GIT_CONFIG_VALUE_0: auth.authorization,
+ });
+ await new Promise((resolve, reject) => {
+ const child = execFileCb(
+ process.execPath,
+ [`--eval=${AUTHENTICATED_GIT}`, ...args],
+ { cwd: baseDir, env, windowsHide: true },
+ (error) => (error ? reject(error) : resolve())
+ );
+ child.stdin?.on("error", () => {});
+ child.stdin?.end(input);
+ });
}
private async cloneShallow(
- authUrl: string,
+ auth: { url: string; authorization?: string },
displayUrl: string,
targetDir: string,
- ref?: string
+ ref?: string,
+ canonicalCheckout = false
): Promise {
- const args = ["--depth", "1", ...(ref ? ["--branch", ref] : [])];
+ const args = [
+ "--depth",
+ "1",
+ ...(ref ? ["--branch", ref] : []),
+ ...(canonicalCheckout ? ["-c", "core.autocrlf=false", "-c", "core.eol=lf"] : []),
+ ];
try {
- await this.gitWithNoPrompt().clone(authUrl, targetDir, args);
+ if (auth.authorization) {
+ if (ref?.startsWith("-")) throw new Error("Invalid Git reference.");
+ await this.authenticatedGit({ ...auth, authorization: auth.authorization }, [
+ "clone",
+ "--depth",
+ "1",
+ ...(ref ? [`--branch=${ref}`] : []),
+ ...(canonicalCheckout ? ["-c", "core.autocrlf=false", "-c", "core.eol=lf"] : []),
+ "--",
+ auth.url,
+ targetDir,
+ ]);
+ } else {
+ await this.gitWithNoPrompt().clone(auth.url, targetDir, args);
+ }
} catch (err) {
- this.classifyAndThrow(err, displayUrl);
+ this.classifyAndThrow(err, displayUrl, auth.authorization);
}
}
private async cloneSparse(
- authUrl: string,
+ auth: { url: string; authorization?: string },
displayUrl: string,
targetDir: string,
subpath: string,
- ref?: string
+ ref?: string,
+ canonicalCheckout = false
): Promise {
try {
- await this.gitWithNoPrompt().clone(authUrl, targetDir, [
- "--filter=blob:none",
- "--no-checkout",
- ]);
- await this.gitWithNoPrompt(targetDir).raw(["sparse-checkout", "set", subpath]);
- await this.gitWithNoPrompt(targetDir).checkout(ref ?? "HEAD");
+ if (auth.authorization) {
+ if (ref?.startsWith("-")) throw new Error("Invalid Git reference.");
+ const authenticated = { ...auth, authorization: auth.authorization };
+ await this.authenticatedGit(authenticated, [
+ "clone",
+ "--filter=blob:none",
+ "--no-checkout",
+ ...(canonicalCheckout ? ["-c", "core.autocrlf=false", "-c", "core.eol=lf"] : []),
+ "--",
+ auth.url,
+ targetDir,
+ ]);
+ await this.authenticatedGit(
+ authenticated,
+ ["sparse-checkout", "set", "--", subpath],
+ targetDir
+ );
+ await this.authenticatedGit(authenticated, ["checkout", ref ?? "HEAD", "--"], targetDir);
+ } else {
+ await this.gitWithNoPrompt().clone(auth.url, targetDir, [
+ "--filter=blob:none",
+ "--no-checkout",
+ ...(canonicalCheckout ? ["-c", "core.autocrlf=false", "-c", "core.eol=lf"] : []),
+ ]);
+ await this.gitWithNoPrompt(targetDir).raw(["sparse-checkout", "set", subpath]);
+ await this.gitWithNoPrompt(targetDir).checkout(ref ?? "HEAD");
+ }
} catch (err) {
- this.classifyAndThrow(err, displayUrl);
+ this.classifyAndThrow(err, displayUrl, auth.authorization);
}
}
- private classifyAndThrow(err: unknown, displayUrl: string): never {
+ private classifyAndThrow(err: unknown, displayUrl: string, authorization?: string): never {
const msg = err instanceof Error ? err.message : String(err);
// The URL the user typed may carry their own credential; the message must not.
const safeUrl = withoutCredentials(displayUrl);
@@ -178,14 +384,25 @@ export class PluginFetcherAdapter implements PluginFetcher {
`Authentication failed for "${safeUrl}". ${this.authHint(safeUrl)}`
);
}
- throw new PluginFetchError(`git clone failed for "${safeUrl}": ${scrubCredentials(msg)}`);
+ let safeMessage = scrubCredentials(msg);
+ if (authorization) {
+ const encoded = authorization.slice("Authorization: Basic ".length);
+ const decoded = Buffer.from(encoded, "base64").toString();
+ const separator = decoded.indexOf(":");
+ const secret = decoded.slice(separator + 1) || decoded.slice(0, separator);
+ safeMessage = safeMessage.replaceAll(encoded, "[redacted]");
+ if (secret) safeMessage = safeMessage.replaceAll(secret, "[redacted]");
+ }
+ throw new PluginFetchError(`git clone failed for "${safeUrl}": ${safeMessage}`);
}
private authHint(url: string): string {
- if (url.startsWith("git@")) {
+ if (url.startsWith("git@") || url.startsWith("ssh://")) {
return "Check your SSH key is registered on the remote host.";
}
- return "Set AIDD_TOKEN, run `aidd auth login`, or pass --token . Required scope depends on the host (github: `repo`, gitlab: `read_repository`, bitbucket: `repository:read`).";
+ if (url.startsWith("https://github.com/"))
+ return "Set AIDD_TOKEN, run `aidd auth login`, or pass --token . GitHub private repositories require repository read access.";
+ return "Configure a Git credential helper for this host.";
}
}
diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts
index 22e1b3826..619450461 100644
--- a/cli/src/contexts/framework/application/clean-use-case.ts
+++ b/cli/src/contexts/framework/application/clean-use-case.ts
@@ -46,7 +46,11 @@ import {
detachNativePluginRefs,
machineNativePluginClaim,
} from "./ownership/native-plugin-ownership.js";
-import { assertProjectMcpEntriesRemovable } from "./ownership/project-plugin-cleanup.js";
+import {
+ assertProjectMcpEntriesRemovable,
+ planProjectKiloTuiRemoval,
+ removeProjectKiloTui,
+} from "./ownership/project-plugin-cleanup.js";
import { detachUserPlugin } from "./ownership/user-plugin-ownership.js";
import { deletePluginFilesForTool } from "./plugin/plugin-helpers.js";
import { bestEffortNativeCall } from "./shared/best-effort-native-call.js";
@@ -176,6 +180,7 @@ export class CleanUseCase {
});
}
await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, options.projectRoot);
+ await planProjectKiloTuiRemoval(this.fs, plugin, toolId, options.projectRoot);
}
}
// Read the other projects before host cleanup, but keep this project's claim until every
@@ -272,7 +277,8 @@ export class CleanUseCase {
this.hostPluginRegistries.get(toolId),
registration.hostName,
new Set([...registrations.pluginRefs, ...machineRefs]),
- projectRoot
+ projectRoot,
+ toolId
);
}
}
@@ -604,7 +610,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)}`);
@@ -693,6 +700,11 @@ export class CleanUseCase {
for (const toolId of manifest.getInstalledToolIds()) {
count += await this.deleteMachineLocalSettingsFiles(toolId, projectRoot);
count += await this.removeProjectHooksForTool(manifest, toolId, projectRoot, hooksGuard);
+ if (isAiToolId(toolId)) {
+ for (const plugin of manifest.getPlugins(toolId)) {
+ if (await removeProjectKiloTui(this.fs, plugin, toolId, projectRoot)) count++;
+ }
+ }
}
return count;
}
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..4e3fb5d30 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
@@ -22,6 +22,10 @@ import {
assertNoForeignNativeRefs,
inspectNativeMarketplaceSource,
} from "../ownership/native-marketplace-source-proof.js";
+import {
+ planUserKiloTuiRemoval,
+ removeUserKiloTui,
+} from "../ownership/tui/user-kilo-tui-removal.js";
import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js";
import { bestEffortNativeCall } from "../shared/best-effort-native-call.js";
import { resolveCacheCandidate } from "../shared/purge-declared-cache.js";
@@ -210,6 +214,7 @@ export class CleanUserScopeUseCase {
if (!isAiToolId(toolId)) continue;
for (const plugin of manifest.getPlugins(toolId)) {
if (plugin.scope !== "user") continue;
+ await planUserKiloTuiRemoval(this.fs, toolId, plugin, this.homeDir());
const safe = await userScopeFilesSafeToDelete(
this.fs,
this.logger,
@@ -327,7 +332,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)
@@ -403,6 +409,7 @@ export class CleanUserScopeUseCase {
if (!isAiToolId(toolId)) continue;
for (const plugin of manifest.getPlugins(toolId)) {
if (plugin.scope !== "user") continue;
+ await removeUserKiloTui(this.fs, toolId, plugin, this.homeDir());
const files = await userScopeFilesSafeToDelete(
this.fs,
this.logger,
@@ -419,6 +426,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 +460,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 +480,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/doctor/doctor-registration-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts
index 0f5ec75a3..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,6 +35,8 @@ export interface DoctorRegistrationOptions {
manifest: Manifest;
projectRoot: string;
allowedIds: Set | null;
+ /** 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
@@ -84,7 +86,7 @@ export class DoctorRegistrationUseCase {
async execute(options: DoctorRegistrationOptions): Promise {
return [
- ...(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/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts
index 458151b01..0e288bed6 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,
@@ -95,6 +96,16 @@ 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[];
+ /** Refresh already-owned native refs after an explicitly approved framework release update. */
+ refreshOwnedPlugins?: readonly { toolId: AiToolId; pluginName: string }[];
+ /** Claude refs shown in the approved release preview; never ownership claims. */
+ approvedRefs?: readonly string[];
}
interface ActivationOutcome {
@@ -217,6 +228,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
// A user-scope run lands nothing under `projectRoot`, so no project settings file mirrors it.
if (scope === "project") {
for (const toolId of toolIds) {
+ if (options.refreshOwnedPlugins !== undefined) continue;
if (
resolvePluginsCapability(toolId)?.marketplaceSettings
?.declarativePluginActivationRequiresNativeProof === true
@@ -231,13 +243,27 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
manifest,
marketplaces,
toolIds,
- scope
+ scope,
+ options.enablePlugins ?? [],
+ options.refreshOwnedPlugins ?? [],
+ options.approvedRefs ?? []
);
let declarativeChanged = false;
if (scope === "project") {
for (const [toolId, outcome] of activation.outcomes) {
const settings = resolvePluginsCapability(toolId)?.marketplaceSettings;
- if (settings?.declarativePluginActivationRequiresNativeProof !== true) continue;
+ // A targeted native update preserves Claude's own enabled/disabled settings.
+ if (
+ options.refreshOwnedPlugins !== undefined &&
+ nativeActivationOf(toolId)?.releaseUpdateVerb
+ )
+ continue;
+ if (
+ settings == null ||
+ (options.refreshOwnedPlugins === undefined &&
+ settings.declarativePluginActivationRequiresNativeProof !== true)
+ )
+ continue;
const hostNames = new Set(outcome.marketplaces.map((entry) => entry.hostName));
const provenRefs = outcome.pluginRefs.filter((ref) =>
[...hostNames].some((hostName) => ref.endsWith(`@${hostName}`))
@@ -250,7 +276,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
manifest,
marketplaces,
settings,
- provenRefs
+ provenRefs,
+ options.refreshOwnedPlugins ?? []
))
)
declarativeChanged = true;
@@ -259,24 +286,34 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
if (declarativeChanged) await manifestRepo.save(manifest);
// A refused or unproven host registration cannot establish a new shared-source claim.
// User-scope runs have no project manifest for a later `clean` to decrement it.
+ // A release preview authorizes the update, not a new ownership claim.
+ const claimOutcomes = options.approvedRefs?.length
+ ? new Map(
+ [...activation.outcomes].filter(
+ ([toolId]) => !nativeActivationOf(toolId)?.releaseUpdateVerb
+ )
+ )
+ : activation.outcomes;
if (scope !== "user") {
- await this.recordSharedSourceReference(projectRoot, marketplaces, activation.outcomes);
+ await this.recordSharedSourceReference(projectRoot, marketplaces, claimOutcomes);
}
const wroteHashes = await this.recordWhatActivationWrote(projectRoot, manifest, [
...activation.outcomes.keys(),
]);
const wroteRegistrations = this.recordNativeRegistrations(
manifest,
- activation.outcomes,
- options.marketplaceNames !== undefined
+ claimOutcomes,
+ options.marketplaceNames !== undefined,
+ options.refreshOwnedPlugins ?? []
);
if (wroteHashes || wroteRegistrations) await manifestRepo.save(manifest);
await this.recordMachinePluginClaims(
manifest,
- activation.outcomes,
+ claimOutcomes,
marketplaces,
scope,
- scope === "project" ? projectRoot : undefined
+ scope === "project" ? projectRoot : undefined,
+ options.refreshOwnedPlugins ?? []
);
if (options.recreateFrameworkIfMissing === true && scope === "project") {
await this.purgeStaleProjectCache(projectRoot, marketplaces, activation);
@@ -403,7 +440,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
manifest: Manifest,
marketplaces: readonly Marketplace[],
toolIds: readonly ToolId[],
- scope: MarketplaceScope
+ scope: MarketplaceScope,
+ enablePlugins: readonly string[],
+ refreshOwnedPlugins: readonly { toolId: AiToolId; pluginName: string }[],
+ approvedRefs: readonly string[]
): Promise {
const outcomes = new Map();
const binaryMissing: { toolId: ToolId; binary: string }[] = [];
@@ -414,7 +454,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;
}
@@ -426,7 +470,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
manifest,
marketplaces,
warnings,
- scope
+ scope,
+ enablePlugins,
+ refreshOwnedPlugins,
+ nativeActivationOf(toolId)?.releaseUpdateVerb ? approvedRefs : []
);
outcomes.set(toolId, outcome);
} catch (error) {
@@ -444,7 +491,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
private recordNativeRegistrations(
manifest: Manifest,
activated: ReadonlyMap,
- narrowed: boolean
+ narrowed: boolean,
+ selected: readonly { toolId: AiToolId; pluginName: string }[]
): boolean {
let changed = false;
for (const [toolId, outcome] of activated) {
@@ -467,8 +515,12 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
)
: [];
const preservedHostNames = new Set(outcome.preservedMarketplaces.map((m) => m.hostName));
- const preservedRefs = (existing?.pluginRefs ?? []).filter((ref) =>
- [...preservedHostNames].some((hostName) => ref.endsWith(`@${hostName}`))
+ const preservedRefs = (existing?.pluginRefs ?? []).filter(
+ (ref) =>
+ [...preservedHostNames].some((hostName) => ref.endsWith(`@${hostName}`)) ||
+ (selected.length > 0 &&
+ (nativeActivationOf(toolId)?.releaseUpdateVerb !== undefined ||
+ !this.isSelectedRef(manifest, toolId, ref, selected)))
);
const registrations: NativeRegistrations = {
binary,
@@ -529,7 +581,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
manifest: Manifest,
marketplaces: readonly Marketplace[],
warnings: string[],
- scope: MarketplaceScope
+ scope: MarketplaceScope,
+ enablePlugins: readonly string[],
+ refreshOwnedPlugins: readonly { toolId: AiToolId; pluginName: string }[],
+ approvedRefs: 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 —
@@ -541,6 +596,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
const preservedMarketplaces: NativeMarketplaceRegistration[] = [];
const catalogClaims: NativeMarketplaceRegistration[] = [];
const addedHostNames = new Set();
+ const skippedHostNames = new Set();
+ const refreshSources = new Map();
const recorded = manifest.getNativeRegistrations(toolId)?.marketplaces;
let buildFailed = false;
for (const marketplace of marketplaces) {
@@ -550,21 +607,35 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
marketplace,
projectRoot,
warnings,
- catalogClaims
+ catalogClaims,
+ approvedRefs
);
if (registration.outcome === "build-failed") buildFailed = true;
if (registration.addedThisRun === true) addedHostNames.add(registration.hostName);
+ if (registration.skipNativeWrites) skippedHostNames.add(registration.hostName);
+ if (registration.refreshable && registration.provenance)
+ refreshSources.set(registration.hostName, registration.provenance);
const entry = {
alias: marketplace.name,
hostName: registration.hostName,
...(registration.provenance === undefined ? {} : { provenance: registration.provenance }),
};
const earlier = recorded?.find((m) => m.alias === marketplace.name);
- if (registration.outcome === "registered" && registration.claimable)
- ownMarketplaces.push(entry);
- if (registration.claimable) catalogClaims.push(entry);
- else if (earlier !== undefined) {
- const proof = await this.nativeCatalogProof(toolId, earlier.hostName, projectRoot);
+ if (registration.outcome === "registered" && registration.claimable) {
+ if (registration.skipNativeWrites) {
+ if (earlier !== undefined) preservedMarketplaces.push(earlier);
+ } else ownMarketplaces.push(entry);
+ }
+ if (registration.claimable) {
+ if (!registration.skipNativeWrites) catalogClaims.push(entry);
+ } else if (earlier !== undefined) {
+ const proof = await this.nativeCatalogProof(
+ toolId,
+ earlier.hostName,
+ projectRoot,
+ undefined,
+ approvedRefs
+ );
if (
proof.canonical &&
proof.hostReadable &&
@@ -585,12 +656,14 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
};
const stillProven: NativeMarketplaceRegistration[] = [];
for (const registration of ownMarketplaces) {
+ if (skippedHostNames.has(registration.hostName)) continue;
const fresh = catalogClaims.find((claim) => claim.hostName === registration.hostName);
const proof = await this.nativeCatalogProof(
toolId,
registration.hostName,
projectRoot,
- fresh
+ fresh,
+ approvedRefs
);
if (
!proof.canonical ||
@@ -608,14 +681,89 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
);
}
const hostNameByAlias = new Map(stillProven.map((m) => [m.alias, m.hostName]));
- const { refsToEnable, alreadyOwnedRefs } = await this.refsThisProjectEnables(
- toolId,
- this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias),
- manifest,
- projectRoot
- );
+ const frameworkHost = hostNameByAlias.get(FRAMEWORK_MARKETPLACE_NAME);
+ const requested =
+ frameworkHost === undefined ? [] : enablePlugins.map((name) => `${name}@${frameworkHost}`);
+ const candidateRefs = [
+ ...new Set([
+ ...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias, scope),
+ ...requested,
+ ]),
+ ];
+ const { refsToEnable, alreadyOwnedRefs } = approvedRefs.length
+ ? { refsToEnable: [], alreadyOwnedRefs: [] }
+ : await this.refsThisProjectEnables(toolId, candidateRefs, manifest, projectRoot);
const enabledRefs = [...alreadyOwnedRefs];
+ const sourceStillMatches = async (ref: string): Promise => {
+ const registration = stillProven.find((entry) => ref.endsWith(`@${entry.hostName}`));
+ if (!registration) return false;
+ const expected = refreshSources.get(registration.hostName);
+ if (!expected) return false;
+ const current = await this.nativeCatalogProof(
+ toolId,
+ registration.hostName,
+ projectRoot,
+ registration,
+ approvedRefs
+ );
+ if (
+ !current.canonical ||
+ !current.hostReadable ||
+ current.sourceStatus !== "owned" ||
+ current.foreignRef !== undefined ||
+ !current.source ||
+ !sameNativeMarketplaceSource(expected, current.source)
+ ) {
+ const message = `${toolId}: native source changed before refreshing '${ref}'; plugin left untouched.`;
+ this.logger.warn(message);
+ warnings.push(message);
+ return false;
+ }
+ return true;
+ };
+ if (refreshOwnedPlugins.length && nativeActivationOf(toolId)?.refreshOwnedOnReleaseUpdate) {
+ for (const ref of alreadyOwnedRefs) {
+ if (
+ !this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins) ||
+ !(await sourceStillMatches(ref))
+ )
+ continue;
+ this.bestEffort(
+ () => activator.enablePlugin(ref, scope),
+ `refresh plugin '${ref}'`,
+ warnings
+ );
+ }
+ }
+ if (refreshOwnedPlugins.length && nativeActivationOf(toolId)?.releaseUpdateVerb) {
+ for (const ref of candidateRefs) {
+ if (!this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins)) continue;
+ if (!approvedRefs.includes(ref)) {
+ const message = `${toolId}: '${ref}' was not in the approved preview; release refresh skipped.`;
+ this.logger.warn(message);
+ warnings.push(message);
+ continue;
+ }
+ if (!(await sourceStillMatches(ref))) continue;
+ if (activator.refreshReleasePlugin === undefined)
+ throw new Error(`${toolId}: targeted release refresh is unavailable for '${ref}'.`);
+ this.bestEffort(
+ () => activator.refreshReleasePlugin?.(ref, scope, projectRoot),
+ `refresh plugin '${ref}'`,
+ warnings
+ );
+ }
+ }
for (const ref of refsToEnable) {
+ if (refreshOwnedPlugins.length && nativeActivationOf(toolId)?.releaseUpdateVerb) {
+ continue;
+ }
+ if (
+ refreshOwnedPlugins.length &&
+ (!this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins) ||
+ !(await sourceStillMatches(ref)))
+ )
+ continue;
if (
this.bestEffort(
() => activator.enablePlugin(ref, scope),
@@ -675,7 +823,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
outcomes: ReadonlyMap,
marketplaces: readonly Marketplace[],
scope: MarketplaceScope,
- projectRoot?: string
+ projectRoot?: string,
+ selected: readonly { toolId: AiToolId; pluginName: string }[] = []
): Promise {
if (this.userManifestRepo === undefined) return;
const machine = (await this.userManifestRepo.load()) ?? Manifest.create();
@@ -683,7 +832,11 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
let changed = false;
for (const [toolId, outcome] of outcomes) {
const machineRefs =
- scope === "user" || pluginEnablementIsMachineGlobal(toolId) ? outcome.pluginRefs : [];
+ scope === "user" || pluginEnablementIsMachineGlobal(toolId)
+ ? outcome.pluginRefs.filter(
+ (ref) => !selected.length || this.isSelectedRef(project, toolId, ref, selected)
+ )
+ : [];
const machineMarketplaces = outcome.catalogClaims.filter((registration) =>
marketplaces.some(
(marketplace) =>
@@ -759,7 +912,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[] = [];
@@ -770,6 +924,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;
}
@@ -777,7 +943,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
toolId: ToolId,
hostName: string,
projectRoot: string,
- fresh?: NativeMarketplaceRegistration
+ fresh?: NativeMarketplaceRegistration,
+ approvedRefs: readonly string[] = []
): Promise {
if (!isAiToolId(toolId))
return { canonical: false, hostReadable: false, sourceStatus: "unproven" };
@@ -822,9 +989,13 @@ 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) &&
+ !approvedRefs.includes(ref)
+ )?.[0];
return { ...base, hostReadable: true, ...(foreignRef === undefined ? {} : { foreignRef }) };
}
@@ -836,15 +1007,18 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
marketplace: Marketplace,
projectRoot: string,
warnings: string[],
- freshClaims: readonly NativeMarketplaceRegistration[]
+ freshClaims: readonly NativeMarketplaceRegistration[],
+ approvedRefs: readonly string[]
): Promise<{
hostName: string;
outcome: "registered" | "refused" | "build-failed";
claimable: boolean;
addedThisRun?: boolean;
+ skipNativeWrites?: boolean;
+ refreshable?: boolean;
provenance?: NativeMarketplaceSource;
}> {
- const builtDir = await this.buildForTool(toolId, marketplace, projectRoot);
+ const builtDir = await this.buildForTool(toolId, marketplace, projectRoot, warnings);
if (builtDir === null)
return { hostName: marketplace.name, outcome: "build-failed", claimable: false };
const requestedIdentity = await readMarketplaceCatalogIdentity(this.fs, toolId, builtDir);
@@ -854,11 +1028,20 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
);
}
const hostName = requestedIdentity.name;
+ const approvedSource =
+ nativeActivationOf(toolId)?.marketplaceRegistry && approvedRefs.length
+ ? {
+ alias: marketplace.name,
+ hostName,
+ provenance: { kind: "registry" as const, source: await this.fs.realpath(builtDir) },
+ }
+ : undefined;
const proof = await this.nativeCatalogProof(
toolId,
hostName,
projectRoot,
- freshClaims.find((claim) => claim.hostName === hostName)
+ freshClaims.find((claim) => claim.hostName === hostName) ?? approvedSource,
+ approvedRefs
);
if (
proof.sourceStatus === "unproven" ||
@@ -928,7 +1111,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
samePath(proof.source.root, requestedSource) &&
proof.source.sourceType === "local"))
)
- return { hostName, outcome: "registered", claimable: true, provenance: proof.source };
+ return {
+ hostName,
+ outcome: "registered",
+ claimable: true,
+ provenance: proof.source,
+ refreshable: true,
+ };
const decision = await this.guardAgainstConflict(
toolId,
builtDir,
@@ -945,6 +1134,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
outcome: "registered",
claimable: proof.sourceStatus === "owned",
provenance: proof.source,
+ skipNativeWrites: true,
};
try {
activator.addMarketplace(builtDir, marketplace.scope);
@@ -974,6 +1164,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
claimable: afterReclaim !== undefined,
addedThisRun: afterReclaim !== undefined,
provenance: afterReclaim,
+ refreshable: afterReclaim !== undefined,
};
}
const afterAdd = await this.proveAddedSource(toolId, hostName, builtDir, projectRoot, warnings);
@@ -983,6 +1174,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
claimable: afterAdd !== undefined,
addedThisRun: afterAdd !== undefined,
provenance: afterAdd,
+ refreshable: afterAdd !== undefined,
};
}
@@ -1169,7 +1361,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
private async buildForTool(
toolId: ToolId,
marketplace: Marketplace,
- projectRoot: string
+ projectRoot: string,
+ warnings?: string[]
): Promise {
try {
const { builtDir } = await this.ensureBuilt.execute({
@@ -1180,9 +1373,9 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
});
return builtDir;
} catch (error) {
- this.logger.warn(
- `Native plugin activation — build '${marketplace.name}' for ${toolId} skipped: ${(error as Error).message}`
- );
+ const message = `Native plugin activation — build '${marketplace.name}' for ${toolId} skipped: ${(error as Error).message}`;
+ this.logger.warn(message);
+ warnings?.push(message);
return null;
}
}
@@ -1272,11 +1465,22 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
manifest: Manifest,
marketplaces: readonly Marketplace[],
settings: MarketplaceSettings,
- provenRefs?: readonly string[]
+ provenRefs?: readonly string[],
+ selected: readonly { toolId: AiToolId; pluginName: string }[] = []
): Promise {
const pluginsPath = resolve(projectRoot, settings.settingsPath);
const json = await this.loadSettings(pluginsPath);
- if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces, provenRefs))
+ if (
+ !this.mergeEnabledPlugins(
+ json,
+ settings,
+ toolId,
+ manifest,
+ marketplaces,
+ provenRefs,
+ selected
+ )
+ )
return false;
const content = JSON.stringify(json, null, 2);
await this.fs.writeFile(pluginsPath, content);
@@ -1290,14 +1494,20 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
toolId: ToolId,
manifest: Manifest,
marketplaces: readonly Marketplace[],
- provenRefs?: readonly string[]
+ provenRefs?: readonly string[],
+ selected: readonly { toolId: AiToolId; pluginName: string }[] = []
): boolean {
const pluginsKey = settings.enabledPluginsKey;
if (pluginsKey == null) return false;
const existing = this.existingRecord(json, pluginsKey);
const toAdd: Record = {};
if (provenRefs !== undefined) {
- for (const ref of provenRefs) if (!(ref in existing)) toAdd[ref] = true;
+ for (const ref of provenRefs)
+ if (
+ !(ref in existing) &&
+ (!selected.length || this.isSelectedRef(manifest, toolId, ref, selected))
+ )
+ toAdd[ref] = true;
if (Object.keys(toAdd).length === 0) return false;
json[pluginsKey] = { ...existing, ...toAdd };
return true;
@@ -1320,6 +1530,22 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
return true;
}
+ private isSelectedRef(
+ manifest: Manifest,
+ toolId: ToolId,
+ ref: string,
+ selected: readonly { toolId: AiToolId; pluginName: string }[]
+ ): boolean {
+ return manifest
+ .getPlugins(toolId)
+ .some(
+ (plugin) =>
+ plugin.scope === "project" &&
+ ref.startsWith(`${plugin.name}@`) &&
+ selected.some((target) => target.toolId === toolId && target.pluginName === plugin.name)
+ );
+ }
+
private existingRecord(
json: Record,
settingsKey: string
diff --git a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts
index 4c6696729..0ceefd32a 100644
--- a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts
+++ b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts
@@ -23,8 +23,10 @@ import type { ReadonlySkipList } from "../../../../translate/domain/plugin-trans
import type { Manifest } from "../../../domain/manifest.js";
import {
InstalledPlugin,
+ type KiloTuiProvenance,
type ProjectHooksProvenance,
} from "../../../domain/plugins/installed-plugin.js";
+import { planKiloTuiInstall } from "../../ownership/tui/kilo-tui-config.js";
import { isPluginFileAtDesiredState } from "../../plugin/plugin-helpers.js";
import {
resolveBaseDirFromRecord,
@@ -60,7 +62,8 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
previousMcpEntries: ReadonlyMap = new Map(),
userScopeDirTaken = false,
previousProjectHooks?: ProjectHooksProvenance,
- hooksGuard: ProjectHooksGuard = {}
+ hooksGuard: ProjectHooksGuard = {},
+ previousTui?: KiloTuiProvenance
): Promise<{ skipped: ReadonlySkipList; written?: number }> {
const resolved =
marketplace === undefined ? null : await this.findMarketplace(marketplace, projectRoot);
@@ -75,9 +78,17 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
previousMcpEntries,
userScopeDirTaken,
previousProjectHooks,
- hooksGuard
+ hooksGuard,
+ previousTui
);
}
+ const tui = resolvePluginsCapability(toolId)?.startupTui;
+ const isKiloTui = tui?.plugin === dist.manifest.name && !userScopeDirTaken;
+ previousTui ??= manifest
+ .getPlugins(toolId)
+ .find((p) => p.name === dist.manifest.name)?.tuiPlugin;
+ if (isKiloTui)
+ await planKiloTuiInstall(this.fs, join(projectRoot, tui.directory), projectRoot, previousTui);
const mode = frameworkBuildModeFor(toolId);
const { builtDir } = await this.ensureBuilt.execute({
projectRoot,
@@ -121,6 +132,21 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
: resolveBaseDirFromRecord(scope, toolId, projectRoot, this.homedir);
const owned = userScopeDirTaken ? [] : files;
const written = await this.writeChangedFiles(owned, baseDir);
+ let tuiPlugin = previousTui;
+ if (isKiloTui) {
+ const plan = await planKiloTuiInstall(
+ this.fs,
+ join(projectRoot, tui.directory),
+ projectRoot,
+ previousTui
+ );
+ if (
+ !(await this.fs.fileExists(plan.path)) ||
+ (await this.fs.readFile(plan.path)) !== plan.content
+ )
+ await this.fs.writeFile(plan.path, plan.content);
+ tuiPlugin = plan.provenance;
+ }
manifest.addPlugin(
toolId,
InstalledPlugin.withProjectHooks(
@@ -134,7 +160,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
marketplace
),
hooks.projectHooks
- )
+ ).withTuiPlugin(tuiPlugin)
);
return { skipped: [...mcp.mcpSkips, ...hooks.skipped], written };
}
@@ -159,11 +185,11 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
// sibling by relative path, under `skills//` or, `single-level`, `skills/-/`.
// Hooks land under `flatHooksDir//`, except the loader's own runtime module, renamed to
// the plugin's name in the loader's directory — so hook paths are matched by path, not by name.
- private async readFlatFiles(
+ async readFlatFiles(
builtDir: string,
dist: PluginDistribution,
toolId: AiToolId,
- deliversHooksToProject: boolean
+ deliversHooksToProject = resolvePluginsCapability(toolId)?.hooksDestination === "project"
): Promise {
const name = dist.manifest.name;
const hookPaths = deliversHooksToProject
@@ -204,13 +230,15 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
const paths = new Set(
dist.components.hooks
.filter((f) => f.relativePath !== "hooks/hooks.json")
- .map((f) =>
- flatHooksPathWithLoaderEntry(
- flatHooksDir,
- plugins.flatHooksLoaderEntry,
- name,
- f.relativePath
- )
+ .map(
+ (f) =>
+ plugins.flatHooksPath?.(name, f.relativePath) ??
+ flatHooksPathWithLoaderEntry(
+ flatHooksDir,
+ plugins.flatHooksLoaderEntry,
+ name,
+ f.relativePath
+ )
)
);
const bridge = plugins.flatHooksBridge;
diff --git a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts
index 3d05e378c..14002596d 100644
--- a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts
+++ b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts
@@ -7,16 +7,22 @@ import type { Hasher } from "../../../../../kernel/ports/hasher.js";
import type { PluginSource } from "../../../../../kernel/source.js";
import type { AiToolId } from "../../../../../kernel/tool.js";
import type { PluginsCapability } from "../../../../tools/domain/capabilities/plugins-capability.js";
-import { getToolConfig, isAiTool } from "../../../../tools/domain/registry.js";
+import {
+ getToolConfig,
+ isAiTool,
+ resolvePluginsCapability,
+} from "../../../../tools/domain/registry.js";
import { PluginContentTranslator } from "../../../../translate/domain/content-translator.js";
import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js";
import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js";
import type { Manifest } from "../../../domain/manifest.js";
import {
InstalledPlugin,
+ type KiloTuiProvenance,
type PluginScope,
type ProjectHooksProvenance,
} from "../../../domain/plugins/installed-plugin.js";
+import { planKiloTuiInstall } from "../../ownership/tui/kilo-tui-config.js";
import { isPluginFileAtDesiredState } from "../../plugin/plugin-helpers.js";
import {
resolveBaseDirFromRecord,
@@ -54,10 +60,18 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator {
previousMcpEntries: ReadonlyMap = new Map(),
userScopeDirTaken = false,
previousProjectHooks?: ProjectHooksProvenance,
- hooksGuard: ProjectHooksGuard = {}
+ hooksGuard: ProjectHooksGuard = {},
+ previousTui?: KiloTuiProvenance
): Promise<{ skipped: ReadonlySkipList; written?: number }> {
const ctx = this.resolveFlatToolContext(toolId, dist, projectRoot);
if (ctx === null) return { skipped: [] };
+ const tui = resolvePluginsCapability(toolId)?.startupTui;
+ const isKiloTui = tui?.plugin === dist.manifest.name && !userScopeDirTaken;
+ previousTui ??= manifest
+ .getPlugins(toolId)
+ .find((p) => p.name === dist.manifest.name)?.tuiPlugin;
+ if (isKiloTui)
+ await planKiloTuiInstall(this.fs, join(projectRoot, tui.directory), projectRoot, previousTui);
const mcp = await materializeFlatMcp(
this.fs,
this.hasher,
@@ -90,6 +104,22 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator {
ctx.scope,
manifest
);
+ if (isKiloTui) {
+ const plan = await planKiloTuiInstall(
+ this.fs,
+ join(projectRoot, tui.directory),
+ projectRoot,
+ previousTui
+ );
+ if (
+ !(await this.fs.fileExists(plan.path)) ||
+ (await this.fs.readFile(plan.path)) !== plan.content
+ )
+ await this.fs.writeFile(plan.path, plan.content);
+ const plugin = manifest.getPlugins(toolId).find((p) => p.name === dist.manifest.name);
+ if (plugin !== undefined)
+ manifest.updatePlugin(toolId, plugin.withTuiPlugin(plan.provenance));
+ }
return { skipped: allSkipped, written };
}
diff --git a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts
index e0ae16eb0..faea499ff 100644
--- a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts
+++ b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts
@@ -28,6 +28,7 @@ export interface PluginTranslator {
previousMcpEntries?: ReadonlyMap,
userScopeDirTaken?: boolean,
previousProjectHooks?: ProjectHooksProvenance,
- hooksGuard?: ProjectHooksGuard
+ hooksGuard?: ProjectHooksGuard,
+ previousTui?: import("../../../domain/plugins/installed-plugin.js").KiloTuiProvenance
): Promise<{ skipped: ReadonlySkipList; written?: number }>;
}
diff --git a/cli/src/contexts/framework/application/ownership/global-plugin-files.ts b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts
new file mode 100644
index 000000000..42665e7c6
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts
@@ -0,0 +1,97 @@
+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 { 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";
+
+/**
+ * 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 readGlobalPluginFiles(
+ fs: FileReader,
+ hasher: Hasher,
+ builtDir: string,
+ toolId: AiToolId,
+ dist: PluginDistribution
+): Promise {
+ const machine = globalFilesOf(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. Creates the user
+ * directory itself when the host has not yet.
+ */
+export async function assertGlobalFilesWritable(
+ fs: FileReader & FileWriter,
+ toolId: AiToolId,
+ plugin: Pick | undefined,
+ pluginName: string,
+ files: readonly InstallationFile[],
+ homedir: string
+): Promise {
+ 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);
+ 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;
+ 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/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/project-path-boundary.ts b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts
index 71b04503c..8510bc1d6 100644
--- a/cli/src/contexts/framework/application/ownership/project-path-boundary.ts
+++ b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts
@@ -1,4 +1,5 @@
-import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
+import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
+import { samePath } from "../../../../kernel/paths.js";
import type { FileReader } from "../../../../kernel/ports/file-reader.js";
/** Resolve an existing file or its nearest existing parent before mutating project-local content. */
@@ -25,6 +26,10 @@ export async function assertProjectPathWithinRoot(
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ // A dangling symlink exists even though realpath cannot resolve it. Never treat it
+ // as an absent path whose parent is safe to write through.
+ if (await fs.entryExists?.(candidate))
+ throw new Error(`'${candidate}' is an unresolved symlink; local mutation refused.`);
if (candidate === lexicalRoot) {
throw new Error(`Project root '${projectRoot}' disappeared; local mutation refused.`);
}
@@ -33,6 +38,35 @@ export async function assertProjectPathWithinRoot(
}
}
+/** A tracked file or disposable cache is not owned if any component became a symlink. */
+export async function assertNoSymlinksWithinRoot(
+ fs: FileReader,
+ root: string,
+ path: string
+): Promise {
+ const lexicalRoot = resolve(root);
+ const lexicalPath = resolve(path);
+ if (!strictlyWithin(lexicalRoot, lexicalPath))
+ throw new Error(`'${path}' escapes its owned root '${root}'.`);
+ let canonical = await fs.realpath(lexicalRoot);
+ let candidate = lexicalRoot;
+ for (const segment of relative(lexicalRoot, lexicalPath).split(sep)) {
+ candidate = join(candidate, segment);
+ canonical = join(canonical, segment);
+ try {
+ if (!samePath(await fs.realpath(candidate), canonical))
+ throw new Error(`'${candidate}' is a symlink; refusing to mutate it.`);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ if (!fs.entryExists || (await fs.entryExists(candidate)))
+ throw new Error(
+ `'${candidate}' cannot be verified without following an unresolved symlink.`
+ );
+ return;
+ }
+ }
+}
+
function within(root: string, path: string): boolean {
const rel = relative(root, path);
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
diff --git a/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts
index d2bb2b54c..c636ab609 100644
--- a/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts
+++ b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts
@@ -4,7 +4,11 @@ import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { AiToolId, ToolId } from "../../../../kernel/tool.js";
import type { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js";
import { unmergeOpencodeMcp } from "../../../tools/domain/formats/opencode-mcp-merge.js";
-import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js";
+import {
+ getToolConfig,
+ isAiTool,
+ resolvePluginsCapability,
+} from "../../../tools/domain/registry.js";
import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js";
@@ -12,8 +16,46 @@ import { isFrameworkPrimeFlatMcp } from "../plugin/plugin-target-resolution.js";
import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js";
import { detachNativePluginRefs } from "./native-plugin-ownership.js";
import { assertProjectPathWithinRoot } from "./project-path-boundary.js";
+import { planKiloTuiInstall, planKiloTuiRemoval } from "./tui/kilo-tui-config.js";
import { detachUserPlugin } from "./user-plugin-ownership.js";
+export async function assertProjectTuiUnchanged(
+ fs: FileReader,
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string
+): Promise {
+ if (plugin.tuiPlugin === undefined) return;
+ const tui = resolvePluginsCapability(toolId)?.startupTui;
+ if (tui?.plugin !== plugin.name || plugin.scope !== "project")
+ throw new Error(`${toolId}: cannot locate the owned TUI configuration.`);
+ await planKiloTuiInstall(fs, join(projectRoot, tui.directory), projectRoot, plugin.tuiPlugin);
+}
+
+export async function planProjectKiloTuiRemoval(
+ fs: FileReader,
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string
+) {
+ const tui = resolvePluginsCapability(toolId)?.startupTui;
+ if (tui?.plugin !== plugin.name || plugin.scope !== "project" || plugin.tuiPlugin === undefined)
+ return undefined;
+ return planKiloTuiRemoval(fs, join(projectRoot, tui.directory), projectRoot, plugin.tuiPlugin);
+}
+
+export async function removeProjectKiloTui(
+ fs: FileReader & FileWriter,
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string
+): Promise {
+ const plan = await planProjectKiloTuiRemoval(fs, plugin, toolId, projectRoot);
+ if (plan === undefined) return false;
+ await fs.writeFile(plan.path, plan.content);
+ return true;
+}
+
/** Read-only OpenCode MCP proof, shared by Clean and local plugin removals before any detach. */
export async function assertProjectMcpEntriesRemovable(
fs: FileReader,
@@ -60,6 +102,7 @@ export class ProjectPluginCleanup {
): Promise {
await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot);
await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot);
+ await planProjectKiloTuiRemoval(this.fs, plugin, toolId, projectRoot);
}
async removeLocalIntegration(
@@ -68,6 +111,7 @@ export class ProjectPluginCleanup {
projectRoot: string
): Promise {
const mcpUpdate = await planProjectMcpRemoval(this.fs, plugin, toolId, projectRoot);
+ await removeProjectKiloTui(this.fs, plugin, toolId, projectRoot);
await removeProjectHooks(this.fs, plugin, toolId, projectRoot);
if (mcpUpdate !== undefined) {
await assertProjectPathWithinRoot(this.fs, projectRoot, mcpUpdate.output);
diff --git a/cli/src/contexts/framework/application/ownership/release/pinned-user-framework.ts b/cli/src/contexts/framework/application/ownership/release/pinned-user-framework.ts
new file mode 100644
index 000000000..57b51551b
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/release/pinned-user-framework.ts
@@ -0,0 +1,21 @@
+import type { InstallationFile } from "../../../../../kernel/file.js";
+import type { AiToolId } from "../../../../../kernel/tool.js";
+import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js";
+
+export interface PinnedUserFrameworkResult {
+ readonly sha: string;
+ readonly dist: PluginDistribution;
+ readonly files: readonly InstallationFile[];
+}
+
+/** The catalogue, plugin manifest and built files come from one isolated checkout. */
+export interface PinnedUserFramework {
+ build(
+ ref: string,
+ toolId: AiToolId,
+ pluginName: string,
+ sha?: string
+ ): Promise;
+ /** Reuse private checkout/build trees only for this operation. */
+ withSession?(action: (session: PinnedUserFramework) => Promise): Promise;
+}
diff --git a/cli/src/contexts/framework/application/ownership/release/upgrade-user-framework-use-case.ts b/cli/src/contexts/framework/application/ownership/release/upgrade-user-framework-use-case.ts
new file mode 100644
index 000000000..d58f95082
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/release/upgrade-user-framework-use-case.ts
@@ -0,0 +1,178 @@
+import type { Logger } from "../../../../../kernel/ports/logger.js";
+import { compareSemver, isSemver } from "../../../../../kernel/semver.js";
+import { type AiToolId, isAiToolId } from "../../../../../kernel/tool.js";
+import { FRAMEWORK_MARKETPLACE_NAME } from "../../../../distribution/domain/marketplace.js";
+import { frameworkBuildModeFor, globalFilesOf } from "../../../../tools/domain/registry.js";
+import { Manifest } from "../../../domain/manifest.js";
+import { InstalledPlugin } from "../../../domain/plugins/installed-plugin.js";
+import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
+import type { UserPluginFileUpdater } from "../user-plugin-file-updater.js";
+import type { PinnedUserFramework } from "./pinned-user-framework.js";
+import type { UserReleaseJournal } from "./user-release-journal.js";
+
+export interface UserReleasePreview {
+ readonly release: string;
+ readonly title: string | null;
+ readonly targets: readonly {
+ toolId: AiToolId;
+ name: string;
+ currentRef: string;
+ currentVersion: string;
+ targetVersion: string;
+ dependents: readonly string[];
+ }[];
+ readonly nativeSkipped: number;
+}
+
+/** Updates the individually owned flat copies, without moving a shared marketplace or native claim. */
+export class UpgradeUserFrameworkUseCase {
+ constructor(
+ private readonly deps: {
+ repo: ManifestRepository;
+ builder: PinnedUserFramework;
+ updater: UserPluginFileUpdater;
+ journal: UserReleaseJournal;
+ logger: Logger;
+ resolvePublishedRelease: (
+ repo: string,
+ release: string
+ ) => Promise<{ title: string | null } | null>;
+ }
+ ) {}
+
+ async execute(options: {
+ projectRoot: string;
+ release: string;
+ confirm: (preview: UserReleasePreview) => Promise;
+ }): Promise<{ updated: string[]; nativeSkipped: number; cancelled: boolean }> {
+ const run = () => {
+ const builder = this.deps.builder;
+ return builder.withSession
+ ? builder.withSession((session) => this.executeLocked(options, session))
+ : this.executeLocked(options, builder);
+ };
+ return this.deps.repo.withExclusiveAccess ? this.deps.repo.withExclusiveAccess(run) : run();
+ }
+
+ private async executeLocked(
+ options: {
+ projectRoot: string;
+ release: string;
+ confirm: (preview: UserReleasePreview) => Promise;
+ },
+ builder: PinnedUserFramework
+ ): Promise<{ updated: string[]; nativeSkipped: number; cancelled: boolean }> {
+ const { release, projectRoot, confirm } = options;
+ if (!release.startsWith("v") || !isSemver(release))
+ throw new Error("A root release tag such as v5.13.0 is required.");
+ const loaded = await this.deps.repo.load();
+ if (!loaded) throw new Error("No machine installation manifest exists.");
+ let manifest = Manifest.fromJSON(loaded.toJSON());
+ const publishedRelease = await this.deps.resolvePublishedRelease(
+ "ai-driven-dev/framework",
+ release
+ );
+ if (!publishedRelease)
+ throw new Error(`Release ${release} is not available from the official source.`);
+ const nativeSkipped = manifest
+ .getInstalledToolIds()
+ .reduce(
+ (total, tool) =>
+ total +
+ (isAiToolId(tool)
+ ? (manifest.getNativeRegistrations(tool)?.pluginClaims ?? []).filter((claim) =>
+ claim.ref.endsWith(`@${FRAMEWORK_MARKETPLACE_NAME}`)
+ ).length
+ : 0),
+ 0
+ );
+ const targets = manifest.getInstalledToolIds().flatMap((toolId) => {
+ if (!isAiToolId(toolId) || !globalFilesOf(toolId) || frameworkBuildModeFor(toolId) !== "flat")
+ return [];
+ return manifest
+ .getPlugins(toolId)
+ .filter(
+ (plugin) => plugin.scope === "user" && plugin.marketplace === FRAMEWORK_MARKETPLACE_NAME
+ )
+ .map((plugin) => ({ toolId, plugin }));
+ });
+ if (targets.length === 0)
+ throw new Error(
+ "No flat machine-scoped AIDD plugins found; native installations need their owning tool."
+ );
+ const plans = [];
+ let sha: string | undefined;
+ for (const { toolId, plugin } of targets) {
+ const source = plugin.source;
+ if (plugin.mcpEntries.size || plugin.projectHooks !== undefined || plugin.componentPaths.size)
+ throw new Error(
+ `${toolId}:${plugin.name}: machine ownership includes unsupported contributions; update refused.`
+ );
+ if (
+ source.kind !== "git-subdir" ||
+ source.url !== "https://github.com/ai-driven-dev/framework.git" ||
+ source.path !== `plugins/${plugin.name}` ||
+ !source.ref ||
+ !source.ref.startsWith("v") ||
+ !isSemver(source.ref)
+ )
+ throw new Error(
+ `${toolId}:${plugin.name}: current source is not a pinned official release.`
+ );
+ if (compareSemver(release, source.ref) < 0)
+ throw new Error(`${toolId}:${plugin.name}: refusing a release downgrade.`);
+ const prepared = await builder.build(release, toolId, plugin.name, sha);
+ sha ??= prepared.sha;
+ if (compareSemver(prepared.dist.manifest.version, plugin.version) < 0)
+ throw new Error(`${toolId}:${plugin.name}: refusing a plugin downgrade.`);
+ const candidate = InstalledPlugin.fromJSON({
+ ...plugin.toJSON(),
+ source: { ...source, ref: release, sha: prepared.sha },
+ });
+ const plan = await this.deps.updater.planUpdate(
+ candidate,
+ toolId,
+ projectRoot,
+ this.deps.logger,
+ true,
+ prepared
+ );
+ if (!plan) throw new Error(`${toolId}:${plugin.name}: unable to plan machine update.`);
+ if (JSON.stringify(plugin.tuiPlugin) !== JSON.stringify(plan.next.tuiPlugin))
+ throw new Error(
+ `${toolId}:${plugin.name}: release would change a shared TUI configuration; update refused.`
+ );
+ plans.push({ toolId, plugin, plan, currentRef: source.ref });
+ }
+ const preview: UserReleasePreview = {
+ release,
+ title: publishedRelease.title,
+ nativeSkipped,
+ targets: plans.map(({ toolId, plugin, plan, currentRef }) => ({
+ toolId,
+ name: plugin.name,
+ currentRef,
+ currentVersion: plugin.version,
+ targetVersion: plan.next.version,
+ dependents: plugin.dependents,
+ })),
+ };
+ if (!(await confirm(preview))) return { updated: [], nativeSkipped, cancelled: true };
+ const updated: string[] = [];
+ for (const { toolId, plugin, plan } of plans) {
+ const next = Manifest.fromJSON(manifest.toJSON());
+ next.updatePlugin(toolId, plan.next);
+ try {
+ await this.deps.journal.commit(manifest, next, toolId, plugin.name, plan.files);
+ } catch (error) {
+ throw new Error(
+ `Machine release update incomplete. Completed: ${updated.join(", ") || "none"}. Retry to recover.`,
+ { cause: error }
+ );
+ }
+ manifest = next;
+ updated.push(`${toolId}:${plugin.name}`);
+ }
+ return { updated, nativeSkipped, cancelled: false };
+ }
+}
diff --git a/cli/src/contexts/framework/application/ownership/release/user-release-journal.ts b/cli/src/contexts/framework/application/ownership/release/user-release-journal.ts
new file mode 100644
index 000000000..6a43f1fa6
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/release/user-release-journal.ts
@@ -0,0 +1,240 @@
+import { join, posix } from "node:path";
+import type { 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, isAiToolId } from "../../../../../kernel/tool.js";
+import { globalFilesOf } from "../../../../tools/domain/registry.js";
+import { Manifest } from "../../../domain/manifest.js";
+import { PLUGIN_NAME_REGEX } from "../../../domain/plugins/installed-plugin.js";
+import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
+import { assertProjectPathWithinRoot } from "../project-path-boundary.js";
+
+interface JournalFile {
+ readonly path: string;
+ readonly before: string | null;
+ readonly after: string | null;
+ readonly content: string | null;
+}
+
+interface Journal {
+ readonly tool: AiToolId;
+ readonly plugin: string;
+ readonly boundary: string;
+ readonly oldManifest: unknown;
+ readonly nextManifest: unknown;
+ readonly files: readonly JournalFile[];
+}
+
+export interface UserReleaseJournalStore {
+ read(): Promise;
+ create(content: string): Promise;
+ remove(): Promise;
+}
+
+const digest = /^[a-f0-9]{32}$/;
+
+function object(value: unknown): Record {
+ if (value === null || typeof value !== "object" || Array.isArray(value))
+ throw new Error("Invalid user release journal; refusing recovery.");
+ return value as Record;
+}
+
+function parseJournal(raw: string): Journal {
+ const value = object(JSON.parse(raw));
+ if (
+ typeof value.tool !== "string" ||
+ !isAiToolId(value.tool) ||
+ typeof value.plugin !== "string" ||
+ !PLUGIN_NAME_REGEX.test(value.plugin) ||
+ typeof value.boundary !== "string" ||
+ !Array.isArray(value.files)
+ )
+ throw new Error("Invalid user release journal; refusing recovery.");
+ const files = value.files.map((entry): JournalFile => {
+ const file = object(entry);
+ if (
+ typeof file.path !== "string" ||
+ posix.isAbsolute(file.path) ||
+ file.path.includes("\\") ||
+ posix.normalize(file.path) !== file.path ||
+ file.path === "." ||
+ file.path.startsWith("../") ||
+ !(file.before === null || (typeof file.before === "string" && digest.test(file.before))) ||
+ !(file.after === null || (typeof file.after === "string" && digest.test(file.after))) ||
+ !(file.content === null || typeof file.content === "string") ||
+ (file.after === null) !== (file.content === null)
+ )
+ throw new Error("Invalid user release journal file; refusing recovery.");
+ return { path: file.path, before: file.before, after: file.after, content: file.content };
+ });
+ return {
+ tool: value.tool,
+ plugin: value.plugin,
+ boundary: value.boundary,
+ oldManifest: value.oldManifest,
+ nextManifest: value.nextManifest,
+ files,
+ };
+}
+
+/** Roll forward from recorded old/new bytes; detect edits between checks and retry attempts. */
+export class UserReleaseJournal {
+ constructor(
+ private readonly fs: FileReader & FileWriter,
+ private readonly hasher: Hasher,
+ private readonly repo: ManifestRepository,
+ private readonly context: { home: () => string; store: UserReleaseJournalStore }
+ ) {}
+
+ async commit(
+ oldManifest: Manifest,
+ nextManifest: Manifest,
+ tool: AiToolId,
+ plugin: string,
+ files: readonly InstallationFile[]
+ ): Promise {
+ if ((await this.context.store.read()) !== null)
+ throw new Error("A user release update needs recovery first.");
+ const old = oldManifest.getPlugins(tool).find((item) => item.name === plugin);
+ const next = nextManifest.getPlugins(tool).find((item) => item.name === plugin);
+ const machine = globalFilesOf(tool);
+ if (!old || !next || !machine || old.scope !== "user" || next.scope !== "user")
+ throw new Error("Cannot journal an unowned machine plugin.");
+ const boundary = await this.fs.realpath(machine.dir(this.context.home()));
+ const byPath = new Map(files.map((file) => [file.relativePath, file]));
+ if (byPath.size !== files.length || byPath.size !== next.files.size)
+ throw new Error("Duplicate or missing machine plugin files.");
+ const paths = new Set([...old.files.keys(), ...next.files.keys()]);
+ const entries: JournalFile[] = [];
+ for (const path of paths) {
+ const file = byPath.get(path);
+ const after = next.files.get(path) ?? null;
+ if (
+ (file?.hash.value ?? null) !== after ||
+ (file && this.hasher.hash(file.content).value !== after)
+ )
+ throw new Error(`Unverified target file '${path}'.`);
+ entries.push({
+ path,
+ before: old.files.get(path) ?? null,
+ after,
+ content: file?.content ?? null,
+ });
+ }
+ const journal = parseJournal(
+ JSON.stringify({
+ tool,
+ plugin,
+ boundary,
+ oldManifest: oldManifest.toJSON(),
+ nextManifest: nextManifest.toJSON(),
+ files: entries,
+ })
+ );
+ await this.validate(journal, true);
+ await this.context.store.create(JSON.stringify(journal));
+ await this.apply(journal);
+ }
+
+ async recover(): Promise {
+ const content = await this.context.store.read();
+ if (content === null) return;
+ await this.apply(parseJournal(content));
+ }
+
+ private async validate(journal: Journal, starting = false): Promise {
+ const old = Manifest.fromJSON(journal.oldManifest);
+ const next = Manifest.fromJSON(journal.nextManifest);
+ const beforePlugin = old.getPlugins(journal.tool).find((item) => item.name === journal.plugin);
+ const afterPlugin = next.getPlugins(journal.tool).find((item) => item.name === journal.plugin);
+ const machine = globalFilesOf(journal.tool);
+ if (
+ !beforePlugin ||
+ !afterPlugin ||
+ !machine ||
+ beforePlugin.scope !== "user" ||
+ afterPlugin.scope !== "user"
+ )
+ throw new Error("Journal does not identify an owned machine plugin.");
+ const reverted = Manifest.fromJSON(next.toJSON());
+ reverted.updatePlugin(journal.tool, beforePlugin);
+ if (JSON.stringify(reverted.toJSON()) !== JSON.stringify(old.toJSON()))
+ throw new Error("Journal changes other machine ownership; recovery refused.");
+ const root = machine.dir(this.context.home());
+ if ((await this.fs.realpath(root)) !== journal.boundary)
+ throw new Error("Machine directory changed; release recovery refused.");
+ const paths = new Set([...beforePlugin.files.keys(), ...afterPlugin.files.keys()]);
+ if (
+ new Set(journal.files.map((file) => file.path)).size !== paths.size ||
+ journal.files.length !== paths.size
+ )
+ throw new Error("Journal file list does not match machine ownership.");
+ const foreign = new Set(
+ old
+ .getPlugins(journal.tool)
+ .filter((item) => item.name !== journal.plugin)
+ .flatMap((item) => [...item.files.keys()])
+ );
+ for (const file of old.getToolFiles(journal.tool)) foreign.add(file.relativePath);
+ for (const file of old.getMergeFiles(journal.tool)) foreign.add(file.relativePath);
+ for (const file of journal.files) {
+ if (
+ !paths.has(file.path) ||
+ foreign.has(file.path) ||
+ (beforePlugin.files.get(file.path) ?? null) !== file.before ||
+ (afterPlugin.files.get(file.path) ?? null) !== file.after ||
+ (file.content !== null && this.hasher.hash(file.content).value !== file.after)
+ )
+ throw new Error(`Journal ownership or digest mismatch for '${file.path}'.`);
+ await assertProjectPathWithinRoot(this.fs, root, join(root, file.path));
+ }
+ const current = await this.repo.load();
+ if (
+ !current ||
+ ![old, next].some(
+ (item) => JSON.stringify(item.toJSON()) === JSON.stringify(current.toJSON())
+ )
+ )
+ throw new Error("Machine manifest changed during release update; recovery refused.");
+ if (starting && JSON.stringify(current.toJSON()) !== JSON.stringify(old.toJSON()))
+ throw new Error("Machine manifest changed before release update.");
+ for (const file of journal.files) await this.assertState(root, file, starting);
+ }
+
+ private async assertState(root: string, file: JournalFile, starting = false): Promise {
+ const path = join(root, file.path);
+ await assertProjectPathWithinRoot(this.fs, root, path);
+ const current = (await this.fs.fileExists(path))
+ ? (await this.fs.readFileHash(path)).value
+ : null;
+ if (current !== file.before && (starting || current !== file.after))
+ throw new Error(`'${path}' changed during release update; recovery refused.`);
+ }
+
+ private async apply(journal: Journal): Promise {
+ await this.validate(journal);
+ const machine = globalFilesOf(journal.tool);
+ if (!machine) throw new Error("Machine tool is no longer available.");
+ const root = machine.dir(this.context.home());
+ for (const file of journal.files) {
+ await this.assertState(root, file);
+ const path = join(root, file.path);
+ const current = (await this.fs.fileExists(path))
+ ? (await this.fs.readFileHash(path)).value
+ : null;
+ if (current === file.after) continue;
+ if (file.content === null) await this.fs.deleteFile(path);
+ else await this.fs.writeFile(path, file.content);
+ const applied = (await this.fs.fileExists(path))
+ ? (await this.fs.readFileHash(path)).value
+ : null;
+ if (applied !== file.after) throw new Error(`Failed to apply '${path}'; retry to recover.`);
+ }
+ // Other AIDD writes use the same lock. A non-cooperating editor may still change the
+ // manifest, so check it again immediately before saving rather than discard its changes.
+ await this.validate(journal);
+ await this.repo.save(Manifest.fromJSON(journal.nextManifest));
+ await this.context.store.remove();
+ }
+}
diff --git a/cli/src/contexts/framework/application/ownership/tui/kilo-tui-config.ts b/cli/src/contexts/framework/application/ownership/tui/kilo-tui-config.ts
new file mode 100644
index 000000000..e55cc309b
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/tui/kilo-tui-config.ts
@@ -0,0 +1,99 @@
+import { join } from "node:path";
+import { applyEdits, createScanner, modify, parse, SyntaxKind } from "jsonc-parser";
+import type { FileReader } from "../../../../../kernel/ports/file-reader.js";
+import type { KiloTuiProvenance } from "../../../domain/plugins/installed-plugin.js";
+import { assertProjectPathWithinRoot } from "../project-path-boundary.js";
+
+export interface KiloTuiPlan {
+ readonly path: string;
+ readonly content: string;
+ readonly provenance: KiloTuiProvenance;
+}
+
+const entry = "./hooks/aidd-maintenance/kilo-tui.js" as const;
+
+function comments(source: string): string[] {
+ const scanner = createScanner(source);
+ const result: string[] = [];
+ for (let token = scanner.scan(); token !== SyntaxKind.EOF; token = scanner.scan()) {
+ if (token === SyntaxKind.LineCommentTrivia || token === SyntaxKind.BlockCommentTrivia)
+ result.push(scanner.getTokenValue());
+ }
+ return result;
+}
+
+function edit(source: string, index: number, value: string | undefined): string {
+ const updated = applyEdits(
+ source,
+ modify(source, ["plugin", index], value, {
+ isArrayInsertion: value !== undefined,
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
+ })
+ );
+ if (JSON.stringify(comments(source)) !== JSON.stringify(comments(updated)))
+ throw new Error("Kilo TUI config: refusing an edit that would change user comments.");
+ return updated;
+}
+
+async function readConfig(
+ fs: FileReader,
+ dir: string,
+ boundary: string,
+ previous?: KiloTuiProvenance
+) {
+ await assertProjectPathWithinRoot(fs, boundary, join(dir, "tui.json"));
+ await assertProjectPathWithinRoot(fs, boundary, join(dir, "tui.jsonc"));
+ const json = await fs.fileExists(join(dir, "tui.json"));
+ const jsonc = await fs.fileExists(join(dir, "tui.jsonc"));
+ if (json && jsonc) throw new Error("Kilo TUI: both tui.json and tui.jsonc exist.");
+ const config = json ? "tui.json" : jsonc ? "tui.jsonc" : (previous?.config ?? "tui.jsonc");
+ if (previous !== undefined && previous.config !== config)
+ throw new Error("Kilo TUI: the tracked configuration changed; refusing to modify it.");
+ const path = join(dir, config);
+ const source = json || jsonc ? await fs.readFile(path) : "{}\n";
+ const errors: { error: number; offset: number; length: number }[] = [];
+ const value: unknown = parse(source, errors, { allowTrailingComma: true });
+ if (errors.length || value === null || typeof value !== "object" || Array.isArray(value))
+ throw new Error("Kilo TUI: invalid configuration; refusing to modify it.");
+ const plugins = (value as Record).plugin;
+ if (
+ plugins !== undefined &&
+ (!Array.isArray(plugins) || plugins.some((p) => typeof p !== "string"))
+ )
+ throw new Error("Kilo TUI: plugin must be an array of strings.");
+ return { path, source, plugins: (plugins ?? []) as string[], config };
+}
+
+/** Plans without writing, so collision checks precede all installation mutations. */
+export async function planKiloTuiInstall(
+ fs: FileReader,
+ dir: string,
+ boundary: string,
+ previous?: KiloTuiProvenance
+): Promise {
+ if (previous !== undefined && previous.entry !== entry)
+ throw new Error("Kilo TUI: invalid recorded entry.");
+ const { path, source, plugins, config } = await readConfig(fs, dir, boundary, previous);
+ const count = plugins.filter((p) => p === entry).length;
+ if (count > 1 || (count === 1 && previous === undefined))
+ throw new Error("Kilo TUI: maintenance entry already exists without ownership.");
+ if (count === 0 && previous !== undefined)
+ throw new Error("Kilo TUI: tracked entry was removed; refusing to restore it.");
+ const content = count === 1 ? source : edit(source, plugins.length, entry);
+ return { path, content, provenance: { config, entry } };
+}
+
+/** Refuses changed or duplicate entries, and removes only the tracked entry. */
+export async function planKiloTuiRemoval(
+ fs: FileReader,
+ dir: string,
+ boundary: string,
+ provenance: KiloTuiProvenance
+): Promise {
+ if (provenance.entry !== entry) throw new Error("Kilo TUI: invalid recorded entry.");
+ const { path, source, plugins } = await readConfig(fs, dir, boundary, provenance);
+ const index = plugins.indexOf(entry);
+ if (index === -1 || plugins.lastIndexOf(entry) !== index)
+ throw new Error("Kilo TUI: tracked entry changed; refusing removal.");
+ return { path, content: edit(source, index, undefined), provenance };
+}
diff --git a/cli/src/contexts/framework/application/ownership/tui/user-kilo-tui-removal.ts b/cli/src/contexts/framework/application/ownership/tui/user-kilo-tui-removal.ts
new file mode 100644
index 000000000..f5a069804
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/tui/user-kilo-tui-removal.ts
@@ -0,0 +1,33 @@
+import type { FileReader } from "../../../../../kernel/ports/file-reader.js";
+import type { FileWriter } from "../../../../../kernel/ports/file-writer.js";
+import type { AiToolId } from "../../../../../kernel/tool.js";
+import { globalFilesOf, resolvePluginsCapability } from "../../../../tools/domain/registry.js";
+import type { InstalledPlugin } from "../../../domain/plugins/installed-plugin.js";
+import { planKiloTuiRemoval } from "./kilo-tui-config.js";
+
+export async function planUserKiloTuiRemoval(
+ fs: FileReader,
+ toolId: AiToolId,
+ plugin: InstalledPlugin,
+ home: string
+): Promise {
+ if (plugin.tuiPlugin === undefined) return;
+ const machine = globalFilesOf(toolId);
+ if (resolvePluginsCapability(toolId)?.startupTui?.plugin !== plugin.name || machine === null)
+ throw new Error(`${toolId}: cannot locate the owned TUI configuration.`);
+ await planKiloTuiRemoval(fs, machine.dir(home), home, plugin.tuiPlugin);
+}
+
+export async function removeUserKiloTui(
+ fs: FileReader & FileWriter,
+ toolId: AiToolId,
+ plugin: InstalledPlugin,
+ home: string
+): Promise {
+ if (plugin.tuiPlugin === undefined) return;
+ const machine = globalFilesOf(toolId);
+ if (resolvePluginsCapability(toolId)?.startupTui?.plugin !== plugin.name || machine === null)
+ throw new Error(`${toolId}: cannot locate the owned TUI configuration.`);
+ const plan = await planKiloTuiRemoval(fs, machine.dir(home), home, plugin.tuiPlugin);
+ await fs.writeFile(plan.path, plan.content);
+}
diff --git a/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts
index dfe15769b..c31329ffb 100644
--- a/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts
+++ b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts
@@ -20,6 +20,7 @@ import type {
import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js";
import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js";
import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js";
+import { planUserKiloTuiRemoval, removeUserKiloTui } from "./tui/user-kilo-tui-removal.js";
export class UserMarketplaceRemoveUseCase {
constructor(
@@ -99,6 +100,7 @@ export class UserMarketplaceRemoveUseCase {
);
const safeFiles = await Promise.all(
plugins.map(async ({ toolId, plugin }) => {
+ await planUserKiloTuiRemoval(this.fs, toolId, plugin, resolveHomeDir());
const files = await userScopeFilesSafeToDelete(
this.fs,
{ debug() {}, info() {}, warn() {} },
@@ -132,6 +134,7 @@ export class UserMarketplaceRemoveUseCase {
activator.removeMarketplace(registration.hostName, "user");
}
for (const { toolId, plugin, files } of safeFiles) {
+ await removeUserKiloTui(this.fs, toolId, plugin, resolveHomeDir());
await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user");
manifest.removePlugin(toolId, plugin.name);
}
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..a9c10ba80 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
@@ -1,20 +1,22 @@
import { homedir as nodeHomedir } from "node:os";
-import { join } from "node:path";
+import { dirname, join, relative } from "node:path";
import type { InstallationFile } from "../../../../kernel/file.js";
+import { samePath } from "../../../../kernel/paths.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 { Logger } from "../../../../kernel/ports/logger.js";
-import { compareSemver } from "../../../../kernel/semver.js";
+import { compareSemver, isSemver } from "../../../../kernel/semver.js";
import type { AiToolId } from "../../../../kernel/tool.js";
import {
frameworkBuildModeFor,
getToolConfig,
+ globalFilesOf,
resolvePluginsCapability,
} 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,
@@ -28,6 +30,12 @@ import {
assertUserScopeWriteBoundary,
userScopeFilesSafeToDelete,
} from "../shared/user-scope-plugin-files.js";
+import { assertGlobalFilesWritable, readGlobalPluginFiles } from "./global-plugin-files.js";
+import type {
+ PinnedUserFramework,
+ PinnedUserFrameworkResult,
+} from "./release/pinned-user-framework.js";
+import { planKiloTuiInstall } from "./tui/kilo-tui-config.js";
export interface PlannedUserPluginFileUpdate {
readonly plugin: InstalledPlugin;
@@ -47,6 +55,11 @@ export class UserPluginFileUpdater {
private readonly builtDeps?: BuiltMaterializationDeps
) {}
+ async withPinnedSession(action: (pinned?: PinnedUserFramework) => Promise): Promise {
+ const pinned = this.builtDeps?.pinnedFramework;
+ return pinned?.withSession ? pinned.withSession(action) : action(pinned);
+ }
+
async update(
plugin: InstalledPlugin,
toolId: AiToolId,
@@ -61,26 +74,57 @@ export class UserPluginFileUpdater {
plugin: InstalledPlugin,
toolId: AiToolId,
projectRoot: string,
- logger: Logger
+ logger: Logger,
+ rebuild = false,
+ prepared?: PinnedUserFrameworkResult,
+ session?: PinnedUserFramework
): Promise {
- const dist = await this.loader.loadLatest(plugin, projectRoot);
- if (compareSemver(dist.manifest.version, plugin.version) <= 0) return null;
- const { files, componentPaths } = await this.materializeFiles(
- dist,
- toolId,
- plugin,
- projectRoot
- );
+ const source = plugin.source;
+ const cacheRoot = this.builtDeps?.userCacheRoot?.() ?? projectRoot;
+ const builder = session ?? this.builtDeps?.pinnedFramework;
+ const pinned =
+ prepared ??
+ (source.kind === "git-subdir" &&
+ source.url === "https://github.com/ai-driven-dev/framework.git" &&
+ source.path === `plugins/${plugin.name}` &&
+ source.ref?.startsWith("v") &&
+ isSemver(source.ref) &&
+ frameworkBuildModeFor(toolId) === "flat" &&
+ globalFilesOf(toolId) !== null &&
+ builder !== undefined
+ ? await builder.build(source.ref, toolId, plugin.name, source.sha)
+ : undefined);
+ if (prepared && (source.kind !== "git-subdir" || source.sha !== prepared.sha))
+ throw new Error("Prepared release does not match the selected plugin commit.");
+ const dist = pinned?.dist ?? (await this.loader.loadLatest(plugin, cacheRoot));
+ if (!rebuild && compareSemver(dist.manifest.version, plugin.version) <= 0) return null;
+ const { files, componentPaths } = pinned
+ ? { files: [...pinned.files], componentPaths: new Map() }
+ : await this.materializeFiles(dist, toolId, plugin, cacheRoot);
const home = (this.builtDeps?.homedir ?? nodeHomedir)();
- const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, plugin, toolId, home);
- if (oldSafe.size !== plugin.files.size) {
+ const tui = resolvePluginsCapability(toolId)?.startupTui;
+ const isKiloMaintenance = tui?.plugin === plugin.name;
+ const hasTui = files.some((file) => file.relativePath === tui?.module);
+ const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home);
+ if (isKiloMaintenance && plugin.tuiPlugin !== undefined && !hasTui) {
throw new Error(
- `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.`
+ "Kilo TUI: the new plugin no longer contains the owned TUI module; update refused."
);
}
- await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, files, home);
- const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home);
- await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files);
+ const tuiPlan =
+ isKiloMaintenance && hasTui
+ ? await planKiloTuiInstall(this.fs, baseDir, home, plugin.tuiPlugin)
+ : undefined;
+ const oldSafe = await this.inspectOwnedFiles(
+ plugin,
+ toolId,
+ home,
+ baseDir,
+ files,
+ logger,
+ prepared === undefined
+ );
+ await this.assertWritable(plugin, toolId, files, home);
return {
plugin,
toolId,
@@ -90,36 +134,43 @@ export class UserPluginFileUpdater {
oldSafe,
next: InstalledPlugin.fromDistribution(
dist,
- plugin.source,
+ pinned && source.kind === "git-subdir" ? { ...source, sha: pinned.sha } : plugin.source,
files,
"user",
componentPaths,
plugin.marketplace
- ).withDependents(plugin.dependents),
+ )
+ .withDependents(plugin.dependents)
+ .withTuiPlugin(tuiPlan?.provenance),
};
}
async applyUpdate(plan: PlannedUserPluginFileUpdate, logger: Logger): Promise {
- const oldSafe = await userScopeFilesSafeToDelete(
- this.fs,
- logger,
+ const oldSafe = await this.inspectOwnedFiles(
plan.plugin,
plan.toolId,
- plan.home
+ plan.home,
+ plan.baseDir,
+ plan.files,
+ logger,
+ plan.oldSafe.size < plan.plugin.files.size
);
- if (oldSafe.size !== plan.plugin.files.size || oldSafe.size !== plan.oldSafe.size) {
+ if (
+ oldSafe.size !== plan.oldSafe.size ||
+ [...oldSafe.keys()].some((path) => !plan.oldSafe.has(path))
+ ) {
throw new Error(
`${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);
+ const tuiPlan =
+ plan.next.tuiPlugin === undefined
+ ? undefined
+ : await planKiloTuiInstall(this.fs, plan.baseDir, plan.home, plan.plugin.tuiPlugin);
+ if (tuiPlan !== undefined && tuiPlan.provenance.config !== plan.next.tuiPlugin?.config) {
+ throw new Error("Kilo TUI: configuration changed since planning; update refused.");
+ }
await writePluginFiles([...plan.files], plan.baseDir, this.fs);
const newPaths = new Set(plan.files.map((file) => file.relativePath));
await deleteOldFiles(
@@ -127,9 +178,113 @@ export class UserPluginFileUpdater {
plan.baseDir,
this.fs
);
+ if (tuiPlan !== undefined) {
+ const current = await planKiloTuiInstall(
+ this.fs,
+ plan.baseDir,
+ plan.home,
+ plan.plugin.tuiPlugin
+ );
+ if (current.provenance.config !== plan.next.tuiPlugin?.config) {
+ throw new Error("Kilo TUI: configuration changed since planning; update incomplete.");
+ }
+ await this.fs.writeFile(current.path, current.content);
+ }
return plan.next;
}
+ private async inspectOwnedFiles(
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ home: string,
+ baseDir: string,
+ files: readonly InstallationFile[],
+ logger: Logger,
+ restoreMissing: boolean
+ ): Promise> {
+ const missing = new Set();
+ if (restoreMissing && plugin.files.size > 0) {
+ const targetPaths = new Set(files.map((file) => file.relativePath));
+ for (const [path, hash] of plugin.files) {
+ if (!targetPaths.has(path) || !/^[0-9a-f]{32}$/.test(hash)) continue;
+ const target = join(baseDir, path);
+ if (await this.fs.fileExists(target)) continue;
+ if (!this.fs.entryExists) throw new Error("Cannot safely inspect missing machine files.");
+ if (!(await this.fs.entryExists(target))) missing.add(path);
+ }
+ }
+ const present = plugin.withFiles(
+ new Map([...plugin.files].filter(([path]) => !missing.has(path)))
+ );
+ const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, present, toolId, home);
+ if (oldSafe.size + missing.size !== plugin.files.size) {
+ throw new Error(
+ `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.`
+ );
+ }
+ if (oldSafe.size > 0) {
+ const root = await this.fs.realpath(baseDir);
+ for (const path of oldSafe.keys()) {
+ if (!samePath(await this.fs.realpath(join(baseDir, path)), join(root, path))) {
+ throw new Error(
+ `${toolId}: '${plugin.name}' file '${path}' resolves through a symlink; update refused.`
+ );
+ }
+ }
+ }
+ return oldSafe;
+ }
+
+ /** 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 {
+ const globalFiles = globalFilesOf(toolId);
+ if (globalFiles !== null) {
+ await assertGlobalFilesWritable(this.fs, toolId, plugin, plugin.name, files, home);
+ await this.assertDirectPaths(toolId, plugin.name, files, globalFiles.dir(home));
+ 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_NAME_REGEX.test(plugin.name)) {
+ await this.fs.createDirectory(join(baseDir, plugin.name));
+ }
+ await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, [...files], home);
+ await this.assertDirectPaths(toolId, plugin.name, files, baseDir);
+ await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files);
+ }
+
+ /** Existing destinations and parents must not redirect writes through user symlinks. */
+ private async assertDirectPaths(
+ toolId: AiToolId,
+ pluginName: string,
+ files: readonly InstallationFile[],
+ base: string
+ ): Promise {
+ const root = await this.fs.realpath(base);
+ for (const file of files) {
+ let candidate = join(base, file.relativePath);
+ while (candidate !== base && !(await this.fs.fileExists(candidate))) {
+ if (!this.fs.entryExists) throw new Error("Cannot safely inspect machine file paths.");
+ if (await this.fs.entryExists(candidate))
+ throw new Error(
+ `${toolId}: '${pluginName}' path '${file.relativePath}' contains an unresolved symlink; update refused.`
+ );
+ candidate = dirname(candidate);
+ }
+ if (!samePath(await this.fs.realpath(candidate), join(root, relative(base, candidate))))
+ throw new Error(
+ `${toolId}: '${pluginName}' path '${file.relativePath}' resolves through a symlink; update refused.`
+ );
+ }
+ }
+
private async assertNewFilesDoNotCollide(
plugin: InstalledPlugin,
toolId: AiToolId,
@@ -138,11 +293,11 @@ 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))) {
- throw new Error(
- `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.`
- );
- }
+ const target = join(baseDir, file.relativePath);
+ if (!(await this.fs.fileExists(target))) continue;
+ throw new Error(
+ `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.`
+ );
}
}
@@ -165,6 +320,10 @@ export class UserPluginFileUpdater {
target: toolId,
mode: frameworkBuildModeFor(toolId),
});
+ 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);
const files =
resolvePluginsCapability(toolId)?.hooksDestination === "project"
@@ -172,6 +331,12 @@ export class UserPluginFileUpdater {
: built;
return { files, componentPaths: new Map() };
}
+ 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`."
+ );
+ }
const toolConfig = getToolConfig(toolId);
const forGlobalFiles =
resolvePluginsCapability(toolId)?.hooksDestination === "project" ? withoutHooks(dist) : dist;
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/ownership/user-plugin-update-use-case.ts b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts
index 2978e3fcf..04e2b8485 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
@@ -6,6 +6,7 @@ import type { ManifestRepository } from "../../domain/ports/manifest-repository.
import { resolvePluginToolIds } from "../plugin/plugin-target-resolution.js";
import type { PluginUpdateOptions } from "../plugin/plugin-update-use-case.js";
import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js";
+import type { PinnedUserFramework } from "./release/pinned-user-framework.js";
import type { UserPluginFileUpdater } from "./user-plugin-file-updater.js";
export class UserPluginUpdateUseCase {
@@ -17,11 +18,15 @@ export class UserPluginUpdateUseCase {
) {}
async execute(options: PluginUpdateOptions): Promise {
- const run = () => this.updateLocked(options);
+ const run = () =>
+ this.fileUpdater.withPinnedSession((session) => this.updateLocked(options, session));
return this.repo.withExclusiveAccess === undefined ? run() : this.repo.withExclusiveAccess(run);
}
- private async updateLocked(options: PluginUpdateOptions): Promise {
+ private async updateLocked(
+ options: PluginUpdateOptions,
+ session?: PinnedUserFramework
+ ): Promise {
const loaded = await this.repo.load();
if (loaded === null)
throw new Error(
@@ -39,7 +44,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) =>
@@ -64,13 +71,20 @@ 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),
- }))
- );
+ toolId,
+ options.projectRoot,
+ this.logger,
+ options.rebuild === true,
+ undefined,
+ session
+ );
+ 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/plugin/plugin-add-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts
index 940f4849b..7b5943ad5 100644
--- a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts
+++ b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts
@@ -23,13 +23,17 @@ import type { ReadonlySkipList } from "../../../translate/domain/plugin-translat
import { Manifest } from "../../domain/manifest.js";
import {
InstalledPlugin,
+ type KiloTuiProvenance,
type ProjectHooksProvenance,
} from "../../domain/plugins/installed-plugin.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
import type { PluginTranslator } from "../framework/translator/plugin-translator.js";
import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js";
import type { InstallRuntimeConfigUseCase } from "../install/install-runtime-config-use-case.js";
-import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js";
+import {
+ assertProjectMcpEntriesRemovable,
+ assertProjectTuiUnchanged,
+} from "../ownership/project-plugin-cleanup.js";
import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js";
import { assertProjectHooksRemovable } from "../shared/remove-project-hooks.js";
import type { PluginDistributionLoader } from "./plugin-distribution-loader.js";
@@ -47,6 +51,8 @@ export interface PluginAddOptions {
interactive: boolean;
marketplace?: string;
requiredVersion?: string;
+ /** Re-fetch the distribution instead of trusting a previously cached copy. */
+ freshSource?: boolean;
pluginMetadata?: { name: string; version?: string; strict: boolean };
/** When true, drop any existing entry with the same name before adding (idempotent re-add for setup re-runs). */
replace?: boolean;
@@ -121,11 +127,12 @@ export class PluginAddUseCase implements PluginAdd {
manifest,
options.projectRoot
);
- if (options.replace === true) this.dropExistingPlugin(pluginMetadata.name, toolIds, manifest);
- else this.validateNoDuplicates(pluginMetadata.name, toolIds, manifest);
const adapterMap = this.buildAdapterMap(toolIds);
const flatToolIds = toolIds.filter((id) => adapterMap.get(id)?.mode === "flat");
const nativeToolIds = toolIds.filter((id) => adapterMap.get(id)?.mode !== "flat");
+ if (options.replace === true)
+ this.dropExistingPlugin(pluginMetadata.name, nativeToolIds, manifest);
+ else this.validateNoDuplicates(pluginMetadata.name, toolIds, manifest);
if (flatToolIds.length > 0) {
await this.addLocalPlugin(
options,
@@ -176,17 +183,40 @@ export class PluginAddUseCase implements PluginAdd {
options: PluginAddOptions,
pluginMetadata: NonNullable
): Promise {
- if (pluginMetadata.version) return pluginMetadata.version;
- const dist = await this.readDistribution(options.source, options.projectRoot);
- return dist.manifest.version;
+ if (pluginMetadata.version) {
+ this.assertPluginVersionMatches(
+ pluginMetadata.name,
+ pluginMetadata.version,
+ options.requiredVersion
+ );
+ if (!options.requiredVersion) return pluginMetadata.version;
+ }
+ const distribution = await this.readDistribution(
+ options.source,
+ options.projectRoot,
+ options.freshSource
+ );
+ if (distribution.manifest.name !== pluginMetadata.name)
+ throw new Error(`Cannot verify the source of '${pluginMetadata.name}'.`);
+ this.assertPluginVersionMatches(
+ pluginMetadata.name,
+ distribution.manifest.version,
+ options.requiredVersion
+ );
+ return distribution.manifest.version;
}
private async readDistribution(
source: PluginSource,
- projectRoot: string
+ projectRoot: string,
+ freshSource?: boolean
): Promise {
const cacheDir = join(projectRoot, PLUGIN_CACHE_SUBDIR);
- return this.distributionLoader.load(source, cacheDir);
+ return this.distributionLoader.load(
+ source,
+ cacheDir,
+ freshSource ? { forceRefresh: true } : undefined
+ );
}
private async addLocalPlugin(
@@ -198,7 +228,7 @@ export class PluginAddUseCase implements PluginAdd {
machine: Manifest
): Promise {
const { marketplace, requiredVersion, replace, pluginMetadata } = options;
- const read = await this.readDistribution(source, projectRoot);
+ const read = await this.readDistribution(source, projectRoot, options.freshSource);
const dist = pluginMetadata === undefined ? read : read.withStrict(pluginMetadata.strict);
const pluginName = dist.manifest.name;
this.assertPluginVersionMatches(pluginName, dist.manifest.version, requiredVersion);
@@ -209,7 +239,7 @@ export class PluginAddUseCase implements PluginAdd {
manifest,
projectRoot
);
- const { prevMcpMap, prevHooksMap } = this.prepareForInstall(
+ const { prevMcpMap, prevHooksMap, prevTuiMap } = this.prepareForInstall(
pluginName,
resolvedToolIds,
manifest,
@@ -224,6 +254,7 @@ export class PluginAddUseCase implements PluginAdd {
marketplace,
prevMcpMap,
prevHooksMap,
+ prevTuiMap,
machine
);
}
@@ -239,6 +270,7 @@ export class PluginAddUseCase implements PluginAdd {
if (previous === undefined) continue;
await assertProjectMcpEntriesRemovable(this.fs, previous, toolId, projectRoot);
await assertProjectHooksRemovable(this.fs, previous, toolId, projectRoot);
+ await assertProjectTuiUnchanged(this.fs, previous, toolId, projectRoot);
}
}
@@ -250,21 +282,25 @@ export class PluginAddUseCase implements PluginAdd {
): {
prevMcpMap: Map>;
prevHooksMap: Map;
+ prevTuiMap: Map;
} {
const prevMcpMap = this.collectPreviousMcpEntries(pluginName, toolIds, manifest);
const prevHooksMap = new Map();
+ const prevTuiMap = new Map();
for (const toolId of toolIds) {
const projectHooks = manifest
.getPlugins(toolId)
.find((p) => p.name === pluginName)?.projectHooks;
if (projectHooks !== undefined) prevHooksMap.set(toolId, projectHooks);
+ const tui = manifest.getPlugins(toolId).find((p) => p.name === pluginName)?.tuiPlugin;
+ if (tui !== undefined) prevTuiMap.set(toolId, tui);
}
if (replace === true) {
this.dropExistingPlugin(pluginName, toolIds, manifest);
} else {
this.validateNoDuplicates(pluginName, toolIds, manifest);
}
- return { prevMcpMap, prevHooksMap };
+ return { prevMcpMap, prevHooksMap, prevTuiMap };
}
private async installPluginForAllTools(
@@ -276,6 +312,7 @@ export class PluginAddUseCase implements PluginAdd {
marketplace: string | undefined,
prevMcpMap: Map>,
prevHooksMap: Map,
+ prevTuiMap: Map,
machine: Manifest
): Promise {
const allSkipped: ReadonlySkipList[] = [];
@@ -303,8 +340,10 @@ export class PluginAddUseCase implements PluginAdd {
marketplace,
prev,
foreignDir !== undefined ||
- machine.getPlugins(toolId).some((p) => p.name === dist.manifest.name),
- prevHooksMap.get(toolId)
+ (resolveScopeForInstall(toolId) === "user" &&
+ machine.getPlugins(toolId).some((p) => p.name === dist.manifest.name)),
+ prevHooksMap.get(toolId),
+ prevTuiMap.get(toolId)
);
allSkipped.push(skipped);
allNotices.push(notices);
@@ -408,7 +447,8 @@ export class PluginAddUseCase implements PluginAdd {
marketplace: string | undefined,
previousMcpEntries: ReadonlyMap,
userScopeDirTaken: boolean,
- previousProjectHooks?: ProjectHooksProvenance
+ previousProjectHooks?: ProjectHooksProvenance,
+ previousTui?: KiloTuiProvenance
): Promise<{ skipped: ReadonlySkipList; notices: ReadonlyNoticeList }> {
const toolConfig = getToolConfig(toolId);
if (!isAiTool(toolConfig)) return { skipped: [], notices: [] };
@@ -423,7 +463,9 @@ export class PluginAddUseCase implements PluginAdd {
marketplace,
previousMcpEntries,
userScopeDirTaken,
- previousProjectHooks
+ previousProjectHooks,
+ undefined,
+ previousTui
);
return { ...result, notices: [] };
}
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/plugin/plugin-helpers.ts b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts
index b65d1d349..91ab353bd 100644
--- a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts
+++ b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts
@@ -108,7 +108,8 @@ export async function materializeViaTranslator(
plugin.mcpEntries,
false,
plugin.projectHooks,
- hooksGuard
+ hooksGuard,
+ plugin.tuiPlugin
);
return written ?? 0;
}
diff --git a/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts
index 8a1e40389..f66b01d36 100644
--- a/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts
+++ b/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts
@@ -5,6 +5,7 @@ import {
} from "../../../../kernel/errors.js";
import type { Logger } from "../../../../kernel/ports/logger.js";
import type { Prompter } from "../../../../kernel/ports/prompter.js";
+import type { PluginSourceGitSubdir } from "../../../../kernel/source.js";
import type { AiToolId } from "../../../../kernel/tool.js";
import type { ResolveMarketplaceUseCase } from "../../../distribution/application/resolve-marketplace-use-case.js";
import type { PluginCatalogEntry } from "../../../distribution/domain/catalog.js";
@@ -28,6 +29,8 @@ export interface PluginInstallFromMarketplaceOptions {
/** When true, drop existing plugin entry before adding (idempotent setup re-runs). */
replace?: boolean;
requestedVersionPolicy?: RequestedVersionPolicy;
+ /** Refuse a catalogue source that changed after a release update was reviewed. */
+ expectedSource?: PluginSourceGitSubdir;
}
export interface PluginInstallFromMarketplaceResult {
@@ -81,6 +84,15 @@ export class PluginInstallFromMarketplaceUseCase implements PluginInstallFromMar
chosen.marketplace,
chosen.localPath
);
+ const expected = options.expectedSource;
+ if (
+ expected &&
+ (resolvedSource.kind !== "git-subdir" ||
+ resolvedSource.url !== expected.url ||
+ resolvedSource.path !== expected.path ||
+ resolvedSource.ref !== expected.ref)
+ )
+ throw new Error(`The source of '${options.pluginName}' changed before installation.`);
await this.pluginAddUseCase.execute({
source: resolvedSource,
toolIds: options.toolIds,
@@ -88,6 +100,7 @@ export class PluginInstallFromMarketplaceUseCase implements PluginInstallFromMar
interactive: options.interactive,
marketplace: chosen.marketplace.name,
requiredVersion: effectiveVersion,
+ freshSource: options.expectedSource !== undefined,
pluginMetadata: {
name: chosen.entry.name,
version: chosen.entry.version,
diff --git a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts
index 66d5cd22d..dd0c1f986 100644
--- a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts
+++ b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts
@@ -38,7 +38,15 @@ import {
isMachineOwnedNativeRef,
} from "../ownership/native-plugin-ownership.js";
import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js";
-import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js";
+import {
+ assertProjectMcpEntriesRemovable,
+ planProjectKiloTuiRemoval,
+ removeProjectKiloTui,
+} from "../ownership/project-plugin-cleanup.js";
+import {
+ planUserKiloTuiRemoval,
+ removeUserKiloTui,
+} from "../ownership/tui/user-kilo-tui-removal.js";
import { detachUserPlugin } from "../ownership/user-plugin-ownership.js";
import { resolveCacheCandidate } from "../shared/purge-declared-cache.js";
import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js";
@@ -112,6 +120,7 @@ export class PluginRemoveUseCase {
if (plugin === undefined) continue;
await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot);
await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot);
+ await planProjectKiloTuiRemoval(this.fs, plugin, toolId, projectRoot);
await this.assertProjectNativeSource(
plugin,
toolId,
@@ -249,6 +258,7 @@ export class PluginRemoveUseCase {
}
const safeFiles = await Promise.all(
targets.map(async ({ toolId, plugin }) => {
+ await planUserKiloTuiRemoval(this.fs, toolId, plugin, resolveHomeDir());
const files = await userScopeFilesSafeToDelete(
this.fs,
this.logger,
@@ -307,6 +317,7 @@ export class PluginRemoveUseCase {
});
}
for (const { toolId, plugin, files } of safeFiles) {
+ await removeUserKiloTui(this.fs, toolId, plugin, resolveHomeDir());
await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user");
manifest.removePlugin(toolId, plugin.name);
}
@@ -324,6 +335,7 @@ export class PluginRemoveUseCase {
const plugins = manifest.getPlugins(toolId);
const plugin = plugins.find((p) => p.name === pluginName);
if (plugin === undefined) continue;
+ await removeProjectKiloTui(this.fs, plugin, toolId, projectRoot);
const registrations = manifest.getNativeRegistrations(toolId);
const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir);
const confirmed = await this.removeNativeActivation(
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..8a1058ea7 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
@@ -7,7 +7,11 @@ import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { Hasher } from "../../../../kernel/ports/hasher.js";
import { compareSemver } from "../../../../kernel/semver.js";
import type { AiToolId } from "../../../../kernel/tool.js";
-import { getToolConfig, type ToolConfig } from "../../../tools/domain/registry.js";
+import {
+ getToolConfig,
+ resolvePluginsCapability,
+ type ToolConfig,
+} from "../../../tools/domain/registry.js";
import { PluginContentTranslator } from "../../../translate/domain/content-translator.js";
import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js";
import type { Manifest } from "../../domain/manifest.js";
@@ -16,6 +20,7 @@ import type { ManifestRepository } from "../../domain/ports/manifest-repository.
import type { PluginTranslator } from "../framework/translator/plugin-translator.js";
import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js";
import type { InstallRuntimeConfigUseCase } from "../install/install-runtime-config-use-case.js";
+import { assertProjectTuiUnchanged } from "../ownership/project-plugin-cleanup.js";
import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js";
import type { PluginDistributionLoader } from "./plugin-distribution-loader.js";
import {
@@ -31,6 +36,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 {
@@ -109,10 +117,16 @@ export class PluginUpdateUseCase {
manifest: Manifest
): Promise {
const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir);
+ await assertProjectTuiUnchanged(this.fs, plugin, toolId, projectRoot);
await deleteOldFiles(plugin.files, baseDir, this.fs);
const toolConfig = getToolConfig(toolId);
const translator = this.resolveTranslator(toolConfig);
- if (translator !== null && plugin.marketplace !== undefined) {
+ if (
+ translator !== null &&
+ (plugin.marketplace !== undefined ||
+ (translator.mode === "flat" &&
+ resolvePluginsCapability(toolId)?.startupTui?.plugin === plugin.name))
+ ) {
await materializeViaTranslator(translator, dist, toolId, plugin, projectRoot, manifest);
return;
}
diff --git a/cli/src/contexts/framework/application/setup-use-case.ts b/cli/src/contexts/framework/application/setup-use-case.ts
index 1424928bc..3604b9762 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 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 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-global-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts
new file mode 100644
index 000000000..7cb41176f
--- /dev/null
+++ b/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts
@@ -0,0 +1,136 @@
+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 { 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";
+import type { PluginInstallMode } from "../../domain/setup-flow.js";
+import type { PinnedUserFramework } from "../ownership/release/pinned-user-framework.js";
+import type {
+ PlannedUserPluginFileUpdate,
+ UserPluginFileUpdater,
+} from "../ownership/user-plugin-file-updater.js";
+
+export interface InstallGlobalPluginsOptions {
+ readonly projectRoot: string;
+ readonly toolIds: readonly ToolId[];
+ readonly mode: PluginInstallMode;
+ readonly pluginNames: readonly string[];
+}
+
+export interface InstallGlobalPluginsResult {
+ /** 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 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.
+ *
+ * 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 InstallGlobalPluginsUseCase {
+ constructor(
+ private readonly registry: Pick,
+ private readonly resolveMarketplace: ResolveMarketplace,
+ private readonly fileUpdater: Pick &
+ Partial>,
+ private readonly logger: Logger
+ ) {}
+
+ async execute(
+ manifest: Manifest,
+ options: InstallGlobalPluginsOptions,
+ record: (manifest: Manifest) => Promise
+ ): Promise {
+ if (options.mode === "none" || options.mode === "interactive") return { plugins: [] };
+ return this.fileUpdater.withPinnedSession
+ ? this.fileUpdater.withPinnedSession((session) =>
+ this.executeInSession(manifest, options, record, session)
+ )
+ : this.executeInSession(manifest, options, record);
+ }
+
+ private async executeInSession(
+ manifest: Manifest,
+ options: InstallGlobalPluginsOptions,
+ record: (manifest: Manifest) => Promise,
+ session?: PinnedUserFramework
+ ): Promise {
+ 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);
+ const plans: { readonly plan: PlannedUserPluginFileUpdate; readonly owned: boolean }[] = [];
+ for (const toolId of options.toolIds) {
+ if (!copiesPluginFilesGlobally(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 === undefined
+ ? resolved
+ : owned.source.kind === "git-subdir" &&
+ owned.source.url === "https://github.com/ai-driven-dev/framework.git" &&
+ owned.source.path === `plugins/${owned.name}` &&
+ owned.source.sha !== undefined
+ ? owned
+ : resolved
+ .withFiles(owned.files)
+ .withDependents(owned.dependents)
+ .withTuiPlugin(owned.tuiPlugin);
+ const args = [plugin, toolId as AiToolId, options.projectRoot, this.logger, true] as const;
+ const plan = session
+ ? await this.fileUpdater.planUpdate(...args, undefined, session)
+ : await this.fileUpdater.planUpdate(...args);
+ 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) };
+ }
+}
+
+function selectEntries(
+ catalog: readonly PluginCatalogEntry[],
+ options: InstallGlobalPluginsOptions
+): 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..a62034e9e 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
@@ -1,19 +1,22 @@
+import { dirname } from "node:path";
import type { VersionReader } from "../../../../kernel/ports/version-reader.js";
import { Manifest } from "../../domain/manifest.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
-import type { SetupFlow } from "../../domain/setup-flow.js";
+import { SetupFlow } from "../../domain/setup-flow.js";
import type {
MarketplaceSyncSettings,
MarketplaceSyncSettingsResult,
} 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 { InstallGlobalPluginsUseCase } from "./install-global-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 +28,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 installGlobalPluginsUseCase?: InstallGlobalPluginsUseCase
) {}
async execute(flow: SetupFlow): Promise {
@@ -40,19 +44,42 @@ export class SetupMachineScopeUseCase {
flow: SetupFlow,
source: Awaited>
): Promise {
+ // A machine installation has no project cache; resolve and build under its own config root.
+ flow = new SetupFlow({ ...flow, projectRoot: dirname(this.userManifestRepo.path) });
const isNew = await this.initUserManifest();
await this.setupMarketplaceRegistration.registerIfPresent(flow, source);
await this.registerUserScopeTools(flow);
+ const plugins = await this.installGlobalPlugins(flow);
const activation: MarketplaceSyncSettingsResult =
await this.marketplaceSyncSettingsUseCase.execute({
projectRoot: flow.projectRoot,
scope: "user",
manifestRepo: this.userManifestRepo,
+ toolIds: flow.aiTools,
+ 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, globalPlugins: plugins };
+ return isNew ? { kind: "initialized", ...outcome } : { kind: "up-to-date", ...outcome };
+ }
+
+ /** Copies the chosen plugins' global files and returns their names, which every tool with
+ * native activation then enables machine-wide. */
+ 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.installGlobalPluginsUseCase.execute(
+ manifest,
+ {
+ projectRoot: flow.projectRoot,
+ toolIds: flow.aiTools,
+ mode: flow.pluginMode,
+ pluginNames: flow.pluginNames,
+ },
+ (updated) => this.userManifestRepo.save(updated)
+ );
+ return plugins;
}
private async initUserManifest(): Promise {
diff --git a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts
index 7775f761c..e7396a68d 100644
--- a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts
+++ b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts
@@ -13,6 +13,7 @@ import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js";
import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js";
import type { PluginTranslator } from "../framework/translator/plugin-translator.js";
import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js";
+import type { PinnedUserFramework } from "../ownership/release/pinned-user-framework.js";
import {
deleteOldFiles,
isPluginFileAtDesiredState,
@@ -38,6 +39,8 @@ export interface BuiltMaterializationDeps {
ensureBuilt: EnsureBuiltMarketplace;
marketplaceRegistry: MarketplaceRegistry;
homedir: () => string;
+ pinnedFramework?: PinnedUserFramework;
+ userCacheRoot?: () => string;
}
export class ApplyPluginFilesUseCase {
diff --git a/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts b/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts
index 09cf30f4c..81c7be1fa 100644
--- a/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts
+++ b/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts
@@ -1,3 +1,4 @@
+import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import {
@@ -13,6 +14,7 @@ import type { Marketplace } from "../../../distribution/domain/marketplace.js";
import type { FrameworkBuildMode } from "../../../tools/domain/registry.js";
import type { FrameworkBuild } from "../../../translate/application/translate-source.js";
import type { FrameworkBuildTarget } from "../../../translate/domain/build-target.js";
+import { assertNoSymlinksWithinRoot } from "../ownership/project-path-boundary.js";
/** `undefined` when the target/mode pair has no build. */
export type FrameworkBuildFor = (
@@ -67,10 +69,14 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace {
// `resolve()`, matching `sourceDir` below: on Windows a drive-less `projectRoot` yields a
// drive-less `builtDir` here while the build resolves its own outDir copy for validation only,
// leaving the write target diverging from the path that gets checked. Both scopes need it.
+ const userRoot = options.marketplace.scope === "user" ? this.userCacheRoot() : null;
+ const cacheBoundary = resolve(userRoot ?? options.projectRoot);
+ if (userRoot !== null && !(await this.fs.fileExists(cacheBoundary)))
+ await this.fs.createDirectory(cacheBoundary);
const builtDir = resolve(
- options.marketplace.scope === "user"
+ userRoot !== null
? userBuiltMarketplaceDir(
- this.userCacheRoot(),
+ userRoot,
this.version.get(),
options.marketplace.name,
options.target
@@ -85,13 +91,15 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace {
const sentinel = this.sentinelValue(resolved.catalog?.version);
const memoKey = `${options.marketplace.name}:${options.target}:${sentinel}`;
const memoized = this.memo.get(memoKey);
+ await assertNoSymlinksWithinRoot(this.fs, cacheBoundary, builtDir);
if (memoized !== undefined) return memoized;
const result = await this.ensure(
options,
builtDir,
resolve(resolved.localPath),
sentinel,
- this.versionIsTrustworthy(options)
+ this.versionIsTrustworthy(options),
+ cacheBoundary
);
this.memo.set(memoKey, result);
return result;
@@ -120,13 +128,14 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace {
builtDir: string,
sourceDir: string,
sentinel: string,
- versionIsTrustworthy: boolean
+ versionIsTrustworthy: boolean,
+ cacheBoundary: string
): Promise {
const version = sentinel.split(":")[1];
if (versionIsTrustworthy && (await this.isFresh(builtDir, sentinel))) {
return { builtDir, version, rebuilt: false };
}
- await this.build(options.target, options.mode, sourceDir, builtDir);
+ await this.build(options.target, options.mode, sourceDir, builtDir, cacheBoundary);
await this.fs.writeFile(join(builtDir, SENTINEL_FILE), sentinel);
return { builtDir, version, rebuilt: true };
}
@@ -143,12 +152,16 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace {
target: FrameworkBuildTarget,
mode: FrameworkBuildMode,
sourceDir: string,
- builtDir: string
+ builtDir: string,
+ cacheBoundary: string
): Promise {
if (this.nested(sourceDir, builtDir)) {
- await this.buildViaTemp(target, mode, sourceDir, builtDir);
+ await this.buildViaTemp(target, mode, sourceDir, builtDir, cacheBoundary);
return;
}
+ // This is an AIDD-owned disposable cache; a new build must not inherit deleted files.
+ await assertNoSymlinksWithinRoot(this.fs, cacheBoundary, builtDir);
+ await this.fs.deleteDirectory(builtDir);
await this.runBuild(target, mode, sourceDir, builtDir);
}
@@ -160,14 +173,20 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace {
target: FrameworkBuildTarget,
mode: FrameworkBuildMode,
sourceDir: string,
- builtDir: string
+ builtDir: string,
+ cacheBoundary: string
): Promise {
- const temp = join(tmpdir(), `aidd-built-${target}-${mode}`);
- await this.fs.deleteDirectory(temp);
- await this.runBuild(target, mode, sourceDir, temp);
- await this.fs.deleteDirectory(builtDir);
- await this.copyDir(temp, builtDir);
- await this.fs.deleteDirectory(temp);
+ const temp = join(tmpdir(), `aidd-built-${target}-${mode}-${randomUUID()}`);
+ if (await this.fs.entryExists?.(temp))
+ throw new Error("Temporary build directory already exists.");
+ try {
+ await this.runBuild(target, mode, sourceDir, temp);
+ await assertNoSymlinksWithinRoot(this.fs, cacheBoundary, builtDir);
+ await this.fs.deleteDirectory(builtDir);
+ await this.copyDir(temp, builtDir);
+ } finally {
+ await this.fs.deleteDirectory(temp);
+ }
}
// Every outDir reaching this method is either `builtMarketplaceDir()` or a temp dir this class
diff --git a/cli/src/contexts/framework/application/upgrade-file-guard.ts b/cli/src/contexts/framework/application/upgrade-file-guard.ts
new file mode 100644
index 000000000..e67e5f69a
--- /dev/null
+++ b/cli/src/contexts/framework/application/upgrade-file-guard.ts
@@ -0,0 +1,42 @@
+import { resolve } from "node:path";
+import type { FileReader } from "../../../kernel/ports/file-reader.js";
+import type { FileWriter } from "../../../kernel/ports/file-writer.js";
+import { assertNoSymlinksWithinRoot } from "./ownership/project-path-boundary.js";
+
+export async function assertUpgradeWrite(
+ fs: FileReader,
+ projectRoot: string,
+ path: string,
+ tracked: ReadonlyMap
+): Promise {
+ await assertNoSymlinksWithinRoot(fs, projectRoot, path);
+ const expected = tracked.get(resolve(path));
+ if (await fs.fileExists(path)) {
+ if (expected === undefined) throw new Error(`Cannot overwrite untracked file '${path}'.`);
+ if ((await fs.readFileHash(path)).value !== expected)
+ throw new Error(`Cannot overwrite modified file '${path}'.`);
+ } else if (expected !== undefined) {
+ throw new Error(`Cannot overwrite missing tracked file '${path}'.`);
+ }
+}
+
+/** Protect project content while replacing a plugin; the original hashes are its ownership proof. */
+export function protectUpgradeWrites(
+ fs: T,
+ projectRoot: string,
+ tracked: ReadonlyMap
+): T {
+ const owned = new Map([...tracked].map(([path, hash]) => [resolve(projectRoot, path), hash]));
+ return new Proxy(fs, {
+ get(target, property, receiver) {
+ if (property === "writeFile") {
+ return async (path: string, content: string) => {
+ await assertUpgradeWrite(target, projectRoot, path, owned);
+ await target.writeFile(path, content);
+ };
+ }
+ const value = Reflect.get(target, property, receiver);
+ return typeof value === "function" ? value.bind(target) : value;
+ },
+ });
+}
diff --git a/cli/src/contexts/framework/application/upgrade-framework-use-case.ts b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts
new file mode 100644
index 000000000..e4a0839f4
--- /dev/null
+++ b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts
@@ -0,0 +1,362 @@
+import { compareSemver, isSemver } from "../../../kernel/semver.js";
+import type { PluginSource, PluginSourceGitSubdir } from "../../../kernel/source.js";
+import { type AiToolId, isAiToolId } from "../../../kernel/tool.js";
+import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../../distribution/domain/marketplace.js";
+import type { Manifest } from "../domain/manifest.js";
+import { resolvePluginSourceFromMarketplace } from "../domain/plugins/plugin-source-resolver.js";
+
+export interface FrameworkUpgradeTarget {
+ readonly toolId: AiToolId;
+ readonly pluginName: string;
+}
+
+interface FrameworkUpgradePreviewTarget extends FrameworkUpgradeTarget {
+ readonly currentRef: string;
+ readonly currentVersion: string;
+ readonly targetVersion?: string;
+}
+
+export interface FrameworkUpgradePreview {
+ readonly currentRef: string;
+ readonly release: string;
+ readonly title: string | null;
+ readonly targets: readonly FrameworkUpgradePreviewTarget[];
+ readonly globalSkipped: number;
+ readonly unverifiedUserRefs: number;
+ readonly globalPlugins: readonly {
+ toolId: AiToolId;
+ pluginName: string;
+ currentVersion: string;
+ source: PluginSource;
+ }[];
+ readonly shared: boolean;
+ readonly impact: readonly ClaudeInstallation[];
+}
+
+export interface ClaudeInstallation {
+ readonly ref: string;
+ readonly scope: "local" | "project" | "user";
+ readonly projectPath?: string;
+}
+
+interface UpgradeDeps {
+ listMarketplaces(projectRoot: string): Promise;
+ saveMarketplace(projectRoot: string, marketplace: Marketplace): Promise;
+ loadManifest(): Promise;
+ loadUserManifest?(): Promise;
+ resolvePublishedRelease(repo: string, release: string): Promise<{ title: string | null } | null>;
+ resolveTarget(
+ marketplace: Marketplace,
+ projectRoot: string
+ ): Promise<{
+ catalog: {
+ version?: string;
+ plugins: readonly { name: string; source: PluginSource; version?: string }[];
+ } | null;
+ localPath: string;
+ }>;
+ readTargetVersion(localPath: string, pluginName: string): Promise;
+ preflight(
+ manifest: Manifest,
+ projectRoot: string,
+ targets: readonly FrameworkUpgradeTarget[],
+ marketplace: Marketplace,
+ localPath: string
+ ): Promise;
+ verifyImpact?(
+ projectRoot: string,
+ impact: readonly ClaudeInstallation[],
+ source?: string
+ ): Promise;
+ install(options: {
+ pluginName: string;
+ version: string;
+ expectedSource: PluginSourceGitSubdir;
+ toolIds: AiToolId[];
+ projectRoot: string;
+ fromMarketplace: string;
+ interactive: boolean;
+ autoSelect: boolean;
+ replace: boolean;
+ }): Promise;
+ sync(
+ projectRoot: string,
+ toolIds: readonly AiToolId[],
+ targets: readonly FrameworkUpgradeTarget[],
+ approvedRefs: readonly string[]
+ ): Promise<{
+ warnings: readonly string[];
+ errors: readonly { scope: string; message: string }[];
+ binaryMissing: readonly { toolId: string; binary: string }[];
+ }>;
+}
+
+export interface FrameworkUpgradeOptions {
+ readonly projectRoot: string;
+ readonly release: string;
+ readonly allowShared: boolean;
+ readonly confirm: (preview: FrameworkUpgradePreview) => Promise;
+}
+
+/** Upgrades project-owned AIDD plugins without adding them to tools that never had them. */
+export class UpgradeFrameworkUseCase {
+ constructor(private readonly deps: UpgradeDeps) {}
+
+ async execute(
+ options: FrameworkUpgradeOptions
+ ): Promise<{ updated: string[]; globalSkipped: number; cancelled: boolean }> {
+ const { projectRoot, release, allowShared, confirm } = options;
+ if (!release.startsWith("v") || !isSemver(release))
+ throw new Error("A root release tag such as v5.13.0 is required.");
+ const marketplace = (await this.deps.listMarketplaces(projectRoot)).find(
+ (m) => m.name === FRAMEWORK_MARKETPLACE_NAME
+ );
+ if (!marketplace) throw new Error("The AIDD marketplace is not registered for this project.");
+ if (
+ marketplace.source.kind !== "github" ||
+ marketplace.source.repo !== "ai-driven-dev/framework"
+ ) {
+ throw new Error(
+ "The AIDD marketplace source is local or unsupported; its release cannot be upgraded automatically."
+ );
+ }
+ const currentRef = marketplace.source.ref;
+ if (!currentRef || !currentRef.startsWith("v") || !isSemver(currentRef)) {
+ throw new Error(
+ "The AIDD marketplace source is a branch or unversioned; its release cannot be upgraded automatically."
+ );
+ }
+ if (compareSemver(release, currentRef) < 0)
+ throw new Error("The selected release is older than the configured marketplace.");
+ const manifest = await this.deps.loadManifest();
+ if (!manifest)
+ throw new Error("No project installation manifest exists; there is nothing to upgrade.");
+ const targets: FrameworkUpgradePreviewTarget[] = [];
+ let globalSkipped = 0;
+ const userRefs: FrameworkUpgradeTarget[] = [];
+ for (const id of manifest.getInstalledToolIds()) {
+ if (!isAiToolId(id)) continue;
+ for (const plugin of manifest.getPlugins(id)) {
+ if (plugin.marketplace !== FRAMEWORK_MARKETPLACE_NAME) continue;
+ if (plugin.scope === "user") {
+ globalSkipped++;
+ userRefs.push({ toolId: id, pluginName: plugin.name });
+ continue;
+ }
+ const source = plugin.source;
+ if (
+ source.kind !== "git-subdir" ||
+ source.url !== "https://github.com/ai-driven-dev/framework.git" ||
+ source.path !== `plugins/${plugin.name}` ||
+ !source.ref ||
+ !source.ref.startsWith("v") ||
+ !isSemver(source.ref)
+ ) {
+ throw new Error(
+ `Cannot upgrade ${id}:${plugin.name}: its source is not a versioned official AIDD plugin.`
+ );
+ }
+ if (compareSemver(source.ref, release) > 0)
+ throw new Error(`Cannot downgrade ${id}:${plugin.name} from ${source.ref}.`);
+ targets.push({
+ toolId: id,
+ pluginName: plugin.name,
+ currentRef: source.ref,
+ currentVersion: plugin.version,
+ });
+ }
+ }
+ if (targets.length === 0)
+ throw new Error(
+ "No project-scoped AIDD plugins found; global installations must be updated separately."
+ );
+ const publishedRelease = await this.deps.resolvePublishedRelease(
+ marketplace.source.repo,
+ release
+ );
+ if (!publishedRelease)
+ throw new Error(`Release ${release} is not available from the configured source.`);
+ const nextData = marketplace.toJSON();
+ nextData.source = { ...marketplace.source, ref: release };
+ delete nextData.version;
+ delete nextData.lastFetched;
+ const next = Marketplace.fromJSON(nextData);
+ const { catalog, localPath } = await this.deps.resolveTarget(next, projectRoot);
+ if (
+ !catalog ||
+ !catalog.version ||
+ !isSemver(catalog.version) ||
+ compareSemver(catalog.version, release) !== 0
+ ) {
+ throw new Error(
+ `Release ${release} does not contain the expected versioned AIDD marketplace.`
+ );
+ }
+ const names = new Set(catalog.plugins.map((plugin) => plugin.name));
+ const missing = targets.filter((target) => !names.has(target.pluginName));
+ if (missing.length)
+ throw new Error(
+ `Release ${release} is missing installed plugins: ${missing.map((x) => x.pluginName).join(", ")}.`
+ );
+ const versions = new Map();
+ const previewTargets: FrameworkUpgradePreviewTarget[] = [];
+ for (const target of targets) {
+ const entry = catalog.plugins.find((plugin) => plugin.name === target.pluginName);
+ if (!entry) throw new Error(`Cannot verify source of ${target.pluginName}.`);
+ const resolved = resolvePluginSourceFromMarketplace(entry.source, next, localPath);
+ if (
+ resolved.kind !== "git-subdir" ||
+ resolved.url !== "https://github.com/ai-driven-dev/framework.git" ||
+ resolved.path !== `plugins/${target.pluginName}` ||
+ resolved.ref !== release
+ )
+ throw new Error(`Cannot verify source of ${target.pluginName} in ${release}.`);
+ let targetVersion = versions.get(target.pluginName);
+ if (targetVersion === undefined) {
+ targetVersion = await this.deps.readTargetVersion(localPath, target.pluginName);
+ if (!isSemver(targetVersion))
+ throw new Error(`Cannot verify the target version of ${target.pluginName}.`);
+ versions.set(target.pluginName, targetVersion);
+ }
+ if (entry.version && entry.version !== targetVersion)
+ throw new Error(`The catalog version of ${target.pluginName} does not match its manifest.`);
+ if (
+ !isSemver(target.currentVersion) ||
+ compareSemver(targetVersion, target.currentVersion) < 0
+ )
+ throw new Error(
+ `Cannot downgrade ${target.toolId}:${target.pluginName} from ${target.currentVersion} to ${targetVersion}.`
+ );
+ previewTargets.push({ ...target, targetVersion });
+ }
+ const preflight = await this.deps.preflight(manifest, projectRoot, targets, next, localPath);
+ const userManifest = await this.deps.loadUserManifest?.();
+ const globalPlugins = (userManifest?.getInstalledToolIds() ?? []).flatMap((toolId) =>
+ !isAiToolId(toolId)
+ ? []
+ : (userManifest?.getPlugins(toolId) ?? [])
+ .filter(
+ (plugin) =>
+ plugin.scope === "user" &&
+ (plugin.marketplace === FRAMEWORK_MARKETPLACE_NAME ||
+ (plugin.marketplace === undefined &&
+ plugin.source.kind === "git-subdir" &&
+ plugin.source.url === "https://github.com/ai-driven-dev/framework.git" &&
+ plugin.source.path === `plugins/${plugin.name}`))
+ )
+ .map((plugin) => ({
+ toolId,
+ pluginName: plugin.name,
+ currentVersion: plugin.version,
+ source: plugin.source,
+ }))
+ );
+ const unverifiedUserRefs = userRefs.filter(
+ (ref) =>
+ !globalPlugins.some(
+ (entry) => entry.toolId === ref.toolId && entry.pluginName === ref.pluginName
+ )
+ ).length;
+ const impact = preflight?.impact ?? [];
+ const shared = marketplace.scope === "user" || preflight?.shared === true;
+ if (
+ !(await confirm({
+ currentRef,
+ release,
+ title: publishedRelease.title,
+ targets: previewTargets,
+ globalSkipped,
+ unverifiedUserRefs,
+ globalPlugins,
+ shared,
+ impact,
+ }))
+ ) {
+ return { updated: [], globalSkipped, cancelled: true };
+ }
+ if (shared && !allowShared) {
+ throw new Error(
+ "The AIDD marketplace is shared with other projects; pass --allow-shared after reviewing the impact."
+ );
+ }
+ await this.deps.verifyImpact?.(projectRoot, impact, preflight?.source);
+ nextData.version = catalog.version;
+ await this.deps.saveMarketplace(projectRoot, Marketplace.fromJSON(nextData));
+ const updated: string[] = [];
+ let nativeChecked = false;
+ try {
+ for (const target of previewTargets) {
+ if (target.toolId === preflight?.tool && !nativeChecked) {
+ await this.deps.verifyImpact?.(projectRoot, impact, preflight?.source);
+ nativeChecked = true;
+ }
+ if (target.targetVersion === undefined)
+ throw new Error(`Cannot verify ${target.pluginName} version.`);
+ await this.deps.install({
+ pluginName: target.pluginName,
+ version: target.targetVersion,
+ expectedSource: {
+ kind: "git-subdir",
+ url: "https://github.com/ai-driven-dev/framework.git",
+ path: `plugins/${target.pluginName}`,
+ ref: release,
+ },
+ toolIds: [target.toolId],
+ projectRoot,
+ fromMarketplace: FRAMEWORK_MARKETPLACE_NAME,
+ interactive: false,
+ autoSelect: true,
+ replace: true,
+ });
+ const installed = (await this.deps.loadManifest())
+ ?.getPlugins(target.toolId)
+ .find((plugin) => plugin.name === target.pluginName);
+ if (
+ !installed ||
+ installed.version !== target.targetVersion ||
+ installed.scope !== "project" ||
+ installed.marketplace !== FRAMEWORK_MARKETPLACE_NAME ||
+ installed.source.kind !== "git-subdir" ||
+ installed.source.url !== "https://github.com/ai-driven-dev/framework.git" ||
+ installed.source.path !== `plugins/${target.pluginName}` ||
+ installed.source.ref !== release
+ ) {
+ throw new Error(
+ `Unable to verify the installed source for ${target.toolId}:${target.pluginName}.`
+ );
+ }
+ updated.push(`${target.toolId}:${target.pluginName}`);
+ }
+ const activation = await this.deps.sync(
+ projectRoot,
+ [...new Set(targets.map((target) => target.toolId))],
+ targets,
+ [...new Set(impact.map((entry) => entry.ref))]
+ );
+ if (
+ activation.errors.length ||
+ activation.binaryMissing.length ||
+ activation.warnings.length
+ ) {
+ const problems = [
+ ...activation.warnings,
+ ...activation.errors.map((error) => `${error.scope}: ${error.message}`),
+ ...activation.binaryMissing.map(
+ (error) => `${error.toolId}: ${error.binary} is unavailable`
+ ),
+ ];
+ throw new Error(problems.join("; "));
+ }
+ } catch (error) {
+ throw new Error(
+ `Upgrade incomplete after the shared marketplace changed to ${release}. Plugin sources updated: ${updated.join(", ") || "none"}. ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+ return { updated, globalSkipped, cancelled: false };
+ }
+}
diff --git a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts
index 1753f048c..1380808dd 100644
--- a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts
+++ b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts
@@ -41,6 +41,11 @@ export type ComponentPathMap = BrandedMap<"ComponentPathMap">;
/** MCP server name → MD5 hash of the contributed server JSON (OpenCode merge tracking). */
export type McpDigestMap = BrandedMap<"McpDigestMap">;
+export interface KiloTuiProvenance {
+ readonly config: "tui.json" | "tui.jsonc";
+ readonly entry: "./hooks/aidd-maintenance/kilo-tui.js";
+}
+
/** Exact merged Cursor hook commands and copied project scripts recorded at install. */
export interface ProjectHooksProvenance {
entries: readonly { event: string; command: string; digest: string }[];
@@ -76,6 +81,7 @@ export interface PluginEntryData {
componentPaths?: Record;
mcpEntries?: Record;
projectHooks?: ProjectHooksEntryData;
+ tuiPlugin?: KiloTuiProvenance;
marketplace?: string;
/** Canonical project roots using machine-owned user-scope files. Only the user manifest owns this list. */
dependents?: string[];
@@ -91,6 +97,7 @@ export class InstalledPlugin {
readonly componentPaths: ComponentPathMap;
readonly mcpEntries: McpDigestMap;
readonly projectHooks?: ProjectHooksProvenance;
+ readonly tuiPlugin?: KiloTuiProvenance;
readonly marketplace?: string;
readonly dependents: readonly string[];
@@ -104,6 +111,7 @@ export class InstalledPlugin {
componentPaths: ComponentPathMap;
mcpEntries: McpDigestMap;
projectHooks?: ProjectHooksProvenance;
+ tuiPlugin?: KiloTuiProvenance;
marketplace?: string;
dependents: readonly string[];
}) {
@@ -116,6 +124,7 @@ export class InstalledPlugin {
this.componentPaths = params.componentPaths;
this.mcpEntries = params.mcpEntries;
this.projectHooks = params.projectHooks;
+ this.tuiPlugin = params.tuiPlugin;
this.marketplace = params.marketplace;
this.dependents = params.dependents;
}
@@ -154,6 +163,7 @@ export class InstalledPlugin {
componentPaths: plugin.componentPaths,
mcpEntries: asMcpDigestMap(mcpEntries),
projectHooks: plugin.projectHooks,
+ tuiPlugin: plugin.tuiPlugin,
marketplace: plugin.marketplace,
dependents: plugin.dependents,
});
@@ -173,6 +183,7 @@ export class InstalledPlugin {
componentPaths: plugin.componentPaths,
mcpEntries: plugin.mcpEntries,
projectHooks,
+ tuiPlugin: plugin.tuiPlugin,
marketplace: plugin.marketplace,
dependents: plugin.dependents,
});
@@ -257,6 +268,7 @@ export class InstalledPlugin {
entries: data.projectHooks.entries,
scripts: new Map(Object.entries(data.projectHooks.scripts)),
},
+ tuiPlugin: data.tuiPlugin,
marketplace: data.marketplace,
dependents: data.dependents ?? [],
});
@@ -280,6 +292,7 @@ export class InstalledPlugin {
};
}
if (this.marketplace !== undefined) data.marketplace = this.marketplace;
+ if (this.tuiPlugin !== undefined) data.tuiPlugin = this.tuiPlugin;
if (this.dependents.length > 0) data.dependents = [...this.dependents];
return data;
}
@@ -288,6 +301,10 @@ export class InstalledPlugin {
return this.files.has(relPath);
}
+ withTuiPlugin(provenance: KiloTuiProvenance | undefined): InstalledPlugin {
+ return InstalledPlugin.fromJSON({ ...this.toJSON(), tuiPlugin: provenance });
+ }
+
withVersion(v: string): InstalledPlugin {
return new InstalledPlugin({
name: this.name,
@@ -299,6 +316,7 @@ export class InstalledPlugin {
componentPaths: this.componentPaths,
mcpEntries: this.mcpEntries,
projectHooks: this.projectHooks,
+ tuiPlugin: this.tuiPlugin,
marketplace: this.marketplace,
dependents: this.dependents,
});
@@ -315,6 +333,7 @@ export class InstalledPlugin {
componentPaths: this.componentPaths,
mcpEntries: this.mcpEntries,
projectHooks: this.projectHooks,
+ tuiPlugin: this.tuiPlugin,
marketplace: this.marketplace,
dependents: this.dependents,
});
@@ -331,6 +350,7 @@ export class InstalledPlugin {
componentPaths: this.componentPaths,
mcpEntries: this.mcpEntries,
projectHooks: this.projectHooks,
+ tuiPlugin: this.tuiPlugin,
marketplace: this.marketplace,
dependents: [...new Set(dependents)],
});
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/framework/infrastructure/pinned-user-framework-adapter.ts b/cli/src/contexts/framework/infrastructure/pinned-user-framework-adapter.ts
new file mode 100644
index 000000000..dcfae0b59
--- /dev/null
+++ b/cli/src/contexts/framework/infrastructure/pinned-user-framework-adapter.ts
@@ -0,0 +1,154 @@
+import { execFile as execFileCallback } from "node:child_process";
+import { mkdir, mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import type { FileReader } from "../../../kernel/ports/file-reader.js";
+import type { Hasher } from "../../../kernel/ports/hasher.js";
+import { compareSemver, isSemver } from "../../../kernel/semver.js";
+import type { AiToolId } from "../../../kernel/tool.js";
+import { Marketplace } from "../../distribution/domain/marketplace.js";
+import type { PluginCatalogRepository } from "../../distribution/domain/ports/plugin-catalog-repository.js";
+import type { PluginFetcher } from "../../distribution/domain/ports/plugin-fetcher.js";
+import { frameworkBuildModeFor } from "../../tools/domain/registry.js";
+import { readGlobalPluginFiles } from "../application/ownership/global-plugin-files.js";
+import type {
+ PinnedUserFramework,
+ PinnedUserFrameworkResult,
+} from "../application/ownership/release/pinned-user-framework.js";
+import type { FrameworkBuildFor } from "../application/shared/ensure-built-marketplace-use-case.js";
+import { PLUGIN_NAME_REGEX } from "../domain/plugins/installed-plugin.js";
+import { resolvePluginSourceFromMarketplace } from "../domain/plugins/plugin-source-resolver.js";
+import type { PluginDistributionReader } from "../domain/ports/plugin-distribution-reader.js";
+
+const execFile = promisify(execFileCallback);
+
+interface Checkout {
+ readonly path: string;
+ readonly cache: string;
+ readonly sha: string;
+}
+
+export class PinnedUserFrameworkAdapter implements PinnedUserFramework {
+ constructor(
+ private readonly fs: FileReader,
+ private readonly hasher: Hasher,
+ private readonly fetcher: PluginFetcher,
+ private readonly catalogs: PluginCatalogRepository,
+ private readonly plugins: PluginDistributionReader,
+ private readonly buildFor: FrameworkBuildFor
+ ) {}
+
+ async build(
+ ref: string,
+ toolId: AiToolId,
+ pluginName: string,
+ sha?: string
+ ): Promise {
+ return this.withSession((session) => session.build(ref, toolId, pluginName, sha));
+ }
+
+ async withSession(action: (session: PinnedUserFramework) => Promise): Promise {
+ const temporary = await mkdtemp(join(tmpdir(), "aidd-user-release-"));
+ const checkouts = new Map>();
+ const builds = new Map>();
+ try {
+ return await action({
+ build: (ref, toolId, pluginName, sha) =>
+ this.buildInSession(temporary, checkouts, builds, ref, toolId, pluginName, sha),
+ });
+ } finally {
+ await rm(temporary, { recursive: true, force: true });
+ }
+ }
+
+ private async buildInSession(
+ temporary: string,
+ checkouts: Map>,
+ builds: Map>,
+ ref: string,
+ toolId: AiToolId,
+ pluginName: string,
+ sha?: string
+ ): Promise {
+ if (!ref.startsWith("v") || !isSemver(ref) || !PLUGIN_NAME_REGEX.test(pluginName))
+ throw new Error("Expected a versioned official framework release and plugin name.");
+ const source = {
+ kind: "github",
+ repo: "ai-driven-dev/framework",
+ ref,
+ ...(sha ? { sha } : {}),
+ } as const;
+ let pending = checkouts.get(ref);
+ if (pending === undefined) {
+ const cache = join(temporary, `source-${checkouts.size}`);
+ pending = (async () => {
+ const path = await this.fetcher.fetch(source, cache, { canonicalCheckout: true });
+ const head = (await execFile("git", ["-C", path, "rev-parse", "HEAD"])).stdout.trim();
+ if (!/^[a-f0-9]{40}$/.test(head) || (sha !== undefined && head !== sha))
+ throw new Error("The framework checkout does not match the recorded release commit.");
+ return { path, cache, sha: head };
+ })();
+ checkouts.set(ref, pending);
+ } else {
+ // Before each reuse, reject a changed checkout, even when the build is already cached.
+ const prior = await pending;
+ await this.fetcher.fetch({ ...source, sha: prior.sha }, prior.cache);
+ }
+ const acquired = await pending;
+ if (sha !== undefined && acquired.sha !== sha)
+ throw new Error("The framework checkout does not match the recorded release commit.");
+ const checkout = acquired.path;
+ const head = acquired.sha;
+ const catalog = await this.catalogs.load(checkout);
+ if (
+ !catalog?.version ||
+ !isSemver(catalog.version) ||
+ compareSemver(catalog.version, ref) !== 0
+ )
+ throw new Error(`Release ${ref} has an invalid framework marketplace version.`);
+ const entry = catalog.plugins.find((item) => item.name === pluginName);
+ if (!entry) throw new Error(`Release ${ref} does not contain '${pluginName}'.`);
+ const marketplace = Marketplace.create({
+ name: "aidd-framework",
+ source,
+ scope: "project",
+ addedAt: "1970-01-01T00:00:00.000Z",
+ });
+ const resolved = resolvePluginSourceFromMarketplace(entry.source, marketplace, checkout);
+ if (
+ resolved.kind !== "git-subdir" ||
+ resolved.url !== "https://github.com/ai-driven-dev/framework.git" ||
+ resolved.path !== `plugins/${pluginName}` ||
+ resolved.ref !== ref
+ )
+ throw new Error(`Release ${ref} has an unverified source for '${pluginName}'.`);
+ const dist = await this.plugins.read(join(checkout, "plugins", pluginName));
+ if (
+ dist.manifest.name !== pluginName ||
+ !isSemver(dist.manifest.version) ||
+ (entry.version && entry.version !== dist.manifest.version)
+ )
+ throw new Error(`Release ${ref} has inconsistent metadata for '${pluginName}'.`);
+ const mode = frameworkBuildModeFor(toolId);
+ if (mode !== "flat")
+ throw new Error(`${toolId}: this release update supports flat machine installations only.`);
+ const buildKey = `${ref}\u0000${head}\u0000${toolId}`;
+ let built = builds.get(buildKey);
+ if (built === undefined) {
+ const outDir = join(temporary, `build-${builds.size}`);
+ built = (async () => {
+ await mkdir(outDir);
+ const build = this.buildFor(toolId, mode, outDir);
+ if (!build) throw new Error(`${toolId}: no compatible framework build.`);
+ await build.execute({ sourceDir: checkout, outDir, target: toolId, mode });
+ return outDir;
+ })();
+ builds.set(buildKey, built);
+ }
+ const outDir = await built;
+ const files = await readGlobalPluginFiles(this.fs, this.hasher, outDir, toolId, dist);
+ await this.fetcher.fetch({ ...source, sha: head }, acquired.cache);
+ return { sha: head, dist, files };
+ }
+}
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..599cdc002 100644
--- a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts
+++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts
@@ -1,11 +1,27 @@
-import { mkdir, rm, rmdir } from "node:fs/promises";
+import { execFile as execFileCallback } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import {
+ link,
+ lstat,
+ mkdir,
+ readFile,
+ readlink,
+ rm,
+ rmdir,
+ unlink,
+ writeFile,
+} from "node:fs/promises";
+import { hostname } from "node:os";
import { dirname } from "node:path";
import { setTimeout as pause } from "node:timers/promises";
+import { promisify } from "node:util";
import { userManifestPath } from "../../../kernel/paths.js";
import type { Manifest } from "../domain/manifest.js";
import type { ManifestRepository } from "../domain/ports/manifest-repository.js";
import { readManifestFile } from "./manifest-file-io.js";
+const execFile = promisify(execFileCallback);
+
/**
* The user-scope counterpart of `ManifestRepositoryAdapter` — same schema, same version and refusal
* rule, same file I/O. Only the path differs, and what a version-refusal message names to fix it.
@@ -18,7 +34,8 @@ import { readManifestFile } from "./manifest-file-io.js";
export class UserManifestRepositoryAdapter implements ManifestRepository {
constructor(
private readonly userConfigDir: () => string,
- private readonly atomicWriter: (path: string, content: string) => Promise
+ private readonly atomicWriter: (path: string, content: string) => Promise,
+ private readonly beforeAction?: () => Promise
) {}
get path(): string {
@@ -29,7 +46,7 @@ export class UserManifestRepositoryAdapter implements ManifestRepository {
return readManifestFile({
path: this.path,
location: "for this machine",
- reinstallCommand: "aidd setup --scope user",
+ reinstallCommand: "aidd setup --global",
});
}
@@ -46,24 +63,130 @@ export class UserManifestRepositoryAdapter implements ManifestRepository {
const lock = `${this.path}.lock`;
await mkdir(dirname(lock), { recursive: true });
const deadline = Date.now() + 30_000;
- while (true) {
+ const realm = await localProcessRealm();
+ // The link is atomic and already contains its owner at the instant it becomes visible.
+ // A directory lock left by an older CLI remains unrecognized and is never removed here.
+ const claim = `${lock}.${process.pid}-${randomBytes(12).toString("hex")}`;
+ await writeFile(claim, JSON.stringify({ pid: process.pid, host: hostname(), realm }), {
+ mode: 0o600,
+ flag: "wx",
+ });
+ try {
+ while (true) {
+ const acquired = await withLockGuard(lock, deadline, async () => {
+ try {
+ await link(claim, lock);
+ return true;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
+ await removeDeadOwner(lock, realm);
+ return false;
+ }
+ });
+ if (acquired) break;
+ await waitForLock(lock, deadline);
+ }
try {
- await mkdir(lock);
- break;
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
- if (Date.now() >= deadline) {
- throw new Error(
- `User manifest is busy: ${lock}. Retry after the other AIDD operation finishes.`
- );
- }
- await pause(100);
+ await this.beforeAction?.();
+ return await action();
+ } finally {
+ // Hold the same short guard as recovery: ownership cannot change between a reaper's
+ // liveness check and unlink. If this guard cannot be acquired, leave the lock intact.
+ await withLockGuard(lock, Date.now() + 30_000, () => unlink(lock));
}
+ } finally {
+ await unlink(claim);
}
+ }
+}
+
+async function waitForLock(lock: string, deadline: number): Promise {
+ if (Date.now() >= deadline)
+ throw new Error(
+ `User manifest is busy: ${lock}. Wait for other AIDD operations to finish. If none are running, inspect and remove this stale lock path, then retry; never remove a lock held by an active process.`
+ );
+ await pause(100);
+}
+
+/** Serialize lock acquisition, recovery and release so a stale observation cannot remove a new owner. */
+async function withLockGuard(
+ lock: string,
+ deadline: number,
+ action: () => Promise
+): Promise {
+ const guard = `${lock}.guard`;
+ while (true) {
try {
- return await action();
- } finally {
- await rmdir(lock);
+ await mkdir(guard);
+ break;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
+ await waitForLock(guard, deadline);
+ }
+ }
+ try {
+ return await action();
+ } finally {
+ await rmdir(guard);
+ }
+}
+
+/** Unknown, foreign-host, legacy and live locks stay untouched. PID reuse only delays recovery. */
+async function removeDeadOwner(lock: string, realm: string | undefined): Promise {
+ if (realm === undefined) return;
+ let owner: unknown;
+ try {
+ const stat = await lstat(lock);
+ if (!stat.isFile() || stat.size > 1024) return;
+ owner = JSON.parse(await readFile(lock, "utf8"));
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return;
+ throw error;
+ }
+ if (
+ owner === null ||
+ typeof owner !== "object" ||
+ !("pid" in owner) ||
+ !("host" in owner) ||
+ !("realm" in owner)
+ )
+ return;
+ const pid = owner.pid;
+ if (
+ typeof pid !== "number" ||
+ !Number.isSafeInteger(pid) ||
+ pid <= 0 ||
+ owner.host !== hostname() ||
+ owner.realm !== realm
+ )
+ return;
+ try {
+ process.kill(pid, 0);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") await unlink(lock);
+ }
+}
+
+/** A PID means the same thing only in the same boot and PID namespace. Unknown realms fail closed. */
+async function localProcessRealm(): Promise {
+ try {
+ if (process.platform === "linux") {
+ const boot = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
+ const namespace = await readlink("/proc/self/ns/pid");
+ if (/^[0-9a-f-]{36}$/.test(boot) && /^pid:\[\d+\]$/.test(namespace))
+ return `linux:${boot}:${namespace}`;
+ }
+ if (process.platform === "darwin") {
+ const { stdout } = await execFile("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], {
+ timeout: 1000,
+ maxBuffer: 256,
+ });
+ const boot = stdout.trim();
+ if (/^[0-9a-f-]{36}$/i.test(boot)) return `darwin:${boot}`;
}
+ } catch {
+ // Recovery is optional: acquisition still works, but an unverifiable owner is never reaped.
+ return undefined;
}
+ return undefined;
}
diff --git a/cli/src/contexts/framework/infrastructure/user-release-journal-store.ts b/cli/src/contexts/framework/infrastructure/user-release-journal-store.ts
new file mode 100644
index 000000000..e8429c33b
--- /dev/null
+++ b/cli/src/contexts/framework/infrastructure/user-release-journal-store.ts
@@ -0,0 +1,68 @@
+import { randomBytes } from "node:crypto";
+import { constants } from "node:fs";
+import { type FileHandle, link, lstat, mkdir, open, realpath, unlink } from "node:fs/promises";
+import { basename, dirname, join } from "node:path";
+import type { UserReleaseJournalStore } from "../application/ownership/release/user-release-journal.js";
+
+/** A private, exclusively created journal adjacent to the machine manifest. */
+export class UserReleaseJournalStoreAdapter implements UserReleaseJournalStore {
+ constructor(private readonly manifestPath: () => string) {}
+ private get path(): string {
+ return `${this.manifestPath()}.release-journal`;
+ }
+
+ async read(): Promise {
+ let handle: FileHandle;
+ try {
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
+ handle = await open(this.path, constants.O_RDONLY | noFollow);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
+ throw error;
+ }
+ try {
+ const info = await handle.stat();
+ const pathInfo = await lstat(this.path);
+ const dir = await realpath(dirname(this.path));
+ if (
+ !info.isFile() ||
+ !pathInfo.isFile() ||
+ info.ino !== pathInfo.ino ||
+ info.dev !== pathInfo.dev ||
+ (process.platform !== "win32" &&
+ ((info.mode & 0o077) !== 0 || info.uid !== process.getuid?.())) ||
+ (await realpath(this.path)) !== join(dir, basename(this.path))
+ ) {
+ throw new Error("User release journal is not a private owned file; recovery refused.");
+ }
+ return await handle.readFile("utf8");
+ } finally {
+ await handle.close();
+ }
+ }
+
+ async create(content: string): Promise {
+ const dir = dirname(this.path);
+ await mkdir(dir, { recursive: true });
+ const temp = `${this.path}.${process.pid}-${randomBytes(12).toString("hex")}.tmp`;
+ const handle = await open(temp, "wx", 0o600);
+ try {
+ try {
+ await handle.writeFile(content, "utf8");
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+ // link is an atomic no-replace operation. Another journal is never overwritten.
+ await link(temp, this.path);
+ } finally {
+ await unlink(temp);
+ }
+ }
+
+ async remove(): Promise {
+ if ((await this.read()) === null)
+ throw new Error("User release journal disappeared before completion.");
+ await unlink(this.path);
+ }
+}
diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts
index b488a508f..4596ff41b 100644
--- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts
+++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts
@@ -22,6 +22,15 @@ 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` 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. */
+ 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[];
@@ -35,6 +44,10 @@ export interface NativeActivation {
upgradeVerb?: string;
/** Exact host command for updating one installed plugin ref. Copilot declares `update`; Codex does not. */
updateVerb?: string;
+ /** Targeted refresh of an existing installation after a framework release update. */
+ releaseUpdateVerb?: string;
+ /** Reinstall an already-owned plugin from the rebuilt local catalogue after an explicit release update. */
+ refreshOwnedOnReleaseUpdate?: boolean;
/** Verb this CLI uses to enable a plugin, after `plugin`. Omit when the tool loads plugins
* from a project file this CLI writes. */
enableVerb?: string;
@@ -70,6 +83,50 @@ 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 and Kilo's skills and agents, Codex's agents.
+ */
+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: GlobalPlugin) => 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 GlobalPlugin {
+ readonly name: string;
+ 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 flatGlobalFilePath(directory: string): GlobalFiles["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 GlobalFilesParams {
+ globalFiles?: GlobalFiles;
+ startupTui?: { readonly plugin: string; readonly directory: string; readonly module: string };
+}
+
export interface NativePluginsParams extends ProjectHooksDelivery {
mode: "native";
pluginsDir: string;
@@ -121,6 +178,8 @@ export type FlatHooksSupport =
* a bridge must be told to run. Omit where the loader has no such convention.
*/
flatHooksLoaderEntry?: FlatHooksLoaderEntry;
+ /** Host-specific placement for hook files, shared by build and direct installation. */
+ flatHooksPath?: (plugin: string, relativePath: string) => string;
/** See {@link FlatHooksBridge}. Omit when this loader triggers a plugin's hooks some
* other way. */
flatHooksBridge?: FlatHooksBridge;
@@ -153,10 +212,12 @@ export type HooksSupport =
| { acceptsHooks: true; hooksTrustNotice?: string }
| { acceptsHooks: false; hooksUnsupportedReason: string };
-type PluginsParams =
+type PluginsParams = (
| (NativePluginsParams & HooksSupport)
| FlatPluginsParams
- | UnsupportedPluginsParams;
+ | UnsupportedPluginsParams
+) &
+ GlobalFilesParams;
export class PluginsCapability {
readonly mode: PluginsMode;
@@ -183,6 +244,7 @@ export class PluginsCapability {
readonly flatHooksDir: string | null;
/** See {@link FlatHooksSupport.flatHooksLoaderEntry}. */
readonly flatHooksLoaderEntry: FlatHooksLoaderEntry | null;
+ readonly flatHooksPath: ((plugin: string, relativePath: string) => string) | null;
/** See {@link FlatHooksBridge}, or `null` when this capability declares none. */
readonly flatHooksBridge: FlatHooksBridge | null;
readonly flatSkillLayout: FlatSkillLayout;
@@ -190,11 +252,20 @@ export class PluginsCapability {
readonly nativeActivation: NativeActivation | null;
readonly translationMode: PluginTranslationMode | null;
readonly installScope: "project" | "user";
+ /** See {@link GlobalFiles}, or `null` when a machine-scope install copies no file. */
+ readonly globalFiles: GlobalFiles | null;
+ readonly startupTui: {
+ readonly plugin: string;
+ readonly directory: string;
+ readonly module: string;
+ } | null;
private readonly _userPluginsDir?: (homedir: string) => string;
constructor(params: PluginsParams) {
this.mode = params.mode;
+ this.globalFiles = params.globalFiles ?? null;
+ this.startupTui = params.startupTui ?? null;
this.translationMode = PluginsCapability.resolveTranslationMode(params);
this.installScope = PluginsCapability.resolveInstallScope(params);
PluginsCapability.validateUserScope(params);
@@ -216,6 +287,7 @@ export class PluginsCapability {
this.projectHooksFormat = params.projectHooksFormat ?? null;
this.flatHooksDir = null;
this.flatHooksLoaderEntry = null;
+ this.flatHooksPath = null;
this.flatHooksBridge = null;
this.flatSkillLayout = "nested";
this.marketplaceSettings = params.marketplaceSettings ?? null;
@@ -231,6 +303,7 @@ export class PluginsCapability {
this.flatHooksLoaderEntry = params.acceptsHooks
? (params.flatHooksLoaderEntry ?? null)
: null;
+ this.flatHooksPath = params.acceptsHooks ? (params.flatHooksPath ?? null) : null;
this.flatHooksBridge = params.acceptsHooks ? (params.flatHooksBridge ?? null) : null;
this.flatSkillLayout = params.flatSkillLayout ?? "nested";
this.hooksTrustNotice = null;
@@ -257,6 +330,7 @@ export class PluginsCapability {
this.hooksUnsupportedReason = params.hooksUnsupportedReason;
this.flatHooksDir = null;
this.flatHooksLoaderEntry = null;
+ this.flatHooksPath = null;
this.flatHooksBridge = null;
this.flatSkillLayout = "nested";
this.hooksTrustNotice = null;
@@ -285,7 +359,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.globalFiles?.dir(homedir) ?? null;
}
pluginOutputDir(pluginName: string): string | null {
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/src/contexts/tools/domain/ports/native-plugin-activator.ts b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts
index eb01d94e7..82df4bc65 100644
--- a/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts
+++ b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts
@@ -34,6 +34,7 @@ export interface NativePluginActivator {
*/
enablePlugin(pluginRef: string, scope?: MarketplaceScope): void;
updatePlugin?(pluginRef: string): void;
+ refreshReleasePlugin?(pluginRef: string, scope: MarketplaceScope, projectRoot: string): void;
/**
* Uninstalls a plugin referenced as `@`, the counterpart of
* {@link enablePlugin}. May throw when the plugin is already absent from the tool's own
diff --git a/cli/src/contexts/tools/domain/profiles/claude/profile.ts b/cli/src/contexts/tools/domain/profiles/claude/profile.ts
index a09f3778f..36bea408d 100644
--- a/cli/src/contexts/tools/domain/profiles/claude/profile.ts
+++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts
@@ -117,9 +117,11 @@ export const claude: AiTool join(homedir, ".codex"), map: codexGlobalFilePath },
acceptsMcp: true,
translationMode: "marketplace",
// Codex only enables plugins from its user-global config plus its own plugin cache; a
@@ -190,7 +192,9 @@ export const codex: AiTool<
pluginRootToken: PLUGIN_ROOT_TOKEN,
nativeActivation: {
binary: "codex",
- upgradeVerb: "upgrade",
+ // Codex rejects `marketplace upgrade` for the local catalogue we compile. Re-adding an
+ // owned plugin refreshes its installed cache from that rebuilt catalogue instead.
+ refreshOwnedOnReleaseUpdate: true,
enableVerb: "add",
disableVerb: "remove",
// Codex's own `plugin remove` deletes a marketplace's cached content but leaves the
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/copilot/profile.ts b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts
index b798a7a01..e7f49e249 100644
--- a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts
+++ b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts
@@ -288,6 +288,9 @@ export const copilot: AiTool<
disableVerb: "uninstall",
sourceCheckVerb: "update",
forceRemoveArgs: ["--force"],
+ // Measured on 1.0.98: `plugin uninstall` sets the ref to `false` in enabledPlugins
+ // rather than deleting it.
+ uninstallLeavesRefDisabled: true,
// Where `copilot plugin marketplace add`/`install` land, measured against the real
// binary. Never written by aidd; named here for a diagnostic alone.
userSettingsPath: (h) => join(h, ".copilot", "settings.json"),
diff --git a/cli/src/contexts/tools/domain/profiles/kilo/headless-run.ts b/cli/src/contexts/tools/domain/profiles/kilo/headless-run.ts
new file mode 100644
index 000000000..55fb5d9de
--- /dev/null
+++ b/cli/src/contexts/tools/domain/profiles/kilo/headless-run.ts
@@ -0,0 +1,7 @@
+export const kiloHeadlessRun = {
+ configDirEnv: "KILO_CONFIG_DIR",
+ pureEnv: "KILO_PURE",
+ pureFlag: "--pure",
+ directoryArg: "--dir",
+ interactiveFlags: ["--interactive", "-i", "--interactive=true"] as readonly string[],
+};
diff --git a/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts
index e7be273fb..82e1c8c81 100644
--- a/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts
+++ b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts
@@ -26,13 +26,16 @@ interface ParsedHookCall {
readonly matcher?: string;
}
-const COMMAND_PATTERN = /^node\s+\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+)(.*)$/;
+// Read the canonical Claude-format manifest before translating it to Kilo.
+const COMMAND_PATTERN =
+ /^node\s+(?:"\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)"|\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+))(.*)$/;
function parseCommand(command: string | undefined): ParsedHookCall | null {
if (typeof command !== "string") return null;
const match = COMMAND_PATTERN.exec(command.trim());
if (match === null) return null;
- const [, script, rest] = match;
+ const [, quotedScript, bareScript, rest] = match;
+ const script = quotedScript ?? bareScript;
return { script, args: rest.trim().length === 0 ? [] : rest.trim().split(/\s+/) };
}
@@ -132,7 +135,7 @@ function stopReasonOf(script, args, payload, directory) {
});
}
-function answerOf(script, args, payload, directory) {
+function answerOf(script, args, payload, directory, systemMessage = false) {
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
@@ -153,7 +156,12 @@ function answerOf(script, args, payload, directory) {
fail("exited with code " + String(code) + (signal === null ? "" : " (" + signal + ")"));
return;
}
- resolve(answerFrom(code, stdout, stderr));
+ if (systemMessage) {
+ try { resolve(String(JSON.parse(stdout)?.systemMessage ?? "").trim()); }
+ catch { resolve(""); }
+ } else {
+ resolve(answerFrom(code, stdout, stderr));
+ }
});
child.stdin.on("error", () => {});
child.stdin.end(JSON.stringify(payload));
@@ -166,9 +174,10 @@ function reportHookFailure(script, error) {
function sessionStartCallsFor(event, directory) {
if (event?.type !== "session.created") return [];
- return SESSION_START.map((hook) => ({
+ return SESSION_START${plugin === "aidd-maintenance" ? '.filter((hook) => hook.matcher !== "startup")' : ""}.map((hook) => ({
script: hook.script,
args: hook.args,
+ ...(hook.matcher === "startup" ? { notify: true } : {}),
payload: { hook_event_name: "SessionStart", session_id: event.properties?.sessionID ?? null, cwd: event.properties?.info?.directory ?? directory },
}));
}
@@ -206,7 +215,16 @@ const ${identifier} = async ({ directory, client }) => {
}
}
for (const call of calls) {
- runHook(call.script, call.args, call.payload, call.payload.cwd);
+ if (call.notify) {
+ const message = await answerOf(call.script, call.args, { ...call.payload, tool: "kilo" }, call.payload.cwd, true);
+ if (message && typeof client?.tui?.showToast === "function") {
+ await client.tui.showToast({ body: { message, variant: "info" } });
+ } else if (message) {
+ console.warn("[aidd] Kilo cannot display the startup message: " + message);
+ }
+ } else {
+ runHook(call.script, call.args, call.payload, call.payload.cwd);
+ }
}
} catch (error) {
console.warn("[aidd] Kilo hook dispatch failed: " + String(error));
diff --git a/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts
index e3ec31b80..ed5f43cef 100644
--- a/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts
+++ b/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts
@@ -4,8 +4,22 @@
import { join } from "node:path";
import { KiloDualConfigError } from "../../../../../kernel/errors.js";
import type { FileReader } from "../../../../../kernel/ports/file-reader.js";
+import { flatGlobalFilePath } from "../../capabilities/plugins-capability.js";
export const KILO_DIRECTORY = ".kilo/";
+const flatMachinePath = flatGlobalFilePath(KILO_DIRECTORY);
+const maintenanceStartup = new Set([
+ ".kilo/hooks/aidd-maintenance/session-start.cjs",
+ ".kilo/hooks/aidd-maintenance/kilo-tui.js",
+ ".kilo/plugin/aidd-maintenance-hooks.js",
+]);
+
+/** Only maintenance's startup files are installed globally; other hooks remain project-scoped. */
+export const kiloGlobalFilePath: ReturnType = (path, plugin) => {
+ if (plugin.name === "aidd-maintenance" && maintenanceStartup.has(path))
+ return path.slice(KILO_DIRECTORY.length);
+ return flatMachinePath(path, plugin);
+};
export const KILO_HOOKS_DIR = `${KILO_DIRECTORY}hooks/`;
export const KILO_PLUGIN_DIR = `${KILO_DIRECTORY}plugin/`;
export const KILO_PLUGIN_ENTRY_BASENAME = "kilo-plugin.js";
diff --git a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts
index 708236ad2..81eb71373 100644
--- a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts
+++ b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts
@@ -1,3 +1,4 @@
+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";
@@ -29,6 +30,7 @@ import {
KILO_HOOKS_DIR,
KILO_PLUGIN_DIR,
KILO_PLUGIN_ENTRY_BASENAME,
+ kiloGlobalFilePath,
makeKiloHooksBridgePath,
resolveKiloConfigPath,
} from "./kilo-paths.js";
@@ -89,6 +91,15 @@ export const kilo: AiTool join(homedir, ".config", "kilo"),
+ map: kiloGlobalFilePath,
+ },
+ startupTui: {
+ plugin: "aidd-maintenance",
+ directory: KILO_DIRECTORY,
+ module: "hooks/aidd-maintenance/kilo-tui.js",
+ },
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/build.ts b/cli/src/contexts/tools/domain/profiles/opencode/build.ts
index c7462d7ea..0ce9ff449 100644
--- a/cli/src/contexts/tools/domain/profiles/opencode/build.ts
+++ b/cli/src/contexts/tools/domain/profiles/opencode/build.ts
@@ -7,7 +7,6 @@
import { InvalidMcpServerConfigError, McpConfigError } from "../../../../../kernel/errors.js";
import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js";
import {
- flatHooksPathWithLoaderEntry,
flatMcpKeyPrefix,
genericFlatAgentPath,
genericFlatSkillTreePath,
@@ -24,8 +23,8 @@ import {
OPENCODE_EVENTS_ASSET,
OPENCODE_EVENTS_PATH,
OPENCODE_FLAT_HOOKS_DIR,
- OPENCODE_HOOKS_DIR,
OPENCODE_PLUGIN_ENTRY_BASENAME,
+ opencodeFlatHooksPath,
} from "./opencode-paths.js";
type FsType = FileReader & FileWriter;
@@ -90,27 +89,13 @@ function opencodeFlatSkillPath(plugin: string, rel: string): string {
return genericFlatSkillTreePath(".opencode/skills/", plugin, rel.replace(/^skills\//, ""));
}
-/** OpenCode's loader scans one flat directory for its own plugin modules, never a "hooks"
- * family, so a plugin's hook scripts are namespaced under `OPENCODE_HOOKS_DIR` instead. The one
- * exception is a script named `OPENCODE_PLUGIN_ENTRY_BASENAME`: that loader's own module,
- * delivered flat and renamed to the plugin's own name. */
-function makeOpencodeFlatHooksPath(): (plugin: string, rel: string) => string {
- return (plugin, rel) =>
- flatHooksPathWithLoaderEntry(
- OPENCODE_HOOKS_DIR,
- { dir: OPENCODE_FLAT_HOOKS_DIR, baseName: OPENCODE_PLUGIN_ENTRY_BASENAME },
- plugin,
- rel
- );
-}
-
/** Supported exactly when the profile names a directory to deliver into — the same
* declaration `acceptsHooks` is paired with, read once rather than restated here. */
function buildOpencodeFlatHooksArtifact(): ArtifactContract {
return {
supported: true,
source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" },
- path: makeOpencodeFlatHooksPath(),
+ path: opencodeFlatHooksPath,
skipHooksJson: true,
hooksBridge: {
generate: generateOpencodeHooksBridge,
diff --git a/cli/src/contexts/tools/domain/profiles/opencode/headless-run.ts b/cli/src/contexts/tools/domain/profiles/opencode/headless-run.ts
new file mode 100644
index 000000000..9b2ae58a6
--- /dev/null
+++ b/cli/src/contexts/tools/domain/profiles/opencode/headless-run.ts
@@ -0,0 +1,7 @@
+export const opencodeHeadlessRun = {
+ configDirEnv: "OPENCODE_CONFIG_DIR",
+ pureEnv: undefined,
+ pureFlag: undefined,
+ directoryArg: undefined,
+ interactiveFlags: [] as readonly string[],
+};
diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts
index be0adf5e9..c0c5fd630 100644
--- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts
+++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts
@@ -7,9 +7,9 @@
*
* Only three hook events are replayed:
*
- * - `SessionStart` runs once per server/directory at factory initialization and requires
- * idempotent commands. V1 did not deliver `session.created` to this event hook;
- * the V2 adapter delivers it.
+ * - Unmatched `SessionStart` runs once per server/directory at initialization and requires
+ * idempotent commands. Hooks matching `startup` run for each delivered session.created.
+ * V1 did not always deliver this event; V2 does.
* - `Stop` maps to V1's `session.idle` or V2's execution terminal, once per turn.
* - `PostToolUse` maps to V1's `tool.execute.after`, the one hook that waits for the script and
* can append its exit-2 stderr to the tool's output, which is how a problem reaches the model.
@@ -48,16 +48,16 @@ interface ClaudeHooksShape {
readonly hooks?: Record;
}
-// Only a command this generator can actually replay: `node ${CLAUDE_PLUGIN_ROOT}/hooks/
-// [args...]`. Anything else is a shape only Claude's own settings.json target ever runs, and is
-// dropped here rather than guessed at.
-const COMMAND_PATTERN = /^node\s+\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+)(.*)$/;
+// Read replayable commands from the canonical Claude-format manifest.
+const COMMAND_PATTERN =
+ /^node\s+(?:"\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)"|\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+))(.*)$/;
function parseCommand(command: string | undefined): ParsedHookCall | null {
if (typeof command !== "string") return null;
const match = COMMAND_PATTERN.exec(command.trim());
if (!match) return null;
- const [, script, rest] = match;
+ const [, quotedScript, bareScript, rest] = match;
+ const script = quotedScript ?? bareScript;
const args = rest.trim().length > 0 ? rest.trim().split(/\s+/) : [];
return { script, args };
}
@@ -74,10 +74,7 @@ function parseGroups(groups: readonly ClaudeMatcherGroup[] | undefined): ParsedP
return calls;
}
-/** A plugin's raw hooks.json — still carrying `${CLAUDE_PLUGIN_ROOT}`, since the generated
- * bridge resolves its scripts from its own `import.meta.url` and the outDir-relative rewrite
- * every other flat target needs buys this one nothing — reduced to the three mapped events.
- * Exported for the generator's own unit test. */
+/** Read source hooks; the generated bridge resolves scripts beside itself. */
export function parseHooksJsonForBridge(rawHooksJson: string): OpencodeHookTable {
const parsed = JSON.parse(rawHooksJson) as ClaudeHooksShape;
const hooks = parsed.hooks ?? {};
@@ -123,6 +120,7 @@ export function generateOpencodeHooksBridge(rawHooksJson: string, plugin: string
// the mapping this generator applies and the measurements behind it.
import { setupOpencodeEvents } from "../hooks/opencode-events.js";
import { spawn } from "node:child_process";
+import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
// Never process.execPath: OpenCode ships as its own standalone binary - that path names \`opencode\`
@@ -240,11 +238,10 @@ function postToolUseCallsFor(call, directory) {
export const ${ident} = async (input) => {
// Sessions whose last Stop asked the model to continue: their next Stop carries stop_hook_active.
const continued = new Set();
- // SessionStart's own approximation (module doc comment above): fired once here, never
- // per session. Silent on purpose: a memory refresh
- // that breaks OpenCode's own startup is worse than one that never ran.
+ const started = new Set();
+ // Existing unmatched hooks are intentionally still run at initialization.
try {
- for (const hook of SESSION_START) {
+ for (const hook of SESSION_START.filter((hook) => hook.matcher !== "startup")) {
runHook(
hook.script,
hook.args,
@@ -258,7 +255,25 @@ export const ${ident} = async (input) => {
return {
event: async ({ event }) => {
try {
- const directory = event?.properties?.directory ?? input.directory;
+ const directory = event?.properties?.info?.directory ?? event?.properties?.directory ?? input.directory;
+ const id = event?.properties?.sessionID ?? event?.properties?.info?.id;
+ if (event?.type === "session.deleted") started.delete(id);
+ if (!input.skipStartup && event?.type === "session.created" && typeof id === "string" && !started.has(id)) {
+ started.add(id);
+ for (const hook of SESSION_START.filter((hook) => hook.matcher === "startup")) {
+ const { code, stdout } = await runCapture(hook.script, hook.args,
+ { hook_event_name: "SessionStart", session_id: id, cwd: directory, tool: "opencode" }, directory);
+ let message = "";
+ if (code === 0) {
+ try { message = String(JSON.parse(stdout)?.systemMessage ?? "").trim(); } catch { message = ""; }
+ }
+ if (message && typeof input.client?.tui?.showToast === "function") {
+ await input.client.tui.showToast({ body: { message, variant: "info" } });
+ } else if (message) {
+ console.warn("[aidd] OpenCode cannot display the startup message: " + message);
+ }
+ }
+ }
for (const call of stopCallsFor(event, directory)) {
const sessionId = call.payload.session_id;
const payload = { ...call.payload, stop_hook_active: continued.has(sessionId) };
@@ -296,7 +311,10 @@ ${ident}.postToolUseCallsFor = postToolUseCallsFor;
export default {
id: ${JSON.stringify(`${plugin}-hooks`)},
server: ${ident},
- setup: (ctx) => setupOpencodeEvents(ctx, ${ident}),
+ setup: (ctx) => setupOpencodeEvents(ctx, (input) => ${ident}({
+ ...input,
+ skipStartup: existsSync(fileURLToPath(new URL("../plugins/${plugin}-tui/tui.js", import.meta.url))),
+ })),
};
`;
}
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..022a76369 100644
--- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts
+++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts
@@ -1,9 +1,27 @@
+import { flatHooksPathWithLoaderEntry } from "../../../../../kernel/materialization/flat-paths.js";
+
/** Where OpenCode keeps what this CLI writes for it. Held apart from the profile so the build
* contract can read the same values: the profile imports the contract, so the contract cannot
* import the profile back. */
+import { flatGlobalFilePath } from "../../capabilities/plugins-capability.js";
+
export const OPENCODE_DIRECTORY = ".opencode/";
+const flatMachinePath = flatGlobalFilePath(OPENCODE_DIRECTORY);
+const maintenanceStartup = new Set([
+ ".opencode/hooks/aidd-maintenance/session-start.cjs",
+ ".opencode/plugins/aidd-maintenance-tui/server.js",
+ ".opencode/plugins/aidd-maintenance-tui/tui.js",
+]);
+
+/** The maintenance TUI and its script are self-contained; the shared event bridge stays project-scoped. */
+export const opencodeGlobalFilePath: ReturnType = (path, plugin) => {
+ if (plugin.name === "aidd-maintenance" && maintenanceStartup.has(path))
+ return path.slice(OPENCODE_DIRECTORY.length);
+ return flatMachinePath(path, plugin);
+};
+
/**
* 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
@@ -23,6 +41,21 @@ export const OPENCODE_EVENTS_PATH = `${OPENCODE_HOOKS_DIR}${OPENCODE_EVENTS_ASSE
* runtime the loader is meant to import — rather than a script an external bridge must run. */
export const OPENCODE_PLUGIN_ENTRY_BASENAME = "opencode-plugin.js";
+/** OpenCode V2 discovers adjacent server.js and tui.js as one native plugin. Other hooks
+ * remain in the normal per-plugin directory, shared by every materialization path. */
+export function opencodeFlatHooksPath(plugin: string, rel: string): string {
+ if (rel === "hooks/opencode-server.js" || rel === "hooks/opencode-tui.js") {
+ const entry = rel === "hooks/opencode-server.js" ? "server.js" : "tui.js";
+ return `.opencode/plugins/${plugin}-tui/${entry}`;
+ }
+ return flatHooksPathWithLoaderEntry(
+ OPENCODE_HOOKS_DIR,
+ { dir: OPENCODE_FLAT_HOOKS_DIR, baseName: OPENCODE_PLUGIN_ENTRY_BASENAME },
+ plugin,
+ rel
+ );
+}
+
/** Where a plugin's generated event bridge lands: flat, in the directory the loader scans,
* renamed per plugin so two plugins each getting one cannot collide. Read by both
* flat-materialization routes, so the path is one fact rather than two that could drift. */
diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts
index 6805756d9..62cd6023e 100644
--- a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts
+++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts
@@ -33,6 +33,8 @@ import {
OPENCODE_FLAT_HOOKS_DIR,
OPENCODE_HOOKS_DIR,
OPENCODE_PLUGIN_ENTRY_BASENAME,
+ opencodeFlatHooksPath,
+ opencodeGlobalFilePath,
} from "./opencode-paths.js";
const DIRECTORY = OPENCODE_DIRECTORY;
@@ -106,8 +108,13 @@ export const opencode: AiTool<
plugins: new PluginsCapability({
mode: "flat",
flatNamespacePrefix: "aidd-",
+ globalFiles: {
+ dir: (homedir) => join(homedir, ".config", "opencode"),
+ map: opencodeGlobalFilePath,
+ },
acceptsHooks: true,
flatHooksDir: OPENCODE_HOOKS_DIR,
+ flatHooksPath: opencodeFlatHooksPath,
flatHooksLoaderEntry: {
dir: OPENCODE_FLAT_HOOKS_DIR,
baseName: OPENCODE_PLUGIN_ENTRY_BASENAME,
diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts
index 237e60676..ef9e47d09 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,
+ GlobalFiles,
NativeActivation,
PluginsCapability,
} from "./capabilities/plugins-capability.js";
@@ -118,13 +119,38 @@ 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 global files into the
+ * 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;
- return capability.nativeActivation !== null || capability.installScope === "user";
+ return (
+ capability.nativeActivation !== null ||
+ capability.installScope === "user" ||
+ capability.globalFiles !== null
+ );
+}
+
+/** 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. */
+export function copiesPluginFilesGlobally(toolId: ToolId): boolean {
+ const capability = resolvePluginsCapability(toolId);
+ if (capability === null) return false;
+ return capability.installScope === "user" || capability.globalFiles !== 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/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts
index 109588375..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
@@ -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,12 +62,41 @@ 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, ...this.scopeArgsFor(scope), ...force],
- `marketplace remove ${name}`
+ [
+ "plugin",
+ "marketplace",
+ "remove",
+ name,
+ ...(everywhere ? userArgs : this.scopeArgsFor(scope)),
+ ...force,
+ ],
+ `marketplace remove ${name}`,
+ 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;
@@ -70,8 +109,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/host-plugin-registry-reader-adapter.ts b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts
index 60d84bd63..7e25faa84 100644
--- a/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts
+++ b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts
@@ -60,9 +60,8 @@ class ClaudeInstalledPluginsReader implements HostPluginRegistryReader {
return failedRead(this.path, error);
}
try {
- const parsed = JSON.parse(content) as { plugins?: Record };
- const plugins = parsed.plugins;
- if (plugins === undefined || typeof plugins !== "object") {
+ const plugins = parseClaudePlugins(content);
+ if (plugins === undefined) {
return { location: this.path, unreadable: "no `plugins` object" };
}
const refs = new Map();
@@ -78,6 +77,46 @@ class ClaudeInstalledPluginsReader implements HostPluginRegistryReader {
}
}
+/** Claude's recorded installations sharing a marketplace. Presence does not prove enablement. */
+export async function claudeInstallations(
+ configDir: string,
+ catalogue: string
+): Promise<{ ref: string; scope: "user" | "project" | "local"; projectPath?: string }[]> {
+ const content = await readFile(join(configDir, "plugins", "installed_plugins.json"), "utf8");
+ const plugins = parseClaudePlugins(content);
+ if (!plugins) throw new Error("Cannot inspect Claude's installed plugins.");
+ const installations: {
+ ref: string;
+ scope: "user" | "project" | "local";
+ projectPath?: string;
+ }[] = [];
+ for (const [ref, entries] of Object.entries(plugins).sort(([a], [b]) => a.localeCompare(b))) {
+ if (!ref.endsWith(`@${catalogue}`)) continue;
+ if (!Array.isArray(entries)) throw new Error(`Cannot inspect Claude installation '${ref}'.`);
+ for (const entry of entries) {
+ if (entry?.scope === "user") installations.push({ ref, scope: "user" });
+ else if (
+ (entry?.scope === "local" || entry?.scope === "project") &&
+ typeof entry.projectPath === "string"
+ )
+ installations.push({
+ ref,
+ scope: entry.scope,
+ projectPath: await resolvedPath(entry.projectPath),
+ });
+ else throw new Error(`Cannot inspect Claude installation '${ref}'.`);
+ }
+ }
+ return installations;
+}
+
+function parseClaudePlugins(content: string): Record | undefined {
+ const parsed = JSON.parse(content) as { plugins?: Record };
+ return parsed.plugins && typeof parsed.plugins === "object" && !Array.isArray(parsed.plugins)
+ ? parsed.plugins
+ : undefined;
+}
+
/**
* Codex's own registry, line-scanned rather than parsed: the file carries arbitrary nested
* tables and multi-line values this adapter has no business understanding, and most of it is
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..5998b5b19 100644
--- a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts
+++ b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts
@@ -4,10 +4,12 @@ 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;
readonly updateVerb?: string;
+ readonly releaseUpdateVerb?: string;
readonly enableVerb?: string;
/** How the tool spells removing a plugin it installed: `remove` for codex, `uninstall` for
* claude and copilot. Absent where this CLI enables plugins through a file it writes. */
@@ -67,6 +69,16 @@ export class NativePluginCliAdapter extends AbstractNativePluginCliAdapter {
this.run(["plugin", verb, pluginRef], `plugin ${verb} ${pluginRef}`);
}
+ refreshReleasePlugin(pluginRef: string, scope: MarketplaceScope, projectRoot: string): void {
+ const verb = this.shape.releaseUpdateVerb;
+ if (verb === undefined) throw new Error(`${this.binary} does not support release refresh.`);
+ this.run(
+ ["plugin", verb, pluginRef, ...this.scopeArgsFor(scope)],
+ `plugin ${verb} ${pluginRef}`,
+ projectRoot
+ );
+ }
+
/** Undoes what `enablePlugin` did. `scope` must match what `enablePlugin` was called with: a
* tool that installed at one scope and uninstalls at its default would silently miss the
* entry it wrote. */
@@ -86,4 +98,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/src/contexts/translate/domain/content-translator.ts b/cli/src/contexts/translate/domain/content-translator.ts
index b688db564..a01114644 100644
--- a/cli/src/contexts/translate/domain/content-translator.ts
+++ b/cli/src/contexts/translate/domain/content-translator.ts
@@ -278,12 +278,13 @@ export class PluginContentTranslator {
.filter((file) => file.relativePath !== `${PLUGIN_HOOKS_DIR}/hooks.json`)
.map((file) =>
this.makeFile(
- flatHooksPathWithLoaderEntry(
- flatHooksDir,
- flatHooksLoaderEntry,
- dist.manifest.name,
- file.relativePath
- ),
+ tool.capabilities.plugins.flatHooksPath?.(dist.manifest.name, file.relativePath) ??
+ flatHooksPathWithLoaderEntry(
+ flatHooksDir,
+ flatHooksLoaderEntry,
+ dist.manifest.name,
+ file.relativePath
+ ),
file.content
)
);
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/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/kernel/ports/file-reader.ts b/cli/src/kernel/ports/file-reader.ts
index 5f673d041..ecd949863 100644
--- a/cli/src/kernel/ports/file-reader.ts
+++ b/cli/src/kernel/ports/file-reader.ts
@@ -7,6 +7,8 @@ export interface FileReader {
* platform, so Windows' native separator must never reach a caller. */
listDirectory(path: string): Promise;
fileExists(path: string): Promise;
+ /** Whether a directory entry exists, including a dangling symbolic link; required for restoration. */
+ entryExists?(path: string): Promise;
readFileHash(path: string): Promise;
listFilesRecursive(dirPath: string): Promise;
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..c3c493b9f 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>;
@@ -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,
+ globalInstall: true,
});
printScopeIssues(output, "User scope", { issues });
const healthy = issues.every((i) => i.severity !== "error");
@@ -153,6 +154,7 @@ interface DoctorCmdOptions {
tool?: string;
plugin?: string;
scope?: string;
+ global?: boolean;
}
export function registerDoctorCommand(program: Command): void {
@@ -168,12 +170,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/framework.ts b/cli/src/presentation/commands/framework.ts
index 506a4e849..778dc4b7f 100644
--- a/cli/src/presentation/commands/framework.ts
+++ b/cli/src/presentation/commands/framework.ts
@@ -4,6 +4,7 @@ import { Manifest } from "../../contexts/framework/domain/manifest.js";
import { isIdeToolId } from "../../contexts/tools/domain/registry.js";
import type { AiToolId, IdeToolId, ToolId } from "../../kernel/tool.js";
import { isAiToolId, VALID_TOOL_IDS } from "../../kernel/tool.js";
+import { assertMachineConfigDirectorySupported } from "../../runtime/machine-config-directory.js";
import { createDeps } from "../../runtime/wiring/framework.js";
import {
printToolAlreadyInstalled,
@@ -22,6 +23,8 @@ import { parseGlobalOptions } from "./global-options.js";
import { reportSyncActivation } from "./sync-native-activation.js";
type Deps = Awaited>;
+type UpgradeOptions = Parameters[0];
+type FrameworkUpgradePreview = Parameters[0];
export function assertKnownToolId(toolId: string): asserts toolId is ToolId {
if (!isAiToolId(toolId) && !isIdeToolId(toolId)) {
@@ -162,6 +165,168 @@ async function runFrameworkUpdate(
);
}
+function printReleaseLinks(
+ output: CLIOutput,
+ release: string,
+ currentRefs: readonly string[],
+ title: string | null
+): void {
+ const repository = "https://github.com/ai-driven-dev/framework";
+ output.info(title ? `Release title: ${title}` : "Release title unavailable.");
+ output.info(`Release notes: ${repository}/releases/tag/${encodeURIComponent(release)}`);
+ const distinctRefs = [...new Set(currentRefs)].filter((ref) => ref !== release);
+ if (distinctRefs.length === 0) return;
+ output.info(
+ "Repository changes between current tags (not limited to installed plugins; tags can move):"
+ );
+ for (const ref of distinctRefs) {
+ output.info(
+ ` ${ref} -> ${release}: ${repository}/compare/${encodeURIComponent(ref)}...${encodeURIComponent(release)}`
+ );
+ }
+}
+
+function printReleasePreview(
+ output: CLIOutput,
+ projectRoot: string,
+ preview: FrameworkUpgradePreview
+): void {
+ output.info(`Project: ${projectRoot}`);
+ output.info(`Marketplace: ${preview.currentRef} -> ${preview.release}`);
+ output.info("Installed project plugins:");
+ for (const target of preview.targets) {
+ const version = target.targetVersion
+ ? `${target.currentVersion} -> ${target.targetVersion}`
+ : target.currentVersion;
+ output.info(
+ ` ${target.toolId}:${target.pluginName} (plugin ${version}; source ${target.currentRef} -> ${preview.release})`
+ );
+ }
+ printReleaseLinks(
+ output,
+ preview.release,
+ [preview.currentRef, ...preview.targets.map((target) => target.currentRef)],
+ preview.title
+ );
+ if (preview.globalPlugins?.length) {
+ output.info("Global AIDD CLI installations (not updated by this command):");
+ for (const plugin of preview.globalPlugins) {
+ const source = plugin.source;
+ const sourceName =
+ source.kind === "git-subdir" &&
+ source.url === "https://github.com/ai-driven-dev/framework.git"
+ ? `ai-driven-dev/framework@${source.ref ?? "unknown"}`
+ : `${source.kind} (source unverified)`;
+ output.info(
+ ` ${plugin.toolId}:${plugin.pluginName} (plugin ${plugin.currentVersion}; source ${sourceName})`
+ );
+ }
+ }
+ if (preview.unverifiedUserRefs) {
+ output.warn(
+ `Project references to user-scope plugins not verified in the machine manifest: ${preview.unverifiedUserRefs}.`
+ );
+ }
+ output.info("Native user installations are not fully inventoried by this command.");
+ if (preview.impact?.length) {
+ output.info("Claude installations sharing this marketplace:");
+ for (const entry of preview.impact)
+ output.info(
+ ` ${entry.ref} (${entry.scope}${entry.projectPath ? `, ${entry.projectPath}` : ""})`
+ );
+ output.info(
+ "Their shared catalogue files may change. Other dependents may exist; enabled states are unverified."
+ );
+ }
+ if (preview.shared) output.warn("This marketplace is shared; other projects may be affected.");
+}
+
+async function runFrameworkReleaseUpdate(
+ deps: Deps,
+ output: CLIOutput,
+ projectRoot: string,
+ options: { release: string; yes: boolean; allowShared: boolean }
+): Promise {
+ const interactive = process.stdout.isTTY ?? false;
+ const result = await deps.upgradeFrameworkUseCase.execute({
+ projectRoot,
+ release: options.release,
+ allowShared: options.allowShared || (interactive && !options.yes),
+ confirm: async (preview) => {
+ printReleasePreview(output, projectRoot, preview);
+ if (options.yes) return true;
+ if (!interactive)
+ throw new Error(
+ "A release update requires confirmation; pass --yes after reviewing its impact."
+ );
+ return deps.prompter.confirm(
+ "Update these project plugins and the displayed marketplace?",
+ false
+ );
+ },
+ });
+ if (result.cancelled) {
+ output.info("Update cancelled; no installation changes were made.");
+ return;
+ }
+ output.success(
+ `Updated project plugin sources to ${options.release}: ${result.updated.join(", ")}.`
+ );
+ output.info(
+ "Restart the affected tools to load the updated plugins. Session reload and every component were not verified."
+ );
+}
+
+async function runUserFrameworkReleaseUpdate(
+ deps: Deps,
+ output: CLIOutput,
+ projectRoot: string,
+ options: { release: string; yes: boolean }
+): Promise {
+ assertMachineConfigDirectorySupported();
+ const interactive = process.stdout.isTTY ?? false;
+ const result = await deps.upgradeUserFrameworkUseCase.execute({
+ projectRoot,
+ release: options.release,
+ confirm: async (preview) => {
+ output.info(`Machine release: ${preview.release}`);
+ for (const target of preview.targets) {
+ output.info(
+ ` ${target.toolId}:${target.name} (plugin ${target.currentVersion} -> ${target.targetVersion}; source ${target.currentRef} -> ${preview.release})`
+ );
+ if (target.dependents.length)
+ output.info(` Dependent projects: ${target.dependents.join(", ")}`);
+ }
+ printReleaseLinks(
+ output,
+ preview.release,
+ preview.targets.map((target) => target.currentRef),
+ preview.title
+ );
+ if (preview.nativeSkipped)
+ output.warn(
+ `${preview.nativeSkipped} native plugin claims are not updated by this command.`
+ );
+ if (options.yes) return true;
+ if (!interactive)
+ throw new Error(
+ "A release update requires confirmation; pass --yes after reviewing its impact."
+ );
+ return deps.prompter.confirm("Update these machine plugin copies?", false);
+ },
+ });
+ if (result.cancelled) {
+ output.info("Update cancelled; no installation changes were made.");
+ return;
+ }
+ output.success(
+ `Updated machine plugin sources to ${options.release}: ${result.updated.join(", ")}.`
+ );
+ output.info(
+ "Restart the affected tools to load updated plugins; dependent projects may need a refresh."
+ );
+}
+
export function registerFrameworkCommand(program: Command): void {
const framework = program
.command("framework")
@@ -205,27 +370,69 @@ export function registerFrameworkCommand(program: Command): void {
framework
.command("update")
- .description(
- "Re-install tool configs from bundled CLI assets, moving to a new version (all installed tools if --tool is omitted; see `marketplace refresh`, which re-fetches catalogs instead)"
- )
+ .description("Update installed tool configs from the CLI, or plugins with --release")
.option("--tool ", "Limit update to a specific AI or IDE tool")
.option("-f, --force", "Overwrite modified files without prompting", false)
- .action(async (cmdOptions: { tool?: string; force: boolean }) => {
- const { verbose, output, projectRoot } = parseGlobalOptions(program);
- const errorHandler = new ErrorHandler(output);
- try {
- const deps = await createDeps(projectRoot, { verbose }, output);
- await runFrameworkUpdate(
- deps,
- output,
- projectRoot,
- cmdOptions.tool as ToolId | undefined,
- cmdOptions
- );
- } catch (error) {
- errorHandler.handle(error);
+ .option("--release ", "Update plugins to a root release, e.g. v5.13.0")
+ .option(
+ "--scope ",
+ "Choose project or machine plugin installations (with --release)"
+ )
+ .option("--yes", "Confirm the displayed release update without prompting", false)
+ .option("--allow-shared", "Confirm a marketplace change shared with other projects", false)
+ .action(
+ async (cmdOptions: {
+ tool?: string;
+ force: boolean;
+ release?: string;
+ scope?: string;
+ yes: boolean;
+ allowShared: boolean;
+ }) => {
+ const { verbose, output, projectRoot } = parseGlobalOptions(program);
+ const errorHandler = new ErrorHandler(output);
+ try {
+ if (!cmdOptions.release && (cmdOptions.yes || cmdOptions.allowShared))
+ throw new Error("--yes and --allow-shared require --release.");
+ if (
+ cmdOptions.scope !== undefined &&
+ cmdOptions.scope !== "project" &&
+ cmdOptions.scope !== "user"
+ )
+ throw new Error("--scope must be project or user.");
+ if (cmdOptions.scope && !cmdOptions.release)
+ throw new Error("--scope requires --release.");
+ if (cmdOptions.scope === "user" && cmdOptions.allowShared)
+ throw new Error("--allow-shared is not needed for machine plugin copies.");
+ if (cmdOptions.release && (cmdOptions.tool || cmdOptions.force))
+ throw new Error("--tool and --force cannot be combined with --release.");
+ const deps = await createDeps(projectRoot, { verbose }, output);
+ if (cmdOptions.release) {
+ if (cmdOptions.scope === "user")
+ await runUserFrameworkReleaseUpdate(deps, output, projectRoot, {
+ release: cmdOptions.release,
+ yes: cmdOptions.yes,
+ });
+ else
+ await runFrameworkReleaseUpdate(deps, output, projectRoot, {
+ release: cmdOptions.release,
+ yes: cmdOptions.yes,
+ allowShared: cmdOptions.allowShared,
+ });
+ return;
+ }
+ await runFrameworkUpdate(
+ deps,
+ output,
+ projectRoot,
+ cmdOptions.tool as ToolId | undefined,
+ cmdOptions
+ );
+ } catch (error) {
+ errorHandler.handle(error);
+ }
}
- });
+ );
framework
.command("rules")
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/marketplace.ts b/cli/src/presentation/commands/marketplace.ts
index 86294c1b2..bfeb6e5ce 100644
--- a/cli/src/presentation/commands/marketplace.ts
+++ b/cli/src/presentation/commands/marketplace.ts
@@ -40,7 +40,7 @@ export function registerMarketplaceCommand(program: Command): void {
.option("--scope ", "Registration scope (default: project)", "project")
.option("--yes", "Skip the trust + cleanup prompts")
.option("--overwrite", "Replace an existing marketplace with the same name")
- .option("--token ", "Auth token (host detected from source URL at fetch time)")
+ .option("--token ", "GitHub token for this operation")
.action(
async (
nameArg: string | undefined,
diff --git a/cli/src/presentation/commands/plugin.ts b/cli/src/presentation/commands/plugin.ts
index 15e69dd72..02483079b 100644
--- a/cli/src/presentation/commands/plugin.ts
+++ b/cli/src/presentation/commands/plugin.ts
@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { parseInstallScope } from "../../contexts/framework/domain/install-scope.js";
-import { assertValidAiToolId, parseToolOption } from "../../kernel/tool.js";
+import { type AiToolId, assertValidAiToolId, parseToolOption } from "../../kernel/tool.js";
+import { assertMachineConfigDirectorySupported } from "../../runtime/machine-config-directory.js";
import { createDeps, createMenuDeps } from "../../runtime/wiring/framework.js";
import {
printInstalledPlugins,
@@ -82,7 +83,7 @@ export function registerPluginCommand(program: Command): void {
.description("Install a plugin (marketplace name, local path, or interactive pick)")
.option("--from ", "Marketplace name (when multiple match)")
.option("--tool ", "Target AI tool (default: all installed)")
- .option("--token ", "Auth token (host detected from source URL at fetch time)")
+ .option("--token ", "GitHub token for this operation")
.option("--scope ", "Install scope; must match the tool's supported scope")
.option("--yes", "Auto-resolve interactive prompts (CI mode)")
.action(
@@ -158,6 +159,23 @@ export function registerPluginCommand(program: Command): void {
assertValidAiToolId(cmdOptions.tool);
const scope = parseInstallScope(cmdOptions.scope) ?? "project";
const deps = await createDeps(projectRoot, { verbose }, output);
+ if (scope === "user") {
+ if (cmdOptions.tool !== undefined) {
+ assertMachineConfigDirectorySupported([cmdOptions.tool]);
+ } else {
+ const machine = await deps.userManifestRepo.load();
+ assertMachineConfigDirectorySupported(
+ (machine?.getInstalledToolIds() ?? []).filter((tool) =>
+ machine
+ ?.getPlugins(tool as AiToolId)
+ .some(
+ (plugin) =>
+ plugin.scope === "user" && (name === undefined || plugin.name === name)
+ )
+ )
+ );
+ }
+ }
const updated = await (scope === "user"
? deps.userPluginUpdateUseCase
: deps.pluginUpdateUseCase
diff --git a/cli/src/presentation/commands/run/command.ts b/cli/src/presentation/commands/run/command.ts
new file mode 100644
index 000000000..55f56b82d
--- /dev/null
+++ b/cli/src/presentation/commands/run/command.ts
@@ -0,0 +1,199 @@
+import { spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync } from "node:fs";
+import { homedir, constants as osConstants } from "node:os";
+import { dirname, isAbsolute, join, resolve } from "node:path";
+import type { Command } from "commander";
+import spawn from "cross-spawn";
+import { Manifest } from "../../../contexts/framework/domain/manifest.js";
+import { userConfigDir } from "../../../runtime/user-config-dir.js";
+
+import { headlessRunTools } from "../../../runtime/wiring/tools.js";
+
+type Tool = keyof typeof headlessRunTools;
+
+function readManifest(path: string): Manifest | null {
+ if (!existsSync(path)) return null;
+ return Manifest.fromJSON(JSON.parse(readFileSync(path, "utf8")));
+}
+
+function projectManifest(cwd: string): { root: string; manifest: Manifest } | null {
+ let root = resolve(cwd);
+ while (true) {
+ const manifest = readManifest(join(root, ".aidd", "manifest.json"));
+ if (manifest) return { root, manifest };
+ if (existsSync(join(root, ".git"))) return null;
+ const parent = dirname(root);
+ if (parent === root) return null;
+ root = parent;
+ }
+}
+
+function hasMaintenanceFiles(root: string): boolean {
+ return [
+ "hooks/aidd-maintenance/session-start.cjs",
+ "plugin/aidd-maintenance-hooks.js",
+ "plugins/aidd-maintenance-tui/server.js",
+ ].some((path) => existsSync(join(root, path)));
+}
+
+function hasProjectMaintenance(cwd: string, tool: Tool): boolean {
+ let root = resolve(cwd);
+ while (true) {
+ if (hasMaintenanceFiles(join(root, `.${tool}`))) return true;
+ if (existsSync(join(root, ".git"))) return false;
+ const parent = dirname(root);
+ if (parent === root) return false;
+ root = parent;
+ }
+}
+
+function installedHook(tool: Tool, cwd: string): string {
+ const override = headlessRunTools[tool].configDirEnv;
+ if (process.env[override])
+ throw new Error(`Cannot verify the active maintenance plugin while ${override} is set.`);
+ const globalRoot = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), tool);
+ if (hasMaintenanceFiles(globalRoot) && hasProjectMaintenance(cwd, tool))
+ throw new Error(
+ `Cannot determine the active maintenance plugin for ${tool}: project and global copies coexist.`
+ );
+ const project = projectManifest(cwd);
+ const projectPlugin = project?.manifest
+ .getPlugins(tool)
+ .find((p) => p.name === "aidd-maintenance");
+ const global =
+ projectPlugin?.scope === "project"
+ ? null
+ : readManifest(join(userConfigDir(), "manifest.json"));
+ const plugin =
+ projectPlugin?.scope === "project"
+ ? projectPlugin
+ : global?.getPlugins(tool).find((p) => p.name === "aidd-maintenance" && p.scope === "user");
+ const rel =
+ plugin?.scope === "project"
+ ? `.${tool}/hooks/aidd-maintenance/session-start.cjs`
+ : "hooks/aidd-maintenance/session-start.cjs";
+ const expected = plugin?.files.get(rel);
+ if (!plugin || !expected)
+ throw new Error(`The aidd-maintenance hook is not installed for ${tool}.`);
+ const root = plugin.scope === "project" && project ? project.root : globalRoot;
+ const path = join(root, rel);
+ if (!existsSync(path)) throw new Error(`The aidd-maintenance hook is not installed for ${tool}.`);
+ const hash = createHash("md5").update(readFileSync(path)).digest("hex");
+ if (hash !== expected)
+ throw new Error(`The installed aidd-maintenance hook for ${tool} was modified.`);
+ return path;
+}
+
+function directoryFor(tool: Tool, args: readonly string[]): string {
+ const settings = headlessRunTools[tool];
+ if (settings.pureEnv && process.env[settings.pureEnv] === "1")
+ throw new Error(`${tool} pure mode disables the maintenance plugin.`);
+ let directory = process.cwd();
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i];
+ if (arg === "--") break;
+ if (
+ arg === "--attach" ||
+ arg.startsWith("--attach=") ||
+ arg === "--server" ||
+ arg.startsWith("--server=")
+ )
+ throw new Error("Remote servers are not supported by aidd run.");
+ if (settings.interactiveFlags.includes(arg))
+ throw new Error(`Use ${tool} directly for interactive sessions.`);
+ if (settings.pureFlag && (arg === settings.pureFlag || arg === `${settings.pureFlag}=true`))
+ throw new Error(`${tool} pure mode disables the maintenance plugin.`);
+ if (settings.directoryArg && arg === settings.directoryArg) {
+ if (!args[i + 1]) throw new Error("--dir requires a directory.");
+ directory = args[++i];
+ } else if (settings.directoryArg && arg.startsWith(`${settings.directoryArg}=`))
+ directory = arg.slice(settings.directoryArg.length + 1);
+ }
+ return isAbsolute(directory) ? directory : resolve(directory);
+}
+
+function printNotice(tool: Tool, cwd: string): void {
+ const script = installedHook(tool, cwd);
+ const result = spawnSync(process.execPath, [script], {
+ cwd,
+ input: JSON.stringify({ hook_event_name: "SessionStart", cwd, tool }),
+ encoding: "utf8",
+ timeout: 10000,
+ });
+ const code = (result.error as NodeJS.ErrnoException | undefined)?.code;
+ const closedInput = code === "EPIPE";
+ if (result.status !== 0 || (result.error && !closedInput)) {
+ const details = [
+ code && Object.hasOwn(osConstants.errno, code) ? code : result.error ? "unknown error" : null,
+ Number.isInteger(result.status) ? `exit ${result.status}` : null,
+ result.signal && Object.hasOwn(osConstants.signals, result.signal)
+ ? `signal ${result.signal}`
+ : null,
+ ].filter(Boolean);
+ throw new Error(
+ `The aidd-maintenance hook failed for ${tool}${details.length ? ` (${details.join(", ")})` : ""}.`
+ );
+ }
+ let message: unknown;
+ try {
+ message = (JSON.parse(result.stdout) as { systemMessage?: unknown }).systemMessage;
+ } catch {
+ throw new Error(`Invalid aidd-maintenance hook output for ${tool}.`);
+ }
+ if (typeof message !== "string" || !message.trim())
+ throw new Error(`Empty aidd-maintenance hook output for ${tool}.`);
+ process.stderr.write(`${message.trim()}\n`);
+}
+
+async function run(tool: Tool, args: readonly string[]): Promise {
+ const cwd = directoryFor(tool, args);
+ printNotice(tool, cwd);
+ await new Promise((resolvePromise) => {
+ const child = spawn(tool, ["run", ...args], { stdio: "inherit" });
+ let failed = false;
+ const signals = ["SIGINT", "SIGTERM"] as const;
+ const listeners = signals.map((signal) => {
+ const listener = () => {
+ child.kill(signal);
+ };
+ process.on(signal, listener);
+ return listener;
+ });
+ child.once("error", (error) => {
+ failed = true;
+ process.stderr.write(`Unable to start ${tool}: ${error.message}\n`);
+ process.exitCode = 1;
+ });
+ child.once("close", (code, signal) => {
+ signals.forEach((name, i) => {
+ process.off(name, listeners[i]);
+ });
+ if (failed) {
+ resolvePromise();
+ return;
+ }
+ if (signal && process.platform !== "win32") process.kill(process.pid, signal);
+ else if (signal) process.exitCode = 1;
+ else if (code !== null) process.exitCode = code;
+ resolvePromise();
+ });
+ });
+}
+
+export function registerRunCommand(program: Command): void {
+ program
+ .command("run [args...]")
+ .description("Run OpenCode or Kilo non-interactively with the installed maintenance notice")
+ .allowUnknownOption()
+ .action(async (tool: string, args: string[]) => {
+ try {
+ if (!Object.hasOwn(headlessRunTools, tool))
+ throw new Error(`Supported tools: ${Object.keys(headlessRunTools).join(", ")}.`);
+ await run(tool as Tool, args);
+ } catch (error) {
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
+ process.exitCode = 1;
+ }
+ });
+}
diff --git a/cli/src/presentation/commands/setup.ts b/cli/src/presentation/commands/setup.ts
index b789ffe52..bf9ed8be0 100644
--- a/cli/src/presentation/commands/setup.ts
+++ b/cli/src/presentation/commands/setup.ts
@@ -1,15 +1,19 @@
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,
+ copiesPluginFilesGlobally,
getAllRegisteredTools,
+ nativeActivationOf,
+ resolvePluginsCapability,
supportsUserScopeActivation,
} from "../../contexts/tools/domain/registry.js";
import type { ToolId } from "../../kernel/tool.js";
import { AI_TOOL_IDS, IDE_TOOL_IDS } from "../../kernel/tool.js";
+import { assertMachineConfigDirectorySupported } from "../../runtime/machine-config-directory.js";
import { createDeps } from "../../runtime/wiring/framework.js";
import {
printDetectedContext,
@@ -32,6 +36,7 @@ interface SetupCmdOptions {
yes?: boolean;
defaultMarketplace?: boolean;
scope?: string;
+ global?: boolean;
}
export function parseSourceFlag(
@@ -58,22 +63,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 +101,23 @@ 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(copiesPluginFilesGlobally);
+ 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(", ")}.`,
+ " `aidd sync --global` refreshes them from the framework source.",
+ ].join("\n");
+}
+
export function registerSetupCommand(program: Command): void {
program
.command("setup")
@@ -103,8 +127,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 +139,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