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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/bin/buddy-agent-harness.mjs +8 -3
- package/dist/cli-Bvnq8zOL.mjs +2122 -0
- package/dist/cli-Bvnq8zOL.mjs.map +1 -0
- package/dist/cli.d.mts +13 -2
- package/dist/cli.d.mts.map +1 -1
- package/dist/cli.mjs +2 -20
- package/dist/index.d.mts +382 -47
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/package.json +3 -2
- 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/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 +40 -7
- package/src/command-output/command-output.ts +1 -4
- 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,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
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
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: {
|
|
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 {
|
|
18
|
-
export
|
|
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 {
|
|
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:
|
|
26
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
})
|