@jhizzard/termdeck 0.7.3 → 0.9.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/docs/orchestrator-guide.md +335 -0
- package/package.json +3 -1
- package/packages/cli/src/auto-orchestrate.js +28 -22
- package/packages/cli/src/index.js +55 -11
- package/packages/cli/src/init-project.js +213 -0
- package/packages/cli/src/init-rumen.js +30 -33
- package/packages/cli/src/mcp-config.js +174 -0
- package/packages/cli/src/stack.js +61 -11
- package/packages/cli/src/templates.js +84 -0
- package/packages/cli/templates/.claude-settings.json.tmpl +32 -0
- package/packages/cli/templates/.gitignore.tmpl +28 -0
- package/packages/cli/templates/CLAUDE.md.tmpl +35 -0
- package/packages/cli/templates/CONTRADICTIONS.md.tmpl +30 -0
- package/packages/cli/templates/README.md.tmpl +15 -0
- package/packages/cli/templates/RESTART-PROMPT.md.tmpl +38 -0
- package/packages/cli/templates/docs-orchestration-README.md.tmpl +29 -0
- package/packages/cli/templates/project_facts.md.tmpl +39 -0
- package/packages/client/public/app.js +895 -2
- package/packages/client/public/index.html +144 -0
- package/packages/client/public/style.css +931 -52
- package/packages/server/src/config.js +96 -0
- package/packages/server/src/index.js +198 -10
- package/packages/server/src/orchestration-preview.js +256 -0
- package/packages/server/src/rag.js +43 -0
- package/packages/server/src/sprint-inject.js +156 -0
- package/packages/server/src/sprint-routes.js +503 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// `termdeck init --project <name>` — Sprint 37 T2.
|
|
4
|
+
//
|
|
5
|
+
// Scaffolds a new project directory with the orchestration patterns TermDeck
|
|
6
|
+
// itself uses: CLAUDE.md (router), CONTRADICTIONS.md (audit trail),
|
|
7
|
+
// project_facts.md (stable facts), README.md, docs/orchestration/ (sprint +
|
|
8
|
+
// restart-prompt scaffolding), .claude/settings.json (sensible permission
|
|
9
|
+
// defaults), and .gitignore. All content comes from packages/cli/templates/
|
|
10
|
+
// rendered with {{placeholder}} substitution via packages/cli/src/templates.js.
|
|
11
|
+
//
|
|
12
|
+
// Public API (used by the CLI entry and by tests):
|
|
13
|
+
// initProject({ name, dryRun, force, cwd }) -> Promise<{ exitCode, files }>
|
|
14
|
+
//
|
|
15
|
+
// CLI shim (used by index.js dispatch):
|
|
16
|
+
// main(argv) -> Promise<exitCode>
|
|
17
|
+
//
|
|
18
|
+
// `argv` here is everything AFTER `init --project` in the original argv —
|
|
19
|
+
// i.e. for `termdeck init --project hello --dry-run`, argv is
|
|
20
|
+
// `['hello', '--dry-run']`.
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const { listTemplates, renderTemplate, TEMPLATES_DIR } = require(path.join(__dirname, 'templates.js'));
|
|
28
|
+
|
|
29
|
+
// Project name validation: lowercase letters, digits, hyphens, optional
|
|
30
|
+
// scoped prefix (@org/name) is intentionally NOT supported here — the user
|
|
31
|
+
// would clone the result and rename if they want a scoped npm package.
|
|
32
|
+
const NAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
33
|
+
|
|
34
|
+
function validateName(name) {
|
|
35
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
36
|
+
return 'Project name is required.';
|
|
37
|
+
}
|
|
38
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
39
|
+
return `Project name "${name}" must not contain slashes or "..".`;
|
|
40
|
+
}
|
|
41
|
+
if (!NAME_RE.test(name)) {
|
|
42
|
+
return `Project name "${name}" must be lowercase letters, digits, and hyphens (no leading/trailing hyphen).`;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readTermdeckVersion() {
|
|
48
|
+
try {
|
|
49
|
+
const pkg = require(path.join(__dirname, '..', '..', '..', 'package.json'));
|
|
50
|
+
return pkg.version || '0.0.0';
|
|
51
|
+
} catch (_e) {
|
|
52
|
+
return '0.0.0';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildVars({ name, projectPath }) {
|
|
57
|
+
return {
|
|
58
|
+
project_name: name,
|
|
59
|
+
project_path: projectPath,
|
|
60
|
+
generated_at: new Date().toISOString(),
|
|
61
|
+
termdeck_version: readTermdeckVersion(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ensureDir(dir) {
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Returns true if the directory either does not exist or exists and is empty.
|
|
70
|
+
function isEmptyOrMissing(dir) {
|
|
71
|
+
if (!fs.existsSync(dir)) return true;
|
|
72
|
+
const stat = fs.statSync(dir);
|
|
73
|
+
if (!stat.isDirectory()) return false;
|
|
74
|
+
return fs.readdirSync(dir).length === 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function previewSnippet(content, headLines = 5) {
|
|
78
|
+
const lines = content.split('\n');
|
|
79
|
+
const head = lines.slice(0, headLines).join('\n');
|
|
80
|
+
const remaining = Math.max(0, lines.length - headLines);
|
|
81
|
+
return remaining === 0 ? head : `${head}\n... (${remaining} more line${remaining === 1 ? '' : 's'})`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function initProject(opts) {
|
|
85
|
+
const { name, dryRun = false, force = false, cwd = process.cwd() } = opts || {};
|
|
86
|
+
|
|
87
|
+
const nameError = validateName(name);
|
|
88
|
+
if (nameError) {
|
|
89
|
+
process.stderr.write(`\n ✗ ${nameError}\n\n`);
|
|
90
|
+
return { exitCode: 1, files: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const projectPath = path.resolve(cwd, name);
|
|
94
|
+
|
|
95
|
+
if (!dryRun && !force && !isEmptyOrMissing(projectPath)) {
|
|
96
|
+
process.stderr.write(`\n ✗ Target ${projectPath} exists and is not empty. Use --force to overwrite, or pick a new name.\n\n`);
|
|
97
|
+
return { exitCode: 1, files: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const vars = buildVars({ name, projectPath });
|
|
101
|
+
const templates = listTemplates();
|
|
102
|
+
const written = [];
|
|
103
|
+
|
|
104
|
+
if (dryRun) {
|
|
105
|
+
process.stdout.write(`\n [dry-run] Would create ${projectPath}/ with ${templates.length} files:\n\n`);
|
|
106
|
+
} else {
|
|
107
|
+
ensureDir(projectPath);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const entry of templates) {
|
|
111
|
+
const dest = path.join(projectPath, entry.targetPath);
|
|
112
|
+
const rendered = renderTemplate(entry.name, vars);
|
|
113
|
+
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
process.stdout.write(` • ${entry.targetPath}\n`);
|
|
116
|
+
const indented = previewSnippet(rendered).split('\n').map((l) => ` ${l}`).join('\n');
|
|
117
|
+
process.stdout.write(`${indented}\n\n`);
|
|
118
|
+
written.push({ template: entry.file, name: entry.name, dest, bytes: Buffer.byteLength(rendered, 'utf8') });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ensureDir(path.dirname(dest));
|
|
123
|
+
fs.writeFileSync(dest, rendered);
|
|
124
|
+
written.push({ template: entry.file, name: entry.name, dest, bytes: Buffer.byteLength(rendered, 'utf8') });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (dryRun) {
|
|
128
|
+
process.stdout.write(` [dry-run] Nothing was written. Re-run without --dry-run to scaffold.\n\n`);
|
|
129
|
+
} else {
|
|
130
|
+
process.stdout.write(`
|
|
131
|
+
Created ${name}/ at ${projectPath}.
|
|
132
|
+
|
|
133
|
+
Next steps:
|
|
134
|
+
cd ${name}
|
|
135
|
+
git init
|
|
136
|
+
# Open ${name}/ in TermDeck — it will pick up the .claude/settings.json automatically.
|
|
137
|
+
# Read CLAUDE.md to see the agent read-order for this project.
|
|
138
|
+
|
|
139
|
+
`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { exitCode: 0, files: written };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// CLI shim. Parses argv and calls initProject(). The dispatch in
|
|
146
|
+
// packages/cli/src/index.js strips the leading `init --project` tokens.
|
|
147
|
+
async function main(argv) {
|
|
148
|
+
const args = argv || [];
|
|
149
|
+
|
|
150
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
151
|
+
process.stdout.write(HELP);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// First positional argument that isn't a flag is the project name.
|
|
156
|
+
let name = null;
|
|
157
|
+
let dryRun = false;
|
|
158
|
+
let force = false;
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < args.length; i++) {
|
|
161
|
+
const tok = args[i];
|
|
162
|
+
if (tok === '--dry-run') { dryRun = true; continue; }
|
|
163
|
+
if (tok === '--force') { force = true; continue; }
|
|
164
|
+
if (tok === '--name' && args[i + 1]) { name = args[i + 1]; i++; continue; }
|
|
165
|
+
if (tok.startsWith('--')) {
|
|
166
|
+
process.stderr.write(`\n ✗ Unknown flag: ${tok}\n${HELP}`);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
if (name === null) {
|
|
170
|
+
name = tok;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
process.stderr.write(`\n ✗ Unexpected extra argument: ${tok}\n${HELP}`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!name) {
|
|
178
|
+
process.stderr.write(`\n ✗ Missing project name.\n${HELP}`);
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { exitCode } = await initProject({ name, dryRun, force, cwd: process.cwd() });
|
|
183
|
+
return exitCode;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const HELP = `
|
|
187
|
+
TermDeck Project Scaffolder
|
|
188
|
+
|
|
189
|
+
Usage: termdeck init --project <name> [flags]
|
|
190
|
+
|
|
191
|
+
Flags:
|
|
192
|
+
--dry-run Print what would be created; write nothing
|
|
193
|
+
--force Overwrite an existing non-empty target directory
|
|
194
|
+
--help, -h Print this message and exit
|
|
195
|
+
|
|
196
|
+
What this does:
|
|
197
|
+
Creates <name>/ in the current directory with a project skeleton:
|
|
198
|
+
CLAUDE.md Agent read-order router
|
|
199
|
+
CONTRADICTIONS.md Audit trail of changed facts/decisions
|
|
200
|
+
project_facts.md Stable per-project facts
|
|
201
|
+
README.md Human-facing intro
|
|
202
|
+
docs/orchestration/ Sprint + restart-prompt scaffolding
|
|
203
|
+
.claude/settings.json Sensible Claude Code permission defaults
|
|
204
|
+
.gitignore Standard Node + .DS_Store + .termdeck/
|
|
205
|
+
|
|
206
|
+
Templates live in packages/cli/templates/ and use {{placeholder}} substitution.
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
module.exports = main;
|
|
210
|
+
module.exports.initProject = initProject;
|
|
211
|
+
module.exports._validateName = validateName;
|
|
212
|
+
module.exports._buildVars = buildVars;
|
|
213
|
+
module.exports.TEMPLATES_DIR = TEMPLATES_DIR;
|
|
@@ -38,6 +38,12 @@ const {
|
|
|
38
38
|
preconditions
|
|
39
39
|
} = require(SETUP_DIR);
|
|
40
40
|
|
|
41
|
+
const {
|
|
42
|
+
CLAUDE_MCP_PATH_CANONICAL,
|
|
43
|
+
readMcpServers,
|
|
44
|
+
writeMcpServers,
|
|
45
|
+
} = require('./mcp-config');
|
|
46
|
+
|
|
41
47
|
// Pinned fallback used only when the npm registry is unreachable. Bump this
|
|
42
48
|
// when you republish @jhizzard/rumen and can't (or won't) rely on `npm view`
|
|
43
49
|
// at deploy time. The wizard prefers the live registry answer — this value
|
|
@@ -470,28 +476,29 @@ async function applySchedule(projectRef, secrets, dryRun) {
|
|
|
470
476
|
}
|
|
471
477
|
}
|
|
472
478
|
|
|
473
|
-
// Backfill SUPABASE_ACCESS_TOKEN into ~/.claude
|
|
479
|
+
// Backfill SUPABASE_ACCESS_TOKEN into ~/.claude.json's Supabase MCP
|
|
474
480
|
// server entry. Background: the meta-installer (`@jhizzard/termdeck-stack`)
|
|
475
481
|
// writes `SUPABASE_ACCESS_TOKEN: 'SUPABASE_PAT_HERE'` as a literal
|
|
476
482
|
// placeholder when it wires the Supabase MCP entry. The user is expected
|
|
477
483
|
// to replace it after install. v0.6.4 unblocked the Rumen install path by
|
|
478
484
|
// telling users to `export SUPABASE_ACCESS_TOKEN=sbp_...` in their shell —
|
|
479
485
|
// but that token only got used for `supabase link`, never propagated into
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
486
|
+
// the MCP config. So Brad's Claude Code was talking to a Supabase MCP
|
|
487
|
+
// server with a placeholder token. Reported 2026-04-26.
|
|
488
|
+
//
|
|
489
|
+
// Sprint 36 T2: default target moved from ~/.claude/mcp.json (legacy) to
|
|
490
|
+
// ~/.claude.json (canonical — what Claude Code v2.1.119+ actually reads).
|
|
491
|
+
// Internal write goes through writeMcpServers so the ~55 unrelated
|
|
492
|
+
// top-level keys Claude Code stores in ~/.claude.json (oauthAccount,
|
|
493
|
+
// projects, installMethod, …) are preserved byte-equivalent.
|
|
485
494
|
//
|
|
486
|
-
//
|
|
487
|
-
// - Only runs if
|
|
495
|
+
// Idempotent and conservative:
|
|
496
|
+
// - Only runs if a token is provided via env or arg
|
|
488
497
|
// - Only updates when the existing value is the literal placeholder
|
|
489
498
|
// 'SUPABASE_PAT_HERE' — preserves any real token the user already set
|
|
490
|
-
// - No-op when
|
|
491
|
-
// meta-installer's Tier 4) or when there's no `supabase` MCP entry
|
|
499
|
+
// - No-op when the file doesn't exist or has no `supabase` entry
|
|
492
500
|
// - No-op (with a soft warning) when the JSON is malformed
|
|
493
|
-
// - Atomic write via tmp-and-rename; mode 0600
|
|
494
|
-
// existing permissions (it already holds the placeholder)
|
|
501
|
+
// - Atomic write via tmp-and-rename; mode 0600
|
|
495
502
|
// - All other mcpServers entries preserved verbatim
|
|
496
503
|
//
|
|
497
504
|
// Returns one of: { status: 'updated', path }, { status: 'already-set', path },
|
|
@@ -502,24 +509,15 @@ function wireAccessTokenInMcpJson({ token, mcpJsonPath, _testFs } = {}) {
|
|
|
502
509
|
const tokenValue = token || process.env.SUPABASE_ACCESS_TOKEN;
|
|
503
510
|
if (!tokenValue) return { status: 'no-token-in-env' };
|
|
504
511
|
|
|
505
|
-
const targetPath = mcpJsonPath ||
|
|
512
|
+
const targetPath = mcpJsonPath || CLAUDE_MCP_PATH_CANONICAL;
|
|
506
513
|
if (!fsImpl.existsSync(targetPath)) return { status: 'no-file' };
|
|
507
514
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
} catch (err) {
|
|
512
|
-
return { status: 'malformed', path: targetPath, error: err.message };
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
let cfg;
|
|
516
|
-
try {
|
|
517
|
-
cfg = JSON.parse(raw);
|
|
518
|
-
} catch (err) {
|
|
519
|
-
return { status: 'malformed', path: targetPath, error: err.message };
|
|
515
|
+
const read = readMcpServers(targetPath);
|
|
516
|
+
if (read.malformed) {
|
|
517
|
+
return { status: 'malformed', path: targetPath, error: read.error };
|
|
520
518
|
}
|
|
521
519
|
|
|
522
|
-
const supabaseEntry =
|
|
520
|
+
const supabaseEntry = read.servers && read.servers.supabase;
|
|
523
521
|
if (!supabaseEntry || typeof supabaseEntry !== 'object') {
|
|
524
522
|
return { status: 'no-supabase-entry', path: targetPath };
|
|
525
523
|
}
|
|
@@ -528,16 +526,15 @@ function wireAccessTokenInMcpJson({ token, mcpJsonPath, _testFs } = {}) {
|
|
|
528
526
|
const current = supabaseEntry.env.SUPABASE_ACCESS_TOKEN;
|
|
529
527
|
if (current === tokenValue) return { status: 'already-set', path: targetPath };
|
|
530
528
|
if (current && current !== 'SUPABASE_PAT_HERE') {
|
|
531
|
-
// User has set a real token already — don't touch it.
|
|
532
529
|
return { status: 'already-set', path: targetPath };
|
|
533
530
|
}
|
|
534
531
|
|
|
535
532
|
supabaseEntry.env.SUPABASE_ACCESS_TOKEN = tokenValue;
|
|
536
533
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
534
|
+
// writeMcpServers re-reads `targetPath` to preserve every top-level key
|
|
535
|
+
// (oauthAccount, projects, installMethod, …) that Claude Code owns. Only
|
|
536
|
+
// .mcpServers gets replaced with our mutated map.
|
|
537
|
+
writeMcpServers(targetPath, read.servers);
|
|
541
538
|
|
|
542
539
|
return { status: 'updated', path: targetPath };
|
|
543
540
|
}
|
|
@@ -608,14 +605,14 @@ async function main(argv) {
|
|
|
608
605
|
|
|
609
606
|
if (!(await link(projectRef, flags.dryRun))) return 4;
|
|
610
607
|
|
|
611
|
-
// Backfill SUPABASE_ACCESS_TOKEN into ~/.claude
|
|
608
|
+
// Backfill SUPABASE_ACCESS_TOKEN into ~/.claude.json now that
|
|
612
609
|
// `supabase link` succeeded (the token is verified-real). The
|
|
613
610
|
// meta-installer wrote a literal 'SUPABASE_PAT_HERE' placeholder
|
|
614
611
|
// there during Tier 4 install — this closes that loop.
|
|
615
612
|
if (!flags.dryRun) {
|
|
616
613
|
const r = wireAccessTokenInMcpJson();
|
|
617
614
|
if (r.status === 'updated') {
|
|
618
|
-
step(
|
|
615
|
+
step(`Backfilled SUPABASE_ACCESS_TOKEN into ${r.path}...`);
|
|
619
616
|
ok();
|
|
620
617
|
} else if (r.status === 'malformed') {
|
|
621
618
|
process.stderr.write(
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Canonical schema/CRUD for the Claude Code MCP server config.
|
|
4
|
+
//
|
|
5
|
+
// Sprint 36 T2: Claude Code v2.1.119+ reads its MCP config from
|
|
6
|
+
// ~/.claude.json (top-level `mcpServers` key, alongside ~55 other internal
|
|
7
|
+
// keys it owns). Earlier installs wrote to ~/.claude/mcp.json, which the
|
|
8
|
+
// current Claude Code never reads. Fresh users hit this as "the install is
|
|
9
|
+
// broken" — Mnestra was wired into the wrong file.
|
|
10
|
+
//
|
|
11
|
+
// This module is the single source of truth for path constants and the
|
|
12
|
+
// read-modify-write helpers all installer/CLI code paths use. Two physical
|
|
13
|
+
// copies exist (this one + packages/stack-installer/src/mcp-config.js) so
|
|
14
|
+
// each published npm package stays self-contained. The stack-installer
|
|
15
|
+
// copy must stay in sync with this one — same exports, same semantics.
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const CLAUDE_MCP_PATH_CANONICAL = path.join(os.homedir(), '.claude.json');
|
|
22
|
+
const CLAUDE_MCP_PATH_LEGACY = path.join(os.homedir(), '.claude', 'mcp.json');
|
|
23
|
+
|
|
24
|
+
// readMcpServers(filePath) → { servers, raw, missing, malformed, error }
|
|
25
|
+
//
|
|
26
|
+
// servers the .mcpServers map (always an object, never undefined)
|
|
27
|
+
// raw the full parsed top-level object (for structure-preserving
|
|
28
|
+
// write-back). Empty object on missing/malformed.
|
|
29
|
+
// missing true when the file doesn't exist
|
|
30
|
+
// malformed true when the file exists but JSON.parse failed
|
|
31
|
+
// error parse error message when malformed
|
|
32
|
+
function readMcpServers(filePath) {
|
|
33
|
+
if (!fs.existsSync(filePath)) {
|
|
34
|
+
return { servers: {}, raw: {}, missing: true, malformed: false };
|
|
35
|
+
}
|
|
36
|
+
let text;
|
|
37
|
+
try {
|
|
38
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return { servers: {}, raw: {}, missing: false, malformed: true, error: err.message };
|
|
41
|
+
}
|
|
42
|
+
if (text.trim() === '') {
|
|
43
|
+
return { servers: {}, raw: {}, missing: false, malformed: false };
|
|
44
|
+
}
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(text);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { servers: {}, raw: {}, missing: false, malformed: true, error: err.message };
|
|
50
|
+
}
|
|
51
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
52
|
+
return { servers: {}, raw: {}, missing: false, malformed: true, error: 'top-level must be an object' };
|
|
53
|
+
}
|
|
54
|
+
const servers = (parsed.mcpServers && typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers))
|
|
55
|
+
? parsed.mcpServers
|
|
56
|
+
: {};
|
|
57
|
+
return { servers, raw: parsed, missing: false, malformed: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// mergeMcpServers(currentServers, legacyServers) → merged map
|
|
61
|
+
//
|
|
62
|
+
// Current wins on key collision — current is the source of truth, legacy
|
|
63
|
+
// is a migration source. Both inputs are tolerated as null/undefined.
|
|
64
|
+
function mergeMcpServers(currentServers, legacyServers) {
|
|
65
|
+
const out = {};
|
|
66
|
+
const legacy = (legacyServers && typeof legacyServers === 'object') ? legacyServers : {};
|
|
67
|
+
const current = (currentServers && typeof currentServers === 'object') ? currentServers : {};
|
|
68
|
+
for (const [name, entry] of Object.entries(legacy)) {
|
|
69
|
+
out[name] = entry;
|
|
70
|
+
}
|
|
71
|
+
for (const [name, entry] of Object.entries(current)) {
|
|
72
|
+
out[name] = entry;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// writeMcpServers(filePath, servers) — atomic, structure-preserving.
|
|
78
|
+
//
|
|
79
|
+
// If the file exists with other top-level keys (the common case for
|
|
80
|
+
// ~/.claude.json), only `.mcpServers` is replaced; everything else
|
|
81
|
+
// survives byte-equivalent through JSON.parse → JSON.stringify. If the
|
|
82
|
+
// file is missing or empty, writes a minimal `{ mcpServers: {...} }`.
|
|
83
|
+
// Atomic via tmp-and-rename. Mode 0600.
|
|
84
|
+
function writeMcpServers(filePath, servers) {
|
|
85
|
+
const existing = readMcpServers(filePath);
|
|
86
|
+
const next = (existing.malformed || existing.missing)
|
|
87
|
+
? {}
|
|
88
|
+
: { ...existing.raw };
|
|
89
|
+
next.mcpServers = servers || {};
|
|
90
|
+
|
|
91
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
92
|
+
const tmp = `${filePath}.tmp.${process.pid}`;
|
|
93
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
94
|
+
fs.renameSync(tmp, filePath);
|
|
95
|
+
try { fs.chmodSync(filePath, 0o600); } catch (_e) { /* best-effort */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// migrateLegacyIfPresent({ dryRun, canonicalPath, legacyPath })
|
|
99
|
+
// → { migrated, kept, wrote, canonicalPath, legacyPath, malformed }
|
|
100
|
+
//
|
|
101
|
+
// migrated array of server names copied from legacy → canonical
|
|
102
|
+
// kept array of names that existed in both (canonical wins,
|
|
103
|
+
// legacy version skipped)
|
|
104
|
+
// wrote true if the canonical file was written
|
|
105
|
+
// malformed { canonical?: error, legacy?: error } when either parse failed
|
|
106
|
+
//
|
|
107
|
+
// Idempotent: a second invocation with no new legacy entries returns
|
|
108
|
+
// migrated: []. Never deletes or modifies the legacy file.
|
|
109
|
+
function migrateLegacyIfPresent(opts = {}) {
|
|
110
|
+
const dryRun = !!opts.dryRun;
|
|
111
|
+
const canonicalPath = opts.canonicalPath || CLAUDE_MCP_PATH_CANONICAL;
|
|
112
|
+
const legacyPath = opts.legacyPath || CLAUDE_MCP_PATH_LEGACY;
|
|
113
|
+
|
|
114
|
+
const canonical = readMcpServers(canonicalPath);
|
|
115
|
+
const legacy = readMcpServers(legacyPath);
|
|
116
|
+
|
|
117
|
+
const malformed = {};
|
|
118
|
+
if (canonical.malformed) malformed.canonical = canonical.error || true;
|
|
119
|
+
if (legacy.malformed) malformed.legacy = legacy.error || true;
|
|
120
|
+
|
|
121
|
+
if (legacy.missing || legacy.malformed) {
|
|
122
|
+
return {
|
|
123
|
+
migrated: [],
|
|
124
|
+
kept: [],
|
|
125
|
+
wrote: false,
|
|
126
|
+
canonicalPath,
|
|
127
|
+
legacyPath,
|
|
128
|
+
malformed: Object.keys(malformed).length ? malformed : undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const migrated = [];
|
|
133
|
+
const kept = [];
|
|
134
|
+
const merged = { ...canonical.servers };
|
|
135
|
+
for (const [name, entry] of Object.entries(legacy.servers)) {
|
|
136
|
+
if (Object.prototype.hasOwnProperty.call(canonical.servers, name)) {
|
|
137
|
+
kept.push(name);
|
|
138
|
+
} else {
|
|
139
|
+
merged[name] = entry;
|
|
140
|
+
migrated.push(name);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (migrated.length === 0) {
|
|
145
|
+
return {
|
|
146
|
+
migrated: [],
|
|
147
|
+
kept,
|
|
148
|
+
wrote: false,
|
|
149
|
+
canonicalPath,
|
|
150
|
+
legacyPath,
|
|
151
|
+
malformed: Object.keys(malformed).length ? malformed : undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!dryRun) writeMcpServers(canonicalPath, merged);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
migrated,
|
|
159
|
+
kept,
|
|
160
|
+
wrote: !dryRun,
|
|
161
|
+
canonicalPath,
|
|
162
|
+
legacyPath,
|
|
163
|
+
malformed: Object.keys(malformed).length ? malformed : undefined,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
CLAUDE_MCP_PATH_CANONICAL,
|
|
169
|
+
CLAUDE_MCP_PATH_LEGACY,
|
|
170
|
+
readMcpServers,
|
|
171
|
+
mergeMcpServers,
|
|
172
|
+
writeMcpServers,
|
|
173
|
+
migrateLegacyIfPresent,
|
|
174
|
+
};
|
|
@@ -22,6 +22,16 @@ const SECRETS_FILE = path.join(CONFIG_DIR, 'secrets.env');
|
|
|
22
22
|
const DEFAULT_MNESTRA_PORT = parseInt(process.env.MNESTRA_PORT || '37778', 10);
|
|
23
23
|
const MNESTRA_LOG = path.join(os.tmpdir(), 'termdeck-mnestra.log');
|
|
24
24
|
|
|
25
|
+
// Sprint 36: Claude Code v2.1.119+ reads MCP servers from ~/.claude.json
|
|
26
|
+
// (canonical). The legacy ~/.claude/mcp.json is still accepted by older
|
|
27
|
+
// versions. Detection checks BOTH; T2 migrates writes to the canonical path.
|
|
28
|
+
// Exported so T2 (init-rumen, stack-installer, supabase-mcp) and any other
|
|
29
|
+
// caller stays in sync — single source of truth for "where does Claude Code
|
|
30
|
+
// look for MCP entries today".
|
|
31
|
+
const CLAUDE_MCP_PATH_CANONICAL = path.join(HOME, '.claude.json');
|
|
32
|
+
const CLAUDE_MCP_PATH_LEGACY = path.join(HOME, '.claude', 'mcp.json');
|
|
33
|
+
const CLAUDE_MCP_PATHS = [CLAUDE_MCP_PATH_CANONICAL, CLAUDE_MCP_PATH_LEGACY];
|
|
34
|
+
|
|
25
35
|
const ANSI = {
|
|
26
36
|
green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m',
|
|
27
37
|
dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m',
|
|
@@ -51,6 +61,23 @@ function subNote(msg) {
|
|
|
51
61
|
process.stdout.write(` ${ANSI.dim}└ ${msg}${ANSI.reset}\n`);
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
// Sprint 36: scan both Claude Code MCP config paths for a Mnestra entry.
|
|
65
|
+
// Returns true if either file parses and contains the substring "mnestra"
|
|
66
|
+
// anywhere in its JSON (covers top-level mcpServers.mnestra AND per-project
|
|
67
|
+
// blocks). Malformed JSON or missing files count as "no entry" — the hint
|
|
68
|
+
// will fire and tell the user to run the installer, which is the desired
|
|
69
|
+
// recovery for both states.
|
|
70
|
+
function hasMnestraMcpEntry() {
|
|
71
|
+
for (const p of CLAUDE_MCP_PATHS) {
|
|
72
|
+
if (!fs.existsSync(p)) continue;
|
|
73
|
+
try {
|
|
74
|
+
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
75
|
+
if (JSON.stringify(j).includes('mnestra')) return true;
|
|
76
|
+
} catch (_e) { /* malformed — skip, treat as missing */ }
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
54
81
|
// ── Args ─────────────────────────────────────────────────────────────
|
|
55
82
|
|
|
56
83
|
function parseArgs(argv) {
|
|
@@ -181,11 +208,32 @@ function isPidTermDeck(pid) {
|
|
|
181
208
|
return /packages\/cli\/src\/index\.js|termdeck/.test(r.stdout || '');
|
|
182
209
|
}
|
|
183
210
|
|
|
211
|
+
// Liveness probe — a TermDeck that answers /api/sessions with a JSON array is
|
|
212
|
+
// not stale; it's the orchestrator's live server, and killing it cascades to
|
|
213
|
+
// every child PTY. On 2026-04-27 this caused two Sprint 36 server-kill
|
|
214
|
+
// incidents (lane workers triggering reclaimPort against the live :3000).
|
|
215
|
+
async function isTermDeckLive(port) {
|
|
216
|
+
try {
|
|
217
|
+
const j = await httpJson(`http://localhost:${port}/api/sessions`, 1500);
|
|
218
|
+
return Array.isArray(j);
|
|
219
|
+
} catch (_e) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
184
224
|
async function reclaimPort(port) {
|
|
185
225
|
const pids = lsofPids(port);
|
|
186
226
|
if (pids.length === 0) return { reclaimed: false, blockerPids: [] };
|
|
187
227
|
const termdeckPids = pids.filter(isPidTermDeck);
|
|
188
228
|
if (termdeckPids.length === 0) return { reclaimed: false, blockerPids: pids };
|
|
229
|
+
|
|
230
|
+
// Self-recognition guard: never kill a responsive TermDeck. Use --port to
|
|
231
|
+
// start a second instance instead.
|
|
232
|
+
if (await isTermDeckLive(port)) {
|
|
233
|
+
subNote(`TermDeck on port ${port} is live (PIDs: ${termdeckPids.join(' ')}) — not killing. Use --port <other> to start a second instance.`);
|
|
234
|
+
return { reclaimed: false, blockerPids: termdeckPids, alreadyLive: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
189
237
|
for (const pid of termdeckPids) {
|
|
190
238
|
try { process.kill(pid, 'SIGTERM'); } catch (_e) { /* already dead */ }
|
|
191
239
|
}
|
|
@@ -449,17 +497,11 @@ async function main(rawArgs) {
|
|
|
449
497
|
|
|
450
498
|
const mnestra = await startMnestra({ skip: args.noMnestra });
|
|
451
499
|
|
|
452
|
-
// MCP
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
try {
|
|
458
|
-
const j = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
|
|
459
|
-
if (!JSON.stringify(j).includes('mnestra')) needsHint = true;
|
|
460
|
-
} catch (_e) { needsHint = true; }
|
|
461
|
-
}
|
|
462
|
-
if (needsHint) subNote(`Hint: add a 'mnestra' entry to ~/.claude/mcp.json for Claude Code`);
|
|
500
|
+
// Sprint 36: MCP-absence hint. Claude Code v2.1.119+ reads from
|
|
501
|
+
// ~/.claude.json; legacy versions read ~/.claude/mcp.json. Mnestra is
|
|
502
|
+
// "wired" if EITHER file mentions it — otherwise the hint fires.
|
|
503
|
+
if (mnestra.active && !hasMnestraMcpEntry()) {
|
|
504
|
+
subNote(`TermDeck doesn't see Mnestra wired in Claude Code yet. Run: npx @jhizzard/termdeck-stack`);
|
|
463
505
|
}
|
|
464
506
|
|
|
465
507
|
const rumen = await checkRumen();
|
|
@@ -482,3 +524,11 @@ module.exports = function (argv) {
|
|
|
482
524
|
return 1;
|
|
483
525
|
});
|
|
484
526
|
};
|
|
527
|
+
|
|
528
|
+
// Sprint 36: shared MCP-config path constants. Other CLI/installer modules
|
|
529
|
+
// (T2's lane: init-rumen.js, stack-installer, supabase-mcp.js) import from
|
|
530
|
+
// here so the canonical-vs-legacy decision lives in exactly one file.
|
|
531
|
+
module.exports.CLAUDE_MCP_PATH_CANONICAL = CLAUDE_MCP_PATH_CANONICAL;
|
|
532
|
+
module.exports.CLAUDE_MCP_PATH_LEGACY = CLAUDE_MCP_PATH_LEGACY;
|
|
533
|
+
module.exports.CLAUDE_MCP_PATHS = CLAUDE_MCP_PATHS;
|
|
534
|
+
module.exports.hasMnestraMcpEntry = hasMnestraMcpEntry;
|