buddy-agent-harness 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/bin/buddy-agent-harness.mjs +8 -3
  5. package/dist/cli-Bvnq8zOL.mjs +2122 -0
  6. package/dist/cli-Bvnq8zOL.mjs.map +1 -0
  7. package/dist/cli.d.mts +13 -2
  8. package/dist/cli.d.mts.map +1 -1
  9. package/dist/cli.mjs +2 -20
  10. package/dist/index.d.mts +382 -47
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -2
  13. package/package.json +3 -2
  14. package/plugin.json +1 -1
  15. package/skills/doctor/SKILL.md +20 -34
  16. package/skills/doctor/references/bridges.md +23 -0
  17. package/skills/doctor/references/configuration.md +12 -0
  18. package/skills/doctor/references/harnesses/claude-code.md +39 -0
  19. package/skills/doctor/references/harnesses/codex.md +37 -0
  20. package/skills/doctor/references/harnesses/copilot-cli.md +39 -0
  21. package/skills/doctor/references/harnesses/cursor.md +40 -0
  22. package/skills/doctor/references/harnesses/devin-desktop.md +20 -0
  23. package/skills/doctor/references/harnesses/gemini-cli.md +38 -0
  24. package/skills/doctor/references/harnesses/windsurf.md +27 -0
  25. package/skills/doctor/references/instructions.md +14 -0
  26. package/skills/doctor/references/mcp.md +31 -0
  27. package/skills/doctor/references/nonstandard.md +27 -0
  28. package/skills/doctor/scripts/doctor.mjs +7 -3
  29. package/skills/enhance/SKILL.md +4 -0
  30. package/skills/init/README.md +1 -1
  31. package/skills/init/SKILL.md +19 -8
  32. package/skills/init/references/detection.md +5 -3
  33. package/skills/init/references/harnesses/cursor.md +9 -2
  34. package/skills/init/scripts/doctor.mjs +14 -0
  35. package/skills/init/scripts/init.mjs +7 -3
  36. package/skills/repair/README.md +21 -0
  37. package/skills/repair/SKILL.md +74 -0
  38. package/skills/repair/references/classes.md +49 -0
  39. package/skills/repair/scripts/doctor.mjs +14 -0
  40. package/src/cli.ts +40 -7
  41. package/src/command-output/command-output.ts +1 -4
  42. package/src/diagnose-bridges/diagnose-bridges.ts +21 -13
  43. package/src/diagnose-bridges/diagnose-instructions.ts +7 -2
  44. package/src/diagnose-bridges/doctor-guidance.ts +636 -90
  45. package/src/diagnose-bridges/doctor.command.ts +55 -28
  46. package/src/diagnose-bridges/git-bridge-state.ts +30 -0
  47. package/src/diagnose-bridges/locator.ts +36 -0
  48. package/src/diagnose-configuration/diagnose-configuration.ts +99 -0
  49. package/src/diagnose-mcp/diagnose-mcp.ts +135 -0
  50. package/src/diagnose-mcp/mcp-baseline.ts +122 -0
  51. package/src/diagnose-mcp/mcp-model.ts +88 -0
  52. package/src/diagnose-mcp/mcp-secrets.ts +101 -0
  53. package/src/diagnose-mcp/mcp-sources.ts +131 -0
  54. package/src/diagnose-nonstandard/diagnose-nonstandard.ts +109 -0
  55. package/src/harness-registry/harness-registry.ts +97 -4
  56. package/src/harness-registry/mcp-config.ts +37 -0
  57. package/src/harness-registry/nonstandard-artifact.ts +39 -0
  58. package/src/index.ts +18 -2
  59. package/src/initialize-harnesses/init.command.ts +13 -18
  60. package/src/initialize-harnesses/initialize-harnesses.ts +6 -3
  61. package/src/is-record/is-record.ts +12 -0
  62. package/src/skill-projection/skill-projection.ts +102 -16
  63. package/dist/cli.mjs.map +0 -1
  64. package/dist/init.command-zbntulIq.mjs +0 -1016
  65. package/dist/init.command-zbntulIq.mjs.map +0 -1
@@ -0,0 +1,101 @@
1
+ import type { McpServer } from './mcp-model.ts'
2
+
3
+ /**
4
+ * Finding a literal credential in MCP configuration, without ever handling one.
5
+ *
6
+ * `doctor` is safe to run from a session-start hook, so its output lands in agent context on every
7
+ * session and from there into transcripts. A value it echoes is amplified far past the file it came
8
+ * from. Every function here answers **where**, never **what**: the return is a field path, and the
9
+ * value never leaves this module.
10
+ *
11
+ * That is also why the redaction lives here rather than in a formatter. `doctor` renders three ways
12
+ * — TOON, `--format text`, and `--format json` — and trusting each of them to mask is three chances
13
+ * to drift. A value that never enters the finding cannot be printed by any of them.
14
+ */
15
+
16
+ /**
17
+ * Words that mark a field as credential-bearing wherever they appear in its name. Each is one that
18
+ * effectively never turns up inside an innocent word, so a bare substring test is safe and catches
19
+ * the run-together spellings a segment test would miss — `MYTOKEN`, `githubsecret`.
20
+ */
21
+ const credentialWord = /token|secret|password|passwd|credential/i
22
+
23
+ /**
24
+ * Words that mark a field only when they are a **segment** of its name. `key` and `auth` are the
25
+ * two that matter, and both are common inside words that carry nothing — `MONKEY`, `AUTHOR`,
26
+ * `KEYSTONE_URL`. Matching them as substrings reports those, and a scanner that cries wolf on a
27
+ * hook-run command is one people stop reading.
28
+ */
29
+ const credentialSegment = new Set(['key', 'apikey', 'auth', 'authorization', 'bearer'])
30
+
31
+ /** A field name split the two ways these are written: `API_KEY` and `apiKey` alike. */
32
+ function segmentsOf(key: string): string[] {
33
+ return key
34
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
35
+ .split(/[^A-Za-z0-9]+/)
36
+ .filter(Boolean)
37
+ .map((segment) => segment.toLowerCase())
38
+ }
39
+
40
+ /**
41
+ * A value that **names** a secret instead of holding one, in the forms the supported hosts
42
+ * document: `${VAR}`, `$VAR`, and the `${env:VAR}` / `${input:id}` variants.
43
+ *
44
+ * Classification is by shape, not by content. An entropy test on the value would be a guess about
45
+ * a string this module has already decided not to look at, and it would fail in both directions —
46
+ * a short password is low-entropy and a base64 configuration blob is high.
47
+ */
48
+ const reference = /\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*/
49
+
50
+ /**
51
+ * A credential-bearing field passes when it carries a reference anywhere in it, because the common
52
+ * legitimate form is a template rather than a bare variable — `Bearer ${LINEAR_TOKEN}` is the
53
+ * documented way to write an authorization header, and requiring the whole value to be a reference
54
+ * would report every correct one.
55
+ */
56
+ function holdsLiteral(value: string): boolean {
57
+ return !reference.test(value)
58
+ }
59
+
60
+ function credentialKey(key: string): boolean {
61
+ return credentialWord.test(key) || segmentsOf(key).some((segment) => credentialSegment.has(segment))
62
+ }
63
+
64
+ /**
65
+ * Whether a URL carries a credential in itself. Two shapes: a password in the userinfo component,
66
+ * and a credential-named query parameter holding a literal. Neither value is read past the test.
67
+ *
68
+ * A URL that does not parse is not reported. It is malformed rather than leaky, and guessing at its
69
+ * structure with a regular expression is how a scanner starts matching the thing it must not touch.
70
+ */
71
+ function urlHoldsCredential(value: string): boolean {
72
+ let url: URL
73
+ try {
74
+ url = new URL(value)
75
+ } catch {
76
+ return false
77
+ }
78
+ if (url.password) return true
79
+ for (const [key, parameter] of url.searchParams) if (credentialKey(key) && holdsLiteral(parameter)) return true
80
+ return false
81
+ }
82
+
83
+ function literalsIn(field: 'env' | 'headers', values: Readonly<Record<string, string>> | undefined): string[] {
84
+ return Object.entries(values ?? {})
85
+ .filter(([key, value]) => credentialKey(key) && holdsLiteral(value))
86
+ .map(([key]) => `${field}.${key}`)
87
+ }
88
+
89
+ /**
90
+ * Every field of one server holding a literal credential, as a path within that server.
91
+ *
92
+ * The path is the whole answer. It is never a truncated value: `sk-ab…` is a leak into the same
93
+ * transcript and buys a reader nothing the field name does not already give them.
94
+ */
95
+ export function credentialFields(server: McpServer): string[] {
96
+ return [
97
+ ...literalsIn('env', server.env),
98
+ ...literalsIn('headers', server.headers),
99
+ ...(server.url && urlHoldsCredential(server.url) ? ['url'] : []),
100
+ ]
101
+ }
@@ -0,0 +1,131 @@
1
+ import { parse as parseToml } from 'smol-toml'
2
+ import { parseJsonWithComments } from '../diagnose-bridges/json-with-comments.ts'
3
+ import type { Position } from '../diagnose-bridges/locator.ts'
4
+ import type { McpConfig } from '../harness-registry/mcp-config.ts'
5
+ import { isRecord } from '../is-record/is-record.ts'
6
+ import type { McpServer, McpTransport } from './mcp-model.ts'
7
+
8
+ /** Where the golden set lives, and why it is namespaced rather than sitting at `.agents/mcp.json`. */
9
+ export const goldenSetPath = '.agents/buddy-agent-harness/mcp.toml'
10
+
11
+ /** The table the golden set keeps its servers under. */
12
+ const goldenKey = 'servers'
13
+
14
+ export type ParsedServers =
15
+ /** The file is absent. Nothing to compare, and not a fault. */
16
+ | { kind: 'absent' }
17
+ | { kind: 'servers'; servers: Map<string, McpServer> }
18
+ | { kind: 'unreadable'; position?: Position | undefined }
19
+
20
+ function stringMap(value: unknown): Record<string, string> | undefined {
21
+ if (!isRecord(value)) return undefined
22
+ const entries = Object.entries(value).filter(([, item]) => typeof item === 'string') as [string, string][]
23
+ return entries.length ? Object.fromEntries(entries) : undefined
24
+ }
25
+
26
+ const transports = new Set<string>(['stdio', 'http', 'sse'])
27
+
28
+ /**
29
+ * The transport a host entry describes. Hosts spell it `type` (Claude Code, VS Code) or
30
+ * `transport`, and several state it nowhere and leave it implied by which of `url` and `command`
31
+ * is present. Inferring it is what lets a `url` entry in one file compare equal to a `type: http`
32
+ * entry in another.
33
+ */
34
+ function transportOf(entry: Record<string, unknown>): McpTransport | undefined {
35
+ const declared = entry['type'] ?? entry['transport']
36
+ if (typeof declared === 'string' && transports.has(declared)) return declared as McpTransport
37
+ if (typeof entry['url'] === 'string') return 'http'
38
+ if (typeof entry['command'] === 'string') return 'stdio'
39
+ return undefined
40
+ }
41
+
42
+ /**
43
+ * One host entry, or one golden entry, in the canonical model. The two formats carry the same
44
+ * field names — the divergence between hosts is the file, the top-level key, and the serialization,
45
+ * not the entry — so one reader serves both directions of the comparison.
46
+ *
47
+ * A field whose value is the wrong type is dropped rather than carried through. A `timeout` that
48
+ * is a string is not a timeout, and letting it into the model would report a divergence whose real
49
+ * cause is a typo the finding does not name.
50
+ */
51
+ function serverFrom(entry: Record<string, unknown>): McpServer {
52
+ const args = Array.isArray(entry['args']) && entry['args'].every((item) => typeof item === 'string')
53
+ const transport = transportOf(entry)
54
+ const env = stringMap(entry['env'])
55
+ const headers = stringMap(entry['headers'])
56
+ return {
57
+ ...(transport ? { transport } : {}),
58
+ ...(typeof entry['command'] === 'string' ? { command: entry['command'] } : {}),
59
+ ...(args ? { args: entry['args'] as string[] } : {}),
60
+ ...(env ? { env } : {}),
61
+ ...(typeof entry['url'] === 'string' ? { url: entry['url'] } : {}),
62
+ ...(headers ? { headers } : {}),
63
+ ...(typeof entry['description'] === 'string' ? { description: entry['description'] } : {}),
64
+ ...(typeof entry['enabled'] === 'boolean' ? { enabled: entry['enabled'] } : {}),
65
+ ...(typeof entry['timeout'] === 'number' ? { timeout: entry['timeout'] } : {}),
66
+ ...(typeof entry['source'] === 'string' ? { source: entry['source'] } : {}),
67
+ }
68
+ }
69
+
70
+ function serversUnder(document: unknown, key: string): Map<string, McpServer> {
71
+ const table = isRecord(document) ? document[key] : undefined
72
+ if (!isRecord(table)) return new Map()
73
+ return new Map(
74
+ Object.entries(table)
75
+ .filter(([, entry]) => isRecord(entry))
76
+ .map(([name, entry]) => [name, serverFrom(entry as Record<string, unknown>)]),
77
+ )
78
+ }
79
+
80
+ /**
81
+ * The position out of a thrown parse error, and nothing else out of it.
82
+ *
83
+ * `smol-toml` reports `line` and `column` on the error alongside a `codeblock` quoting the source,
84
+ * and its `message` embeds that same code block. Both are unusable: in a file of MCP configuration
85
+ * the line a parser failed on is exactly the line holding the credential. Only the two numbers are
86
+ * read, and anything thrown that does not carry them is reported without a position rather than by
87
+ * reaching for its message.
88
+ *
89
+ * Exported because that restriction is the point of the function rather than an implementation
90
+ * detail of it, and it is verified directly.
91
+ */
92
+ export function positionOf(error: unknown): Position | undefined {
93
+ if (!isRecord(error)) return undefined
94
+ const { line, column } = error
95
+ return typeof line === 'number' && typeof column === 'number' ? { line, column } : undefined
96
+ }
97
+
98
+ /** The golden set, parsed. Absent is the common case and is not a fault. */
99
+ export function parseGoldenSet(source: string | undefined): ParsedServers {
100
+ if (source === undefined) return { kind: 'absent' }
101
+ try {
102
+ return { kind: 'servers', servers: serversUnder(parseToml(source), goldenKey) }
103
+ } catch (error) {
104
+ return { kind: 'unreadable', position: positionOf(error) }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * One harness's MCP configuration, parsed. The file is read for its own key and nothing else:
110
+ * `.gemini/settings.json` also carries the instruction bridge, and a reader that treated the whole
111
+ * file as MCP would report the rest of a user's settings as servers it does not recognize.
112
+ *
113
+ * JSON is parsed with comments stripped, because Gemini CLI's loader strips them too and a
114
+ * settings file may legally carry them.
115
+ */
116
+ export function parseTarget(config: McpConfig, source: string | undefined): ParsedServers {
117
+ if (source === undefined) return { kind: 'absent' }
118
+ if (config.format === 'toml') {
119
+ try {
120
+ return { kind: 'servers', servers: serversUnder(parseToml(source), config.key) }
121
+ } catch {
122
+ return { kind: 'unreadable' }
123
+ }
124
+ }
125
+ const document = parseJsonWithComments(source)
126
+ // `parseJsonWithComments` answers `undefined` for a file that does not parse. A file whose
127
+ // literal content is `null` parses and holds no servers, which reads the same here and is
128
+ // reported the same way it would be for `{}`.
129
+ if (document === undefined) return { kind: 'unreadable' }
130
+ return { kind: 'servers', servers: serversUnder(document, config.key) }
131
+ }
@@ -0,0 +1,109 @@
1
+ import { lstatSync, readFileSync } from 'node:fs'
2
+ import { join, posix } from 'node:path'
3
+ import { filesUnder } from '../diagnose-bridges/directory-files.ts'
4
+ import { type NonstandardProblem, type RepairAction, repairFor } from '../diagnose-bridges/doctor-guidance.ts'
5
+ import { harnessRegistry } from '../harness-registry/harness-registry.ts'
6
+ import type { NonstandardArtifact, NonstandardKind } from '../harness-registry/nonstandard-artifact.ts'
7
+
8
+ /**
9
+ * The fifth section of `doctor`: configuration that works for exactly one harness.
10
+ *
11
+ * The other four ask whether something is broken. This one asks how far what is there reaches, and
12
+ * reports the answer when it is "one harness". Nothing here is a fault — a Cursor rule does what it
13
+ * says — so nothing here is repaired by correcting it. Each finding names the canonical form it
14
+ * would convert to, so a repository can be walked toward one where every harness file is generated
15
+ * from a canonical source rather than authored beside it.
16
+ *
17
+ * Read-only, like every other part of `doctor`.
18
+ */
19
+ export type NonstandardFinding = {
20
+ /** Repository-relative path of the artifact, POSIX-separated. */
21
+ path: string
22
+ problem: NonstandardProblem
23
+ detail: string
24
+ /** The conversion. `command` is always empty: every one of these is judgment about content. */
25
+ repair: RepairAction
26
+ }
27
+
28
+ export type DiagnoseNonstandardOptions = {
29
+ root: string
30
+ /** How to name this tool in the repair commands. */
31
+ cli: string
32
+ }
33
+
34
+ const problemOf: Record<NonstandardKind, NonstandardProblem> = {
35
+ instructions: 'nonstandard-instructions',
36
+ rule: 'nonstandard-rule',
37
+ command: 'nonstandard-command',
38
+ skill: 'nonstandard-skill',
39
+ subagent: 'nonstandard-subagent',
40
+ }
41
+
42
+ /**
43
+ * A `.mdc` rule splits on whether its scoping is load-bearing. `globs:` with a value binds the rule
44
+ * to paths, and `AGENTS.md` has no equivalent — it scopes by directory nesting only — so that one
45
+ * converts to a skill, or stays. Without it the rule is always-on prose, which `AGENTS.md` holds
46
+ * verbatim. Read from frontmatter rather than assumed, because the two need different conversions
47
+ * and guessing wrong sends prose to the wrong destination.
48
+ */
49
+ function scopedByGlobs(body: string): boolean {
50
+ const block = /^---\n([\s\S]*?)\n---/.exec(body)
51
+ if (!block) return false
52
+ const globs = /^globs:[ \t]*(.*)$/m.exec(block[1] as string)
53
+ const value = globs?.[1]?.trim()
54
+ return value !== undefined && value.length > 0 && value !== '[]'
55
+ }
56
+
57
+ /** Every skill directory below `directory`, named by its `SKILL.md`. */
58
+ function skillFiles(directory: string): string[] {
59
+ return filesUnder(directory).filter((file) => file.endsWith('SKILL.md'))
60
+ }
61
+
62
+ /**
63
+ * A harness directory that is a symlink is a projection someone already made, not configuration
64
+ * authored here — reporting it would tell a repository to convert what it already converted.
65
+ */
66
+ function authoredHere(path: string): boolean {
67
+ try {
68
+ return !lstatSync(path).isSymbolicLink()
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
74
+ function artifactFiles(root: string, artifact: NonstandardArtifact): string[] {
75
+ const absolute = join(root, artifact.path)
76
+ // A path that is not there fails this too — `lstat` throws and the catch reads as "not ours".
77
+ if (!authoredHere(absolute)) return []
78
+ if (artifact.shape === 'file') return [artifact.path]
79
+ const below = artifact.kind === 'skill' ? skillFiles(absolute) : filesUnder(absolute)
80
+ return below.map((file) => posix.join(artifact.path, file))
81
+ }
82
+
83
+ /**
84
+ * Unlike the other detectors this takes no harness preference and no enabled set. An artifact is
85
+ * reachable by one harness whether or not that harness is enabled here — a `.cursorrules` in a
86
+ * repository nobody opens in Cursor is still instruction content `AGENTS.md` does not carry — so
87
+ * filtering by the enabled set would hide exactly the drift worth converting.
88
+ */
89
+ export function diagnoseNonstandard({ root, cli }: DiagnoseNonstandardOptions): NonstandardFinding[] {
90
+ const findings: NonstandardFinding[] = []
91
+
92
+ for (const harness of harnessRegistry) {
93
+ for (const artifact of harness.project.nonstandard ?? []) {
94
+ for (const path of artifactFiles(root, artifact)) {
95
+ const kind =
96
+ artifact.kind === 'rule' && path.endsWith('.mdc') && !scopedByGlobs(readFileSync(join(root, path), 'utf8'))
97
+ ? 'instructions'
98
+ : artifact.kind
99
+ const problem = problemOf[kind]
100
+ const { detail, repair } = repairFor(problem)
101
+ findings.push({ path, problem, detail, repair: repair({ file: path }, cli) })
102
+ }
103
+ }
104
+ }
105
+
106
+ // No dedupe: one artifact is declared by one harness, and `harness-registry.test.ts` holds that
107
+ // invariant. Collapsing a duplicate here would hide the registry mistake rather than surface it.
108
+ return findings.sort((left, right) => left.path.localeCompare(right.path))
109
+ }
@@ -1,6 +1,8 @@
1
1
  import { lstatSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { InstructionBridge } from './instruction-bridge.ts'
4
+ import type { McpConfig } from './mcp-config.ts'
5
+ import type { NonstandardArtifact } from './nonstandard-artifact.ts'
4
6
 
5
7
  export type HarnessName =
6
8
  | 'claude-code'
@@ -24,6 +26,15 @@ export type HarnessScopeName = 'project' | 'user'
24
26
  * `skillsDirectory` is the projection target the harness needs to see `.agents/skills` at this
25
27
  * scope. A harness that reads `.agents/skills` natively there has none, and is never projected into.
26
28
  *
29
+ * `mcpConfig` is where this harness keeps its MCP servers at this scope. Unlike the other two it
30
+ * is not a bridge into a canonical file the harness cannot read — it is the harness's own
31
+ * configuration, which `doctor` compares against the golden set and never writes.
32
+ *
33
+ * `nonstandard` is what this harness reads that no other harness can. Unlike the three above it is
34
+ * not something the tool maintains — it is what a repository accumulated before it was canonical,
35
+ * and `doctor` reports it so it can be converted. Declared here because the paths are per harness
36
+ * and belong beside the harness's other paths, not in a second list that would drift from this one.
37
+ *
27
38
  * `instructionBridge` is the same question for `AGENTS.md`: what the harness needs at this scope in
28
39
  * order to read it. It belongs per scope for the same reason `skillsDirectory` does — the file
29
40
  * differs. Unlike `skillsDirectory`, the `init` command does not write it; the `init` skill does. It
@@ -33,6 +44,8 @@ export type HarnessScope = {
33
44
  detect: string
34
45
  skillsDirectory?: string
35
46
  instructionBridge?: InstructionBridge
47
+ mcpConfig?: McpConfig
48
+ nonstandard?: readonly NonstandardArtifact[]
36
49
  }
37
50
 
38
51
  export type Harness = {
@@ -75,28 +88,108 @@ export const harnessRegistry: readonly Harness[] = [
75
88
  detect: '.claude',
76
89
  skillsDirectory: '.claude/skills',
77
90
  instructionBridge: { kind: 'import', path: 'CLAUDE.md' },
91
+ mcpConfig: { path: '.mcp.json', key: 'mcpServers', format: 'json' },
92
+ nonstandard: [
93
+ { path: '.claude/commands', shape: 'directory', kind: 'command' },
94
+ { path: '.claude/rules', shape: 'directory', kind: 'rule' },
95
+ { path: '.claude/agents', shape: 'directory', kind: 'subagent' },
96
+ ],
78
97
  },
79
98
  user: { detect: '.claude', skillsDirectory: '.claude/skills' },
80
99
  },
81
- { name: 'cursor', project: { detect: '.cursor' }, user: { detect: '.cursor' } },
82
- { name: 'codex', project: { detect: '.codex' }, user: { detect: '.codex' } },
83
- { name: 'copilot-cli', project: { detect: '.github/skills' }, user: { detect: '.copilot' } },
100
+ {
101
+ name: 'cursor',
102
+ project: {
103
+ detect: '.cursor',
104
+ mcpConfig: { path: '.cursor/mcp.json', key: 'mcpServers', format: 'json' },
105
+ nonstandard: [
106
+ { path: '.cursorrules', shape: 'file', kind: 'instructions' },
107
+ { path: '.cursor/rules', shape: 'directory', kind: 'rule' },
108
+ { path: '.cursor/commands', shape: 'directory', kind: 'command' },
109
+ { path: '.cursor/skills', shape: 'directory', kind: 'skill' },
110
+ ],
111
+ },
112
+ user: { detect: '.cursor' },
113
+ },
114
+ {
115
+ name: 'codex',
116
+ project: {
117
+ detect: '.codex',
118
+ mcpConfig: { path: '.codex/config.toml', key: 'mcp_servers', format: 'toml' },
119
+ nonstandard: [{ path: '.codex/skills', shape: 'directory', kind: 'skill' }],
120
+ },
121
+ user: { detect: '.codex' },
122
+ },
123
+ {
124
+ name: 'copilot-cli',
125
+ project: {
126
+ detect: '.github/skills',
127
+ nonstandard: [
128
+ { path: '.github/copilot-instructions.md', shape: 'file', kind: 'instructions' },
129
+ { path: '.github/instructions', shape: 'directory', kind: 'instructions' },
130
+ { path: '.github/skills', shape: 'directory', kind: 'skill' },
131
+ ],
132
+ },
133
+ user: { detect: '.copilot' },
134
+ },
84
135
  {
85
136
  name: 'gemini-cli',
86
137
  project: {
87
138
  detect: '.gemini',
88
139
  instructionBridge: { kind: 'settings-entry', path: '.gemini/settings.json', key: 'context.fileName' },
140
+ mcpConfig: { path: '.gemini/settings.json', key: 'mcpServers', format: 'json', shared: true },
141
+ nonstandard: [
142
+ { path: 'GEMINI.md', shape: 'file', kind: 'instructions' },
143
+ { path: '.gemini/skills', shape: 'directory', kind: 'skill' },
144
+ ],
89
145
  },
90
146
  user: { detect: '.gemini' },
91
147
  },
92
148
  { name: 'devin-desktop', project: { detect: '.devin' } },
93
149
  {
94
150
  name: 'windsurf',
95
- project: { detect: '.windsurf', skillsDirectory: '.windsurf/skills' },
151
+ project: {
152
+ detect: '.windsurf',
153
+ skillsDirectory: '.windsurf/skills',
154
+ nonstandard: [
155
+ { path: '.windsurfrules', shape: 'file', kind: 'instructions' },
156
+ { path: '.windsurf/rules', shape: 'directory', kind: 'rule' },
157
+ ],
158
+ },
96
159
  deprecated: 'devin-desktop',
97
160
  },
98
161
  ]
99
162
 
163
+ const harnessNames = new Set<string>(harnessRegistry.map((harness) => harness.name))
164
+
165
+ function isHarnessName(value: string): value is HarnessName {
166
+ return harnessNames.has(value)
167
+ }
168
+
169
+ /**
170
+ * The `--harness` option, as names this registry knows. Both commands take the option and neither
171
+ * owns what a name means, so the parse lives beside the list it validates against: a harness added
172
+ * to the registry is accepted by both commands with nothing else to change, and the rejection reads
173
+ * the same either way.
174
+ *
175
+ * An unknown name throws rather than being dropped. A caller that asked for a harness by name and
176
+ * got a report about the two default ones has been told the wrong thing about their repository.
177
+ */
178
+ export function parseHarnesses(value: string | undefined): HarnessName[] {
179
+ const requested = (value ?? '')
180
+ .split(',')
181
+ .map((name) => name.trim())
182
+ .filter(Boolean)
183
+ const unsupported = requested.filter((name) => !isHarnessName(name))
184
+ if (unsupported.length)
185
+ throw new Error(
186
+ `Unsupported harness: ${unsupported.join(', ')}. Supported: ${harnessRegistry
187
+ .map((harness) => harness.name)
188
+ .join(', ')}.`,
189
+ )
190
+ return requested.filter(isHarnessName)
191
+ }
192
+
100
193
  /** Enabled whether or not the repository already contains their directories. */
101
194
  const defaultHarnesses: readonly HarnessName[] = ['claude-code', 'cursor']
102
195
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where one harness keeps its MCP server configuration at one scope, as that harness's own vendor
3
+ * documents it.
4
+ *
5
+ * Recorded per scope like everything else in a `HarnessScope`, and populated at **project** scope
6
+ * only. Much of the world's MCP configuration lives at user scope — `~/.codex/config.toml`,
7
+ * `~/.claude.json`, `claude_desktop_config.json` — and the registry records user scope as
8
+ * described and diagnosable, never written. Reading a user's home directory into output that
9
+ * lands in every session's transcript is a wider blast radius than diagnosis needs, so those paths
10
+ * stay out until something needs them.
11
+ *
12
+ * A harness with no documented project-scope MCP file gets no entry. Copilot CLI is the case that
13
+ * matters: its own documentation states that `.vscode/mcp.json` "is not read by Copilot CLI", and
14
+ * names `~/.copilot/mcp-config.json` instead. That is a documented absence, not a gap — see
15
+ * `.research/mcp-canonical-location/` (E-MCP-11).
16
+ */
17
+ export type McpConfig = {
18
+ /** Repository-relative path of the file, as the vendor documents it. */
19
+ path: string
20
+ /**
21
+ * The key the servers sit under. Not one key across hosts: `mcpServers` for Claude Code, Cursor,
22
+ * and Gemini CLI, `mcp_servers` for Codex, `servers` for VS Code. Reading the wrong one finds
23
+ * nothing and reports a repository as unconfigured.
24
+ */
25
+ key: string
26
+ /**
27
+ * How the file is serialized. It decides the parser, and it is also why comparison is semantic:
28
+ * the same server in TOML and in JSON shares no bytes.
29
+ */
30
+ format: 'json' | 'toml'
31
+ /**
32
+ * Set when the file holds far more than MCP configuration. `.gemini/settings.json` also carries
33
+ * the instruction bridge, so anything reading it must take its key and leave the rest alone —
34
+ * and anything writing it must never rewrite the file wholesale.
35
+ */
36
+ shared?: true
37
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Configuration a harness reads that no other harness can, declared per harness beside the paths
3
+ * `doctor` already knows about it.
4
+ *
5
+ * These are not faults. Every one of them works — for exactly one harness — which is the whole
6
+ * problem: the guidance in a Cursor rule reaches Cursor and nothing else, and nobody finds out
7
+ * except by noticing an agent behaving differently in another tool. `doctor` surfaces them so they
8
+ * can be converted, one at a time, toward a repository whose agent configuration is canonical and
9
+ * whose harness files are generated from it. Zero of these is the north star, not a release gate.
10
+ *
11
+ * `kind` carries the conversion, because the canonical form differs per kind and nothing else about
12
+ * the artifact does. It is what a finding routes on.
13
+ */
14
+ export type NonstandardKind =
15
+ /** Prose an `AGENTS.md` can hold verbatim. The harness file becomes a generated bridge. */
16
+ | 'instructions'
17
+ /**
18
+ * A rule scoped to paths. A skill is the portable form *where the scoping is incidental* — a
19
+ * skill is loaded on relevance rather than by glob, so guidance whose whole point is "these
20
+ * files and no others" has no equivalent yet, and converting it would lose the scoping.
21
+ */
22
+ | 'rule'
23
+ /** A harness command. Claude Code merged commands into skills, so a skill is the portable form. */
24
+ | 'command'
25
+ /** A skill kept under a harness directory rather than the canonical one. */
26
+ | 'skill'
27
+ /** A subagent. No cross-harness format exists, so surfacing it *is* reporting that gap. */
28
+ | 'subagent'
29
+
30
+ export type NonstandardArtifact = {
31
+ /**
32
+ * Repository-relative path. A file is reported as itself; a directory is reported per file
33
+ * below it, so each one can be converted on its own rather than as an all-or-nothing batch.
34
+ */
35
+ path: string
36
+ /** Whether `path` names one file or a directory to walk. */
37
+ shape: 'file' | 'directory'
38
+ kind: NonstandardKind
39
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { run } from './cli.ts'
1
2
  export type {
2
3
  BridgeFinding,
3
4
  BridgeKind,
@@ -14,8 +15,17 @@ export type {
14
15
  InstructionStatus,
15
16
  } from './diagnose-bridges/diagnose-instructions.ts'
16
17
  export { diagnoseInstructions } from './diagnose-bridges/diagnose-instructions.ts'
17
- export { doctorCommand } from './diagnose-bridges/doctor.command.ts'
18
- export type { BridgeProblem, DoctorProblem, InstructionProblem, Repair } from './diagnose-bridges/doctor-guidance.ts'
18
+ export type { DoctorReport } from './diagnose-bridges/doctor.command.ts'
19
+ export { buildDoctorReport, doctorCommand } from './diagnose-bridges/doctor.command.ts'
20
+ export type {
21
+ BridgeProblem,
22
+ ConfigurationFault,
23
+ ConfigurationProblem,
24
+ DoctorProblem,
25
+ InstructionProblem,
26
+ McpProblem,
27
+ Repair,
28
+ } from './diagnose-bridges/doctor-guidance.ts'
19
29
  export {
20
30
  bridgeRepairs,
21
31
  doctorRepairs,
@@ -24,9 +34,15 @@ export {
24
34
  renderDoctorSkill,
25
35
  } from './diagnose-bridges/doctor-guidance.ts'
26
36
  export type { DivergenceDirection } from './diagnose-bridges/git-bridge-state.ts'
37
+ export type { DiagnoseMcpOptions } from './diagnose-mcp/diagnose-mcp.ts'
38
+ export { diagnoseMcp } from './diagnose-mcp/diagnose-mcp.ts'
39
+ export type { McpDirection } from './diagnose-mcp/mcp-baseline.ts'
40
+ export type { McpField, McpServer, McpTransport } from './diagnose-mcp/mcp-model.ts'
41
+ export { goldenSetPath } from './diagnose-mcp/mcp-sources.ts'
27
42
  export type { Harness, HarnessName, HarnessScope, HarnessScopeName } from './harness-registry/harness-registry.ts'
28
43
  export { harnessRegistry } from './harness-registry/harness-registry.ts'
29
44
  export type { InstructionBridge } from './harness-registry/instruction-bridge.ts'
45
+ export type { McpConfig } from './harness-registry/mcp-config.ts'
30
46
  export { activate, harnessCommand, initCommand } from './initialize-harnesses/init.command.ts'
31
47
  export type { InitializeOptions, InitializeResult } from './initialize-harnesses/initialize-harnesses.ts'
32
48
  export { initializeHarnesses } from './initialize-harnesses/initialize-harnesses.ts'
@@ -1,8 +1,9 @@
1
1
  import type { cli } from 'clibuilder'
2
- import { command, z } from 'clibuilder'
2
+ import { command, exitCodes, z } from 'clibuilder'
3
3
  import { parseFormat, writeResult } from '../command-output/command-output.ts'
4
4
  import { doctorCommand } from '../diagnose-bridges/doctor.command.ts'
5
- import { type HarnessName, harnessRegistry } from '../harness-registry/harness-registry.ts'
5
+ import { parseHarnesses } from '../harness-registry/harness-registry.ts'
6
+ import { parseForce } from '../skill-projection/skill-projection.ts'
6
7
  import { initializeHarnesses } from './initialize-harnesses.ts'
7
8
 
8
9
  export const initCommand: cli.Command = command({
@@ -22,8 +23,9 @@ export const initCommand: cli.Command = command({
22
23
  type: z.optional(z.boolean()),
23
24
  },
24
25
  force: {
25
- description: 'Replace conflicting target skill directories.',
26
- type: z.optional(z.boolean()),
26
+ description:
27
+ 'Replace conflicting target skill directories. Names the targets to replace, comma-separated (e.g. .claude/skills); given alone it replaces every conflicting target.',
28
+ type: z.optional(z.string()),
27
29
  },
28
30
  format: {
29
31
  description: 'Output format: toon (default), json, or text for a human-readable report.',
@@ -34,29 +36,22 @@ export const initCommand: cli.Command = command({
34
36
  run(args) {
35
37
  try {
36
38
  const format = parseFormat(args.format)
37
- const requested = args.harness
38
- ?.split(',')
39
- .map((name) => name.trim())
40
- .filter(Boolean)
41
- const unsupported = requested?.filter((name) => !harnessRegistry.some((harness) => harness.name === name))
42
- if (unsupported?.length)
43
- throw new Error(
44
- `Unsupported harness: ${unsupported.join(', ')}. Supported: ${harnessRegistry
45
- .map((harness) => harness.name)
46
- .join(', ')}.`,
47
- )
39
+ const harnesses = parseHarnesses(args.harness)
48
40
  writeResult(
49
41
  initializeHarnesses({
50
42
  root: args.root ?? process.cwd(),
51
- ...(requested?.length ? { harnesses: requested as HarnessName[] } : {}),
43
+ ...(harnesses.length ? { harnesses } : {}),
52
44
  ...(args.copy === undefined ? {} : { copy: args.copy }),
53
- ...(args.force === undefined ? {} : { force: args.force }),
45
+ ...(args.force === undefined ? {} : { force: parseForce(args.force) }),
54
46
  }),
55
47
  format,
56
48
  )
49
+ return exitCodes.success
57
50
  } catch (error) {
58
51
  process.stderr.write(`error: ${error instanceof Error ? error.message : 'Harness initialization failed.'}\n`)
59
- process.exitCode = 1
52
+ // Returned, not written: a command that writes the code reports its failure past `run`
53
+ // rather than to it, leaving a caller that is not the process no way to learn of it.
54
+ return exitCodes.error
60
55
  }
61
56
  },
62
57
  })