buddy-agent-harness 0.7.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.
@@ -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
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Locating a declared dependency on disk, and reading the plugin manifest it may ship.
3
+ *
4
+ * Resolution rather than scanning. Walking `node_modules` finds transitive dependencies nobody
5
+ * vetted, and a skill or plugin is instruction text that steers an agent — an unvetted transitive
6
+ * dependency is a supply-chain surface, not a convenience. Asking the module resolver where a
7
+ * *declared* dependency lives answers the question that was actually asked, and it is correct under
8
+ * npm, pnpm, yarn with the node-modules linker, and bun alike, because all four put a real directory
9
+ * on disk. Only Yarn PnP does not, and it is reported rather than guessed at.
10
+ */
11
+
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { createRequire } from 'node:module'
14
+ import { dirname, join } from 'node:path'
15
+ import type { DepPlugin } from './dep-plugins.ts'
16
+
17
+ /** Why a declared dependency yielded no plugin, when the caller needs to explain itself. */
18
+ export type SkipReason = 'unresolved' | 'no-manifest' | 'unnamed'
19
+
20
+ export type ResolveOutcome = { ok: true; plugin: DepPlugin } | { ok: false; package: string; reason: SkipReason }
21
+
22
+ function readJson(path: string): unknown {
23
+ try {
24
+ return JSON.parse(readFileSync(path, 'utf8'))
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Every ancestor `node_modules/<pkg>` from `startDir` upward. The fallback path only — see
32
+ * {@link packageDir}.
33
+ */
34
+ function ancestorCandidates(startDir: string, pkg: string): string[] {
35
+ const segments = pkg.split('/')
36
+ const candidates: string[] = []
37
+ let dir = startDir
38
+ for (;;) {
39
+ candidates.push(join(dir, 'node_modules', ...segments))
40
+ const parent = dirname(dir)
41
+ if (parent === dir) break
42
+ dir = parent
43
+ }
44
+ return candidates
45
+ }
46
+
47
+ /**
48
+ * The directory a declared dependency was installed into, or `undefined`.
49
+ *
50
+ * Two mechanisms, in order, because neither alone is sufficient:
51
+ *
52
+ * 1. **`require.resolve('<pkg>/package.json')`, anchored at the declaring manifest.** The anchor
53
+ * matters: under pnpm's isolated store only the package that declares a dependency can resolve it,
54
+ * so resolving from a workspace root would miss what a workspace member declared.
55
+ * 2. **A walk up `node_modules`.** Roughly one package in seven declares an `exports` map without a
56
+ * `./package.json` entry, and those refuse the first mechanism with `ERR_PACKAGE_PATH_NOT_EXPORTED`
57
+ * however plainly they are installed. Resolving a deeper file instead is not an escape — the same
58
+ * map blocks that too.
59
+ */
60
+ export function packageDir(pkg: string, fromManifest: string): string | undefined {
61
+ try {
62
+ const require = createRequire(fromManifest)
63
+ return dirname(require.resolve(`${pkg}/package.json`))
64
+ } catch {
65
+ // fall through to the walk
66
+ }
67
+ for (const candidate of ancestorCandidates(dirname(fromManifest), pkg)) {
68
+ if (existsSync(join(candidate, 'package.json'))) return candidate
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ /**
74
+ * Reads the plugin a dependency ships, if it ships one.
75
+ *
76
+ * The version comes from the dependency's own `package.json` rather than from a lockfile. It is the
77
+ * same number — the version actually installed — and reading it needs no knowledge of which of the
78
+ * four lockfile formats this repository uses.
79
+ *
80
+ * A manifest at the package root is what the Agent Plugins Specification pins, and it is the form a
81
+ * harness accepts as a marketplace source without any vendor-specific manifest alongside it.
82
+ */
83
+ export function readDepPlugin(pkg: string, fromManifest: string): ResolveOutcome {
84
+ const dir = packageDir(pkg, fromManifest)
85
+ if (!dir) return { ok: false, package: pkg, reason: 'unresolved' }
86
+
87
+ const manifest = readJson(join(dir, 'plugin.json'))
88
+ if (!manifest || typeof manifest !== 'object') return { ok: false, package: pkg, reason: 'no-manifest' }
89
+
90
+ const record = manifest as Record<string, unknown>
91
+ const name = typeof record['name'] === 'string' ? record['name'] : undefined
92
+ if (!name) return { ok: false, package: pkg, reason: 'unnamed' }
93
+
94
+ // The installed version, not the manifest's: the catalog installs the package from npm, so the
95
+ // number that must match is the one npm published the tree under.
96
+ const installed = readJson(join(dir, 'package.json')) as { version?: unknown } | undefined
97
+ const version = typeof installed?.version === 'string' ? installed.version : undefined
98
+ if (!version) return { ok: false, package: pkg, reason: 'no-manifest' }
99
+
100
+ const description = typeof record['description'] === 'string' ? record['description'] : undefined
101
+ return {
102
+ ok: true,
103
+ plugin: description
104
+ ? { package: pkg, plugin: name, version, description }
105
+ : { package: pkg, plugin: name, version },
106
+ }
107
+ }
108
+
109
+ /**
110
+ * True when the process is running under Yarn's Plug'n'Play resolver, where packages live inside zip
111
+ * archives rather than as directories. A marketplace source must be a real directory and an npm
112
+ * source must be installable, so a PnP project needs `nodeLinker: node-modules` or the affected
113
+ * dependencies unplugged. Detected rather than worked around, so the report can say so.
114
+ */
115
+ export function isPnp(): boolean {
116
+ return Boolean((process.versions as Record<string, string | undefined>)['pnp'])
117
+ }