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.
- 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/bin/buddy-agent-harness.mjs +8 -3
- package/dist/cli-4ekejn5W.mjs +2735 -0
- package/dist/cli-4ekejn5W.mjs.map +1 -0
- package/dist/cli.d.mts +12 -2
- package/dist/cli.d.mts.map +1 -1
- package/dist/cli.mjs +2 -20
- package/dist/index.d.mts +389 -54
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/package.json +6 -5
- package/plugin.json +1 -1
- package/skills/doctor/SKILL.md +20 -34
- package/skills/doctor/references/bridges.md +23 -0
- package/skills/doctor/references/configuration.md +12 -0
- package/skills/doctor/references/harnesses/claude-code.md +39 -0
- package/skills/doctor/references/harnesses/codex.md +37 -0
- package/skills/doctor/references/harnesses/copilot-cli.md +39 -0
- package/skills/doctor/references/harnesses/cursor.md +40 -0
- package/skills/doctor/references/harnesses/devin-desktop.md +20 -0
- package/skills/doctor/references/harnesses/gemini-cli.md +38 -0
- package/skills/doctor/references/harnesses/windsurf.md +27 -0
- package/skills/doctor/references/instructions.md +14 -0
- package/skills/doctor/references/mcp.md +31 -0
- package/skills/doctor/references/nonstandard.md +27 -0
- package/skills/doctor/scripts/doctor.mjs +7 -3
- package/skills/enhance/SKILL.md +4 -0
- package/skills/enhance/references/delegation.md +3 -1
- package/skills/init/README.md +1 -1
- package/skills/init/SKILL.md +19 -8
- package/skills/init/references/detection.md +5 -3
- package/skills/init/references/harnesses/cursor.md +9 -2
- package/skills/init/scripts/doctor.mjs +14 -0
- package/skills/init/scripts/init.mjs +7 -3
- package/skills/repair/README.md +21 -0
- package/skills/repair/SKILL.md +74 -0
- package/skills/repair/references/classes.md +49 -0
- package/skills/repair/scripts/doctor.mjs +14 -0
- package/src/cli.ts +42 -7
- package/src/command-output/command-output.ts +1 -4
- 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/src/diagnose-bridges/diagnose-bridges.ts +21 -13
- package/src/diagnose-bridges/diagnose-instructions.ts +7 -2
- package/src/diagnose-bridges/doctor-guidance.ts +636 -90
- package/src/diagnose-bridges/doctor.command.ts +55 -28
- package/src/diagnose-bridges/git-bridge-state.ts +30 -0
- package/src/diagnose-bridges/locator.ts +36 -0
- package/src/diagnose-configuration/diagnose-configuration.ts +99 -0
- package/src/diagnose-mcp/diagnose-mcp.ts +135 -0
- package/src/diagnose-mcp/mcp-baseline.ts +122 -0
- package/src/diagnose-mcp/mcp-model.ts +88 -0
- package/src/diagnose-mcp/mcp-secrets.ts +101 -0
- package/src/diagnose-mcp/mcp-sources.ts +131 -0
- package/src/diagnose-nonstandard/diagnose-nonstandard.ts +109 -0
- package/src/harness-registry/harness-registry.ts +97 -4
- package/src/harness-registry/mcp-config.ts +37 -0
- package/src/harness-registry/nonstandard-artifact.ts +39 -0
- package/src/index.ts +18 -2
- package/src/initialize-harnesses/init.command.ts +13 -18
- package/src/initialize-harnesses/initialize-harnesses.ts +6 -3
- package/src/is-record/is-record.ts +12 -0
- package/src/skill-projection/skill-projection.ts +102 -16
- package/dist/cli.mjs.map +0 -1
- package/dist/init.command-zbntulIq.mjs +0 -1016
- 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
|
+
}
|