buddy-agent-harness 0.7.0 → 0.9.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +9 -1
- package/dist/cli.mjs +19036 -1
- package/dist/dist-ktNZ1sJo.mjs +2966 -0
- package/dist/esm-BMqwcD7W.mjs +87 -0
- package/dist/esm-CVacKtt6.mjs +64 -0
- package/dist/index.d.mts +31 -18
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2734 -1
- package/dist/index.mjs.map +1 -0
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/skills/doctor/SKILL.md +1 -1
- package/skills/enhance/SKILL.md +54 -10
- package/skills/enhance/references/delegation.history.md +15 -0
- package/skills/enhance/references/delegation.md +66 -1
- package/skills/init/SKILL.md +1 -1
- package/src/cli.ts +2 -0
- package/src/dep-plugins/dep-plugins.command.ts +168 -0
- package/src/dep-plugins/dep-plugins.ts +131 -0
- package/src/dep-plugins/generate.ts +115 -0
- package/src/dep-plugins/harness-plugins.ts +232 -0
- package/src/dep-plugins/reconcile.ts +132 -0
- package/src/dep-plugins/resolve.ts +117 -0
- package/dist/cli-Bvnq8zOL.mjs +0 -2122
- package/dist/cli-Bvnq8zOL.mjs.map +0 -1
- package/dist/cli.d.mts +0 -16
- package/dist/cli.d.mts.map +0 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|