buddy-agent-harness 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/README.md +9 -1
  5. package/bin/buddy-agent-harness.mjs +8 -3
  6. package/dist/cli-4ekejn5W.mjs +2735 -0
  7. package/dist/cli-4ekejn5W.mjs.map +1 -0
  8. package/dist/cli.d.mts +12 -2
  9. package/dist/cli.d.mts.map +1 -1
  10. package/dist/cli.mjs +2 -20
  11. package/dist/index.d.mts +389 -54
  12. package/dist/index.d.mts.map +1 -1
  13. package/dist/index.mjs +2 -2
  14. package/package.json +6 -5
  15. package/plugin.json +1 -1
  16. package/skills/doctor/SKILL.md +20 -34
  17. package/skills/doctor/references/bridges.md +23 -0
  18. package/skills/doctor/references/configuration.md +12 -0
  19. package/skills/doctor/references/harnesses/claude-code.md +39 -0
  20. package/skills/doctor/references/harnesses/codex.md +37 -0
  21. package/skills/doctor/references/harnesses/copilot-cli.md +39 -0
  22. package/skills/doctor/references/harnesses/cursor.md +40 -0
  23. package/skills/doctor/references/harnesses/devin-desktop.md +20 -0
  24. package/skills/doctor/references/harnesses/gemini-cli.md +38 -0
  25. package/skills/doctor/references/harnesses/windsurf.md +27 -0
  26. package/skills/doctor/references/instructions.md +14 -0
  27. package/skills/doctor/references/mcp.md +31 -0
  28. package/skills/doctor/references/nonstandard.md +27 -0
  29. package/skills/doctor/scripts/doctor.mjs +7 -3
  30. package/skills/enhance/SKILL.md +4 -0
  31. package/skills/enhance/references/delegation.md +3 -1
  32. package/skills/init/README.md +1 -1
  33. package/skills/init/SKILL.md +19 -8
  34. package/skills/init/references/detection.md +5 -3
  35. package/skills/init/references/harnesses/cursor.md +9 -2
  36. package/skills/init/scripts/doctor.mjs +14 -0
  37. package/skills/init/scripts/init.mjs +7 -3
  38. package/skills/repair/README.md +21 -0
  39. package/skills/repair/SKILL.md +74 -0
  40. package/skills/repair/references/classes.md +49 -0
  41. package/skills/repair/scripts/doctor.mjs +14 -0
  42. package/src/cli.ts +42 -7
  43. package/src/command-output/command-output.ts +1 -4
  44. package/src/dep-plugins/dep-plugins.command.ts +168 -0
  45. package/src/dep-plugins/dep-plugins.ts +131 -0
  46. package/src/dep-plugins/generate.ts +115 -0
  47. package/src/dep-plugins/harness-plugins.ts +232 -0
  48. package/src/dep-plugins/reconcile.ts +132 -0
  49. package/src/dep-plugins/resolve.ts +117 -0
  50. package/src/diagnose-bridges/diagnose-bridges.ts +21 -13
  51. package/src/diagnose-bridges/diagnose-instructions.ts +7 -2
  52. package/src/diagnose-bridges/doctor-guidance.ts +636 -90
  53. package/src/diagnose-bridges/doctor.command.ts +55 -28
  54. package/src/diagnose-bridges/git-bridge-state.ts +30 -0
  55. package/src/diagnose-bridges/locator.ts +36 -0
  56. package/src/diagnose-configuration/diagnose-configuration.ts +99 -0
  57. package/src/diagnose-mcp/diagnose-mcp.ts +135 -0
  58. package/src/diagnose-mcp/mcp-baseline.ts +122 -0
  59. package/src/diagnose-mcp/mcp-model.ts +88 -0
  60. package/src/diagnose-mcp/mcp-secrets.ts +101 -0
  61. package/src/diagnose-mcp/mcp-sources.ts +131 -0
  62. package/src/diagnose-nonstandard/diagnose-nonstandard.ts +109 -0
  63. package/src/harness-registry/harness-registry.ts +97 -4
  64. package/src/harness-registry/mcp-config.ts +37 -0
  65. package/src/harness-registry/nonstandard-artifact.ts +39 -0
  66. package/src/index.ts +18 -2
  67. package/src/initialize-harnesses/init.command.ts +13 -18
  68. package/src/initialize-harnesses/initialize-harnesses.ts +6 -3
  69. package/src/is-record/is-record.ts +12 -0
  70. package/src/skill-projection/skill-projection.ts +102 -16
  71. package/dist/cli.mjs.map +0 -1
  72. package/dist/init.command-zbntulIq.mjs +0 -1016
  73. package/dist/init.command-zbntulIq.mjs.map +0 -1
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Dependency-provided plugins: the catalog half.
3
+ *
4
+ * A dependency that ships an Agent Plugins manifest at its package root is a plugin the consuming
5
+ * repository can install through its harness. This module turns the set of such dependencies into a
6
+ * marketplace catalog; installing from that catalog is the runtime's own job, and the lifecycle half
7
+ * drives it.
8
+ *
9
+ * Everything here is pure. Resolution against `node_modules` lives in `resolve.ts`.
10
+ */
11
+
12
+ /** One dependency that ships a plugin manifest. */
13
+ export type DepPlugin = {
14
+ /** The npm package name — what a consumer declared, and what the catalog installs by. */
15
+ package: string
16
+ /** The plugin's own name from its manifest, which need not match the package name. */
17
+ plugin: string
18
+ /** The version actually installed, read from the dependency's own `package.json`. */
19
+ version: string
20
+ description?: string
21
+ }
22
+
23
+ /**
24
+ * A catalog entry's `npm` source. Deliberately not a path: a path source is resolved against the
25
+ * marketplace root and may not escape it, so a path-sourced catalog would have to sit at the
26
+ * repository root — the one place the repository's own public catalog already occupies. An `npm`
27
+ * source carries no path, so this catalog can live in its own directory and stay separate from
28
+ * whatever the repository publishes for its own consumers.
29
+ */
30
+ export type NpmSource = {
31
+ source: 'npm'
32
+ package: string
33
+ version: string
34
+ }
35
+
36
+ export type CatalogEntry = {
37
+ name: string
38
+ source: NpmSource
39
+ description?: string
40
+ }
41
+
42
+ /**
43
+ * The Claude-shaped catalog, which Codex, Copilot CLI, and Cursor also read. `owner` is an object
44
+ * whose `name` is required — a string there is a schema error rather than a tolerated shorthand.
45
+ */
46
+ export type Catalog = {
47
+ name: string
48
+ owner: { name: string }
49
+ description: string
50
+ plugins: CatalogEntry[]
51
+ }
52
+
53
+ /**
54
+ * Marketplace names generic enough that two repositories are likely to pick the same one. The
55
+ * collision is silent and costly: registering a second marketplace under a name already in use
56
+ * replaces the first, and the install that follows reports "already installed" and does nothing —
57
+ * so the repository silently runs the other one's plugin versions.
58
+ */
59
+ const GENERIC_NAMES = new Set(['deps', 'dependencies', 'local', 'plugins', 'marketplace', 'plugin', 'default'])
60
+
61
+ export function isGenericName(name: string): boolean {
62
+ return GENERIC_NAMES.has(name.toLowerCase())
63
+ }
64
+
65
+ /**
66
+ * A marketplace name derived from the consuming package's own name, so two repositories do not
67
+ * collide by default. A scope is folded into the name rather than dropped (`@acme/web` → `acme-web`)
68
+ * because the unscoped half is the part most likely to repeat across organizations.
69
+ */
70
+ export function marketplaceName(packageName: string): string {
71
+ const folded = packageName.replace(/^@/, '').replace(/\//g, '-')
72
+ const cleaned = folded
73
+ .replace(/[^a-zA-Z0-9-_.]/g, '-')
74
+ .replace(/-+/g, '-')
75
+ .replace(/^-|-$/g, '')
76
+ return cleaned || 'agent-plugins'
77
+ }
78
+
79
+ /**
80
+ * Dependency names a repository declares, both runtime and development.
81
+ *
82
+ * Development dependencies are included on purpose: a plugin that supplies a project's testing or
83
+ * release workflow is a development dependency, and excluding them would drop most of the cases this
84
+ * exists for. Transitive dependencies are never included — see `resolve.ts`.
85
+ */
86
+ export function declaredDependencies(manifest: unknown): string[] {
87
+ if (!manifest || typeof manifest !== 'object') return []
88
+ const record = manifest as Record<string, unknown>
89
+ const names = new Set<string>()
90
+ for (const field of ['dependencies', 'devDependencies']) {
91
+ const group = record[field]
92
+ if (!group || typeof group !== 'object') continue
93
+ for (const name of Object.keys(group as Record<string, unknown>)) names.add(name)
94
+ }
95
+ return [...names].sort()
96
+ }
97
+
98
+ export type BuildCatalogOptions = {
99
+ name: string
100
+ owner: string
101
+ plugins: readonly DepPlugin[]
102
+ }
103
+
104
+ /**
105
+ * The catalog is derived in full on every run and never merged into what is already on disk. That is
106
+ * what makes the three cases a consumer cares about — a plugin added, a plugin removed, a version
107
+ * moved — fall out of regeneration instead of needing reconciliation logic of their own.
108
+ *
109
+ * Entries are sorted by name so that a regeneration whose inputs did not change produces a
110
+ * byte-identical file, which is what lets a check mode compare rather than interpret.
111
+ */
112
+ export function buildCatalog({ name, owner, plugins }: BuildCatalogOptions): Catalog {
113
+ const entries = [...plugins]
114
+ .sort((a, b) => a.plugin.localeCompare(b.plugin))
115
+ .map(({ plugin, package: pkg, version, description }): CatalogEntry => {
116
+ const source: NpmSource = { source: 'npm', package: pkg, version }
117
+ return description ? { name: plugin, source, description } : { name: plugin, source }
118
+ })
119
+
120
+ return {
121
+ name,
122
+ owner: { name: owner },
123
+ description: 'Plugins provided by this repository’s dependencies. Generated — do not edit.',
124
+ plugins: entries,
125
+ }
126
+ }
127
+
128
+ /** Serialized the way the repository formats JSON, with the trailing newline a text file needs. */
129
+ export function serializeCatalog(catalog: Catalog): string {
130
+ return `${JSON.stringify(catalog, null, '\t')}\n`
131
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Deriving a repository's dependency-plugin catalog, and deciding whether what is on disk matches.
3
+ */
4
+
5
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import {
8
+ buildCatalog,
9
+ type Catalog,
10
+ type DepPlugin,
11
+ declaredDependencies,
12
+ isGenericName,
13
+ marketplaceName,
14
+ serializeCatalog,
15
+ } from './dep-plugins.ts'
16
+ import { isPnp, readDepPlugin, type SkipReason } from './resolve.ts'
17
+
18
+ /**
19
+ * The catalog's home, relative to the repository root.
20
+ *
21
+ * Under this tool's own directory, beside the other files it owns there — the same place as
22
+ * `mcp.toml` and `mcp.projected.json`, and the same shape as `.agents/repobuddy/` and
23
+ * `.agents/cyberlegion/`. A directory named for what it holds rather than for who writes it would be
24
+ * a second convention in a tree that already has one.
25
+ *
26
+ * Not the repository root's `.claude-plugin/`, which is where a repository publishes its *own*
27
+ * plugins for its *own* consumers. Merging the two would leak every dependency-provided plugin into
28
+ * what a consumer sees on adding the published marketplace, and no harness has a notion of a private
29
+ * catalog entry to prevent that. Being a separate directory also makes this one a marketplace root
30
+ * in its own right, which is what lets its entries be resolved without reaching outside it.
31
+ */
32
+ export const CATALOG_DIR: string = join('.agents', 'buddy-agent-harness')
33
+ export const CATALOG_PATH: string = join(CATALOG_DIR, '.claude-plugin', 'marketplace.json')
34
+
35
+ /** A declared dependency that yielded no plugin, kept so a report can say why rather than go quiet. */
36
+ export type Skipped = { package: string; reason: SkipReason }
37
+
38
+ export type DerivedCatalog = {
39
+ catalog: Catalog
40
+ plugins: DepPlugin[]
41
+ /** Only the dependencies that failed to resolve at all; a dependency with no plugin is not news. */
42
+ unresolved: Skipped[]
43
+ /** Set when the marketplace name is one two repositories are likely to share. */
44
+ genericName: boolean
45
+ /** Set under Yarn PnP, where no dependency has a directory for a harness to install from. */
46
+ pnp: boolean
47
+ }
48
+
49
+ function readManifest(path: string): Record<string, unknown> {
50
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
51
+ }
52
+
53
+ /**
54
+ * Derives the catalog from the repository's declared dependencies.
55
+ *
56
+ * Derived in full every time. A plugin newly added to `package.json` appears, one removed disappears,
57
+ * and a version that moved follows — all three without a line of reconciliation logic, because there
58
+ * is nothing to reconcile against. That is the same rule the vendor manifests already follow: the
59
+ * artifact is derived, never authored.
60
+ */
61
+ export function deriveCatalog(root: string): DerivedCatalog {
62
+ const manifestPath = join(root, 'package.json')
63
+ const manifest = readManifest(manifestPath)
64
+
65
+ const plugins: DepPlugin[] = []
66
+ const unresolved: Skipped[] = []
67
+ for (const pkg of declaredDependencies(manifest)) {
68
+ const outcome = readDepPlugin(pkg, manifestPath)
69
+ if (outcome.ok) plugins.push(outcome.plugin)
70
+ else if (outcome.reason === 'unresolved') unresolved.push({ package: pkg, reason: outcome.reason })
71
+ }
72
+
73
+ const owner = typeof manifest['name'] === 'string' ? manifest['name'] : 'unknown'
74
+ const name = marketplaceName(owner)
75
+
76
+ return {
77
+ catalog: buildCatalog({ name, owner, plugins }),
78
+ plugins,
79
+ unresolved,
80
+ genericName: isGenericName(name),
81
+ pnp: isPnp(),
82
+ }
83
+ }
84
+
85
+ /** What a write did, or would do, so a caller can report a no-op as a no-op rather than as a change. */
86
+ export type WriteOutcome = 'created' | 'updated' | 'unchanged'
87
+
88
+ /**
89
+ * What writing the catalog would do, without touching the disk.
90
+ *
91
+ * Comparing the serialized form rather than the parsed one is deliberate: a byte comparison is what
92
+ * lets a check mode be a comparison instead of an interpretation, and `buildCatalog` sorts its
93
+ * entries so that unchanged inputs serialize identically.
94
+ */
95
+ export function catalogStatus(root: string, catalog: Catalog): WriteOutcome {
96
+ let current: string | undefined
97
+ try {
98
+ current = readFileSync(join(root, CATALOG_PATH), 'utf8')
99
+ } catch {
100
+ current = undefined
101
+ }
102
+ if (current === serializeCatalog(catalog)) return 'unchanged'
103
+ return current === undefined ? 'created' : 'updated'
104
+ }
105
+
106
+ /** Writes the catalog, reporting whether it moved. A write that would change nothing writes nothing. */
107
+ export function writeCatalog(root: string, catalog: Catalog): WriteOutcome {
108
+ const outcome = catalogStatus(root, catalog)
109
+ if (outcome === 'unchanged') return outcome
110
+
111
+ const path = join(root, CATALOG_PATH)
112
+ mkdirSync(dirname(path), { recursive: true })
113
+ writeFileSync(path, serializeCatalog(catalog))
114
+ return outcome
115
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * What each harness does with a marketplace catalog, and how to read what it currently has.
3
+ *
4
+ * The runtimes diverge more than they look, and every difference below was established by running
5
+ * the shipped CLI rather than by reading a document:
6
+ *
7
+ * - **Claude Code** refreshes with `plugin update`; re-running `plugin install` is a no-op that
8
+ * reports "already installed" and leaves the stale copy in place. Every verb defaults to user
9
+ * scope, so each one has to name `--scope project` explicitly.
10
+ * - **Codex** has no update verb and no scope. Re-running `plugin add` re-reads the catalog and
11
+ * replaces the cached copy, so the same command installs and refreshes. It records no version in
12
+ * its config — the installed version is only in the cache directory's name.
13
+ * - **Copilot CLI** accepts a path source but **rejects an `npm` source**, so it cannot consume this
14
+ * catalog at all. See {@link COPILOT_LIMITATION}.
15
+ * - **Cursor** exposes no plugin subcommand from a terminal.
16
+ *
17
+ * A consumer should not have to know any of that, which is why it is here rather than in a document.
18
+ */
19
+
20
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ export type PluginRuntimeName = 'claude-code' | 'codex'
24
+
25
+ export type InstalledPlugin = {
26
+ /** `<plugin>@<marketplace>` — the identity a harness installs and uninstalls by. */
27
+ id: string
28
+ version: string
29
+ scope: string
30
+ /** Present for project- and local-scoped installs; absent where a runtime has no scopes. */
31
+ projectPath?: string
32
+ }
33
+
34
+ export type RegisteredMarketplace = { name: string; path: string }
35
+
36
+ /**
37
+ * The state a harness keeps outside the repository. Read, never written: the install is the
38
+ * runtime's own job, and driving it through its own CLI is what keeps that true.
39
+ */
40
+ export type HarnessPluginState = {
41
+ installed: InstalledPlugin[]
42
+ marketplaces: RegisteredMarketplace[]
43
+ }
44
+
45
+ /**
46
+ * One runtime's half of the contract: where its state lives, how to read it, and the commands that
47
+ * move it toward a catalog.
48
+ */
49
+ export type PluginRuntime = {
50
+ name: PluginRuntimeName
51
+ /** Whether this runtime is set up on this machine at all. */
52
+ present(home: string, env?: NodeJS.ProcessEnv): boolean
53
+ state(home: string, env?: NodeJS.ProcessEnv): HarnessPluginState
54
+ register(catalogDir: string): string
55
+ install(id: string): string
56
+ update(id: string): string
57
+ uninstall(id: string): string
58
+ /** Empty where a runtime re-reads the catalog on install and needs no separate refresh. */
59
+ refreshMarketplace(marketplace: string): string
60
+ /**
61
+ * True where an install belongs to one repository rather than the whole machine. Where false,
62
+ * every repository on the machine shares one install and the reconciliation cannot isolate them.
63
+ */
64
+ scoped: boolean
65
+ }
66
+
67
+ /**
68
+ * Why Copilot CLI is absent from the table. Recorded rather than dropped, so the reason travels with
69
+ * the code: `copilot plugin marketplace add` rejects this catalog with
70
+ * `Invalid marketplace.json: plugins.0.source: Invalid input`, while the same catalog with a
71
+ * `./path` source is accepted. Supporting it needs a second, path-sourced catalog — and a path source
72
+ * cannot escape its marketplace root, which is the constraint the `npm` source was chosen to avoid.
73
+ */
74
+ export const COPILOT_LIMITATION =
75
+ 'Copilot CLI rejects an npm marketplace source, so it cannot install from this catalog. It needs a path-sourced catalog, which cannot reach node_modules from a subdirectory.'
76
+
77
+ function readJson(path: string): unknown {
78
+ try {
79
+ return JSON.parse(readFileSync(path, 'utf8'))
80
+ } catch {
81
+ return undefined
82
+ }
83
+ }
84
+
85
+ function directories(path: string): string[] {
86
+ try {
87
+ return readdirSync(path, { withFileTypes: true })
88
+ .filter((entry) => entry.isDirectory())
89
+ .map((entry) => entry.name)
90
+ } catch {
91
+ return []
92
+ }
93
+ }
94
+
95
+ // ── Claude Code ──
96
+
97
+ /**
98
+ * `CLAUDE_CONFIG_DIR` wins where set, which is also what keeps a test off a developer's real
99
+ * configuration.
100
+ */
101
+ export function claudeConfigDir(home: string, env: NodeJS.ProcessEnv = process.env): string {
102
+ return env['CLAUDE_CONFIG_DIR'] || join(home, '.claude')
103
+ }
104
+
105
+ /**
106
+ * Reads what Claude Code has installed.
107
+ *
108
+ * The file keys each plugin by `<plugin>@<marketplace>` and holds an *array* per key, because the
109
+ * same plugin can be installed at more than one scope and, at project scope, once per project.
110
+ */
111
+ export function readClaudeState(configDir: string): HarnessPluginState {
112
+ const installedFile = readJson(join(configDir, 'plugins', 'installed_plugins.json')) as
113
+ | { plugins?: Record<string, unknown> }
114
+ | undefined
115
+ const installed: InstalledPlugin[] = []
116
+ for (const [id, entries] of Object.entries(installedFile?.plugins ?? {})) {
117
+ if (!Array.isArray(entries)) continue
118
+ for (const entry of entries) {
119
+ if (!entry || typeof entry !== 'object') continue
120
+ const record = entry as Record<string, unknown>
121
+ const version = typeof record['version'] === 'string' ? record['version'] : undefined
122
+ const scope = typeof record['scope'] === 'string' ? record['scope'] : undefined
123
+ if (!version || !scope) continue
124
+ const projectPath = typeof record['projectPath'] === 'string' ? record['projectPath'] : undefined
125
+ installed.push(projectPath ? { id, version, scope, projectPath } : { id, version, scope })
126
+ }
127
+ }
128
+
129
+ const knownFile = readJson(join(configDir, 'plugins', 'known_marketplaces.json')) as
130
+ | Record<string, { source?: { path?: unknown } }>
131
+ | undefined
132
+ const marketplaces: RegisteredMarketplace[] = []
133
+ for (const [name, value] of Object.entries(knownFile ?? {})) {
134
+ marketplaces.push({ name, path: typeof value?.source?.path === 'string' ? value.source.path : '' })
135
+ }
136
+
137
+ return { installed, marketplaces }
138
+ }
139
+
140
+ export const claudeCode: PluginRuntime = {
141
+ name: 'claude-code',
142
+ scoped: true,
143
+ present: (home, env = process.env) => existsSync(claudeConfigDir(home, env)),
144
+ state: (home, env = process.env) => readClaudeState(claudeConfigDir(home, env)),
145
+ register: (catalogDir) => `claude plugin marketplace add ./${catalogDir} --scope project`,
146
+ install: (id) => `claude plugin install ${id} --scope project`,
147
+ /**
148
+ * Not `install`: re-running that on an already-installed plugin changes nothing and says so, which
149
+ * reads as success. `--scope project` is required rather than cosmetic — every verb defaults to
150
+ * user scope, so an update omitting it fails with "not installed at scope user" against a plugin
151
+ * that is plainly installed.
152
+ */
153
+ update: (id) => `claude plugin update ${id} --scope project`,
154
+ uninstall: (id) => `claude plugin uninstall ${id} --scope project`,
155
+ refreshMarketplace: (marketplace) => `claude plugin marketplace update ${marketplace}`,
156
+ }
157
+
158
+ // ── Codex ──
159
+
160
+ export function codexHome(home: string, env: NodeJS.ProcessEnv = process.env): string {
161
+ return env['CODEX_HOME'] || join(home, '.codex')
162
+ }
163
+
164
+ /**
165
+ * Reads what Codex has installed.
166
+ *
167
+ * The installed version comes from the cache layout, `plugins/cache/<marketplace>/<plugin>/<version>`,
168
+ * because Codex's `config.toml` records only that a plugin is enabled — never which version.
169
+ *
170
+ * Registered marketplaces are read from `config.toml` by scanning for its `[marketplaces.<name>]`
171
+ * tables. A deliberately narrow read rather than a TOML parser: only two keys are needed, and the
172
+ * cost of being wrong is a report that suggests re-registering, which is harmless to re-run.
173
+ */
174
+ export function readCodexState(home: string): HarnessPluginState {
175
+ const cacheRoot = join(home, 'plugins', 'cache')
176
+ const installed: InstalledPlugin[] = []
177
+ for (const marketplace of directories(cacheRoot)) {
178
+ for (const plugin of directories(join(cacheRoot, marketplace))) {
179
+ for (const version of directories(join(cacheRoot, marketplace, plugin))) {
180
+ installed.push({ id: `${plugin}@${marketplace}`, version, scope: 'user' })
181
+ }
182
+ }
183
+ }
184
+
185
+ const marketplaces: RegisteredMarketplace[] = []
186
+ let config = ''
187
+ try {
188
+ config = readFileSync(join(home, 'config.toml'), 'utf8')
189
+ } catch {
190
+ config = ''
191
+ }
192
+ // The entry itself is carried rather than its name, so assigning a source needs no index lookup
193
+ // and no guard against one that cannot fail.
194
+ let current: RegisteredMarketplace | undefined
195
+ for (const line of config.split('\n')) {
196
+ const header = /^\s*\[marketplaces\.(?:"([^"]+)"|([^\]]+))\]\s*$/.exec(line)
197
+ if (header) {
198
+ current = { name: (header[1] ?? header[2]) as string, path: '' }
199
+ marketplaces.push(current)
200
+ continue
201
+ }
202
+ if (/^\s*\[/.test(line)) {
203
+ current = undefined
204
+ continue
205
+ }
206
+ const source = current ? /^\s*source\s*=\s*"([^"]*)"\s*$/.exec(line) : undefined
207
+ if (source?.[1] && current) current.path = source[1]
208
+ }
209
+
210
+ return { installed, marketplaces }
211
+ }
212
+
213
+ export const codex: PluginRuntime = {
214
+ name: 'codex',
215
+ /**
216
+ * Codex installs into `CODEX_HOME` with no notion of scope, so one install is shared by every
217
+ * repository on the machine. Two repositories whose catalogs share a marketplace name cannot be
218
+ * told apart here, which is what makes the marketplace name derived from the package name matter.
219
+ */
220
+ scoped: false,
221
+ present: (home, env = process.env) => existsSync(codexHome(home, env)),
222
+ state: (home, env = process.env) => readCodexState(codexHome(home, env)),
223
+ register: (catalogDir) => `codex plugin marketplace add ./${catalogDir}`,
224
+ install: (id) => `codex plugin add ${id}`,
225
+ /** No update verb: re-running `add` re-reads the catalog and replaces the cached copy. */
226
+ update: (id) => `codex plugin add ${id}`,
227
+ uninstall: (id) => `codex plugin remove ${id}`,
228
+ /** `marketplace upgrade` refreshes git snapshots only; `add` already re-reads a local catalog. */
229
+ refreshMarketplace: () => '',
230
+ }
231
+
232
+ export const runtimes: readonly PluginRuntime[] = [claudeCode, codex]
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Comparing a derived catalog against what a harness actually has installed.
3
+ *
4
+ * The harness reconciles nothing on its own. A catalog entry that appears is not installed; one that
5
+ * disappears leaves the plugin installed and failing to load, with `prune` declining to remove it
6
+ * because it was never an auto-installed dependency. So the delta has to be computed, and every
7
+ * conclusion here comes from that asymmetry.
8
+ *
9
+ * Pure. Reading harness state lives in `harness-plugins.ts`.
10
+ */
11
+
12
+ import type { Catalog } from './dep-plugins.ts'
13
+ import type { InstalledPlugin, RegisteredMarketplace } from './harness-plugins.ts'
14
+
15
+ export type ActionKind = 'register' | 'install' | 'update' | 'uninstall'
16
+
17
+ export type Action = {
18
+ kind: ActionKind
19
+ /** `<plugin>@<marketplace>`, or the marketplace name for `register`. */
20
+ subject: string
21
+ /** Why, in the terms a reader can act on: the version moving, or the entry that went away. */
22
+ detail: string
23
+ }
24
+
25
+ export type Reconciliation = {
26
+ actions: Action[]
27
+ /**
28
+ * Set when a marketplace of this name is registered against a different directory — the silent
29
+ * collision, which produces no error from the harness at any point.
30
+ */
31
+ collision?: string
32
+ /** Set when the catalog's marketplace is not registered with the harness at all. */
33
+ unregistered: boolean
34
+ }
35
+
36
+ export type ReconcileOptions = {
37
+ catalog: Catalog
38
+ /** Where the catalog lives, relative to the repository root. */
39
+ catalogDir: string
40
+ /** Absolute repository root, used to keep another project's installs out of the comparison. */
41
+ root: string
42
+ installed: readonly InstalledPlugin[]
43
+ marketplaces: readonly RegisteredMarketplace[]
44
+ /**
45
+ * Whether this runtime installs per repository. Where it does not — Codex keeps one install per
46
+ * machine — every install under this marketplace is this repository's by default, because there
47
+ * is nothing to tell two repositories apart.
48
+ */
49
+ scoped?: boolean
50
+ }
51
+
52
+ /**
53
+ * Installs belonging to this repository and this catalog.
54
+ *
55
+ * On a scoped runtime a user-scoped install is deliberately excluded rather than counted: it is
56
+ * shared with every other repository on the machine, so treating it as satisfying this repository's
57
+ * catalog would let one project's pin silently stand in for another's.
58
+ */
59
+ function ours(
60
+ installed: readonly InstalledPlugin[],
61
+ marketplace: string,
62
+ root: string,
63
+ scoped: boolean,
64
+ ): InstalledPlugin[] {
65
+ const suffix = `@${marketplace}`
66
+ return installed.filter((entry) => entry.id.endsWith(suffix) && (!scoped || entry.projectPath === root))
67
+ }
68
+
69
+ export function reconcile({
70
+ catalog,
71
+ catalogDir,
72
+ root,
73
+ installed,
74
+ marketplaces,
75
+ scoped = true,
76
+ }: ReconcileOptions): Reconciliation {
77
+ const registration = marketplaces.find((entry) => entry.name === catalog.name)
78
+ const actions: Action[] = []
79
+
80
+ // A registration pointing somewhere else is the collision case. Reported rather than repaired:
81
+ // re-registering would take the name from whichever repository currently holds it, trading one
82
+ // silent breakage for another.
83
+ const expected = catalogDir.split(/[\\/]/).filter(Boolean)
84
+ const pointsHere = registration ? expected.every((segment) => registration.path.includes(segment)) : false
85
+ const collision = registration && !pointsHere ? registration.path : undefined
86
+
87
+ if (!registration) {
88
+ actions.push({
89
+ kind: 'register',
90
+ subject: catalog.name,
91
+ detail: 'marketplace is not registered with this harness',
92
+ })
93
+ }
94
+
95
+ const mine = ours(installed, catalog.name, root, scoped)
96
+ const byId = new Map(mine.map((entry) => [entry.id, entry]))
97
+
98
+ for (const entry of catalog.plugins) {
99
+ const id = `${entry.name}@${catalog.name}`
100
+ const current = byId.get(id)
101
+ if (!current) {
102
+ actions.push({ kind: 'install', subject: id, detail: `not installed; catalog pins ${entry.source.version}` })
103
+ continue
104
+ }
105
+ if (current.version !== entry.source.version) {
106
+ actions.push({
107
+ kind: 'update',
108
+ subject: id,
109
+ detail: `installed ${current.version}, catalog pins ${entry.source.version}`,
110
+ })
111
+ }
112
+ }
113
+
114
+ // Installed but no longer in the catalog: the dependency was removed or stopped shipping a plugin.
115
+ // Nothing else removes these — the harness leaves them installed and failing to load.
116
+ const wanted = new Set(catalog.plugins.map((entry) => `${entry.name}@${catalog.name}`))
117
+ for (const entry of mine) {
118
+ if (!wanted.has(entry.id)) {
119
+ actions.push({
120
+ kind: 'uninstall',
121
+ subject: entry.id,
122
+ detail: 'installed but no longer in the catalog; it will report a load failure until removed',
123
+ })
124
+ }
125
+ }
126
+
127
+ return {
128
+ actions,
129
+ ...(collision ? { collision } : {}),
130
+ unregistered: !registration,
131
+ }
132
+ }