howtofixcli 0.7.13 → 0.7.15

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 (161) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-6FUWMLWO.js +1 -0
  5. package/dist/chunk-7CHDNTH6.js +1 -0
  6. package/dist/chunk-7DTQKDO4.js +2 -0
  7. package/dist/chunk-AGGIYXN7.js +2 -0
  8. package/dist/chunk-AUDMRGWF.js +189 -0
  9. package/dist/chunk-CPJKKHGH.js +1 -0
  10. package/dist/chunk-DAPXGSGD.js +12 -0
  11. package/dist/chunk-DOD7MUGX.js +1 -0
  12. package/dist/chunk-DV2BBBBJ.js +2 -0
  13. package/dist/chunk-EIBOS5KO.js +5 -0
  14. package/dist/chunk-EYWOVCSS.js +6 -0
  15. package/dist/chunk-FPXVDP32.js +8 -0
  16. package/dist/chunk-GXB7RZHH.js +1 -0
  17. package/dist/chunk-HT3MFLAV.js +34 -0
  18. package/dist/chunk-ILDDDLRC.js +1 -0
  19. package/dist/chunk-JFYVRWJI.js +2 -0
  20. package/dist/chunk-JMKK26AT.js +2 -0
  21. package/dist/chunk-LAHVHRCS.js +2 -0
  22. package/dist/chunk-LOOFOBTS.js +1 -0
  23. package/dist/chunk-LSJC73DG.js +1 -0
  24. package/dist/chunk-MOKUBKE7.js +1 -0
  25. package/dist/chunk-N345KWWK.js +1 -0
  26. package/dist/chunk-ONZLNFQ2.js +3 -0
  27. package/dist/chunk-ORJ2QZBG.js +3 -0
  28. package/dist/chunk-PCALBZOO.js +2 -0
  29. package/dist/chunk-SX5VE6XA.js +110 -0
  30. package/dist/chunk-TSEKPGK4.js +1 -0
  31. package/dist/chunk-UNK5HRQR.js +1 -0
  32. package/dist/chunk-UTYFLLSM.js +1 -0
  33. package/dist/chunk-V2RPZMLK.js +1 -0
  34. package/dist/chunk-V3R4FYTJ.js +1 -0
  35. package/dist/chunk-V656XVXV.js +8 -0
  36. package/dist/chunk-VLMQSD25.js +1 -0
  37. package/dist/chunk-WKUJ7CFO.js +1 -0
  38. package/dist/chunk-WOT6VMZA.js +1 -0
  39. package/dist/chunk-WUPU6LTD.js +4 -0
  40. package/dist/chunk-WX3V5P7U.js +5 -0
  41. package/dist/chunk-Y7YM5R75.js +5 -0
  42. package/dist/chunk-YW7NORH3.js +2 -0
  43. package/dist/main.js +99 -540
  44. package/package.json +17 -64
  45. package/dist/account-credential.js +0 -136
  46. package/dist/account-credential.test.js +0 -38
  47. package/dist/activity.js +0 -124
  48. package/dist/agent-defs.js +0 -237
  49. package/dist/agent-limiter.js +0 -68
  50. package/dist/agents.js +0 -554
  51. package/dist/anim.js +0 -361
  52. package/dist/args.js +0 -228
  53. package/dist/at-file.js +0 -28
  54. package/dist/brand-extension.js +0 -87
  55. package/dist/brand.js +0 -76
  56. package/dist/browser.js +0 -58
  57. package/dist/cache-report.js +0 -139
  58. package/dist/checkpoint.js +0 -437
  59. package/dist/claims.js +0 -167
  60. package/dist/cli-auth.js +0 -142
  61. package/dist/cli-checkin.js +0 -120
  62. package/dist/cli-credentials.js +0 -254
  63. package/dist/clipboard.js +0 -149
  64. package/dist/collapse.js +0 -129
  65. package/dist/commands.js +0 -294
  66. package/dist/compaction-guard.js +0 -81
  67. package/dist/context-extension.js +0 -17
  68. package/dist/dcp/commands.js +0 -172
  69. package/dist/dcp/compress-state.js +0 -139
  70. package/dist/dcp/compress.js +0 -329
  71. package/dist/dcp/dcp-extension.js +0 -295
  72. package/dist/dcp/dedup.js +0 -93
  73. package/dist/dcp/ids.js +0 -189
  74. package/dist/dcp/index.js +0 -25
  75. package/dist/dcp/middle-prune.js +0 -76
  76. package/dist/dcp/prompts.js +0 -68
  77. package/dist/dcp/protected-content.js +0 -221
  78. package/dist/dcp/protected.js +0 -161
  79. package/dist/dcp/prune.js +0 -418
  80. package/dist/dcp/state.js +0 -176
  81. package/dist/device-id.js +0 -90
  82. package/dist/device-id.test.js +0 -23
  83. package/dist/errors.js +0 -63
  84. package/dist/extensions.js +0 -48
  85. package/dist/fatal.js +0 -35
  86. package/dist/feed.js +0 -498
  87. package/dist/fs-atomic.js +0 -54
  88. package/dist/gateway-errors.js +0 -90
  89. package/dist/git.js +0 -230
  90. package/dist/goal-extension.js +0 -88
  91. package/dist/goal.js +0 -222
  92. package/dist/hooks-extension.js +0 -176
  93. package/dist/hooks.js +0 -443
  94. package/dist/howtofix-account.js +0 -735
  95. package/dist/hud-line.js +0 -153
  96. package/dist/hwid.js +0 -130
  97. package/dist/interrupt.js +0 -60
  98. package/dist/json-events.js +0 -50
  99. package/dist/level-style.js +0 -112
  100. package/dist/loop-guard.js +0 -95
  101. package/dist/lsp-tools.js +0 -32
  102. package/dist/lsp.js +0 -429
  103. package/dist/mcp-config.js +0 -85
  104. package/dist/mcp-lifecycle.js +0 -147
  105. package/dist/model-catalog.js +0 -217
  106. package/dist/model-facts.js +0 -35
  107. package/dist/model-resolve.js +0 -65
  108. package/dist/notes.js +0 -78
  109. package/dist/oauth-login.js +0 -568
  110. package/dist/paths.js +0 -61
  111. package/dist/permissions.js +0 -298
  112. package/dist/plan-mode.js +0 -363
  113. package/dist/preset-command.js +0 -227
  114. package/dist/preset-extension.js +0 -35
  115. package/dist/preset-mcp.js +0 -292
  116. package/dist/presets.js +0 -727
  117. package/dist/prompt-files.js +0 -103
  118. package/dist/prompt-text.js +0 -41
  119. package/dist/proxy.js +0 -137
  120. package/dist/quality/ast.js +0 -196
  121. package/dist/quality/cognitive.js +0 -83
  122. package/dist/quality/cyclomatic.js +0 -30
  123. package/dist/quality/halstead.js +0 -83
  124. package/dist/quality/index.js +0 -18
  125. package/dist/quality/length.js +0 -3
  126. package/dist/quality/maintainability.js +0 -15
  127. package/dist/quality/nesting.js +0 -57
  128. package/dist/quality/report.js +0 -803
  129. package/dist/quality/smells.js +0 -67
  130. package/dist/quality-tool.js +0 -63
  131. package/dist/read-only-commands.js +0 -597
  132. package/dist/repo-map.js +0 -292
  133. package/dist/rtk.js +0 -193
  134. package/dist/sandbox.js +0 -105
  135. package/dist/schema.js +0 -214
  136. package/dist/session-lifecycle.js +0 -19
  137. package/dist/session-retention.js +0 -111
  138. package/dist/session.js +0 -447
  139. package/dist/sessions.js +0 -100
  140. package/dist/settings.js +0 -148
  141. package/dist/skills.js +0 -35
  142. package/dist/slop-tool.js +0 -96
  143. package/dist/spend-cap.js +0 -43
  144. package/dist/subagent.js +0 -171
  145. package/dist/tar.js +0 -146
  146. package/dist/theme.js +0 -79
  147. package/dist/thinking.js +0 -45
  148. package/dist/todo.js +0 -114
  149. package/dist/tool-loadout.js +0 -97
  150. package/dist/tool-render.js +0 -769
  151. package/dist/tools.js +0 -176
  152. package/dist/ui.js +0 -4778
  153. package/dist/update.js +0 -551
  154. package/dist/usage-segment.js +0 -69
  155. package/dist/web-tools.js +0 -46
  156. package/dist/web.js +0 -265
  157. package/dist/welcome.js +0 -179
  158. package/dist/workflow-engine.js +0 -246
  159. package/dist/workflow-progress.js +0 -446
  160. package/dist/workflow-view.js +0 -195
  161. package/dist/workflow.js +0 -349
@@ -1,227 +0,0 @@
1
- /**
2
- * `howtofix preset <new|check|pack>` — the authoring half of a preset.
3
- *
4
- * Installing a preset was covered; writing one was not, so the only way to make
5
- * one was to read `presets/` in this repo and copy the shape. That works for us
6
- * and not for anyone else, and the registry's rules — a `preset.json` that
7
- * validates, a `SKILL.md` whose frontmatter the loader accepts, a name the
8
- * installer will not reject — are exactly the rules an author needs told while
9
- * writing, not after publishing.
10
- *
11
- * `check` runs the *same* `validatePreset` the installer runs, so a preset that
12
- * passes here cannot be refused at install for a reason this could have named.
13
- * That is the point: a separate authoring validator would drift, and the drift
14
- * would only be discovered by the person installing.
15
- *
16
- * No preset runs code, and nothing here changes that: `pack` writes an archive,
17
- * it does not execute anything inside one.
18
- */
19
- import { execFileSync } from "node:child_process";
20
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
21
- import { join, relative, resolve } from "node:path";
22
- import { readPreset, validatePreset } from "./presets.js";
23
- /** The scaffold written by `preset new`, with the name already filled in. */
24
- function scaffold(name) {
25
- const title = name.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
26
- return [
27
- {
28
- path: "preset.json",
29
- body: `${JSON.stringify({
30
- name,
31
- title,
32
- description: "One line saying what this preset is for.",
33
- category: "engineering",
34
- version: "1.0.0",
35
- skills: [`${name}-skill`],
36
- }, null, 2)}\n`,
37
- },
38
- {
39
- path: "prompt.md",
40
- body: [
41
- "Rules the model should follow while this preset is active.",
42
- "",
43
- "This text is appended to the system prompt, never substituted for it, so it",
44
- "adds to the agent's own rules rather than replacing them. Delete this file if",
45
- "the preset only needs skills.",
46
- "",
47
- ].join("\n"),
48
- },
49
- {
50
- path: join("skills", `${name}-skill`, "SKILL.md"),
51
- body: [
52
- "---",
53
- `name: ${name}-skill`,
54
- "description: When the model should reach for this skill. This line is what",
55
- " the model reads when deciding, so say when to use it, not what it contains.",
56
- "---",
57
- "",
58
- `# ${title}`,
59
- "",
60
- "What the model should do, as instructions. A skill is read on demand: only the",
61
- "frontmatter above is in every request, so the body costs nothing until used.",
62
- "",
63
- "Do not add `disable-model-invocation: true` unless the skill must only ever run",
64
- "from `/skill:<name>` — it keeps the skill out of the model's catalogue entirely.",
65
- "",
66
- ].join("\n"),
67
- },
68
- ];
69
- }
70
- function usage() {
71
- return [
72
- "usage:",
73
- " howtofix preset new <name> [dir] write a preset skeleton",
74
- " howtofix preset check [dir] validate a preset with the installer's rules",
75
- " howtofix preset pack [dir] build <name>-<version>.tar.gz for publishing",
76
- "",
77
- "A preset is a directory holding preset.json, an optional prompt.md, and a",
78
- "skills/ tree. `check` runs the same validation the installer runs, so a preset",
79
- "that passes here will not be refused at install.",
80
- ].join("\n");
81
- }
82
- /** Every file under `dir`, relative, sorted — for the packed listing. */
83
- function walk(dir) {
84
- const out = [];
85
- const visit = (d) => {
86
- for (const entry of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
87
- const full = join(d, entry.name);
88
- if (entry.isDirectory())
89
- visit(full);
90
- else
91
- out.push(relative(dir, full).split("\\").join("/"));
92
- }
93
- };
94
- visit(dir);
95
- return out;
96
- }
97
- /**
98
- * Validate and report. Returns true when the preset is installable.
99
- *
100
- * `readPreset` and `validatePreset` are the installer's own functions, imported
101
- * rather than reimplemented — a second validator would agree with this one and
102
- * disagree with the installer, which is the failure that matters.
103
- */
104
- function check(dir) {
105
- const { manifest, problems } = readPreset(dir);
106
- if (!manifest) {
107
- console.log(`not installable: ${dir}`);
108
- for (const problem of problems)
109
- console.log(` - ${problem}`);
110
- return false;
111
- }
112
- const extra = validatePreset(dir, manifest);
113
- if (extra.length > 0) {
114
- console.log(`not installable: ${dir}`);
115
- for (const problem of extra)
116
- console.log(` - ${problem}`);
117
- return false;
118
- }
119
- const skills = manifest.skills ?? [];
120
- let invocable = 0;
121
- let invokeOnly = 0;
122
- for (const skill of skills) {
123
- const file = join(dir, "skills", skill, "SKILL.md");
124
- const frontmatter = existsSync(file) ? /^---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(file, "utf8")) : null;
125
- const flag = frontmatter && /^disable-model-invocation:\s*(.*)$/m.exec(frontmatter[1]);
126
- const on = flag &&
127
- flag[1]
128
- .trim()
129
- .replace(/^["']|["']$/g, "")
130
- .toLowerCase() === "true";
131
- if (on)
132
- invokeOnly += 1;
133
- else
134
- invocable += 1;
135
- }
136
- const files = walk(dir);
137
- const bytes = files.reduce((sum, f) => sum + statSync(join(dir, f)).size, 0);
138
- console.log(`installable: ${manifest.name} ${manifest.version ?? ""}`.trim());
139
- console.log(` ${files.length} files, ${(bytes / 1024).toFixed(1)} KB`);
140
- console.log(` ${skills.length} skill(s): ${invocable} the model can choose`);
141
- if (invokeOnly > 0) {
142
- console.log(` ${invokeOnly} invoke-only — reachable only as /skill:<name>`);
143
- }
144
- return true;
145
- }
146
- /**
147
- * Pack the directory as a gzipped tar, using `tar` rather than writing one.
148
- *
149
- * An archive format is not something to hand-roll: `tar` is present on every
150
- * platform this ships to, and a subtly wrong archive is discovered by the person
151
- * downloading it. The command is given the file list explicitly so the archive
152
- * cannot pick up whatever happens to sit in the directory.
153
- */
154
- function pack(dir) {
155
- const { manifest, problems } = readPreset(dir);
156
- if (!manifest) {
157
- console.log(`cannot pack: ${dir}`);
158
- for (const problem of problems)
159
- console.log(` - ${problem}`);
160
- return false;
161
- }
162
- const extra = validatePreset(dir, manifest);
163
- if (extra.length > 0) {
164
- console.log(`cannot pack: the preset does not validate`);
165
- for (const problem of extra)
166
- console.log(` - ${problem}`);
167
- return false;
168
- }
169
- const version = manifest.version ?? "1.0.0";
170
- const archive = resolve(`${manifest.name}-${version}.tar.gz`);
171
- const files = walk(dir);
172
- execFileSync("tar", ["-czf", archive, "-C", dir, ...files]);
173
- const size = statSync(archive).size;
174
- console.log(`packed ${archive}`);
175
- console.log(` ${files.length} files, ${(size / 1024).toFixed(1)} KB`);
176
- console.log("");
177
- console.log("to offer it to the community registry, open a pull request adding an entry");
178
- console.log("that names the repo, a pinned commit, and the skill directory inside it.");
179
- return true;
180
- }
181
- /** Entry point for `howtofix preset ...`. Returns the process exit code. */
182
- export function runPresetCommand(argv) {
183
- const [sub, target] = argv;
184
- if (!sub || sub === "--help" || sub === "-h") {
185
- console.log(usage());
186
- return sub ? 0 : 2;
187
- }
188
- if (sub === "new") {
189
- if (!target) {
190
- console.log("preset new: a name is required\n");
191
- console.log(usage());
192
- return 2;
193
- }
194
- if (!/^[a-z0-9][a-z0-9-]*$/.test(target)) {
195
- console.log(`preset new: "${target}" is not a usable name.`);
196
- console.log(" lower-case letters, digits and hyphens; must start with a letter or digit.");
197
- console.log(" The installer refuses anything else, and it refuses at install time.");
198
- return 2;
199
- }
200
- const parent = argv[2] ? resolve(argv[2]) : resolve(".");
201
- const dir = join(parent, target);
202
- if (existsSync(dir)) {
203
- console.log(`preset new: ${dir} already exists.`);
204
- return 1;
205
- }
206
- for (const file of scaffold(target)) {
207
- const full = join(dir, file.path);
208
- mkdirSync(join(full, ".."), { recursive: true });
209
- writeFileSync(full, file.body);
210
- console.log(` wrote ${relative(dir, full)}`);
211
- }
212
- console.log(`\nwrote ${dir}`);
213
- console.log(` edit preset.json and the skill, then: howtofix preset check ${target}`);
214
- return 0;
215
- }
216
- if (sub === "check" || sub === "pack") {
217
- const dir = resolve(target ?? ".");
218
- if (!existsSync(join(dir, "preset.json"))) {
219
- console.log(`${sub}: no preset.json in ${dir}`);
220
- return 2;
221
- }
222
- return (sub === "check" ? check(dir) : pack(dir)) ? 0 : 1;
223
- }
224
- console.log(`preset: unknown subcommand "${sub}"\n`);
225
- console.log(usage());
226
- return 2;
227
- }
@@ -1,35 +0,0 @@
1
- import { readMessageText, writeMessageText } from "./prompt-text.js";
2
- import { installedPresetPrompts, installedPresetSkillPaths } from "./presets.js";
3
- /** Render the installed presets' prompt text as one labelled block. */
4
- export function renderPresetPrompts(cwd) {
5
- const prompts = installedPresetPrompts(cwd);
6
- if (prompts.length === 0)
7
- return "";
8
- const blocks = prompts.map((preset) => `<preset name="${preset.name}">\n${preset.text}\n</preset>`);
9
- return ["The user installed the following presets. Their rules apply to this session.", "", ...blocks].join("\n");
10
- }
11
- export function createPresetExtension(cwd) {
12
- return (hxf) => {
13
- // `resources_discover` is the SDK's own channel for an extension to hand the
14
- // resource loader extra skill directories, and it is re-emitted on
15
- // `session.reload()` — which is what makes a preset installed mid-session
16
- // visible without restarting the CLI. Registering the paths here rather than
17
- // only through `additionalSkillPaths` also means the loader owns them, so a
18
- // removed preset's skills disappear on the next reload instead of lingering.
19
- hxf.on("resources_discover", async () => {
20
- const skillPaths = installedPresetSkillPaths(cwd);
21
- return skillPaths.length > 0 ? { skillPaths } : undefined;
22
- });
23
- hxf.on("context_with_system", async (event) => {
24
- const block = renderPresetPrompts(cwd);
25
- if (!block)
26
- return undefined;
27
- const head = event.messages[0];
28
- if (!head || head.role !== "system")
29
- return undefined;
30
- const combined = `${readMessageText(head.content)}\n\n${block}`;
31
- const messages = event.messages.map((message) => (message === head ? { ...head, content: writeMessageText(head.content, combined) } : message));
32
- return { messages };
33
- });
34
- };
35
- }
@@ -1,292 +0,0 @@
1
- /**
2
- * MCP servers contributed by a preset.
3
- *
4
- * A preset was skills and prompt text only. That covers knowledge, not
5
- * capability: a preset that needs a tool — a filesystem server, a docs server —
6
- * could do nothing but tell the user to go and configure one by hand, and the
7
- * command for that lives in the SDK's own CLI (`hxf mcp add`), which the product
8
- * does not expose. A catalogue entry that can install the server it needs is the
9
- * difference between "here is how you would do this" and it working.
10
- *
11
- * So `preset.json` may carry an `mcp` object in the same `mcpServers` shape every
12
- * other MCP client uses. Installing the preset merges those servers into the
13
- * agent's `mcp.json`; removing it takes back exactly the ones it added.
14
- *
15
- * Three rules, because this writes configuration that starts processes:
16
- *
17
- * - **Never overwrite a server the user already has.** A preset that replaces
18
- * an existing entry could silently repoint a server at another binary or
19
- * another URL. A name that is taken is reported and skipped, never merged.
20
- * - **Never remove a server someone else still claims.** Removal only drops a
21
- * name when no other installed preset declares it.
22
- * - **Never run anything at install time.** The entry is written, not executed;
23
- * the SDK connects it on the next session, and the user can see it with `/mcp`
24
- * before it ever starts.
25
- */
26
- import { existsSync, readFileSync } from "node:fs";
27
- import { dirname, join } from "node:path";
28
- import { fileURLToPath } from "node:url";
29
- import { writeFileAtomicSync } from "./fs-atomic.js";
30
- import { AGENT_DIR } from "./paths.js";
31
- /** Where the agent's servers live. The same file the SDK's own MCP support reads. */
32
- export function mcpConfigPath(agentDir = AGENT_DIR) {
33
- return join(agentDir, "mcp.json");
34
- }
35
- /**
36
- * Validate the `mcp` block of a manifest.
37
- *
38
- * Returns every problem rather than the first, like the rest of the preset
39
- * validator: an author fixing one at a time is an author making several attempts.
40
- */
41
- export function validateMcpBlock(value) {
42
- const problems = [];
43
- if (value === undefined)
44
- return problems;
45
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
46
- return ["mcp must be an object of server names"];
47
- }
48
- for (const [name, raw] of Object.entries(value)) {
49
- // The SDK's own rule: letters, digits, `_` and `-`. A name outside it would be
50
- // rejected when the config is read, which is a failure at session start
51
- // rather than at install — exactly the kind of thing this check exists to
52
- // catch while the author is still looking.
53
- if (!/^[A-Za-z0-9_-]+$/.test(name)) {
54
- problems.push(`mcp server "${name}" must use only letters, digits, _ and -`);
55
- continue;
56
- }
57
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
58
- problems.push(`mcp server "${name}" must be an object`);
59
- continue;
60
- }
61
- const entry = raw;
62
- const hasCommand = typeof entry.command === "string" && entry.command.trim() !== "";
63
- const hasUrl = typeof entry.url === "string" && entry.url.trim() !== "";
64
- if (!hasCommand && !hasUrl) {
65
- problems.push(`mcp server "${name}" needs either a command or a url`);
66
- }
67
- if (entry.args !== undefined && !Array.isArray(entry.args)) {
68
- problems.push(`mcp server "${name}": args must be an array`);
69
- }
70
- for (const key of ["env", "headers"]) {
71
- const map = entry[key];
72
- if (map === undefined)
73
- continue;
74
- if (typeof map !== "object" || map === null || Array.isArray(map)) {
75
- problems.push(`mcp server "${name}": ${key} must be an object of strings`);
76
- continue;
77
- }
78
- for (const [k, v] of Object.entries(map)) {
79
- if (typeof v !== "string")
80
- problems.push(`mcp server "${name}": ${key}.${k} must be a string`);
81
- }
82
- }
83
- }
84
- return problems;
85
- }
86
- /** Read `mcp.json`, or an empty config when it is missing or unreadable. */
87
- function readConfig(path) {
88
- if (!existsSync(path))
89
- return { mcpServers: {} };
90
- try {
91
- const parsed = JSON.parse(readFileSync(path, "utf8"));
92
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
93
- return { mcpServers: {} };
94
- const servers = parsed.mcpServers;
95
- if (!servers || typeof servers !== "object" || Array.isArray(servers))
96
- return { mcpServers: {} };
97
- return { mcpServers: servers };
98
- }
99
- catch {
100
- // A corrupt file is left alone rather than replaced: it may hold servers the
101
- // user configured, and overwriting it to add one would lose them all. The
102
- // install reports that it could not read the config.
103
- return { mcpServers: {} };
104
- }
105
- }
106
- /**
107
- * Add a preset's servers to `mcp.json`.
108
- *
109
- * An existing name is skipped and reported, never replaced. Returns what was
110
- * added and what was already there, so the install message can name both.
111
- */
112
- export function addMcpServers(servers, agentDir = AGENT_DIR) {
113
- const names = Object.keys(servers);
114
- if (names.length === 0)
115
- return { added: [], skipped: [], problems: [] };
116
- const path = mcpConfigPath(agentDir);
117
- const problems = [];
118
- if (existsSync(path)) {
119
- // Distinguish "no file yet" from "a file this cannot parse": the first is a
120
- // normal install, the second must not be silently overwritten.
121
- try {
122
- JSON.parse(readFileSync(path, "utf8"));
123
- }
124
- catch {
125
- return { added: [], skipped: [], problems: [`${path} is not valid JSON; leaving it alone`] };
126
- }
127
- }
128
- const config = readConfig(path);
129
- const added = [];
130
- const skipped = [];
131
- for (const name of names) {
132
- if (config.mcpServers[name] !== undefined) {
133
- skipped.push(name);
134
- continue;
135
- }
136
- config.mcpServers[name] = servers[name];
137
- added.push(name);
138
- }
139
- if (added.length > 0) {
140
- try {
141
- writeFileAtomicSync(path, `${JSON.stringify(config, null, 2)}\n`);
142
- }
143
- catch (err) {
144
- return { added: [], skipped, problems: [`could not write ${path}: ${err instanceof Error ? err.message : String(err)}`] };
145
- }
146
- }
147
- return { added, skipped, problems };
148
- }
149
- /**
150
- * Take back the named servers, but only the ones nothing else claims.
151
- *
152
- * `claimed` is every server name any *other* installed preset still declares.
153
- * Dropping one of those would break the preset that still needs it, which is a
154
- * silent failure the next time that preset is used.
155
- */
156
- export function removeMcpServers(names, claimed = new Set(), agentDir = AGENT_DIR) {
157
- const path = mcpConfigPath(agentDir);
158
- const problems = [];
159
- if (names.length === 0 || !existsSync(path))
160
- return { removed: [], kept: [], problems };
161
- const config = readConfig(path);
162
- const removed = [];
163
- const kept = [];
164
- for (const name of names) {
165
- if (config.mcpServers[name] === undefined)
166
- continue;
167
- if (claimed.has(name)) {
168
- kept.push(name);
169
- continue;
170
- }
171
- delete config.mcpServers[name];
172
- removed.push(name);
173
- }
174
- if (removed.length > 0) {
175
- try {
176
- writeFileAtomicSync(path, `${JSON.stringify(config, null, 2)}\n`);
177
- }
178
- catch (err) {
179
- return { removed: [], kept, problems: [`could not write ${path}: ${err instanceof Error ? err.message : String(err)}`] };
180
- }
181
- }
182
- return { removed, kept, problems };
183
- }
184
- /** A one-line summary of a server entry, for a menu or a `show`. */
185
- export function describeMcpServer(spec) {
186
- if (typeof spec.url === "string" && spec.url)
187
- return `http: ${spec.url}`;
188
- const command = typeof spec.command === "string" ? spec.command : "";
189
- const args = Array.isArray(spec.args) ? spec.args.filter((a) => typeof a === "string") : [];
190
- return `stdio: ${[command, ...args].join(" ")}`.trim();
191
- }
192
- /** Where the catalogue lives, next to the bundled presets. */
193
- export function mcpHubPath() {
194
- return join(dirname(fileURLToPath(import.meta.url)), "presets", "mcp-hub.json");
195
- }
196
- /**
197
- * Read the catalogue.
198
- *
199
- * Never throws: a missing or malformed document is an empty catalogue with a
200
- * reason, because the menu that calls this is not a place that can fail to open.
201
- */
202
- export function readMcpHub(path = mcpHubPath()) {
203
- const problems = [];
204
- if (!existsSync(path))
205
- return { entries: [], problems: [`no MCP catalogue at ${path}`] };
206
- let parsed;
207
- try {
208
- parsed = JSON.parse(readFileSync(path, "utf8"));
209
- }
210
- catch (err) {
211
- return { entries: [], problems: [`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`] };
212
- }
213
- const raw = parsed?.entries;
214
- if (!Array.isArray(raw))
215
- return { entries: [], problems: [`${path} has no entries array`] };
216
- const entries = [];
217
- for (const item of raw) {
218
- if (typeof item !== "object" || item === null || Array.isArray(item))
219
- continue;
220
- const record = item;
221
- const name = typeof record.name === "string" ? record.name.trim() : "";
222
- if (!name) {
223
- problems.push("an entry has no name");
224
- continue;
225
- }
226
- // The same rule the SDK enforces when it reads `mcp.json`. A name that fails
227
- // it here would fail at connect time, in a session, with no hint that the
228
- // catalogue was the source.
229
- if (!/^[A-Za-z0-9_-]+$/.test(name)) {
230
- problems.push(`"${name}" is not a usable server name`);
231
- continue;
232
- }
233
- const hasCommand = typeof record.command === "string" && record.command.trim() !== "";
234
- const hasUrl = typeof record.url === "string" && record.url.trim() !== "";
235
- if (!hasCommand && !hasUrl) {
236
- problems.push(`"${name}" has neither a command nor a url`);
237
- continue;
238
- }
239
- entries.push({
240
- name,
241
- title: typeof record.title === "string" ? record.title : name,
242
- description: typeof record.description === "string" ? record.description : "",
243
- category: typeof record.category === "string" ? record.category : "other",
244
- preset: typeof record.preset === "string" ? record.preset : undefined,
245
- command: typeof record.command === "string" ? record.command : undefined,
246
- url: typeof record.url === "string" ? record.url : undefined,
247
- args: Array.isArray(record.args) ? record.args.filter((a) => typeof a === "string") : undefined,
248
- env: record.env && typeof record.env === "object" && !Array.isArray(record.env) ? record.env : undefined,
249
- needs: Array.isArray(record.needs) ? record.needs.filter((n) => typeof n === "string") : undefined,
250
- docs: typeof record.docs === "string" ? record.docs : undefined,
251
- });
252
- }
253
- return { entries, problems };
254
- }
255
- /** Categories present in the catalogue, sorted. */
256
- export function mcpHubCategories(entries) {
257
- return [...new Set(entries.map((entry) => entry.category))].sort();
258
- }
259
- /** Entries in one category, sorted by title. */
260
- export function mcpHubIn(entries, category) {
261
- return entries.filter((entry) => entry.category === category).sort((a, b) => a.title.localeCompare(b.title));
262
- }
263
- /** The catalogue entry for a name, or undefined. */
264
- export function mcpHubFind(entries, name) {
265
- return entries.find((entry) => entry.name === name);
266
- }
267
- /** The server definition an entry describes, in the shape `mcp.json` holds. */
268
- export function serverSpecFromEntry(entry) {
269
- const spec = {};
270
- if (entry.command)
271
- spec.command = entry.command;
272
- if (entry.url)
273
- spec.url = entry.url;
274
- if (entry.args && entry.args.length > 0)
275
- spec.args = [...entry.args];
276
- if (entry.env && Object.keys(entry.env).length > 0)
277
- spec.env = { ...entry.env };
278
- if (entry.description)
279
- spec.description = entry.description;
280
- return spec;
281
- }
282
- /**
283
- * The environment variables an entry needs that are not set in this process.
284
- *
285
- * Reported rather than refused: the server is configured with `${NAME}` and the
286
- * SDK reads it at connect time, so a user who sets the variable in their shell
287
- * profile before the next session is fine. Saying which are missing turns a
288
- * server that connects and then fails every call into a sentence.
289
- */
290
- export function missingEnvFor(entry, env = process.env) {
291
- return (entry.needs ?? []).filter((name) => !env[name]);
292
- }