geneseed 4.0.0 → 4.2.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/CHANGELOG.md +66 -0
- package/README.md +2 -2
- package/SHIPPED.md +3 -3
- package/adapters/claude-code/README.md +0 -21
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
- package/adapters/opencode/plugins/geneseed-activity.js +105 -2
- package/adapters/opencode/plugins/geneseed-context.js +96 -1
- package/adapters/opencode/plugins/geneseed-guard.js +64 -1
- package/adapters/opencode/plugins/geneseed-learn.js +127 -14
- package/adapters/opencode/plugins/geneseed-notify.js +46 -1
- package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
- package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
- package/docs/guides/mcp-filesystem.md +2 -2
- package/docs/guides/mcp-gitlab.md +2 -2
- package/docs/guides/mcp-markitdown.md +3 -3
- package/docs/guides/mcp.md +7 -7
- package/docs/guides/project-context.md +1 -1
- package/docs/guides/uninstall.md +2 -0
- package/docs/guides/upgrade.md +2 -0
- package/docs/reference/opencode-plugins.md +9 -0
- package/docs/token-footprint.md +1 -1
- package/docs/understand/take-it-out.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +6 -0
- package/js/build/driver.mjs +47 -11
- package/js/build/emit-claude.mjs +22 -10
- package/js/build/emit-common.mjs +9 -3
- package/js/build/emit-opencode.mjs +18 -11
- package/js/build/source.mjs +1 -0
- package/js/build/version.mjs +10 -3
- package/js/cli-table.json +16 -1
- package/js/hosts/hooks.mjs +4 -0
- package/js/hosts/installs.mjs +1 -1
- package/js/hosts/mcp.mjs +23 -152
- package/js/hosts/native.mjs +38 -6
- package/js/hosts/opencode.mjs +1 -1
- package/js/hosts/owned.mjs +159 -0
- package/js/hosts/settings.mjs +4 -0
- package/js/hosts/usage.mjs +62 -0
- package/js/inspect/checks-repo.mjs +76 -1
- package/js/inspect/cost.mjs +83 -0
- package/js/inspect/doctor.mjs +2 -1
- package/js/inspect/inventory.mjs +1 -1
- package/js/maintain/uninstall.mjs +77 -42
- package/js/web/actions.mjs +15 -77
- package/js/web/api.mjs +74 -4
- package/js/web/gates-api.mjs +44 -0
- package/js/web/handler.mjs +2 -2
- package/js/web/routes.mjs +9 -4
- package/js/web/update-check.mjs +61 -0
- package/js/web/usage-api.mjs +95 -0
- package/package.json +1 -1
- package/registry.json +18 -25
- package/src/AGENT.md.tmpl +0 -1
- package/src/agents/_template.md +2 -1
- package/src/agents/researcher.md +2 -1
- package/src/agents/reviewer.md +9 -4
- package/src/agents/tester.md +3 -3
- package/src/doctrines/rigor.md +8 -7
- package/src/skills/brainstorm/builder-check.md +16 -0
- package/src/skills/brainstorm.md +19 -11
- package/src/skills/daydream/SKILL.md +1 -1
- package/src/skills/daydream/VENDOR.md +4 -0
- package/src/skills/debug/ci.md +4 -4
- package/src/skills/debug.md +4 -4
- package/src/skills/develop.md +15 -4
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/geneseed-code-review.md +13 -3
- package/src/skills/handoff.md +1 -1
- package/src/skills/parallel-agents.md +4 -4
- package/src/skills/pipeline.md +3 -3
- package/src/skills/plan.md +18 -6
- package/src/skills/review-response.md +5 -2
- package/src/skills/security-audit.md +7 -1
- package/src/skills/ship.md +2 -1
- package/src/skills/wayfinder.md +25 -6
- package/src/skills/worktree.md +7 -5
- package/themes/_TEMPLATE.json +0 -1
- package/themes/biker.json +0 -1
- package/themes/commentator.json +0 -1
- package/themes/cyberpunk.json +0 -1
- package/themes/gamer.json +0 -1
- package/themes/imperial.json +0 -1
- package/themes/joker.json +0 -1
- package/themes/marvin.json +0 -1
- package/themes/mean.json +0 -1
- package/themes/military.json +0 -1
- package/themes/neutral.json +0 -1
- package/themes/pirate.json +0 -1
- package/themes/sports.json +0 -1
- package/themes/verstappen.json +0 -1
- package/themes/wizard.json +0 -1
- package/web/dist/assets/{Activity-BCtqxjfy.js → Activity-ClEuDk9q.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Ba3Oo4dE.js → ActivityDetail-CS9IuJlw.js} +1 -1
- package/web/dist/assets/Dashboard-BiRFjkBS.js +2 -0
- package/web/dist/assets/Docs-CD20mrVv.js +5 -0
- package/web/dist/assets/{GreenhouseView-DnJ1Ggp1.js → GreenhouseView-CL3PubNb.js} +1 -1
- package/web/dist/assets/{Installs-CWoNKFBk.js → Installs-BFLOVV0r.js} +2 -2
- package/web/dist/assets/{JournalView-g6-bw4c8.js → JournalView-BvsSkjSn.js} +1 -1
- package/web/dist/assets/{Laws-DkVWR1tb.js → Laws-BVoYOkVW.js} +2 -2
- package/web/dist/assets/{Library-CK9HZzjV.js → Library-Bl7xqOlh.js} +1 -1
- package/web/dist/assets/{Loops-BP98SMOH.js → Loops-Cnpep8FX.js} +1 -1
- package/web/dist/assets/{OperatorHudView-DI_n12M9.js → OperatorHudView-B0AMVmtV.js} +1 -1
- package/web/dist/assets/{Personal-Bk89f13a.js → Personal-CBg_x2kA.js} +1 -1
- package/web/dist/assets/{Tabs-3WuYIE1b.js → Tabs-2gX8I69E.js} +1 -1
- package/web/dist/assets/api-Bp5eW_6m.js +1 -0
- package/web/dist/assets/{drawable-oCUeq9e5.js → drawable-KPn0rKI7.js} +1 -1
- package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
- package/web/dist/assets/index-DoD_g2CM.js +2 -0
- package/web/dist/index.html +3 -3
- package/src/skills/herdr.md +0 -154
- package/web/dist/assets/Dashboard-DpHza68-.js +0 -2
- package/web/dist/assets/Docs-Bw_-UK0O.js +0 -5
- package/web/dist/assets/api-CxgfYu18.js +0 -1
- package/web/dist/assets/index-CiXwChvf.js +0 -2
|
@@ -10,7 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import {
|
|
11
11
|
ALIASES_RE, PLUGIN_SRC, RETIRED_SKILL_IDS, ROOT, SRC, THEMES, WORKFLOW_SRC, aliasesOf, knownSkillIds,
|
|
12
12
|
} from '../build/source.mjs';
|
|
13
|
-
import { CLAUDE_STYLE, liveRecordedHooks } from '../hosts/hosts.mjs';
|
|
13
|
+
import { CLAUDE_STYLE, HOSTS, liveRecordedHooks } from '../hosts/hosts.mjs';
|
|
14
14
|
import { claudeCfg, installTargets } from '../hosts/installs.mjs';
|
|
15
15
|
import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
|
|
16
16
|
import { COMMAND_SET } from '../hosts/opencode.mjs';
|
|
@@ -507,6 +507,81 @@ export function moduleMapProblems() {
|
|
|
507
507
|
return problems;
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Each host's label as the two hand-written tables below spell it, keyed by `HOSTS`' `host` id.
|
|
512
|
+
*
|
|
513
|
+
* Local to this arm rather than a column on `HOSTS` (`js/hosts/hosts.mjs`): `HOSTS` is the
|
|
514
|
+
* machine-facing table (config dirs, project markers, catalog flags), and a doc-table LABEL is
|
|
515
|
+
* a fact about two Markdown files, not about an install.
|
|
516
|
+
*/
|
|
517
|
+
const HOST_LABELS = { opencode: 'OpenCode', claude: 'Claude Code', bob: 'Bob', openclaude: 'OpenClaude' };
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The two hand-written, one-column-per-host tables, and the non-host columns each one carries
|
|
521
|
+
* (a header cell that is neither a `HOST_LABELS` value nor one of these is an unknown label
|
|
522
|
+
* that LOOKS like it was meant to be a host column).
|
|
523
|
+
*/
|
|
524
|
+
const HOST_MATRIX_FILES = [
|
|
525
|
+
{ rel: 'docs/concepts/host-matrix.md', otherCols: new Set(['Capability']) },
|
|
526
|
+
{ rel: 'docs/concepts/enforcement-table.md', otherCols: new Set(['id', 'Pack']) },
|
|
527
|
+
];
|
|
528
|
+
|
|
529
|
+
/** The first `| … |` table row of `text`, as trimmed, non-empty cells — its header. */
|
|
530
|
+
function firstTableHeader(text) {
|
|
531
|
+
for (const line of text.split('\n')) {
|
|
532
|
+
const t = line.trim();
|
|
533
|
+
if (t.startsWith('|')) return t.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* `js/README.md`'s sibling gate for the two hand-written host tables: each one's header row
|
|
540
|
+
* must name exactly the hosts `HOSTS` lists, by their `HOST_LABELS` spelling — no fewer (a new
|
|
541
|
+
* host shipped with no column) and no stray cell that merely LOOKS like a host label (a typo,
|
|
542
|
+
* or a host renamed in `HOST_LABELS` but not in the doc).
|
|
543
|
+
*
|
|
544
|
+
* A host with no `HOST_LABELS` entry is its own problem, independent of either file: nothing
|
|
545
|
+
* in a table header can be compared against a label that does not exist.
|
|
546
|
+
*
|
|
547
|
+
* `root` is a parameter (default: `ROOT`) so a test can point this at a temp copy with one
|
|
548
|
+
* column removed, rather than mutating the real docs tree.
|
|
549
|
+
*/
|
|
550
|
+
export function hostMatrixProblems(root = ROOT) {
|
|
551
|
+
const problems = [];
|
|
552
|
+
const expected = new Set();
|
|
553
|
+
for (const h of HOSTS) {
|
|
554
|
+
const label = HOST_LABELS[h.host];
|
|
555
|
+
if (!label) { problems.push(`[authoring] host ${h.host} has no HOST_LABELS entry`); continue; }
|
|
556
|
+
expected.add(label);
|
|
557
|
+
}
|
|
558
|
+
for (const { rel, otherCols } of HOST_MATRIX_FILES) {
|
|
559
|
+
let text;
|
|
560
|
+
try {
|
|
561
|
+
text = readText(path.join(root, ...rel.split('/')));
|
|
562
|
+
} catch (e) {
|
|
563
|
+
problems.push(`[authoring] ${rel} is unreadable: ${e.message}`);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const header = firstTableHeader(text);
|
|
567
|
+
if (!header) {
|
|
568
|
+
problems.push(`[authoring] ${rel} has no table header row`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const present = new Set(header);
|
|
572
|
+
for (const label of [...expected].sort()) {
|
|
573
|
+
if (!present.has(label)) problems.push(`[authoring] ${rel} has no column for ${label}`);
|
|
574
|
+
}
|
|
575
|
+
for (const cell of header) {
|
|
576
|
+
if (!expected.has(cell) && !otherCols.has(cell)) {
|
|
577
|
+
problems.push(`[authoring] ${rel} column '${cell}' is not a host HOST_LABELS names or `
|
|
578
|
+
+ 'a known non-host column — a stale or misspelled label');
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return problems;
|
|
583
|
+
}
|
|
584
|
+
|
|
510
585
|
/**
|
|
511
586
|
* The AI Harness Scorecard checks this repo passes, written out — the ratchet's floor.
|
|
512
587
|
*
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT ONE INSTALL COSTS EVERY SESSION, read off the files it emitted — the Overview's
|
|
3
|
+
* always-on figure. Characters, not tokens: the console shows ≈ chars ÷ 4 and says it is an
|
|
4
|
+
* estimate, because a tokenizer would be a vendored dependency for a number that only has to
|
|
5
|
+
* be honest about its size.
|
|
6
|
+
*
|
|
7
|
+
* Four parts, each the thing the host loads before the user types:
|
|
8
|
+
* - `carrier` the root instruction file (`AGENT.md`, `CLAUDE.md`, Bob's `rules/geneseed.md` or
|
|
9
|
+
* `AGENTS.md`), CRLF decoded exactly as the smoke test decodes it, so the figure
|
|
10
|
+
* and `CARRIER_CEILING` are the same measurement.
|
|
11
|
+
* - `skills` the name + description every native skill lists (aliases included: they are
|
|
12
|
+
* SKILL.md files the host lists like any other).
|
|
13
|
+
* - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
|
|
14
|
+
* - `context` the session files the context hook injects (untouched seeds already skipped).
|
|
15
|
+
* The per-repo project context is NOT counted: it changes with the working dir.
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { sessionFiles } from '../hosts/hooks-context.mjs';
|
|
20
|
+
import { carriersFor } from '../hosts/installs.mjs';
|
|
21
|
+
import { frontmatter } from '../hosts/memory-files.mjs';
|
|
22
|
+
import { listDir } from '../hosts/hooks-prims.mjs';
|
|
23
|
+
import { isFile, readText } from '../lib/fs.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* THE ANTI-BLOAT CEILING on the carrier, per footprint — the most a root file may weigh with
|
|
27
|
+
* every doctrine pack active. `tests/unit/emit_smoke.test.mjs` gates every emit against it and the
|
|
28
|
+
* console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
|
|
29
|
+
* exactly the measured overrun and say why in that test's docblock, which keeps its history.
|
|
30
|
+
*/
|
|
31
|
+
export const CARRIER_CEILING = { full: 62_076, lean: 38_890 };
|
|
32
|
+
|
|
33
|
+
const decode = (p) => readText(p).split('\r\n').join('\n');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
|
|
37
|
+
* (`—` for one em dash), and `frontmatter()` has already stripped the quotes, so the
|
|
38
|
+
* escapes are decoded here or one character counts as six.
|
|
39
|
+
*/
|
|
40
|
+
function fmValue(raw) {
|
|
41
|
+
if (!raw) return '';
|
|
42
|
+
if (raw.includes('\\')) {
|
|
43
|
+
try { return JSON.parse(`"${raw}"`); } catch { /* not a JSON escape after all: count as written */ }
|
|
44
|
+
}
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** name + description of one spec file; `fallbackName` for agents, whose name is the file stem. */
|
|
49
|
+
function listedChars(file, fallbackName) {
|
|
50
|
+
let fm;
|
|
51
|
+
try { [fm] = frontmatter(decode(file)); } catch { return 0; }
|
|
52
|
+
const name = fmValue(fm.get('name')) || fallbackName;
|
|
53
|
+
return name.length + fmValue(fm.get('description')).length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `root` is the install's root (`installTargets`), `dir` its data dir (`viewCfg`), which holds
|
|
58
|
+
* `skills/`, `agents/` and the session files. Every part that cannot be read counts 0; a missing
|
|
59
|
+
* carrier answers `null` for the whole cost, so the console shows "not measured", not a tiny bar.
|
|
60
|
+
*/
|
|
61
|
+
export function alwaysOnCost({ host, root, dir, footprint }) {
|
|
62
|
+
const carrierPath = carriersFor(host).map((rel) => path.join(root, rel)).find(isFile);
|
|
63
|
+
if (!carrierPath) return null;
|
|
64
|
+
const carrier = decode(carrierPath).length;
|
|
65
|
+
|
|
66
|
+
let skills = 0;
|
|
67
|
+
const skillsDir = path.join(dir, 'skills');
|
|
68
|
+
for (const name of listDir(skillsDir)) {
|
|
69
|
+
const f = path.join(skillsDir, name, 'SKILL.md');
|
|
70
|
+
if (isFile(f)) skills += listedChars(f, name);
|
|
71
|
+
}
|
|
72
|
+
let agents = 0;
|
|
73
|
+
if (host !== 'bob') {
|
|
74
|
+
const agentsDir = path.join(dir, 'agents');
|
|
75
|
+
for (const name of listDir(agentsDir)) {
|
|
76
|
+
if (name.endsWith('.md')) agents += listedChars(path.join(agentsDir, name), name.slice(0, -3));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let context = 0;
|
|
80
|
+
try { for (const s of sessionFiles(dir)) context += s.text.length; } catch { /* none */ }
|
|
81
|
+
|
|
82
|
+
return { carrier, skills, agents, context, ceiling: CARRIER_CEILING[footprint] ?? null };
|
|
83
|
+
}
|
package/js/inspect/doctor.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { authoringProblems, loopProblems } from './checks-authoring.mjs';
|
|
|
42
42
|
import {
|
|
43
43
|
checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
|
|
44
44
|
} from './checks-build.mjs';
|
|
45
|
-
import { moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
|
|
45
|
+
import { hostMatrixProblems, moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
|
|
46
46
|
import { NOTE, isDoctorNote, sortedProblems, sortedUnique, stemOf, withTempDir } from './scan.mjs';
|
|
47
47
|
|
|
48
48
|
/**
|
|
@@ -249,6 +249,7 @@ export function doctorCollect({
|
|
|
249
249
|
problems = problems.concat(ran('openclaude_dual', 'OpenClaude dual harness',
|
|
250
250
|
openclaudeDualHarnessNotes()));
|
|
251
251
|
problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
|
|
252
|
+
problems = problems.concat(ran('host_matrix', 'Host tables', hostMatrixProblems()));
|
|
252
253
|
problems = problems.concat(ran('scorecard', 'Scorecard floor', scorecardProblems()));
|
|
253
254
|
// P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
|
|
254
255
|
// `rituals/harness.py` and compared that against a digest baked into `cli.json`, to catch a
|
package/js/inspect/inventory.mjs
CHANGED
|
@@ -106,7 +106,7 @@ export const SKILL_CLASS = {
|
|
|
106
106
|
// knowledge base, terminal) rather than about the user's code. They sat under
|
|
107
107
|
// `understand` before, which made that class a grab-bag.
|
|
108
108
|
wiki: 'harness', geneseed: 'harness', rule: 'harness', profile: 'harness',
|
|
109
|
-
|
|
109
|
+
'skill-forge': 'harness',
|
|
110
110
|
teach: 'learn', quiz: 'learn', 'learn-mode': 'learn',
|
|
111
111
|
};
|
|
112
112
|
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
resolvePath, settingsFile,
|
|
60
60
|
} from '../hosts/hosts.mjs';
|
|
61
61
|
import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
|
|
62
|
+
import { hashesOf, ownedState, ownedWithin } from '../hosts/owned.mjs';
|
|
62
63
|
import {
|
|
63
64
|
atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
|
|
64
65
|
loadJsonObject, mergeClaudeSettings, opencodeTarget, OPENCODE_SENTINEL, opencodeSentinelWrite,
|
|
@@ -67,7 +68,7 @@ import {
|
|
|
67
68
|
} from '../hosts/settings.mjs';
|
|
68
69
|
import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
|
|
69
70
|
import { indexOfDeepEqual, isDict, jsonDumps, deepEquals } from '../lib/json.mjs';
|
|
70
|
-
import { comparePaths, isAbsolutePath
|
|
71
|
+
import { comparePaths, isAbsolutePath } from '../lib/paths.mjs';
|
|
71
72
|
|
|
72
73
|
const hostSpec = (host) => HOSTS.find((h) => h.host === host);
|
|
73
74
|
|
|
@@ -145,7 +146,7 @@ function pruneAncestors(start, stop) {
|
|
|
145
146
|
|
|
146
147
|
/**
|
|
147
148
|
* The manifest's `owned` list, unlinked one by one with the ancestor prune, returning
|
|
148
|
-
* `[removed, failed]`.
|
|
149
|
+
* `[removed, failed, kept]`.
|
|
149
150
|
*
|
|
150
151
|
* The third copy of this loop in `_harness_mcp.py` and the reason it is one function here:
|
|
151
152
|
* `_uninstall_global` (rooted at the target), `_claude_uninstall` (rooted at the cfg dir) and
|
|
@@ -157,15 +158,28 @@ function pruneAncestors(start, stop) {
|
|
|
157
158
|
* `ownedWithin` first, the same containment boundary deactivate draws: the manifest is a file
|
|
158
159
|
* on disk, and without it an `owned` entry of `../../.bashrc` was unlinked — and the prune
|
|
159
160
|
* then climbed from ITS parent, which never meets `base`, removing empty directories as it went.
|
|
160
|
-
*
|
|
161
|
+
*
|
|
162
|
+
* Task 5 (O-1's uninstall half): `hashes` is the manifest's recorded `hashes` map (`hashesOf`,
|
|
163
|
+
* `{}` for an older manifest — the constraint that keeps a hash-less install's behaviour
|
|
164
|
+
* unchanged). Unless `force`, a rel whose `ownedState` reads `'modified'` is left on disk rather
|
|
165
|
+
* than unlinked and lands in `kept`, not `failed` — it is not an error, the file just is not
|
|
166
|
+
* Geneseed's to delete any more once the user has edited it. `'unknown'` and `'missing'` are
|
|
167
|
+
* NOT `'modified'` and are unlinked exactly as before, so an older, hash-less manifest's
|
|
168
|
+
* uninstall is byte-identical to today's.
|
|
169
|
+
* @returns {[number, string[], string[]]}
|
|
161
170
|
*/
|
|
162
|
-
function unlinkOwned(base, owned, label = '') {
|
|
171
|
+
function unlinkOwned(base, owned, label = '', hashes = {}, force = false) {
|
|
163
172
|
let removed = 0;
|
|
164
173
|
const failed = [];
|
|
174
|
+
const kept = [];
|
|
165
175
|
for (const rel of ownedWithin(base, owned)) {
|
|
166
176
|
const victim = path.join(base, rel);
|
|
167
177
|
try {
|
|
168
178
|
if (isFile(victim)) {
|
|
179
|
+
if (!force && ownedState(base, rel, hashes) === 'modified') {
|
|
180
|
+
kept.push(`${label}${rel}`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
169
183
|
unlinkSync(victim);
|
|
170
184
|
removed += 1;
|
|
171
185
|
pruneAncestors(path.dirname(victim), base);
|
|
@@ -178,24 +192,7 @@ function unlinkOwned(base, owned, label = '') {
|
|
|
178
192
|
failed.push(`${label}${rel} (${e && e.message ? e.message : e})`);
|
|
179
193
|
}
|
|
180
194
|
}
|
|
181
|
-
return [removed, failed];
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
|
|
186
|
-
* `owned` goes through (uninstall, both deactivates).
|
|
187
|
-
*
|
|
188
|
-
* `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
|
|
189
|
-
* boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
|
|
190
|
-
* `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
|
|
191
|
-
* `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
|
|
192
|
-
* concatenated it into `<root>/C:/evil.md`, which passes containment and would then be acted
|
|
193
|
-
* on. `path.resolve` has pathlib's semantics for exactly this case, and collapses `..`
|
|
194
|
-
* besides, which is why the guard runs on the resolved path rather than the lexical one.
|
|
195
|
-
*/
|
|
196
|
-
function ownedWithin(base, rels) {
|
|
197
|
-
const rroot = resolvePath(base);
|
|
198
|
-
return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
|
|
195
|
+
return [removed, failed, kept];
|
|
199
196
|
}
|
|
200
197
|
|
|
201
198
|
/** The two WARNs every reversal prints when an owned file survives. Identical in all three. */
|
|
@@ -204,6 +201,19 @@ function warnSurvivors(failed) {
|
|
|
204
201
|
+ `${failed.length} owned file(s): ${failed.join(', ')}\n`);
|
|
205
202
|
}
|
|
206
203
|
|
|
204
|
+
/**
|
|
205
|
+
* The message a reversal prints for every rel `unlinkOwned` left on disk because the user had
|
|
206
|
+
* edited it and `--force` was not given. Separate from `warnSurvivors`: a kept file is not an
|
|
207
|
+
* error (nothing failed) — it is the user's now, exactly like a refused claim on re-emit, and
|
|
208
|
+
* the uninstall completes around it. So the hint is `--force` on the uninstall itself, not a
|
|
209
|
+
* retry: once it completes there is no install left to rerun against.
|
|
210
|
+
*/
|
|
211
|
+
function warnKept(kept) {
|
|
212
|
+
printErr(`[uninstall] kept ${kept.length} file(s) you edited: ${kept.join(', ')}`
|
|
213
|
+
+ ' — they are yours now (uninstall with --force to delete edited files too)\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Only a FAILED unlink keeps the manifest and markers — a kept edit never does. */
|
|
207
217
|
function warnMarkersKept() {
|
|
208
218
|
printErr('[uninstall] WARN: the manifest and markers were KEPT so '
|
|
209
219
|
+ '`geneseed uninstall` can be retried once the file(s) are '
|
|
@@ -332,11 +342,12 @@ function ownedOf(man) {
|
|
|
332
342
|
* 'absent' and the retry the WARN promises would bounce off `cmdUninstall`'s own gate,
|
|
333
343
|
* stranding the leftovers forever.
|
|
334
344
|
*/
|
|
335
|
-
export function claudeUninstall(cfg, archiveMemory) {
|
|
345
|
+
export function claudeUninstall(cfg, archiveMemory, force = false) {
|
|
336
346
|
const man = claudeReadManifest(cfg);
|
|
337
347
|
const managed = managedOf(man);
|
|
338
|
-
const [removed, failed] = unlinkOwned(cfg, ownedOf(man));
|
|
348
|
+
const [removed, failed, kept] = unlinkOwned(cfg, ownedOf(man), '', hashesOf(man), force);
|
|
339
349
|
if (failed.length) warnSurvivors(failed);
|
|
350
|
+
if (kept.length) warnKept(kept);
|
|
340
351
|
const hooks = managed.settings_hooks || [];
|
|
341
352
|
const sf = settingsFile(cfg, managed);
|
|
342
353
|
const unwired = unwireClaudeSettings(sf, hooks);
|
|
@@ -354,7 +365,7 @@ export function claudeUninstall(cfg, archiveMemory) {
|
|
|
354
365
|
archived = archiveStore(path.join(cfg, 'memory'));
|
|
355
366
|
}
|
|
356
367
|
// `unmerged` reports REALITY: hooks were recorded AND the unwire actually rewrote the file.
|
|
357
|
-
return { removed, unmerged: Boolean(hooks.length) && unwired, archived, failed };
|
|
368
|
+
return { removed, unmerged: Boolean(hooks.length) && unwired, archived, failed, kept };
|
|
358
369
|
}
|
|
359
370
|
|
|
360
371
|
/**
|
|
@@ -364,11 +375,12 @@ export function claudeUninstall(cfg, archiveMemory) {
|
|
|
364
375
|
* install and an OpenCode one can share one entry point: the Claude family has no
|
|
365
376
|
* opencode.json to unmerge and a settings.json plus a managed block instead.
|
|
366
377
|
*/
|
|
367
|
-
export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
|
|
368
|
-
if (CLAUDE_STYLE.includes(host)) return claudeUninstall(target, archiveMemory);
|
|
378
|
+
export function uninstallGlobal(target, archiveMemory, host = 'opencode', force = false) {
|
|
379
|
+
if (CLAUDE_STYLE.includes(host)) return claudeUninstall(target, archiveMemory, force);
|
|
369
380
|
const man = claudeReadManifest(target);
|
|
370
|
-
const [removed, failed] = unlinkOwned(target, ownedOf(man));
|
|
381
|
+
const [removed, failed, kept] = unlinkOwned(target, ownedOf(man), '', hashesOf(man), force);
|
|
371
382
|
if (failed.length) warnSurvivors(failed);
|
|
383
|
+
if (kept.length) warnKept(kept);
|
|
372
384
|
for (const d of ['agents', 'skills', 'plugins']) {
|
|
373
385
|
const p = path.join(target, d);
|
|
374
386
|
if (isDir(p) && isEmptyDir(p)) rmdirQuiet(p);
|
|
@@ -382,7 +394,7 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
|
|
|
382
394
|
if (archiveMemory && isDir(path.join(target, 'memory'))) {
|
|
383
395
|
archived = archiveStore(path.join(target, 'memory'));
|
|
384
396
|
}
|
|
385
|
-
return { removed, unmerged, archived, failed };
|
|
397
|
+
return { removed, unmerged, archived, failed, kept };
|
|
386
398
|
}
|
|
387
399
|
|
|
388
400
|
/**
|
|
@@ -431,19 +443,22 @@ export function installAgentEntryOf(instr) {
|
|
|
431
443
|
* describing a file that no longer exists, which the fallback would then paper over with the
|
|
432
444
|
* canonical spelling — and a bundle sub-dir layout would be left wired.
|
|
433
445
|
*/
|
|
434
|
-
function opencodeProjectUninstall(root) {
|
|
446
|
+
function opencodeProjectUninstall(root, force = false) {
|
|
435
447
|
const entry = installAgentEntry(root, 'project');
|
|
436
448
|
let removed = 0;
|
|
437
449
|
let failed = [];
|
|
450
|
+
let kept = [];
|
|
438
451
|
let man = null;
|
|
439
452
|
const oc = path.join(root, '.opencode');
|
|
440
453
|
const manifestPath = path.join(oc, GLOBAL_MANIFEST);
|
|
441
454
|
if (isFile(manifestPath)) {
|
|
442
455
|
man = claudeReadManifest(oc);
|
|
443
|
-
[removed, failed] = unlinkOwned(oc, ownedOf(man), '.opencode/');
|
|
456
|
+
[removed, failed, kept] = unlinkOwned(oc, ownedOf(man), '.opencode/', hashesOf(man), force);
|
|
444
457
|
if (failed.length) warnSurvivors(failed);
|
|
458
|
+
if (kept.length) warnKept(kept);
|
|
445
459
|
// Survivors gate, mirroring the other two reversals: `_project_qualifies` keys off the
|
|
446
460
|
// manifest, so deleting it while an owned file survives makes the install unfindable.
|
|
461
|
+
// A kept (edited) file is not a survivor: it is the user's now, not this install's.
|
|
447
462
|
if (!failed.length) {
|
|
448
463
|
unlinkQuiet(manifestPath);
|
|
449
464
|
if (isDir(oc) && isEmptyDir(oc)) rmdirQuiet(oc);
|
|
@@ -464,6 +479,7 @@ function opencodeProjectUninstall(root) {
|
|
|
464
479
|
man ? skillPermissionNames(man) : []);
|
|
465
480
|
const result = { removed, unmerged, archived: null };
|
|
466
481
|
if (failed.length) result.failed = failed;
|
|
482
|
+
if (kept.length) result.kept = kept;
|
|
467
483
|
return result;
|
|
468
484
|
}
|
|
469
485
|
|
|
@@ -506,8 +522,16 @@ export function ownedDirsFor(root, host, scope, data) {
|
|
|
506
522
|
* plugins), so a locked owned file OUTSIDE those (Bob's `rules/geneseed.md`) would slip the
|
|
507
523
|
* gate while the reversal kept its manifest, and deleting the root markers here would then
|
|
508
524
|
* contradict that "KEPT" promise. The reversal's own `failed` list closes that hole.
|
|
525
|
+
*
|
|
526
|
+
* A KEPT file (Task 5: the user edited it, no `--force`) is the opposite case: it is the
|
|
527
|
+
* user's now, exactly like a refused claim on re-emit, so it must NOT hold the install open —
|
|
528
|
+
* a manifest kept for it left the install 'active' and the next `rebuild-all`/`update`
|
|
529
|
+
* re-wired hooks into a repo the user had uninstalled. So a watched directory whose only
|
|
530
|
+
* remaining files are kept ones is not a survivor, and `kept` never gates the markers; it is
|
|
531
|
+
* returned so a caller (the web's remove) can name the files and the `--force` way out.
|
|
509
532
|
*/
|
|
510
|
-
export function installUninstall(root, host = 'opencode', scope = 'global', memory = 'keep'
|
|
533
|
+
export function installUninstall(root, host = 'opencode', scope = 'global', memory = 'keep',
|
|
534
|
+
force = false) {
|
|
511
535
|
if (!['keep', 'archive', 'delete'].includes(memory)) memory = 'keep';
|
|
512
536
|
if (installState(root, host, scope) === 'absent') {
|
|
513
537
|
return { ok: false, error: 'nothing installed here' };
|
|
@@ -517,13 +541,13 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
517
541
|
// install's, and they go with it.
|
|
518
542
|
let summary;
|
|
519
543
|
if (CLAUDE_STYLE.includes(host)) {
|
|
520
|
-
summary = claudeUninstall(data, false);
|
|
544
|
+
summary = claudeUninstall(data, false, force);
|
|
521
545
|
rmtreeQuiet(path.join(data, DISABLED_STASH, host));
|
|
522
546
|
} else if (installKind(root) === 'global') {
|
|
523
|
-
summary = uninstallGlobal(root, false, 'opencode');
|
|
547
|
+
summary = uninstallGlobal(root, false, 'opencode', force);
|
|
524
548
|
rmtreeQuiet(path.join(root, DISABLED_STASH));
|
|
525
549
|
} else {
|
|
526
|
-
summary = opencodeProjectUninstall(root);
|
|
550
|
+
summary = opencodeProjectUninstall(root, force);
|
|
527
551
|
rmtreeQuiet(path.join(root, DISABLED_STASH));
|
|
528
552
|
}
|
|
529
553
|
// 2. Memory + notebook, independent of the owned-file removal above.
|
|
@@ -536,7 +560,14 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
536
560
|
}
|
|
537
561
|
// 3. The root markers, but only once the reversal's dirs are actually gone.
|
|
538
562
|
const failed = summary.failed || [];
|
|
539
|
-
const
|
|
563
|
+
const kept = summary.kept || [];
|
|
564
|
+
// `kept` rels are spelled relative to the reversal's own base: the cfg dir for the Claude
|
|
565
|
+
// family, the root for OpenCode (whose project reversal already prefixes `.opencode/`).
|
|
566
|
+
const keptAbs = new Set(kept.map((r) => path.join(CLAUDE_STYLE.includes(host) ? data : root, r)));
|
|
567
|
+
const survivors = ownedDirsFor(root, host, scope, data).filter((d) => existsSync(d)
|
|
568
|
+
&& !(keptAbs.size && readdirSync(d, { recursive: true, withFileTypes: true })
|
|
569
|
+
.every((e) => e.isDirectory() || keptAbs.has(path.join(e.parentPath, e.name)))));
|
|
570
|
+
const withKept = (o) => (kept.length ? { ...o, kept } : o);
|
|
540
571
|
if (survivors.length || failed.length) {
|
|
541
572
|
if (survivors.length && !failed.length) {
|
|
542
573
|
// With `failed` set the reversal already warned twice — no third overlapping WARN.
|
|
@@ -544,12 +575,15 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
544
575
|
+ `${survivors.join(', ')}. The install marker was KEPT so you can retry `
|
|
545
576
|
+ '`geneseed uninstall` once the file(s) are unlocked/removable.\n');
|
|
546
577
|
}
|
|
547
|
-
|
|
578
|
+
// `incomplete` names every reason the root markers stayed — directory survivors and a
|
|
579
|
+
// failed unlink — deduped.
|
|
580
|
+
const named = survivors.concat(failed);
|
|
581
|
+
const out = withKept({
|
|
548
582
|
ok: true,
|
|
549
583
|
removed: summary.removed ?? 0,
|
|
550
584
|
memory,
|
|
551
|
-
incomplete:
|
|
552
|
-
};
|
|
585
|
+
incomplete: named.filter((f, i) => named.indexOf(f) === i),
|
|
586
|
+
});
|
|
553
587
|
if (archived.length) out.archived = archived;
|
|
554
588
|
return out;
|
|
555
589
|
}
|
|
@@ -623,7 +657,7 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
623
657
|
}
|
|
624
658
|
// 4. Tidy an emptied marker dir (.claude/.bob/.openclaude) so no husk lingers in the repo.
|
|
625
659
|
if (data !== root && isDir(data) && isEmptyDir(data)) rmdirQuiet(data);
|
|
626
|
-
const out = { ok: true, removed: summary.removed ?? 0, memory };
|
|
660
|
+
const out = withKept({ ok: true, removed: summary.removed ?? 0, memory });
|
|
627
661
|
if (archived.length) out.archived = archived;
|
|
628
662
|
return out;
|
|
629
663
|
}
|
|
@@ -806,7 +840,7 @@ export function cmdUninstall(args) {
|
|
|
806
840
|
}
|
|
807
841
|
}
|
|
808
842
|
const memory = args.archiveMemory ? 'archive' : 'keep';
|
|
809
|
-
const s = installUninstall(root, host, scope, memory);
|
|
843
|
+
const s = installUninstall(root, host, scope, memory, Boolean(args.force));
|
|
810
844
|
if (!(s.ok ?? true)) {
|
|
811
845
|
printErr(`[uninstall] failed: ${s.error || 'unknown error'}\n`);
|
|
812
846
|
return 1;
|
|
@@ -820,7 +854,8 @@ export function cmdUninstall(args) {
|
|
|
820
854
|
+ 'the install marker was kept — retry `geneseed uninstall` once they\'re '
|
|
821
855
|
+ `removable. ${cfgfile} updated where needed; memory/notebook ${mem}.\n`);
|
|
822
856
|
} else {
|
|
823
|
-
|
|
857
|
+
const keptNote = s.kept ? `, kept ${s.kept.length} you edited` : '';
|
|
858
|
+
printOut(`[uninstall] done — removed ${s.removed} file(s)${keptNote}; ${cfgfile} updated where `
|
|
824
859
|
+ `needed; memory/notebook ${mem}. Start a new session to apply.\n`);
|
|
825
860
|
}
|
|
826
861
|
if (scope === 'global') printSurvivingProjectInventory(root);
|
package/js/web/actions.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { setupBuildArgs } from '../build/generate.mjs';
|
|
|
42
42
|
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
43
43
|
import { activeLoops, setLoopPreset } from '../loop/registry.mjs';
|
|
44
44
|
import {
|
|
45
|
-
|
|
45
|
+
HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
|
|
46
46
|
opencodeConfigDir, resolvePath,
|
|
47
47
|
} from '../hosts/hosts.mjs';
|
|
48
48
|
import {
|
|
@@ -50,11 +50,10 @@ import {
|
|
|
50
50
|
installState, installTargets, legacyProcessCarrierOfDir, modeOfDir, postureOfDir, trustOfDir,
|
|
51
51
|
} from '../hosts/installs.mjs';
|
|
52
52
|
import {
|
|
53
|
-
|
|
54
|
-
mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
|
|
53
|
+
isDict, mcpCommented, mcpInstallTargets, mcpLoad, mcpServerNames, mcpState,
|
|
55
54
|
} from '../hosts/mcp.mjs';
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
55
|
+
import { isFile, isDir } from '../lib/fs.mjs';
|
|
56
|
+
import { formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
|
|
58
57
|
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
59
58
|
import { normcase, which, within } from '../lib/paths.mjs';
|
|
60
59
|
import { stripWhitespace } from '../lib/text.mjs';
|
|
@@ -348,18 +347,20 @@ export function apiLoopsPresetMutate(state, body) {
|
|
|
348
347
|
|
|
349
348
|
// ---- MCP ----------------------------------------------------------------------------------
|
|
350
349
|
|
|
350
|
+
/**
|
|
351
|
+
* The MCP servers each active install ALREADY has, and nothing it could have. Read-only by
|
|
352
|
+
* decision (2026-10): which servers to wire depends on the user and on the host, so the console
|
|
353
|
+
* shows what is in each config and where that file lives, and the user edits it. There is no
|
|
354
|
+
* preset to add and no toggle, so nothing here ever rewrites a config — including
|
|
355
|
+
* `~/.claude.json`, which holds far more than MCP wiring.
|
|
356
|
+
*/
|
|
351
357
|
export function apiMcp() {
|
|
352
358
|
const out = [];
|
|
353
359
|
for (const [label, p, host, scope, root] of mcpInstallTargets()) {
|
|
354
360
|
if (installState(root, host, scope) !== 'active') continue;
|
|
355
361
|
const cfg = mcpLoad(p, host);
|
|
356
|
-
const servers =
|
|
357
|
-
|
|
358
|
-
const [lbl, desc] = mcpMeta(name);
|
|
359
|
-
servers.push({ name, label: lbl, desc,
|
|
360
|
-
preset: Object.hasOwn(MCP_PRESETS, name),
|
|
361
|
-
state: mcpState(cfg, name, host) });
|
|
362
|
-
}
|
|
362
|
+
const servers = mcpServerNames(cfg, host)
|
|
363
|
+
.map((name) => ({ name, state: mcpState(cfg, name, host) }));
|
|
363
364
|
out.push({ label, path: p, host, root, exists: isFile(p),
|
|
364
365
|
commented: mcpCommented(p), servers });
|
|
365
366
|
}
|
|
@@ -370,10 +371,8 @@ export function apiMcp() {
|
|
|
370
371
|
/**
|
|
371
372
|
* The MCP config paths a request body is allowed to name, as `path -> [path, host]`.
|
|
372
373
|
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
* endpoint exactly as it is of the toggle: an unlisted path must 404 before anything is opened.
|
|
376
|
-
* Two copies of a security check drift, and the copy that drifts is the one nobody re-read.
|
|
374
|
+
* `/api/reveal` hands a path to the desktop's file manager, and this allowlist is the whole
|
|
375
|
+
* security of that endpoint: an unlisted path must 404 before anything is opened.
|
|
377
376
|
*
|
|
378
377
|
* It is rebuilt per call rather than cached: installs come and go while the daemon runs, and a
|
|
379
378
|
* stale allowlist is wrong in both directions — refusing a real target, or naming a dead one.
|
|
@@ -386,67 +385,6 @@ export function mcpTargetPaths() {
|
|
|
386
385
|
return known;
|
|
387
386
|
}
|
|
388
387
|
|
|
389
|
-
/**
|
|
390
|
-
* Enable / disable / first-add one server.
|
|
391
|
-
*
|
|
392
|
-
* THE PATH COMES OUT OF THE REQUEST BODY, so the allowlist is the whole security of this
|
|
393
|
-
* endpoint: `known` is built from the detected install targets and an unlisted path is a 404
|
|
394
|
-
* before anything is read or written. Without it this is "write arbitrary JSON to an
|
|
395
|
-
* arbitrary file", behind a CSRF token and nothing else.
|
|
396
|
-
*
|
|
397
|
-
* The Claude-shaped hosts are parsed ONCE, STRICTLY, and that exact object is what gets
|
|
398
|
-
* rewritten — so the safety check and the value saved come from the same read, with no
|
|
399
|
-
* time-of-check/time-of-use gap and no comment-stripper touching a string that contains
|
|
400
|
-
* `,]`. A file that will not parse is refused rather than clobbered: it may be
|
|
401
|
-
* `~/.claude.json`, which holds projects and history far beyond MCP wiring.
|
|
402
|
-
*/
|
|
403
|
-
export function apiMcpToggle(state, body) {
|
|
404
|
-
const name = strOr(bget(body, 'name'));
|
|
405
|
-
const want = isTruthy(bget(body, 'enabled'));
|
|
406
|
-
const pathArg = strOr(bget(body, 'path'));
|
|
407
|
-
const known = mcpTargetPaths();
|
|
408
|
-
const hit = known.get(pathArg);
|
|
409
|
-
if (hit === undefined || !name) {
|
|
410
|
-
throw new NotFound(`mcp target ${pathArg || '(none)'}`);
|
|
411
|
-
}
|
|
412
|
-
const [p, host] = hit;
|
|
413
|
-
if (mcpCommented(p)) {
|
|
414
|
-
return { ok: false, error: 'config holds comments — edit it by hand to keep them' };
|
|
415
|
-
}
|
|
416
|
-
let cfg;
|
|
417
|
-
if (CLAUDE_STYLE.includes(host)) {
|
|
418
|
-
if (isFile(p)) {
|
|
419
|
-
let parsed;
|
|
420
|
-
try {
|
|
421
|
-
parsed = parseJson(readText(p));
|
|
422
|
-
} catch {
|
|
423
|
-
parsed = null;
|
|
424
|
-
}
|
|
425
|
-
if (!isDict(parsed)) {
|
|
426
|
-
return { ok: false,
|
|
427
|
-
error: "couldn't parse this config — edit it by hand to avoid data loss" };
|
|
428
|
-
}
|
|
429
|
-
cfg = parsed;
|
|
430
|
-
} else {
|
|
431
|
-
cfg = {};
|
|
432
|
-
}
|
|
433
|
-
} else {
|
|
434
|
-
cfg = mcpLoad(p);
|
|
435
|
-
}
|
|
436
|
-
if (mcpState(cfg, name, host) === 'absent') {
|
|
437
|
-
if (!Object.hasOwn(MCP_PRESETS, name)) {
|
|
438
|
-
return { ok: false, error: `unknown server '${name}'` };
|
|
439
|
-
}
|
|
440
|
-
if (!want) return { ok: false, error: `'${name}' is not configured` };
|
|
441
|
-
cfg = mcpApply(cfg, name, mcpPresetBlock(name, host), host);
|
|
442
|
-
cfg = mcpSetEnabled(cfg, name, true, host);
|
|
443
|
-
} else {
|
|
444
|
-
cfg = mcpSetEnabled(cfg, name, want, host);
|
|
445
|
-
}
|
|
446
|
-
mcpSave(p, cfg);
|
|
447
|
-
return { ok: true, name, state: mcpState(cfg, name, host) };
|
|
448
|
-
}
|
|
449
|
-
|
|
450
388
|
// ---- the harness selector ------------------------------------------------------------------
|
|
451
389
|
|
|
452
390
|
/**
|