@canonical/pragma-cli 0.32.0 → 0.33.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/README.md +72 -0
- package/dist/pragma +0 -0
- package/package.json +14 -13
- package/src/bin.ts +2 -2
- package/src/capabilities/block/blockList.test.ts +79 -0
- package/src/capabilities/block/blockList.verb.ts +18 -5
- package/src/capabilities/block/pack.ts +12 -1
- package/src/capabilities/block/parity.test.ts +9 -2
- package/src/capabilities/capabilities/capabilities.test.ts +10 -1
- package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
- package/src/capabilities/capabilities/catalog.ts +7 -3
- package/src/capabilities/capabilities/hints.ts +8 -17
- package/src/capabilities/capabilities/types.ts +2 -0
- package/src/capabilities/colophon/colophon.render.test.ts +31 -4
- package/src/capabilities/colophon/colophon.render.ts +13 -4
- package/src/capabilities/colophon/colophon.test.ts +3 -3
- package/src/capabilities/colophon/colophon.verb.ts +4 -1
- package/src/capabilities/colophon/markdownTerminal.ts +33 -14
- package/src/capabilities/colophon/pragmaColophon.ts +4 -4
- package/src/capabilities/colophon/types.ts +1 -1
- package/src/capabilities/config/fields.ts +21 -92
- package/src/capabilities/config/set.test.ts +6 -6
- package/src/capabilities/config/set.verb.ts +5 -5
- package/src/capabilities/config/show.render.test.ts +65 -0
- package/src/capabilities/config/show.render.ts +56 -10
- package/src/capabilities/config/show.verb.ts +5 -5
- package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
- package/src/capabilities/create/byteEquality.test.ts +7 -4
- package/src/capabilities/create/compiledCreate.subprocess.test.ts +1 -1
- package/src/capabilities/create/create.test.ts +32 -3
- package/src/capabilities/create/create.verb.ts +69 -11
- package/src/capabilities/doctor/checks/checkConfigFile.ts +1 -1
- package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
- package/src/capabilities/doctor/checks/checkMcpConfigured.ts +10 -4
- package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
- package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
- package/src/capabilities/doctor/doctor.render.test.ts +170 -0
- package/src/capabilities/doctor/doctor.render.ts +116 -34
- package/src/capabilities/doctor/doctor.test.ts +31 -1
- package/src/capabilities/doctor/runChecks.ts +28 -4
- package/src/capabilities/doctor/types.ts +15 -0
- package/src/capabilities/ontology/index.ts +10 -3
- package/src/capabilities/ontology/ontology.test.ts +157 -5
- package/src/capabilities/ontology/verbs.ts +192 -93
- package/src/capabilities/prompt/prompt.render.test.ts +94 -0
- package/src/capabilities/prompt/prompt.render.ts +23 -6
- package/src/capabilities/prompt/prompt.test.ts +61 -0
- package/src/capabilities/reference.test.ts +32 -0
- package/src/capabilities/resources/provider.ts +28 -14
- package/src/capabilities/resources/resources.test.ts +99 -0
- package/src/capabilities/scopeTypeSync.test.ts +37 -0
- package/src/capabilities/setup/index.ts +7 -1
- package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
- package/src/capabilities/setup/operations/setupGenerator.ts +157 -61
- package/src/capabilities/setup/operations/setupLsp.ts +56 -12
- package/src/capabilities/setup/operations/setupMcp.ts +216 -39
- package/src/capabilities/setup/operations/setupSkills.ts +7 -1
- package/src/capabilities/setup/setup.render.test.ts +109 -0
- package/src/capabilities/setup/setup.render.ts +61 -14
- package/src/capabilities/setup/setup.test.ts +352 -1
- package/src/capabilities/setup/setup.verb.ts +57 -5
- package/src/capabilities/setup/types.ts +59 -2
- package/src/capabilities/shared/bands.ts +16 -0
- package/src/capabilities/sources/collectStatus.ts +19 -4
- package/src/capabilities/sources/runUpdate.ts +58 -1
- package/src/capabilities/sources/sources.test.ts +187 -1
- package/src/capabilities/sources/status.render.test.ts +65 -0
- package/src/capabilities/sources/status.render.ts +50 -23
- package/src/capabilities/surface.test.ts +34 -9
- package/src/capabilities/tier/lookup.render.test.ts +72 -0
- package/src/capabilities/tier/lookup.render.ts +33 -8
- package/src/capabilities/tier/lookup.test.ts +15 -0
- package/src/capabilities/tier/runLookup.ts +28 -0
- package/src/constants.test.ts +1 -1
- package/src/constants.ts +2 -2
- package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +13 -14
- package/src/kernel/completion/bashDrive.test.ts +3 -3
- package/src/kernel/completion/complete.test.ts +3 -5
- package/src/kernel/completion/entitySource.ts +14 -6
- package/src/kernel/completion/model.test.ts +2 -2
- package/src/kernel/completion/model.ts +0 -1
- package/src/kernel/completion/parse.test.ts +2 -4
- package/src/kernel/error/PragmaError.test.ts +4 -4
- package/src/kernel/error/renderError.ts +1 -1
- package/src/kernel/packs/compile.ts +1 -1
- package/src/kernel/project/cli/buildProgram.test.ts +51 -7
- package/src/kernel/project/cli/buildProgram.ts +11 -0
- package/src/kernel/project/cli/globalFlags.test.ts +12 -4
- package/src/kernel/project/cli/globalFlags.ts +14 -14
- package/src/kernel/project/cli/rootHelp.ts +4 -2
- package/src/kernel/project/mcp/instructions.ts +1 -1
- package/src/kernel/project/mcp/mcpError.ts +52 -0
- package/src/kernel/project/mcp/prompts/prompts.test.ts +8 -0
- package/src/kernel/project/mcp/prompts/provider.ts +36 -6
- package/src/kernel/render/formatters.ts +5 -4
- package/src/kernel/render/renderers.test.ts +34 -0
- package/src/kernel/render/renderers.ts +25 -7
- package/src/kernel/render/style.test.ts +31 -0
- package/src/kernel/render/style.ts +74 -0
- package/src/kernel/runtime/graphpack/build.ts +28 -0
- package/src/kernel/runtime/graphpack/buildIndex.test.ts +103 -0
- package/src/kernel/runtime/graphpack/buildIndex.ts +124 -36
- package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -6
- package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -2
- package/src/kernel/runtime/graphpack/graphpack.test.ts +69 -1
- package/src/kernel/runtime/graphpack/read.ts +28 -13
- package/src/kernel/runtime/graphpack/types.ts +14 -0
- package/src/kernel/runtime/loadSession.ts +4 -12
- package/src/kernel/runtime/refs/resolve.ts +63 -3
- package/src/kernel/runtime/storeReadiness.ts +61 -0
- package/src/kernel/runtime/types.ts +1 -1
- package/src/kernel/runtime/wasmEmbed.test.ts +3 -1
- package/src/kernel/spec/emitReference.test.ts +196 -0
- package/src/kernel/spec/emitReference.ts +491 -0
- package/src/kernel/spec/emitSurface.ts +4 -2
- package/src/kernel/spec/index.ts +2 -0
- package/src/kernel/spec/surfaceConformance.test.ts +2 -2
- package/src/testing/PARITY_GAPS.ts +1 -1
- package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
- package/src/testing/behavioral/completion.test.ts +2 -2
- package/src/testing/behavioral/disclosure.test.ts +12 -0
- package/src/testing/behavioral/docExamples.test.ts +283 -0
- package/src/testing/behavioral/errorMatrix.storeless.test.ts +2 -1
- package/src/testing/behavioral/journeys.cli.test.ts +3 -1
- package/src/testing/behavioral/journeys.defaultPack.test.ts +571 -0
- package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
- package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
- package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +176 -0
- package/src/testing/behavioral/mcpSurface.test.ts +1 -1
- package/src/testing/behavioral/staleCommands.test.ts +76 -0
- package/src/testing/eval/__snapshots__/eval.test.ts.snap +2 -2
- package/src/testing/eval/cases/assembly.ts +3 -3
- package/src/testing/eval/cases/effectful.ts +6 -3
- package/src/testing/eval/cases/readNouns.ts +4 -1
- package/src/testing/fixtures/fixtureCapability.ts +156 -0
- package/src/testing/fixtures/graph/defaultPack.ts +178 -0
- package/src/testing/fixtures/graph/owlExport.ts +95 -0
- package/src/testing/helpers/extractSnippets.ts +29 -0
- package/src/testing/helpers/golden.ts +1 -1
- package/src/capabilities/config/field.test.ts +0 -188
|
@@ -2,84 +2,261 @@
|
|
|
2
2
|
* `setup mcp` — register the pragma MCP server in detected AI harnesses.
|
|
3
3
|
*
|
|
4
4
|
* Split into `detectMcp` (harness detection runs FOR REAL up front, via
|
|
5
|
-
* `runTask` over safe reads,
|
|
6
|
-
*
|
|
7
|
-
* write body driven by the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* `runTask` over safe reads, and the resolved config targets are deduplicated
|
|
6
|
+
* into per-file {@link TargetGroup}s for the chosen `--scope`) and `composeMcp`
|
|
7
|
+
* (a pure, re-runnable write body driven by the SELECTED groups). Each write is
|
|
8
|
+
* a distinct `(path, mcpKey)` so two harnesses sharing a file (VS Code + Cline)
|
|
9
|
+
* each preserve the other; every group emits a band-prefixed `info()` manifest
|
|
10
|
+
* line, so the recap and a `--dry-run` show which band each file belongs to.
|
|
11
|
+
*
|
|
12
|
+
* Detection ALSO reads each group's existing config (via `readMcpConfigFrom`,
|
|
13
|
+
* FOR REAL up front) and classifies every group as `absent` (no pragma entry
|
|
14
|
+
* yet), `configured` (the pragma entry already matches what we'd write), or
|
|
15
|
+
* `drifted` (a pragma entry exists but differs). The wizard shows that prior
|
|
16
|
+
* state in each row and DEFAULT-DESELECTS the already-`configured` files; the
|
|
17
|
+
* write itself stays idempotent (a re-merge of the identical entry is
|
|
18
|
+
* byte-for-byte), so a re-run is a no-op the recap reports as "unchanged" — the
|
|
19
|
+
* same state-awareness `setup skills` has always had.
|
|
10
20
|
*/
|
|
11
21
|
|
|
12
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
McpServerConfig,
|
|
24
|
+
PlatformEnv,
|
|
25
|
+
TargetGroup,
|
|
26
|
+
} from "@canonical/harnesses";
|
|
13
27
|
import { info, sequence_, type Task, warn } from "@canonical/task";
|
|
14
28
|
import { MCP_SERVER_NAME } from "../../../constants.js";
|
|
15
29
|
import type { PragmaRuntime } from "../../../kernel/runtime/types.js";
|
|
30
|
+
import type {
|
|
31
|
+
ConfiguredTarget,
|
|
32
|
+
McpTargetState,
|
|
33
|
+
ScopeSelection,
|
|
34
|
+
} from "../types.js";
|
|
16
35
|
|
|
17
|
-
/** The `
|
|
18
|
-
type
|
|
36
|
+
/** The `writeMcpConfigTargets` builder, captured from the dynamic harness import. */
|
|
37
|
+
type WriteMcpConfigTargets =
|
|
38
|
+
typeof import("@canonical/harnesses").writeMcpConfigTargets;
|
|
19
39
|
|
|
20
40
|
/**
|
|
21
|
-
* The detected MCP state: the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
41
|
+
* The detected MCP state: the per-file target groups (already scoped to the
|
|
42
|
+
* requested `--scope`), a by-path map of each group's prior
|
|
43
|
+
* {@link McpTargetState} (read up front), the project root, and the
|
|
44
|
+
* (dynamically imported) target-based writer the pure `composeMcp` needs
|
|
45
|
+
* synchronously. Keeping `groups` a plain {@link TargetGroup}[] leaves every
|
|
46
|
+
* existing `.groups` consumer (path/harnessNames/scope) unchanged; the state
|
|
47
|
+
* rides alongside, keyed by the group's `path`.
|
|
24
48
|
*/
|
|
25
49
|
export interface McpDetection {
|
|
26
|
-
readonly
|
|
50
|
+
readonly groups: readonly TargetGroup[];
|
|
51
|
+
readonly stateByPath: ReadonlyMap<string, McpTargetState>;
|
|
27
52
|
readonly cwd: string;
|
|
28
|
-
readonly
|
|
53
|
+
readonly platform: PlatformEnv;
|
|
54
|
+
readonly writeMcpConfigTargets: WriteMcpConfigTargets;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The pragma MCP server entry we register — the SINGLE source of truth both the
|
|
59
|
+
* classifier (does the existing entry match this?) and the writer (`composeMcp`)
|
|
60
|
+
* consume, so "already configured" means byte-for-byte what a write would emit.
|
|
61
|
+
*
|
|
62
|
+
* @param cwd - The project root recorded on the entry.
|
|
63
|
+
* @returns The pragma {@link McpServerConfig}.
|
|
64
|
+
*/
|
|
65
|
+
export function pragmaMcpEntry(cwd: string): McpServerConfig {
|
|
66
|
+
return { command: "pragma", args: ["mcp"], cwd };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether an existing MCP entry equals the pragma entry we would write, over the
|
|
71
|
+
* fields we control (`command`/`args`/`cwd`). A shallow structural compare — a
|
|
72
|
+
* harness that carries extra keys (e.g. `env`) still reads as `configured` as
|
|
73
|
+
* long as our three fields match, so we never churn a file we did not author.
|
|
74
|
+
*/
|
|
75
|
+
function entryMatches(
|
|
76
|
+
existing: McpServerConfig,
|
|
77
|
+
want: McpServerConfig,
|
|
78
|
+
): boolean {
|
|
79
|
+
const sameArgs =
|
|
80
|
+
(existing.args ?? []).length === (want.args ?? []).length &&
|
|
81
|
+
(want.args ?? []).every((a, i) => existing.args?.[i] === a);
|
|
82
|
+
return (
|
|
83
|
+
existing.command === want.command && existing.cwd === want.cwd && sameArgs
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Classify one target group against its on-disk config: `absent` when no pragma
|
|
89
|
+
* entry exists in ANY of the group's writes, `configured` when EVERY write
|
|
90
|
+
* already carries a matching pragma entry, `drifted` otherwise (present in at
|
|
91
|
+
* least one write but not matching everywhere). Reads each write's file for real
|
|
92
|
+
* via `readMcpConfigFrom`.
|
|
93
|
+
*
|
|
94
|
+
* @param group - The target group whose writes to inspect.
|
|
95
|
+
* @param want - The pragma entry a write would emit.
|
|
96
|
+
* @param readMcpConfigFrom - The harness reader (dynamically imported).
|
|
97
|
+
* @param runTask - The node Task interpreter.
|
|
98
|
+
* @returns The group's {@link McpTargetState}.
|
|
99
|
+
* @note Impure — reads each write's config file.
|
|
100
|
+
*/
|
|
101
|
+
async function classifyGroup(
|
|
102
|
+
group: TargetGroup,
|
|
103
|
+
want: McpServerConfig,
|
|
104
|
+
readMcpConfigFrom: typeof import("@canonical/harnesses").readMcpConfigFrom,
|
|
105
|
+
runTask: typeof import("@canonical/task/node").runTask,
|
|
106
|
+
): Promise<McpTargetState> {
|
|
107
|
+
let present = 0;
|
|
108
|
+
let matching = 0;
|
|
109
|
+
for (const write of group.writes) {
|
|
110
|
+
const servers = await runTask(readMcpConfigFrom(write));
|
|
111
|
+
const existing = servers[MCP_SERVER_NAME];
|
|
112
|
+
if (existing === undefined) continue;
|
|
113
|
+
present += 1;
|
|
114
|
+
if (entryMatches(existing, want)) matching += 1;
|
|
115
|
+
}
|
|
116
|
+
if (present === 0) return "absent";
|
|
117
|
+
if (matching === group.writes.length) return "configured";
|
|
118
|
+
return "drifted";
|
|
29
119
|
}
|
|
30
120
|
|
|
31
121
|
/**
|
|
32
|
-
* Detect the AI harnesses present in the project (real reads, up front)
|
|
122
|
+
* Detect the AI harnesses present in the project (real reads, up front),
|
|
123
|
+
* resolve+dedup their config targets for the chosen scope, AND read each
|
|
124
|
+
* group's existing config to classify it `absent`/`configured`/`drifted` — so
|
|
125
|
+
* the wizard can show prior state and default-deselect already-`configured`
|
|
126
|
+
* files.
|
|
33
127
|
*
|
|
34
128
|
* @param rt - The per-invocation runtime.
|
|
35
|
-
* @
|
|
36
|
-
* @
|
|
129
|
+
* @param scope - The resolved `--scope` selection.
|
|
130
|
+
* @returns The scoped target groups, their prior states, + the config writer.
|
|
131
|
+
* @note Impure — reads the filesystem via `detectHarnesses` + `readMcpConfigFrom`.
|
|
37
132
|
*/
|
|
38
|
-
export async function detectMcp(
|
|
133
|
+
export async function detectMcp(
|
|
134
|
+
rt: PragmaRuntime,
|
|
135
|
+
scope: ScopeSelection,
|
|
136
|
+
): Promise<McpDetection> {
|
|
39
137
|
const cwd = rt.cwd;
|
|
40
|
-
const [
|
|
138
|
+
const [
|
|
139
|
+
{
|
|
140
|
+
detectHarnesses,
|
|
141
|
+
groupTargetsForScope,
|
|
142
|
+
readMcpConfigFrom,
|
|
143
|
+
readPlatformEnv,
|
|
144
|
+
writeMcpConfigTargets,
|
|
145
|
+
},
|
|
146
|
+
{ runTask },
|
|
147
|
+
] = await Promise.all([
|
|
41
148
|
import("@canonical/harnesses"),
|
|
42
149
|
import("@canonical/task/node"),
|
|
43
150
|
]);
|
|
44
|
-
const
|
|
45
|
-
|
|
151
|
+
const platform = readPlatformEnv();
|
|
152
|
+
const detected = await runTask(detectHarnesses(cwd, platform));
|
|
153
|
+
const groups = groupTargetsForScope(detected, cwd, scope, platform);
|
|
154
|
+
|
|
155
|
+
// Read each group's existing config (for real, up front) so the recap/preview
|
|
156
|
+
// and the default selection reflect true prior state — the same discipline
|
|
157
|
+
// `detectSkills` uses for its per-link create/skip/replace decision.
|
|
158
|
+
const want = pragmaMcpEntry(cwd);
|
|
159
|
+
const stateByPath = new Map<string, McpTargetState>();
|
|
160
|
+
await Promise.all(
|
|
161
|
+
groups.map(async (group) => {
|
|
162
|
+
const state = await classifyGroup(
|
|
163
|
+
group,
|
|
164
|
+
want,
|
|
165
|
+
readMcpConfigFrom,
|
|
166
|
+
runTask,
|
|
167
|
+
);
|
|
168
|
+
stateByPath.set(group.path, state);
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return { groups, stateByPath, cwd, platform, writeMcpConfigTargets };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The prior state of a group, defaulting to `absent` for an unknown path (so a
|
|
177
|
+
* caller never has to guard the map lookup).
|
|
178
|
+
*/
|
|
179
|
+
export function mcpGroupState(d: McpDetection, path: string): McpTargetState {
|
|
180
|
+
return d.stateByPath.get(path) ?? "absent";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The target groups the user selected (by path), or all when none recorded. */
|
|
184
|
+
export function selectedGroups(
|
|
185
|
+
d: McpDetection,
|
|
186
|
+
selectedPaths: readonly string[],
|
|
187
|
+
): TargetGroup[] {
|
|
188
|
+
return d.groups.filter((g) => selectedPaths.includes(g.path));
|
|
46
189
|
}
|
|
47
190
|
|
|
48
191
|
/**
|
|
49
|
-
* Compose the per-
|
|
50
|
-
* re-runnable; builds ABSOLUTE config paths itself).
|
|
192
|
+
* Compose the per-target config writes for the SELECTED groups (pure —
|
|
193
|
+
* re-runnable; builds ABSOLUTE config paths itself). The prior
|
|
194
|
+
* {@link McpTargetState} drives the manifest MESSAGE (already-configured /
|
|
195
|
+
* updated / added) but NOT whether the write runs: the idempotent
|
|
196
|
+
* read-modify-write is always composed so it carries its `undo` and a re-run is
|
|
197
|
+
* byte-identical when already `configured`. The wizard already default-DESELECTS
|
|
198
|
+
* `configured` files (so they are usually not in `groups` at all); a `configured`
|
|
199
|
+
* file that IS selected is still rewritten idempotently.
|
|
51
200
|
*
|
|
52
201
|
* @param d - The detection gathered up front.
|
|
53
|
-
* @param
|
|
54
|
-
* @returns A Task that writes the pragma MCP config into each chosen
|
|
202
|
+
* @param groups - The target groups the user chose (a subset of `d.groups`).
|
|
203
|
+
* @returns A Task that writes the pragma MCP config into each chosen file.
|
|
55
204
|
*/
|
|
56
205
|
export function composeMcp(
|
|
57
206
|
d: McpDetection,
|
|
58
|
-
|
|
207
|
+
groups: readonly TargetGroup[],
|
|
59
208
|
): Task<void> {
|
|
60
|
-
|
|
61
|
-
if (chosen.length === 0) {
|
|
209
|
+
if (groups.length === 0) {
|
|
62
210
|
return warn("No AI harnesses selected — nothing to configure.");
|
|
63
211
|
}
|
|
64
|
-
const pragmaMcpConfig =
|
|
212
|
+
const pragmaMcpConfig = pragmaMcpEntry(d.cwd);
|
|
65
213
|
// Re-runnable combinators (NOT a single-use `gen`): `execute` interprets the
|
|
66
|
-
// task twice (preview + perform).
|
|
214
|
+
// task twice (preview + perform). One combined write per file keeps a shared
|
|
215
|
+
// file (VS Code + Cline) a single read-modify-write — dry-run safe.
|
|
67
216
|
const tasks: Task<unknown>[] = [];
|
|
68
|
-
for (const
|
|
217
|
+
for (const group of groups) {
|
|
218
|
+
const state = mcpGroupState(d, group.path);
|
|
219
|
+
const names = group.harnessNames.join(", ");
|
|
220
|
+
tasks.push(
|
|
221
|
+
d.writeMcpConfigTargets(group.writes, MCP_SERVER_NAME, pragmaMcpConfig),
|
|
222
|
+
);
|
|
223
|
+
const verb =
|
|
224
|
+
state === "configured"
|
|
225
|
+
? "already configured →"
|
|
226
|
+
: state === "drifted"
|
|
227
|
+
? "updated →"
|
|
228
|
+
: "→";
|
|
69
229
|
tasks.push(
|
|
70
|
-
|
|
230
|
+
info(
|
|
231
|
+
`[${group.scope}] pragma MCP server ${verb} ${group.path} (${names})`,
|
|
232
|
+
),
|
|
71
233
|
);
|
|
72
|
-
tasks.push(info(`✓ pragma MCP server added to ${h.configPath}`));
|
|
73
234
|
}
|
|
74
235
|
return sequence_(tasks);
|
|
75
236
|
}
|
|
76
237
|
|
|
77
|
-
/** The harness names configured
|
|
78
|
-
export function mcpConfigured(
|
|
238
|
+
/** The harness names configured across a selection (for the result summary). */
|
|
239
|
+
export function mcpConfigured(groups: readonly TargetGroup[]): string[] {
|
|
240
|
+
const names = new Set<string>();
|
|
241
|
+
for (const group of groups) {
|
|
242
|
+
for (const name of group.harnessNames) names.add(name);
|
|
243
|
+
}
|
|
244
|
+
return [...names].sort();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The per-file {@link ConfiguredTarget}s for a selection (for the result), each
|
|
249
|
+
* carrying its prior {@link McpTargetState} so the recap distinguishes newly
|
|
250
|
+
* added, updated, and already-configured targets.
|
|
251
|
+
*/
|
|
252
|
+
export function mcpTargets(
|
|
79
253
|
d: McpDetection,
|
|
80
|
-
|
|
81
|
-
):
|
|
82
|
-
return
|
|
83
|
-
.
|
|
84
|
-
|
|
254
|
+
groups: readonly TargetGroup[],
|
|
255
|
+
): ConfiguredTarget[] {
|
|
256
|
+
return groups.map((group) => ({
|
|
257
|
+
name: group.harnessNames.join(", "),
|
|
258
|
+
band: group.scope,
|
|
259
|
+
path: group.path,
|
|
260
|
+
state: mcpGroupState(d, group.path),
|
|
261
|
+
}));
|
|
85
262
|
}
|
|
@@ -148,6 +148,10 @@ export async function detectSkills(
|
|
|
148
148
|
* interprets the task twice (preview + perform). `created` links carry an undo;
|
|
149
149
|
* a `replaced` link is delete-then-relink (idempotent); `skipped` is a no-op.
|
|
150
150
|
*
|
|
151
|
+
* FOLLOW-UP(AV-284): global-band skills — skills only link into per-project
|
|
152
|
+
* harness dirs today; a `--scope global` machine-band skills location is
|
|
153
|
+
* deferred. The manifest line is `[project]`-prefixed to make that explicit.
|
|
154
|
+
*
|
|
151
155
|
* @param d - The detection gathered up front.
|
|
152
156
|
* @returns A Task that mkdirs each target and creates/replaces each symlink.
|
|
153
157
|
*/
|
|
@@ -168,7 +172,9 @@ export function composeSkills(d: SkillsDetection): Task<void> {
|
|
|
168
172
|
}
|
|
169
173
|
}
|
|
170
174
|
tasks.push(
|
|
171
|
-
info(
|
|
175
|
+
info(
|
|
176
|
+
`[project] Linked ${d.skillCount} skill(s) into ${d.targets.length} dir(s).`,
|
|
177
|
+
),
|
|
172
178
|
);
|
|
173
179
|
return sequence_(tasks);
|
|
174
180
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render goldens for `pragma setup` — the MCP recap grouped by band.
|
|
3
|
+
*
|
|
4
|
+
* `summarizeMcpTargets` (the recap's band grouping) previously shipped
|
|
5
|
+
* executed-but-never-asserted; this pins the ACTUAL banded recap text for BOTH
|
|
6
|
+
* the plain and llm formatters, and locks the unified Global/Project vocabulary
|
|
7
|
+
* (the recap no longer says MACHINE/PROJECT). The setup formatters use no
|
|
8
|
+
* colour, so no chalk level dance is needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { setupFormatters } from "./setup.render.js";
|
|
13
|
+
import type { SetupResult } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A two-band MCP result: a global (Windsurf) and a project (Cursor) target,
|
|
17
|
+
* both newly `absent`→added — the clean first-run recap (no state tally).
|
|
18
|
+
*/
|
|
19
|
+
const BOTH_BANDS: SetupResult = {
|
|
20
|
+
kind: "mcp",
|
|
21
|
+
configured: ["Cursor", "Windsurf"],
|
|
22
|
+
targets: [
|
|
23
|
+
{
|
|
24
|
+
name: "Windsurf",
|
|
25
|
+
band: "global",
|
|
26
|
+
path: "/home/u/.codeium/windsurf/mcp_config.json",
|
|
27
|
+
state: "absent",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "Cursor",
|
|
31
|
+
band: "project",
|
|
32
|
+
path: "/proj/.cursor/mcp.json",
|
|
33
|
+
state: "absent",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("setup render — MCP recap banded by Global/Project", () => {
|
|
39
|
+
it("plain lists the global band before the project band", () => {
|
|
40
|
+
expect(setupFormatters.plain(BOTH_BANDS)).toBe(
|
|
41
|
+
"Configured MCP — Global: Windsurf · Project: Cursor.",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("llm is the same banded recap prefixed with a bullet", () => {
|
|
46
|
+
expect(setupFormatters.llm(BOTH_BANDS)).toBe(
|
|
47
|
+
"- Configured MCP — Global: Windsurf · Project: Cursor.",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("uses the unified labels, never MACHINE/PROJECT", () => {
|
|
52
|
+
const out = setupFormatters.plain(BOTH_BANDS);
|
|
53
|
+
expect(out).not.toContain("MACHINE");
|
|
54
|
+
expect(out).not.toContain("PROJECT");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("a single-band selection lists only that band", () => {
|
|
58
|
+
const projectOnly: SetupResult = {
|
|
59
|
+
kind: "mcp",
|
|
60
|
+
configured: ["Cursor"],
|
|
61
|
+
targets: [
|
|
62
|
+
{
|
|
63
|
+
name: "Cursor",
|
|
64
|
+
band: "project",
|
|
65
|
+
path: "/p/.cursor/mcp.json",
|
|
66
|
+
state: "absent",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
expect(setupFormatters.plain(projectOnly)).toBe(
|
|
71
|
+
"Configured MCP — Project: Cursor.",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("appends the state tally when a target was already present (re-run)", () => {
|
|
76
|
+
// A mix of already-configured + drifted + newly-added surfaces the
|
|
77
|
+
// idempotency breakdown, so a re-run reads as a no-op rather than a rewrite.
|
|
78
|
+
const rerun: SetupResult = {
|
|
79
|
+
kind: "mcp",
|
|
80
|
+
configured: ["Cursor", "Windsurf"],
|
|
81
|
+
targets: [
|
|
82
|
+
{
|
|
83
|
+
name: "Windsurf",
|
|
84
|
+
band: "global",
|
|
85
|
+
path: "/home/u/.codeium/windsurf/mcp_config.json",
|
|
86
|
+
state: "configured",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "Cursor",
|
|
90
|
+
band: "project",
|
|
91
|
+
path: "/proj/.cursor/mcp.json",
|
|
92
|
+
state: "drifted",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
expect(setupFormatters.plain(rerun)).toBe(
|
|
97
|
+
"Configured MCP — Global: Windsurf · Project: Cursor (0 added, 1 updated, 1 unchanged).",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("no targets → a plain 'nothing configured' line", () => {
|
|
102
|
+
const none: SetupResult = { kind: "mcp", configured: [], targets: [] };
|
|
103
|
+
expect(setupFormatters.plain(none)).toBe("No harnesses configured.");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("json is the exact SetupResult round-trip", () => {
|
|
107
|
+
expect(JSON.parse(setupFormatters.json(BOTH_BANDS))).toEqual(BOTH_BANDS);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -4,31 +4,78 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Formatters } from "../../kernel/spec/types.js";
|
|
7
|
-
import
|
|
7
|
+
import { BAND_LABELS } from "../shared/bands.js";
|
|
8
|
+
import type {
|
|
9
|
+
ConfiguredTarget,
|
|
10
|
+
SetupResult,
|
|
11
|
+
SetupSkillsResult,
|
|
12
|
+
} from "./types.js";
|
|
8
13
|
|
|
9
14
|
/** Summarize a skills result: created / skipped / replaced counts. */
|
|
10
|
-
function
|
|
15
|
+
function summarizeSkills(result: SetupSkillsResult): string {
|
|
11
16
|
const created = result.actions.filter((a) => a.action === "created").length;
|
|
12
17
|
const skipped = result.actions.filter((a) => a.action === "skipped").length;
|
|
13
18
|
const replaced = result.actions.filter((a) => a.action === "replaced").length;
|
|
14
19
|
return `${created} created, ${replaced} replaced, ${skipped} unchanged across ${result.harnessCount} harness(es)`;
|
|
15
20
|
}
|
|
16
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Summarize the MCP targets grouped by band — the global (user/home) band
|
|
24
|
+
* before the project (per-repo) band — so the recap shows which files at which
|
|
25
|
+
* level got the pragma server. The Global/Project labels match the doctor
|
|
26
|
+
* report and the CLI's `--global`/`--local` grammar (see {@link BAND_LABELS}).
|
|
27
|
+
*
|
|
28
|
+
* A trailing state tally is appended ONLY when something was already present
|
|
29
|
+
* (an `updated` or `unchanged` target) — a clean first run (every target newly
|
|
30
|
+
* `added`) keeps the plain `Configured MCP — …` line, so the idempotency detail
|
|
31
|
+
* surfaces exactly when it is informative (a re-run made clear it was a no-op).
|
|
32
|
+
*/
|
|
33
|
+
function summarizeMcpTargets(targets: readonly ConfiguredTarget[]): string {
|
|
34
|
+
if (targets.length === 0) return "No harnesses configured.";
|
|
35
|
+
const section = (band: ConfiguredTarget["band"], label: string): string => {
|
|
36
|
+
const names = targets.filter((t) => t.band === band).map((t) => t.name);
|
|
37
|
+
return names.length > 0 ? `${label}: ${names.join(", ")}` : "";
|
|
38
|
+
};
|
|
39
|
+
const parts = [
|
|
40
|
+
section("global", BAND_LABELS.global),
|
|
41
|
+
section("project", BAND_LABELS.project),
|
|
42
|
+
]
|
|
43
|
+
.filter((p) => p.length > 0)
|
|
44
|
+
.join(" · ");
|
|
45
|
+
const updated = targets.filter((t) => t.state === "drifted").length;
|
|
46
|
+
const unchanged = targets.filter((t) => t.state === "configured").length;
|
|
47
|
+
if (updated === 0 && unchanged === 0) {
|
|
48
|
+
// Clean first run — every target newly added; keep the original recap line.
|
|
49
|
+
return `Configured MCP — ${parts}.`;
|
|
50
|
+
}
|
|
51
|
+
const added = targets.filter((t) => t.state === "absent").length;
|
|
52
|
+
return `Configured MCP — ${parts} (${added} added, ${updated} updated, ${unchanged} unchanged).`;
|
|
53
|
+
}
|
|
54
|
+
|
|
17
55
|
/** Render a setup result to a single human line (shared by plain/llm). */
|
|
18
|
-
function
|
|
56
|
+
function renderSetupLine(data: SetupResult): string {
|
|
19
57
|
switch (data.kind) {
|
|
20
|
-
case "completions":
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
58
|
+
case "completions": {
|
|
59
|
+
if (!data.installed) {
|
|
60
|
+
return "No shell detected — completions were not installed.";
|
|
61
|
+
}
|
|
62
|
+
switch (data.state) {
|
|
63
|
+
case "installed":
|
|
64
|
+
return `${data.shell} completions already up to date at ${data.path}.`;
|
|
65
|
+
case "stale":
|
|
66
|
+
return `Updated ${data.shell} completions at ${data.path}.`;
|
|
67
|
+
default:
|
|
68
|
+
return `Installed ${data.shell} completions at ${data.path}.`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
24
71
|
case "lsp":
|
|
25
|
-
return
|
|
72
|
+
return data.state === "installed"
|
|
73
|
+
? "Terrazzo LSP extension already installed (up to date)."
|
|
74
|
+
: "Terrazzo LSP extension is installed (up to date).";
|
|
26
75
|
case "mcp":
|
|
27
|
-
return data.
|
|
28
|
-
? `Configured MCP for: ${data.configured.join(", ")}.`
|
|
29
|
-
: "No harnesses configured.";
|
|
76
|
+
return summarizeMcpTargets(data.targets);
|
|
30
77
|
case "skills":
|
|
31
|
-
return `Skills: ${
|
|
78
|
+
return `Skills: ${summarizeSkills(data.result)}.`;
|
|
32
79
|
case "all":
|
|
33
80
|
return data.steps.length > 0
|
|
34
81
|
? `Setup complete — ran: ${data.steps.join(", ")}.`
|
|
@@ -38,10 +85,10 @@ function line(data: SetupResult): string {
|
|
|
38
85
|
|
|
39
86
|
export const setupFormatters: Formatters<SetupResult> = {
|
|
40
87
|
plain(data) {
|
|
41
|
-
return
|
|
88
|
+
return renderSetupLine(data);
|
|
42
89
|
},
|
|
43
90
|
llm(data) {
|
|
44
|
-
return `- ${
|
|
91
|
+
return `- ${renderSetupLine(data)}`;
|
|
45
92
|
},
|
|
46
93
|
json(data) {
|
|
47
94
|
return JSON.stringify(data);
|