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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: enhance
3
- description: Use this skill when a repository already has an AGENTS.md and you want to add guidance it is missing — offering vetted sections one at a time and writing only what the user approves. Runs on its own or straight after init.
3
+ description: Use this skill when a repository already has an AGENTS.md and you want to add guidance it is missing, or to refresh a vetted section it carries in an outdated form — offering each one at a time and writing only what the user approves. Runs on its own or straight after init.
4
4
  argument-hint: '[--root <dir>]'
5
5
  ---
6
6
 
@@ -12,11 +12,11 @@ The split matters: initialization has to run everywhere and invent nothing, so i
12
12
 
13
13
  Every addition is **offered, never written on sight**. An addition asserts something about how the repository is worked in — it stays true whether or not this tool ever ran — so it is material under the rule in `../init/references/agents-md.md`, and material content needs approval. Nothing here goes inside the `buddy-agent-harness` managed region; that region is for the tool's own bookkeeping.
14
14
 
15
- One addition ships today: `references/delegation.md`.
15
+ One addition ships today: `references/delegation.md`. Each addition's reference file carries the text to offer, a `## Covered when` criterion for the subject already being present, and a `## Stale when` criterion for the file already carrying a **wording this addition has since retired**. The retired wordings themselves are kept beside it — for delegation, `references/delegation.history.md` — and are what a present section is compared against.
16
16
 
17
17
  ## 1. Find the instruction file
18
18
 
19
- Locate the Git repository root. The target is the root `AGENTS.md`.
19
+ Where the invocation names `--root <dir>`, that directory is the repository: read and write inside it only. Otherwise locate the Git repository root. The target is the root `AGENTS.md`.
20
20
 
21
21
  If there is no root `AGENTS.md`, stop and say so. This skill adds to an existing file; creating one is `init`'s job, so point there and write nothing.
22
22
 
@@ -30,9 +30,9 @@ Judge against all of it together. That combined text is what an agent effectivel
30
30
 
31
31
  **Read those files; do not consolidate them.** Merging them into `AGENTS.md` is `init`'s work and belongs to `init` alone. If you find content that should be consolidated, say so and recommend `init` — then carry on with the coverage judgment.
32
32
 
33
- ## 3. Judge coverage
33
+ ## 3. Classify each addition
34
34
 
35
- For each addition, ask: **does the merged view already tell the agent what this addition would tell it?**
35
+ Ask the coverage question first: **does the merged view already tell the agent what this addition would tell it?**
36
36
 
37
37
  Judge by meaning, not by heading or wording. A repository covering delegation under `## Working with subagents`, or in three sentences inside a longer section, is covered. A repository that mentions subagents only to name a tool is not.
38
38
 
@@ -40,28 +40,72 @@ When in doubt, treat it as covered and say why. A missing offer costs the user n
40
40
 
41
41
  **A heading inside a fenced code block is not a heading.** Text between ``` or ~~~ fences is an example of a file, not part of this one, and an addition quoted inside a fence is being shown rather than adopted. This is not hypothetical: every addition here is offered as a fenced block containing its own heading, so a repository documenting this tool — or an `AGENTS.md` that quotes one — carries the exact heading the addition would write, inside a fence, while remaining entirely uncovered. Judge only the prose the agent actually reads as instruction.
42
42
 
43
+ Then ask the second question **of text that read as covered, and only of that text**: did that text come from this addition, at a version it used to ship? Never ask it of text that read as uncovered, and never ask it first.
44
+
45
+ Answer it against the addition's own texts, which its reference keeps beside it: the one it offers now, and the ones it has retired, in a sibling history file. The reference states how under `## Stale when`.
46
+
47
+ **First, is the section already current?** It is when it carries every sentence of the text you would offer, in order — the owner may have added paragraphs of their own around or between them — and asserts no sentence found only in a retired wording. Say so and stop. Resemblance is not enough; containment is the test.
48
+
49
+ **Otherwise, compare against the retired wordings, and never weigh a section against the current text to decide it is stale** — differing from the current wording tells you nothing, since differing is what a rewrite produces.
50
+
51
+ Each addition ends this step in one of five states:
52
+
53
+ - **already current** — the section carries the text you would offer. Offer nothing, and say that is why. Do **not** report it as the owner's own: they did not write it, this package did, and telling someone they authored your text is the same mistake as replacing what they did author.
54
+ - **absent** — offer it as an addition.
55
+ - **from a retired wording, in the root `AGENTS.md`** — offer the current text as a replacement.
56
+ - **the owner's own** — offer nothing, and name what covers it.
57
+ - **you cannot tell** — do not decide it either way. Put it to the owner (step 4).
58
+
59
+ A section from a retired wording in a file that is **not** the root `AGENTS.md` — a `CLAUDE.md`, a `.cursorrules` — is reported by name and offered nothing. This skill writes one file; replacing the root copy while an older copy stays in a harness file leaves the repository holding two versions instead of one. Recommend `init` and carry on.
60
+
43
61
  ## 4. Offer
44
62
 
45
- Where an addition is uncovered, show its text **verbatim** — the whole thing, not a summary — say where it would go, and ask.
63
+ Where an addition is **absent**, show its text **verbatim** — the whole thing, not a summary — say where it would go, and ask.
64
+
65
+ Where an addition came from a **retired wording**, show the section as it stands in `AGENTS.md`, then the current text **verbatim**, say that it would replace that section and nothing else, and ask.
66
+
67
+ **Name what the replacement would take with it.** A stale section often carries paragraphs the owner added to it — rules of their own, sitting under the same heading. Replacing the section removes those too. So before you ask, name every paragraph in that section that appears in no retired wording, say the replacement would remove it, and let the owner weigh that. An approval given for "refresh the wording" is not an approval to delete what they wrote.
46
68
 
47
- Do not argue for it past one sentence. The user is reading the actual text; that is the argument.
69
+ Where you **cannot tell**, say that first, in those words. Then show three texts — the section as it stands, the retired wording it partly tracks, and the current text — and put three answers to the owner:
70
+
71
+ - **it is theirs** — you leave it alone.
72
+ - **it came from here** — you offer the replacement.
73
+ - **settle it by measurement instead** — score both wordings and go with what wins.
74
+
75
+ Put the third every time the repository can actually do it, because the owner may not remember either, and the question they care about is not where the section came from but **which wording serves them better**. Provenance is a stand-in for that; when the stand-in fails, ask the real question.
76
+
77
+ Measurement means running each wording against a set of real tasks and comparing how the agent behaves — whatever harness this repository has for that. In this package's own repository it is the `eval-delegation` skill; a consumer repository will have its own or none. **Look before you offer it.** Where the repository has no such harness, say the third answer would need one it does not have, and put the other two.
78
+
79
+ **Ask before running it, and never run it unasked** — it is many model runs, and the owner is the one paying for them.
80
+
81
+ Where the owner asks for it, run it over the section as it stands and the current text, and report both scores.
82
+
83
+ - The current text scores **better** — offer it as a replacement, on that basis and not on provenance. Say what it scored. The offer is an offer like any other: it still waits for a yes.
84
+ - The section scores **as well or better** — offer nothing, say so, and leave it alone. A wording that serves this repository better than ours is not something to replace, whatever its history.
85
+
86
+ Do not argue for any of it past one sentence. The user is reading the actual text; that is the argument.
48
87
 
49
88
  ## 5. Write what was approved
50
89
 
51
- On approval, append the section to the root `AGENTS.md`, outside the managed region, preserving the surrounding file exactly.
90
+ On approval of an **addition**, write the section into the root `AGENTS.md` at the end of the owner's prose, outside the managed region, preserving the surrounding file exactly.
91
+
92
+ On approval of a **replacement**, replace that one section in place — from its heading through to the next heading of the same or higher level — and leave every other byte of the file as it was. Do not relocate it, do not reformat around it, and do not touch the managed region.
52
93
 
53
94
  Strip the fence when you write. The ``` markers around the addition in its reference file are there so you can see where the text begins and ends; the section goes into `AGENTS.md` as prose, not as a code block.
54
95
 
55
- On a decline, write nothing.
96
+ On a decline, write nothing. A declined replacement leaves the section exactly where it stands, and so does an unanswered question.
56
97
 
57
98
  ## 6. Report
58
99
 
59
- Report every run, whichever way it went: what you read, what you judged covered and why, what you offered, and what was written. A run that offers nothing still reports — that is the only way the user can tell "already covered" from "did not look".
100
+ Report every run, whichever way it went: what you read, the verdict for each addition and why — already current, absent, the owner's own, from a retired wording, or undecidable — what you offered, and what was written. A run that offers nothing still reports — that is the only way the user can tell "already covered" from "did not look".
60
101
 
61
102
  ## Rules
62
103
 
63
104
  - **Detection decides every run.** There is no first-run path and no memory of a previous decline; run the same way every time. A section the user deleted reads as absent and is offered again, because absence is the whole state. If that becomes annoying, the fix is the user declining `init`'s offer to run this skill, not a flag here.
64
105
  - **Never write without approval.** The offer is the whole point.
106
+ - **Never silently overwrite.** A replacement is an offer like any other; the same gate that governs an addition governs it, word for word.
107
+ - **Never guess whose words they are.** Where you cannot tell an edited copy of a retired wording from the owner's own prose, say so and ask. Deciding it silently in either direction is the one failure this path exists to avoid.
108
+ - **Never run an evaluation unasked.** It costs the owner many model runs. Offer it; wait.
65
109
  - **Never edit an addition to fit a repository.** The wording is fixed. Offer it as written or not at all.
66
110
  - **Never touch the managed region**, a nested `AGENTS.md`, or any file other than the root `AGENTS.md`.
67
111
  - Local agent configuration only. Do not change workflows, repository settings, or unrelated project files.
@@ -0,0 +1,15 @@
1
+ # Superseded wordings: Delegation
2
+
3
+ Every wording this addition has retired, newest first, each exactly as it was offered. Read this
4
+ file when a `## Delegation` section is already present and reads as covered; it is what the section
5
+ in front of you is compared against.
6
+
7
+ Nothing here is ever offered. The text to offer is in `delegation.md`.
8
+
9
+ ## Superseded 2026-09 — replaced by the current two-paragraph wording
10
+
11
+ ```markdown
12
+ ## Delegation
13
+
14
+ If this harness can spawn subagents, delegate bulk-mechanical work and research whose answer is far smaller than the reading behind it. Do it yourself when the brief would cost more than the task — a one-line edit is not worth a subagent. Keep the judgment calls and final decisions; delegate the fact-gathering that feeds them. The cheaper the subagent, the less should break if it gets the answer wrong. Brief every one you spawn: it inherits no context, so give it the context, the why, and what done looks like.
15
+ ```
@@ -11,12 +11,67 @@ The merged view already tells the agent **when to hand work to a subagent**. Any
11
11
  Naming a subagent tool, or listing available models, is not coverage. Neither is a delegation
12
12
  rule that appears only in a skill body — this addition is for the always-loaded file.
13
13
 
14
+ ## Stale when
15
+
16
+ **First: is the section already current?** It is when it carries every sentence of the text below, in
17
+ its order — the owner may have added paragraphs of their own around or between them, and those are
18
+ theirs to keep — **and** asserts no sentence found only in a retired wording. Offer nothing, compare it
19
+ against no retired wording, and report it as **already current**, not as the owner's own: this package
20
+ wrote that text, and saying otherwise credits them with it.
21
+
22
+ Containment, not resemblance. A section that carries the current text but also still asserts a
23
+ retired sentence is holding two versions at once, and is not current: go on to the two questions.
24
+
25
+ Otherwise, compare the `## Delegation` section already in the file against each wording in
26
+ `delegation.history.md`. Those are the exact texts this addition used to offer. Ask two questions
27
+ about the section, and only these two:
28
+
29
+ 1. **Do whole sentences of that wording survive verbatim in it?** Not a phrase — a whole sentence.
30
+ 2. **Does that wording's structure survive?** Its sentences, in its order, each doing its job.
31
+
32
+ **Both questions answer yes or no, never neither** — where you cannot tell whether a sentence
33
+ survives, or whether the structure does, the answer is **no**. That is what keeps the four cases
34
+ below exhaustive.
35
+
36
+ **When the two answers agree, they decide. When they disagree, you cannot tell.**
37
+
38
+ | whole sentences | structure | |
39
+ | --- | --- | --- |
40
+ | yes | yes | **Stale.** It is that wording, edited. Offer the replacement. |
41
+ | no | no | **The owner's.** Written from scratch. Offer nothing. |
42
+ | yes | no | **You cannot tell.** A line of ours carried into prose they wrote — or a line they reached themselves. |
43
+ | no | yes | **You cannot tell.** Their words in our shape — a reword of ours, or the order the subject naturally takes. |
44
+
45
+ The two disagreeing cases are not a gap in the test; they are the test reporting honestly that the
46
+ evidence points both ways. Say so, show the owner the three texts, and ask. **Do not decide them.**
47
+
48
+ Match a sentence literally. A shared idea is not a sentence surviving, and a paraphrase of one is not
49
+ that sentence — a whole passage does not turn up in prose someone wrote themselves, which is what
50
+ makes question 1 worth asking; a memorable phrase does, which is why no phrase answers it.
51
+
52
+ **A quoted sentence is not an asserted one, and does not count for question 1.** A sentence inside quotation marks, inside a fence, or in
53
+ a sentence that disputes it is being *shown*, not followed — the same rule that makes a heading inside
54
+ a fenced block not a heading. A section that quotes one in order to reject it is the owner arguing
55
+ with this package, which is the strongest sign the prose is theirs.
56
+
57
+ Once both answers are yes, offer the replacement. Do **not** then read the section's remaining
58
+ sentences against the current text and reconsider — that comparison always finds differences, because
59
+ differing sentences are what a rewrite is. That applies to the section's other prose only. It
60
+ licenses nothing about the two questions, which are answered exactly as written.
61
+
62
+ **Do not shortcut any of this on the subject a section talks about.** A section that mentions what
63
+ model a subagent inherits, or picking one, is not thereby the current wording — anyone can write a
64
+ sentence about choosing a model, including on top of a wording retired long ago. Only the two
65
+ questions decide, and they decide against the texts themselves.
66
+
14
67
  ## Offer this text verbatim
15
68
 
16
69
  ```markdown
17
70
  ## Delegation
18
71
 
19
- If this harness can spawn subagents, delegate bulk-mechanical work and research whose answer is far smaller than the reading behind it. Do it yourself when the brief would cost more than the task — a one-line edit is not worth a subagent. Keep the judgment calls and final decisions; delegate the fact-gathering that feeds them. The cheaper the subagent, the less should break if it gets the answer wrong. Brief every one you spawn: it inherits no context, so give it the context, the why, and what done looks like.
72
+ If this harness can spawn subagents, delegate the mechanical work and the research whose answer is far smaller than the reading behind it. Keep the judgment calls and the decisions; anything you would finish in less time than briefing it takes, do yourself.
73
+
74
+ A subagent inherits your model if you do not pick one, and none of your context either way. Pick the cheapest, unless you cannot say what a right answer looks like or could not cheaply tell a wrong one. Give it the context, the why, and what done looks like.
20
75
  ```
21
76
 
22
77
  ## Do not edit it
@@ -25,4 +80,14 @@ The wording is fixed. In particular, do not add model names, tiers, or a table o
25
80
  section is written to stay correct as model lineups change, and naming one breaks that. Offer it
26
81
  as written or not at all.
27
82
 
83
+ If the text is ever revised, the same change must do two things:
84
+
85
+ - append the outgoing wording to `delegation.history.md`, newest first, exactly as it was offered;
86
+ - add scenarios for the **shape of that revision** — which sentences the new text rewrote, dropped or
87
+ added against the outgoing one — and pass the gate against the new pair.
88
+
89
+ The comparison above is checked against the wordings that exist, not against every revision someone
90
+ might one day make. How a sentence-level comparison behaves depends on how the two texts differ, so a
91
+ revision is the moment its behaviour is known and the moment it is verified.
92
+
28
93
  Place it at the end of the user's prose, outside the `buddy-agent-harness` managed region.
@@ -75,7 +75,7 @@ One case does need asking even though it only creates a file: a nested `AGENTS.m
75
75
  2. Move approved skills and commands to `.agents/skills/<name>/SKILL.md`, preferring `git mv` so history follows. Fix frontmatter per `references/frontmatter.md` — this is where cross-harness portability is won or lost.
76
76
  3. Merge approved instruction content into `AGENTS.md`, preserving the author's wording. Append; do not restructure. Replace a harness file with a pointer only where approved. Content only some tasks need belongs in a skill, not in `AGENTS.md`.
77
77
  4. Apply the conversions that were approved, one at a time, and nothing else from the list. A rule approved as a skill moves to `.agents/skills/<name>/SKILL.md` with frontmatter per `references/frontmatter.md`; instruction content approved for consolidation is appended as in the step above and its harness file left as a generated copy. Everything declined, and everything with no candidate, stays exactly where it is.
78
- 5. Run `node scripts/init.mjs` at the repository root, resolving that path against this skill's own directory. The launcher runs the CLI that shipped beside it, so nothing is downloaded; fall back to `npx -y buddy-agent-harness@^0.7.0 init` when the path cannot be resolved. It links `.agents/skills` into the harnesses that need it and skips those that read it natively. Add harnesses the user names with `--harness codex,gemini-cli`.
78
+ 5. Run `node scripts/init.mjs` at the repository root, resolving that path against this skill's own directory. The launcher runs the CLI that shipped beside it, so nothing is downloaded; fall back to `npx -y buddy-agent-harness@^0.9.0 init` when the path cannot be resolved. It links `.agents/skills` into the harnesses that need it and skips those that read it natively. Add harnesses the user names with `--harness codex,gemini-cli`.
79
79
  6. If the command reports a conflict, resolve the named target and retry. Pass the target to `--force` — `--force .claude/skills` replaces that projection and reports any other conflict untouched, where a bare `--force` replaces every conflicting target at once. Reach for the bare form only when replacing all of them is what you came for. Use `--copy` only where links are unavailable — a copy is a snapshot, not a live projection, so say so when you fall back.
80
80
  7. Apply the instruction bridges `init` does not write. Claude Code reads `CLAUDE.md`, not `AGENTS.md` — create a `CLAUDE.md` containing `@AGENTS.md`. Gemini CLI needs `AGENTS.md` added to `context.fileName` in `.gemini/settings.json`. Both are detailed in the matching `references/harnesses/<harness>.md`; apply only the ones you enabled. Where nested `AGENTS.md` files exist, write the same stub in each directory holding one — `references/agents-md.md` covers the one case that stops for approval first.
81
81
  8. If any bridge now exists, write or restore the non-material region in `AGENTS.md` per `references/agents-md.md`, unless the markers are present and empty. Without it, an agent asked to change a skill edits `.claude/skills/<name>/SKILL.md` — which a link resolves back to canonical, but a copy silently forks. Skip this when every enabled harness reads `.agents/skills/` natively; with no bridge there is nothing to warn about.
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { cli, exitCodes } from 'clibuilder'
3
+ import { depPluginsCommand } from './dep-plugins/dep-plugins.command.ts'
3
4
  import { doctorCommand } from './diagnose-bridges/doctor.command.ts'
4
5
  import { initCommand } from './initialize-harnesses/init.command.ts'
5
6
 
@@ -24,6 +25,7 @@ function app() {
24
25
  })
25
26
  .command(initCommand)
26
27
  .command(doctorCommand)
28
+ .command(depPluginsCommand)
27
29
  }
28
30
 
29
31
  /**
@@ -0,0 +1,168 @@
1
+ import { homedir } from 'node:os'
2
+ import { resolve } from 'node:path'
3
+ import type { cli } from 'clibuilder'
4
+ import { command, exitCodes, z } from 'clibuilder'
5
+ import { parseFormat, writeResult } from '../command-output/command-output.ts'
6
+ import { CATALOG_DIR, CATALOG_PATH, catalogStatus, deriveCatalog, type WriteOutcome, writeCatalog } from './generate.ts'
7
+ import { COPILOT_LIMITATION, type PluginRuntime, runtimes } from './harness-plugins.ts'
8
+ import { type Action, reconcile } from './reconcile.ts'
9
+
10
+ export type DepPluginsReport = {
11
+ catalog: string
12
+ marketplace: string
13
+ outcome: WriteOutcome | 'would-change' | 'current'
14
+ /** Always emitted, so a healthy run states its zero rather than leaving a reader to infer it. */
15
+ plugins: { name: string; package: string; version: string }[] | string
16
+ /** Every runtime detected on this machine, and whether it matches the catalog. */
17
+ harness: { runtime: string; status: string }[] | string
18
+ /**
19
+ * What each runtime needs in order to match the catalog, and the command that does it. Reported
20
+ * rather than run: this writes outside the repository, and a generation step should not reach into
21
+ * a developer's harness state without being asked.
22
+ *
23
+ * One flat row per action rather than actions nested under each runtime: a uniform record renders
24
+ * as a table in both TOON and text, where a nested array collapses into a JSON blob.
25
+ */
26
+ actions: { runtime: string; do: string; subject: string; why: string }[] | string
27
+ notes?: string[]
28
+ help?: string[]
29
+ }
30
+
31
+ /** The command that performs one action, so a reader is not left to look the verb up per runtime. */
32
+ function commandFor(runtime: PluginRuntime, action: Action, marketplace: string): string {
33
+ switch (action.kind) {
34
+ case 'register':
35
+ return runtime.register(CATALOG_DIR)
36
+ case 'install':
37
+ return runtime.install(action.subject)
38
+ case 'update': {
39
+ // Where a runtime caches the catalog it has to be refreshed first, or the update reinstalls
40
+ // the version already cached rather than the one the catalog now pins. Codex re-reads a local
41
+ // catalog on every add and needs no such step, so it declares no refresh command.
42
+ const refresh = runtime.refreshMarketplace(marketplace)
43
+ const update = runtime.update(action.subject)
44
+ return refresh ? `${refresh} && ${update}` : update
45
+ }
46
+ case 'uninstall':
47
+ return runtime.uninstall(action.subject)
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Notes name the conditions a caller cannot see from the entry list and would otherwise discover as a
53
+ * failure later — a dependency that is declared but absent, a marketplace name two repositories are
54
+ * likely to share, a resolver under which no dependency has a directory at all.
55
+ */
56
+ function notesFor(derived: ReturnType<typeof deriveCatalog>): string[] {
57
+ const notes: string[] = []
58
+ if (derived.pnp) {
59
+ notes.push(
60
+ 'Yarn Plug’n’Play is active: dependencies live inside zip archives, so no harness can install from them. Set nodeLinker: node-modules, or unplug the plugin dependencies.',
61
+ )
62
+ }
63
+ if (derived.genericName) {
64
+ notes.push(
65
+ `Marketplace name "${derived.catalog.name}" is generic. Registering a second marketplace under a name already in use replaces the first silently, and the install that follows reports "already installed" — the repository then runs the other one's versions.`,
66
+ )
67
+ }
68
+ for (const { package: pkg } of derived.unresolved) {
69
+ notes.push(`Declared dependency "${pkg}" is not installed; install dependencies before generating.`)
70
+ }
71
+ return notes
72
+ }
73
+
74
+ export const depPluginsCommand: cli.Command = command({
75
+ name: 'dep-plugins',
76
+ description:
77
+ 'Derive the marketplace catalog for plugins shipped by this repository’s dependencies, so a harness can install them. Writes the catalog; installing is the harness’s own job.',
78
+ options: {
79
+ root: {
80
+ description: 'Repository directory. Defaults to the current directory.',
81
+ type: z.optional(z.string()),
82
+ },
83
+ check: {
84
+ description: 'Report whether the catalog is current without writing it. For CI and postinstall.',
85
+ type: z.optional(z.boolean()),
86
+ },
87
+ format: {
88
+ description: 'Output format: toon (default), json, or text for a human-readable report.',
89
+ type: z.optional(z.string()),
90
+ default: 'toon',
91
+ },
92
+ },
93
+ run(args) {
94
+ try {
95
+ const format = parseFormat(args.format)
96
+ const root = args.root ?? process.cwd()
97
+ const derived = deriveCatalog(root)
98
+
99
+ // `--check` compares without touching the disk, so it is safe from a postinstall hook and from
100
+ // CI, where the question is only whether the committed catalog still matches the declared
101
+ // dependencies.
102
+ const outcome = args.check
103
+ ? catalogStatus(root, derived.catalog) === 'unchanged'
104
+ ? ('current' as const)
105
+ : ('would-change' as const)
106
+ : writeCatalog(root, derived.catalog)
107
+
108
+ // Read-only, and against each harness's own state files rather than its CLI: asking a runtime
109
+ // what it has should not depend on that runtime being launchable from here. Only runtimes
110
+ // actually set up on this machine are reported, so a report names work a reader can do.
111
+ const home = homedir()
112
+ const notes = notesFor(derived)
113
+ const detected = runtimes.filter((candidate) => candidate.present(home))
114
+ const harness: { runtime: string; status: string }[] = []
115
+ const actions: { runtime: string; do: string; subject: string; why: string }[] = []
116
+
117
+ for (const runtime of detected) {
118
+ const state = runtime.state(home)
119
+ const delta = reconcile({
120
+ catalog: derived.catalog,
121
+ catalogDir: CATALOG_DIR,
122
+ root: resolve(root),
123
+ installed: state.installed,
124
+ marketplaces: state.marketplaces,
125
+ scoped: runtime.scoped,
126
+ })
127
+ if (delta.collision) {
128
+ notes.push(
129
+ `${runtime.name}: marketplace "${derived.catalog.name}" is already registered against ${delta.collision}. Registering over it replaces the other repository's silently, and the install that follows reports "already installed" — rename this repository's marketplace instead.`,
130
+ )
131
+ }
132
+ harness.push({
133
+ runtime: runtime.name,
134
+ status: delta.actions.length ? `${delta.actions.length} to apply` : 'in sync',
135
+ })
136
+ for (const action of delta.actions) {
137
+ actions.push({
138
+ runtime: runtime.name,
139
+ do: commandFor(runtime, action, derived.catalog.name),
140
+ subject: action.subject,
141
+ why: action.detail,
142
+ })
143
+ }
144
+ }
145
+ if (derived.plugins.length) notes.push(COPILOT_LIMITATION)
146
+
147
+ const report: DepPluginsReport = {
148
+ catalog: CATALOG_PATH,
149
+ marketplace: derived.catalog.name,
150
+ outcome,
151
+ plugins: derived.plugins.length
152
+ ? derived.plugins.map(({ plugin, package: pkg, version }) => ({ name: plugin, package: pkg, version }))
153
+ : '0 dependencies ship a plugin — nothing to install',
154
+ harness: harness.length ? harness : 'no supported harness detected on this machine',
155
+ actions: actions.length ? actions : '0 actions — every detected harness matches the catalog',
156
+ ...(notes.length ? { notes } : {}),
157
+ }
158
+ writeResult(report, format)
159
+
160
+ // A stale catalog under `--check` is the one failure this command reports through its exit
161
+ // code, because that is the whole question the flag asks. Everything else the report carries.
162
+ return outcome === 'would-change' ? exitCodes.error : exitCodes.success
163
+ } catch (error) {
164
+ process.stderr.write(`error: ${error instanceof Error ? error.message : 'Catalog generation failed.'}\n`)
165
+ return exitCodes.error
166
+ }
167
+ },
168
+ })
@@ -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
+ }