openalmanac 0.3.1 → 0.3.3

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/dist/setup.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { spawnSync } from "child_process";
5
6
  import { performLogin } from "./login-core.js";
6
7
  import { getAuthStatus } from "./auth.js";
7
8
  const TOOL_GROUPS = [
@@ -131,9 +132,15 @@ const vis = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
131
132
  const w = (s) => process.stdout.write(s + "\n");
132
133
  /* ── File helpers ───────────────────────────────────────────────── */
133
134
  const CLAUDE_DIR = join(homedir(), ".claude");
134
- const CLAUDE_JSON = join(homedir(), ".claude.json"); // Claude Desktop
135
- const CLAUDE_CODE_MCP = join(CLAUDE_DIR, "mcp.json"); // Claude Code
135
+ const CLAUDE_JSON = join(homedir(), ".claude.json"); // Claude Code user-scoped MCP config
136
+ const CLAUDE_CODE_MCP = join(CLAUDE_DIR, "mcp.json"); // Claude Code local MCP config
136
137
  const SETTINGS_JSON = join(CLAUDE_DIR, "settings.json");
138
+ const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
139
+ const CURSOR_MCP_JSON = join(homedir(), ".cursor", "mcp.json");
140
+ const OPENCODE_DIR = join(homedir(), ".config", "opencode");
141
+ const OPENCODE_JSON = join(OPENCODE_DIR, "opencode.json");
142
+ const OPENCODE_JSONC = join(OPENCODE_DIR, "opencode.jsonc");
143
+ const WINDSURF_MCP_JSON = join(homedir(), ".codeium", "mcp_config.json");
137
144
  function ensureDir(dir) {
138
145
  if (!existsSync(dir))
139
146
  mkdirSync(dir, { recursive: true });
@@ -151,34 +158,518 @@ function writeJson(path, data) {
151
158
  }
152
159
  /* ── Step 1 — MCP server ───────────────────────────────────────── */
153
160
  const ALMANAC_MCP_ENTRY = { command: "npx", args: ["-y", "openalmanac@latest"] };
161
+ const SUPPORTED_CLIENT_IDS = [
162
+ "claude-code",
163
+ "claude-desktop",
164
+ "codex",
165
+ "cursor",
166
+ "opencode",
167
+ "windsurf",
168
+ ];
169
+ const SUPPORTED_CLIENTS = {
170
+ "claude-code": {
171
+ id: "claude-code",
172
+ name: "Claude Code",
173
+ selectionLabel: "Claude Code",
174
+ detect: () => hasCommand("claude") || existsSync(CLAUDE_JSON) || existsSync(CLAUDE_DIR),
175
+ configure: (mode) => {
176
+ const snippets = [
177
+ {
178
+ path: CLAUDE_JSON,
179
+ content: jsonSnippet({
180
+ mcpServers: {
181
+ almanac: { ...ALMANAC_MCP_ENTRY },
182
+ },
183
+ }),
184
+ },
185
+ {
186
+ path: CLAUDE_CODE_MCP,
187
+ content: jsonSnippet({
188
+ mcpServers: {
189
+ almanac: { ...ALMANAC_MCP_ENTRY },
190
+ },
191
+ }),
192
+ },
193
+ ];
194
+ const changedPrimary = configureJsonMcpFile(CLAUDE_JSON, mode);
195
+ const changedSecondary = configureJsonMcpFile(CLAUDE_CODE_MCP, mode);
196
+ return { changed: changedPrimary || changedSecondary, snippets };
197
+ },
198
+ supportsPermissions: true,
199
+ },
200
+ "claude-desktop": {
201
+ id: "claude-desktop",
202
+ name: "Claude Desktop",
203
+ selectionLabel: "Claude Desktop",
204
+ detect: () => {
205
+ const path = getClaudeDesktopConfigPath();
206
+ return Boolean(path && (existsSync(path) || isClaudeDesktopInstalled()));
207
+ },
208
+ configure: (mode) => {
209
+ const path = getClaudeDesktopConfigPath();
210
+ if (!path)
211
+ return { changed: false, snippets: [] };
212
+ return {
213
+ changed: configureJsonMcpFile(path, mode),
214
+ snippets: [
215
+ {
216
+ path,
217
+ content: jsonSnippet({
218
+ mcpServers: {
219
+ almanac: { ...ALMANAC_MCP_ENTRY },
220
+ },
221
+ }),
222
+ },
223
+ ],
224
+ };
225
+ },
226
+ },
227
+ codex: {
228
+ id: "codex",
229
+ name: "Codex",
230
+ selectionLabel: "Codex",
231
+ detect: () => hasCommand("codex") || existsSync(CODEX_CONFIG) || existsSync(join(homedir(), ".codex")),
232
+ configure: (mode) => ({
233
+ changed: configureCodexToml(CODEX_CONFIG, mode),
234
+ snippets: [
235
+ {
236
+ path: CODEX_CONFIG,
237
+ content: codexSnippet(),
238
+ },
239
+ ],
240
+ }),
241
+ },
242
+ cursor: {
243
+ id: "cursor",
244
+ name: "Cursor",
245
+ selectionLabel: "Cursor",
246
+ detect: () => hasCommand("cursor-agent") ||
247
+ existsSync(CURSOR_MCP_JSON) ||
248
+ existsSync(join(homedir(), ".cursor")),
249
+ configure: (mode) => ({
250
+ changed: configureJsonMcpFile(CURSOR_MCP_JSON, mode),
251
+ snippets: [
252
+ {
253
+ path: CURSOR_MCP_JSON,
254
+ content: jsonSnippet({
255
+ mcpServers: {
256
+ almanac: { ...ALMANAC_MCP_ENTRY },
257
+ },
258
+ }),
259
+ },
260
+ ],
261
+ }),
262
+ },
263
+ opencode: {
264
+ id: "opencode",
265
+ name: "OpenCode",
266
+ selectionLabel: "OpenCode",
267
+ detect: () => hasCommand("opencode") ||
268
+ existsSync(OPENCODE_JSON) ||
269
+ existsSync(OPENCODE_JSONC) ||
270
+ existsSync(OPENCODE_DIR),
271
+ configure: (mode) => {
272
+ const path = getOpenCodeConfigPath();
273
+ return {
274
+ changed: configureOpenCodeFile(path, mode),
275
+ snippets: [
276
+ {
277
+ path,
278
+ content: jsonSnippet({
279
+ "$schema": "https://opencode.ai/config.json",
280
+ mcp: {
281
+ almanac: {
282
+ type: "local",
283
+ command: [ALMANAC_MCP_ENTRY.command, ...ALMANAC_MCP_ENTRY.args],
284
+ enabled: true,
285
+ },
286
+ },
287
+ }),
288
+ },
289
+ ],
290
+ };
291
+ },
292
+ },
293
+ windsurf: {
294
+ id: "windsurf",
295
+ name: "Windsurf",
296
+ selectionLabel: "Windsurf",
297
+ detect: () => hasCommand("windsurf") ||
298
+ existsSync(WINDSURF_MCP_JSON) ||
299
+ existsSync(join(homedir(), ".codeium")),
300
+ configure: (mode) => ({
301
+ changed: configureJsonMcpFile(WINDSURF_MCP_JSON, mode),
302
+ snippets: [
303
+ {
304
+ path: WINDSURF_MCP_JSON,
305
+ content: jsonSnippet({
306
+ mcpServers: {
307
+ almanac: { ...ALMANAC_MCP_ENTRY },
308
+ },
309
+ }),
310
+ },
311
+ ],
312
+ }),
313
+ },
314
+ };
315
+ function parseSetupArgs(argv) {
316
+ const options = {
317
+ all: false,
318
+ clients: [],
319
+ dryRun: false,
320
+ print: false,
321
+ yes: false,
322
+ };
323
+ for (let i = 0; i < argv.length; i++) {
324
+ const arg = argv[i];
325
+ if (arg === "--all") {
326
+ options.all = true;
327
+ continue;
328
+ }
329
+ if (arg === "--print") {
330
+ options.print = true;
331
+ continue;
332
+ }
333
+ if (arg === "--dry-run") {
334
+ options.dryRun = true;
335
+ continue;
336
+ }
337
+ if (arg === "--yes" || arg === "-y") {
338
+ options.yes = true;
339
+ continue;
340
+ }
341
+ if (arg === "--client") {
342
+ const value = argv[i + 1];
343
+ if (!value) {
344
+ throw new Error("Missing value for --client");
345
+ }
346
+ i++;
347
+ options.clients.push(...parseClientList(value));
348
+ continue;
349
+ }
350
+ if (arg.startsWith("--client=")) {
351
+ options.clients.push(...parseClientList(arg.slice("--client=".length)));
352
+ continue;
353
+ }
354
+ throw new Error(`Unknown setup flag: ${arg}`);
355
+ }
356
+ if (options.all && options.clients.length > 0) {
357
+ throw new Error("Use either --all or --client, not both");
358
+ }
359
+ options.clients = Array.from(new Set(options.clients));
360
+ return options;
361
+ }
362
+ function parseClientList(value) {
363
+ return value
364
+ .split(",")
365
+ .map((part) => part.trim().toLowerCase())
366
+ .filter(Boolean)
367
+ .map((part) => normalizeClientId(part));
368
+ }
369
+ function normalizeClientId(value) {
370
+ const aliases = {
371
+ claude: "claude-code",
372
+ "claude-code": "claude-code",
373
+ "claude-desktop": "claude-desktop",
374
+ desktop: "claude-desktop",
375
+ codex: "codex",
376
+ cursor: "cursor",
377
+ opencode: "opencode",
378
+ windsurf: "windsurf",
379
+ };
380
+ const normalized = aliases[value];
381
+ if (!normalized) {
382
+ throw new Error(`Unsupported client "${value}". Supported clients: ${SUPPORTED_CLIENT_IDS.join(", ")}`);
383
+ }
384
+ return normalized;
385
+ }
386
+ function hasCommand(command) {
387
+ const checker = process.platform === "win32" ? "where" : "which";
388
+ const result = spawnSync(checker, [command], { stdio: "ignore" });
389
+ return result.status === 0;
390
+ }
391
+ function getClaudeDesktopConfigPath() {
392
+ if (process.platform === "darwin") {
393
+ return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
394
+ }
395
+ if (process.platform === "linux") {
396
+ return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
397
+ }
398
+ if (process.platform === "win32") {
399
+ const appData = process.env.APPDATA;
400
+ if (!appData)
401
+ return null;
402
+ return join(appData, "Claude", "claude_desktop_config.json");
403
+ }
404
+ return null;
405
+ }
406
+ function isClaudeDesktopInstalled() {
407
+ if (process.platform === "darwin") {
408
+ return (existsSync("/Applications/Claude.app") ||
409
+ existsSync(join(homedir(), "Applications", "Claude.app")));
410
+ }
411
+ if (process.platform === "linux") {
412
+ return (existsSync("/usr/share/applications/claude.desktop") ||
413
+ existsSync(join(homedir(), ".local", "share", "applications", "claude.desktop")) ||
414
+ existsSync("/opt/Claude/claude"));
415
+ }
416
+ if (process.platform === "win32") {
417
+ const localAppData = process.env.LOCALAPPDATA;
418
+ const programFiles = process.env.ProgramFiles;
419
+ return Boolean((localAppData &&
420
+ existsSync(join(localAppData, "Programs", "Claude", "Claude.exe"))) ||
421
+ (programFiles && existsSync(join(programFiles, "Claude", "Claude.exe"))));
422
+ }
423
+ return false;
424
+ }
425
+ function jsonSnippet(data) {
426
+ return JSON.stringify(data, null, 2);
427
+ }
428
+ function getOpenCodeConfigPath() {
429
+ if (existsSync(OPENCODE_JSON))
430
+ return OPENCODE_JSON;
431
+ if (existsSync(OPENCODE_JSONC))
432
+ return OPENCODE_JSONC;
433
+ return OPENCODE_JSON;
434
+ }
435
+ function codexSnippet() {
436
+ return [
437
+ "[mcp_servers.almanac]",
438
+ `command = ${tomlString(ALMANAC_MCP_ENTRY.command)}`,
439
+ `args = ${tomlArray(ALMANAC_MCP_ENTRY.args)}`,
440
+ ].join("\n");
441
+ }
154
442
  function isAlmanacCurrent(server) {
155
443
  return (server?.command === "npx" &&
156
444
  JSON.stringify(server.args) === JSON.stringify(ALMANAC_MCP_ENTRY.args));
157
445
  }
158
- function configureMcp() {
159
- let changed = false;
160
- // Claude Desktop — ~/.claude.json
161
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
- const desktop = readJson(CLAUDE_JSON);
163
- if (!desktop.mcpServers)
164
- desktop.mcpServers = {};
165
- if (!isAlmanacCurrent(desktop.mcpServers.almanac)) {
166
- desktop.mcpServers.almanac = { ...ALMANAC_MCP_ENTRY };
167
- writeJson(CLAUDE_JSON, desktop);
168
- changed = true;
169
- }
170
- // Claude Code — ~/.claude/mcp.json
171
- ensureDir(CLAUDE_DIR);
446
+ function isOpenCodeAlmanacCurrent(entry) {
447
+ return (entry?.type === "local" &&
448
+ entry?.enabled === true &&
449
+ JSON.stringify(entry.command) ===
450
+ JSON.stringify([ALMANAC_MCP_ENTRY.command, ...ALMANAC_MCP_ENTRY.args]));
451
+ }
452
+ function configureJsonMcpFile(path, mode) {
172
453
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
- const code = readJson(CLAUDE_CODE_MCP);
174
- if (!code.mcpServers)
175
- code.mcpServers = {};
176
- if (!isAlmanacCurrent(code.mcpServers.almanac)) {
177
- code.mcpServers.almanac = { ...ALMANAC_MCP_ENTRY };
178
- writeJson(CLAUDE_CODE_MCP, code);
179
- changed = true;
454
+ const json = readJson(path);
455
+ if (!json.mcpServers)
456
+ json.mcpServers = {};
457
+ if (isAlmanacCurrent(json.mcpServers.almanac)) {
458
+ return false;
459
+ }
460
+ if (mode === "apply") {
461
+ ensureDir(dirname(path));
462
+ json.mcpServers.almanac = { ...ALMANAC_MCP_ENTRY };
463
+ writeJson(path, json);
464
+ }
465
+ return true;
466
+ }
467
+ function configureCodexToml(path, mode) {
468
+ const current = readToml(path);
469
+ const next = upsertCodexServer(current);
470
+ if (current.trim() === next.trim()) {
471
+ return false;
472
+ }
473
+ if (mode === "apply") {
474
+ ensureDir(dirname(path));
475
+ writeFileSync(path, next.endsWith("\n") ? next : next + "\n");
476
+ }
477
+ return true;
478
+ }
479
+ function configureOpenCodeFile(path, mode) {
480
+ const current = readJsonWithComments(path);
481
+ if (!current.$schema) {
482
+ current.$schema = "https://opencode.ai/config.json";
483
+ }
484
+ const mcp = isRecord(current.mcp) ? current.mcp : {};
485
+ if (!isRecord(current.mcp)) {
486
+ current.mcp = mcp;
487
+ }
488
+ if (isOpenCodeAlmanacCurrent(mcp.almanac)) {
489
+ return false;
490
+ }
491
+ mcp.almanac = {
492
+ type: "local",
493
+ command: [ALMANAC_MCP_ENTRY.command, ...ALMANAC_MCP_ENTRY.args],
494
+ enabled: true,
495
+ };
496
+ if (mode === "apply") {
497
+ ensureDir(dirname(path));
498
+ writeJson(path, current);
499
+ }
500
+ return true;
501
+ }
502
+ function readToml(path) {
503
+ try {
504
+ return readFileSync(path, "utf-8");
505
+ }
506
+ catch {
507
+ return "";
180
508
  }
181
- return changed;
509
+ }
510
+ function readJsonWithComments(path) {
511
+ try {
512
+ const raw = readFileSync(path, "utf-8");
513
+ const parsed = JSON.parse(stripJsonComments(raw));
514
+ return isRecord(parsed) ? parsed : {};
515
+ }
516
+ catch {
517
+ return {};
518
+ }
519
+ }
520
+ function stripJsonComments(input) {
521
+ let result = "";
522
+ let inString = false;
523
+ let inLineComment = false;
524
+ let inBlockComment = false;
525
+ let escaped = false;
526
+ for (let i = 0; i < input.length; i++) {
527
+ const char = input[i];
528
+ const next = input[i + 1];
529
+ if (inLineComment) {
530
+ if (char === "\n") {
531
+ inLineComment = false;
532
+ result += char;
533
+ }
534
+ continue;
535
+ }
536
+ if (inBlockComment) {
537
+ if (char === "*" && next === "/") {
538
+ inBlockComment = false;
539
+ i++;
540
+ }
541
+ continue;
542
+ }
543
+ if (inString) {
544
+ result += char;
545
+ if (escaped) {
546
+ escaped = false;
547
+ }
548
+ else if (char === "\\") {
549
+ escaped = true;
550
+ }
551
+ else if (char === "\"") {
552
+ inString = false;
553
+ }
554
+ continue;
555
+ }
556
+ if (char === "/" && next === "/") {
557
+ inLineComment = true;
558
+ i++;
559
+ continue;
560
+ }
561
+ if (char === "/" && next === "*") {
562
+ inBlockComment = true;
563
+ i++;
564
+ continue;
565
+ }
566
+ if (char === "\"") {
567
+ inString = true;
568
+ }
569
+ result += char;
570
+ }
571
+ return result;
572
+ }
573
+ function isRecord(value) {
574
+ return typeof value === "object" && value !== null && !Array.isArray(value);
575
+ }
576
+ function upsertCodexServer(content) {
577
+ const sectionName = "mcp_servers.almanac";
578
+ const header = `[${sectionName}]`;
579
+ const nextBody = [
580
+ `command = ${tomlString(ALMANAC_MCP_ENTRY.command)}`,
581
+ `args = ${tomlArray(ALMANAC_MCP_ENTRY.args)}`,
582
+ ];
583
+ const lines = content === "" ? [] : content.split(/\r?\n/);
584
+ const start = lines.findIndex((line) => line.trim() === header);
585
+ if (start === -1) {
586
+ const prefix = content.trimEnd();
587
+ const block = [header, ...nextBody].join("\n");
588
+ return prefix === "" ? block + "\n" : `${prefix}\n\n${block}\n`;
589
+ }
590
+ let end = lines.length;
591
+ for (let i = start + 1; i < lines.length; i++) {
592
+ if (/^\s*\[.+\]\s*$/.test(lines[i])) {
593
+ end = i;
594
+ break;
595
+ }
596
+ }
597
+ const existingBody = lines.slice(start + 1, end);
598
+ const preserved = existingBody.filter((line) => {
599
+ const trimmed = line.trim();
600
+ return !trimmed.startsWith("command =") && !trimmed.startsWith("args =");
601
+ });
602
+ const replacement = [header, ...nextBody, ...preserved];
603
+ const updated = [...lines.slice(0, start), ...replacement, ...lines.slice(end)];
604
+ return updated.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "") + "\n";
605
+ }
606
+ function tomlString(value) {
607
+ return JSON.stringify(value);
608
+ }
609
+ function tomlArray(values) {
610
+ return `[${values.map((value) => tomlString(value)).join(", ")}]`;
611
+ }
612
+ function detectClients() {
613
+ return SUPPORTED_CLIENT_IDS.map((id) => SUPPORTED_CLIENTS[id]).filter((client) => client.detect());
614
+ }
615
+ function resolveClients(options) {
616
+ if (options.clients.length > 0) {
617
+ return options.clients.map((id) => SUPPORTED_CLIENTS[id]);
618
+ }
619
+ const detected = detectClients();
620
+ if (options.all)
621
+ return detected;
622
+ return detected;
623
+ }
624
+ function applyClientSetup(clients, mode) {
625
+ const configured = [];
626
+ const alreadyConfigured = [];
627
+ for (const client of clients) {
628
+ const result = client.configure(mode);
629
+ if (result.changed) {
630
+ configured.push(client.name);
631
+ }
632
+ else {
633
+ alreadyConfigured.push(client.name);
634
+ }
635
+ }
636
+ return { configured, alreadyConfigured };
637
+ }
638
+ function printSetupPlan(clients, options) {
639
+ const heading = options.dryRun ? "Dry run" : "OpenAlmanac MCP setup";
640
+ process.stdout.write(`${heading}\n\n`);
641
+ if (clients.length === 0) {
642
+ process.stdout.write("No supported clients detected. Use --client <name> to force a target or --print to inspect supported snippets.\n");
643
+ if (options.print) {
644
+ process.stdout.write("\nSupported clients:\n");
645
+ for (const id of SUPPORTED_CLIENT_IDS) {
646
+ process.stdout.write(`- ${SUPPORTED_CLIENTS[id].name}\n`);
647
+ }
648
+ }
649
+ return;
650
+ }
651
+ const mode = options.print ? "print" : "dry-run";
652
+ for (const client of clients) {
653
+ const result = client.configure(mode);
654
+ const status = result.changed
655
+ ? options.print
656
+ ? "snippet"
657
+ : "would configure"
658
+ : "already configured";
659
+ process.stdout.write(`- ${client.name}: ${status}\n`);
660
+ if (options.print) {
661
+ for (const snippet of result.snippets) {
662
+ process.stdout.write(` Path: ${snippet.path}\n`);
663
+ process.stdout.write(`${indentBlock(snippet.content, " ")}\n`);
664
+ }
665
+ }
666
+ }
667
+ }
668
+ function indentBlock(content, prefix) {
669
+ return content
670
+ .split("\n")
671
+ .map((line) => `${prefix}${line}`)
672
+ .join("\n");
182
673
  }
183
674
  /* ── Step 2 — Permissions ──────────────────────────────────────── */
184
675
  function configurePermissions(tools) {
@@ -201,29 +692,30 @@ function configurePermissions(tools) {
201
692
  writeJson(SETTINGS_JSON, settings);
202
693
  return tools.length;
203
694
  }
204
- /* ── Agent selection screen ─────────────────────────────────────── */
205
- function renderAgentSelect(_cursor, mode = "default") {
695
+ /* ── Client selection screen ────────────────────────────────────── */
696
+ function renderClientSelect(clients, selected, cursor, mode = "default") {
206
697
  process.stdout.write("\x1b[2J\x1b[H");
207
698
  renderHeader(mode);
208
699
  printBadge();
209
700
  w("");
210
- stepActive(`Select your agent`);
701
+ stepActive(`Select where to install Almanac`);
211
702
  w(BAR);
212
- for (const agent of AGENTS) {
213
- if (agent.supported) {
214
- w(` ${DIM}\u2502${RST} ${BLUE}\u276f${RST} ${BLUE}\u25cf${RST} ${BOLD}${agent.name}${RST}`);
215
- }
216
- else {
217
- w(` ${DIM}\u2502${RST} ${DIM}\u25cb ${agent.name}${" "}coming soon${RST}`);
218
- }
703
+ for (let i = 0; i < clients.length; i++) {
704
+ const client = clients[i];
705
+ const arrow = i === cursor ? `${BLUE}\u276f${RST}` : " ";
706
+ const check = selected[i] ? `${BLUE}\u2713${RST}` : " ";
707
+ const label = i === cursor ? `${BOLD}${client.selectionLabel ?? client.name}${RST}` : client.selectionLabel ?? client.name;
708
+ w(` ${DIM}\u2502${RST} ${arrow} [${check}] ${label}`);
219
709
  }
220
710
  w(BAR);
221
- w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[enter]${RST} confirm ${DIM}[q] quit${RST}`);
711
+ w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[space]${RST} toggle ${BLUE}${BOLD}[\u2191\u2193]${RST} move ${BLUE}${BOLD}[a]${RST} all ${BLUE}${BOLD}[enter]${RST} confirm ${DIM}[q] quit${RST}`);
222
712
  w("");
223
713
  }
224
- function runAgentSelect(mode = "default") {
714
+ function runClientSelect(clients, mode = "default") {
225
715
  return new Promise((resolve) => {
226
- renderAgentSelect(0, mode);
716
+ const selected = clients.map(() => true);
717
+ let cursor = 0;
718
+ renderClientSelect(clients, selected, cursor, mode);
227
719
  process.stdin.setRawMode(true);
228
720
  process.stdin.resume();
229
721
  process.stdin.setEncoding("utf-8");
@@ -239,16 +731,38 @@ function runAgentSelect(mode = "default") {
239
731
  console.log("\n Setup cancelled.\n");
240
732
  process.exit(0);
241
733
  }
242
- if (key === "\r" || key === "\n") {
734
+ if (key === "\x1b[A" || key === "k") {
735
+ cursor = (cursor - 1 + clients.length) % clients.length;
736
+ }
737
+ else if (key === "\x1b[B" || key === "j") {
738
+ cursor = (cursor + 1) % clients.length;
739
+ }
740
+ else if (key === " ") {
741
+ selected[cursor] = !selected[cursor];
742
+ }
743
+ else if (key === "a") {
744
+ const all = selected.every(Boolean);
745
+ selected.fill(!all);
746
+ }
747
+ else if (key === "\r" || key === "\n") {
243
748
  cleanup();
244
- const supported = AGENTS.find((a) => a.supported);
245
- resolve(supported.name);
749
+ const chosen = clients.filter((_, index) => selected[index]);
750
+ if (chosen.length === 0) {
751
+ console.log("\n Select at least one client.\n");
752
+ process.exit(1);
753
+ }
754
+ resolve(chosen);
246
755
  return;
247
756
  }
757
+ renderClientSelect(clients, selected, cursor, mode);
248
758
  };
249
759
  process.stdin.on("data", onData);
250
760
  });
251
761
  }
762
+ async function runAgentSelect(mode = "default") {
763
+ const [client] = await runClientSelect([SUPPORTED_CLIENTS["claude-code"]], mode);
764
+ return client.name;
765
+ }
252
766
  /* ── Login step ─────────────────────────────────────────────────── */
253
767
  function loginLabel(result) {
254
768
  if (result.status === "already")
@@ -278,12 +792,15 @@ function waitForKey(prompt) {
278
792
  });
279
793
  }
280
794
  async function runLoginStep(agent, mcpChanged, toolCount, mode = "default") {
795
+ const label = agent.includes(",") ? "Clients" : "Agent";
281
796
  const priorSteps = () => {
282
- stepDone(`Agent \u2192 ${WHITE_BOLD}${agent}${RST}`);
797
+ stepDone(`${label} \u2192 ${WHITE_BOLD}${agent}${RST}`);
283
798
  w(BAR);
284
799
  stepDone(`MCP server ${mcpChanged ? "configured" : `${DIM}already configured${RST}`}`);
285
- w(BAR);
286
- stepDone(`${BLUE}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed`);
800
+ if (toolCount !== null) {
801
+ w(BAR);
802
+ stepDone(`${BLUE}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed`);
803
+ }
287
804
  w(BAR);
288
805
  };
289
806
  function renderLoginChoice(name, cursor) {
@@ -428,16 +945,16 @@ async function runLoginStep(agent, mcpChanged, toolCount, mode = "default") {
428
945
  }
429
946
  /* ── Tool permissions TUI ───────────────────────────────────────── */
430
947
  const MAX_NAME = Math.max(...TOOL_GROUPS.map((g) => g.name.length));
431
- function renderToolSelect(selected, cursor, agent, mcpChanged, mode = "default") {
948
+ function renderToolSelect(selected, cursor, clientsLabel, mcpChanged, mode = "default") {
432
949
  process.stdout.write("\x1b[2J\x1b[H");
433
950
  renderHeader(mode);
434
951
  printBadge();
435
952
  w("");
436
- stepDone(`Agent \u2192 ${WHITE_BOLD}${agent}${RST}`);
953
+ stepDone(`Clients \u2192 ${WHITE_BOLD}${clientsLabel}${RST}`);
437
954
  w(BAR);
438
955
  stepDone(`MCP server ${mcpChanged ? "configured" : `${DIM}already configured${RST}`}`);
439
956
  w(BAR);
440
- stepActive(`Select tool permissions ${DIM}deselect any you'd rather approve manually${RST}`);
957
+ stepActive(`Select Claude Code tool permissions ${DIM}deselect any you'd rather approve manually${RST}`);
441
958
  w(BAR);
442
959
  for (let i = 0; i < TOOL_GROUPS.length; i++) {
443
960
  const arrow = i === cursor ? `${BLUE}\u276f${RST}` : " ";
@@ -451,11 +968,11 @@ function renderToolSelect(selected, cursor, agent, mcpChanged, mode = "default")
451
968
  w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[space]${RST} toggle ${BLUE}${BOLD}[\u2191\u2193]${RST} move ${BLUE}${BOLD}[a]${RST} all ${BLUE}${BOLD}[enter]${RST} confirm ${DIM}[q] quit${RST}`);
452
969
  w("");
453
970
  }
454
- function runToolSelect(agent, mcpChanged, mode = "default") {
971
+ function runToolSelect(clientsLabel, mcpChanged, mode = "default") {
455
972
  return new Promise((resolve) => {
456
973
  const selected = TOOL_GROUPS.map(() => true);
457
974
  let cursor = 0;
458
- renderToolSelect(selected, cursor, agent, mcpChanged, mode);
975
+ renderToolSelect(selected, cursor, clientsLabel, mcpChanged, mode);
459
976
  process.stdin.setRawMode(true);
460
977
  process.stdin.resume();
461
978
  process.stdin.setEncoding("utf-8");
@@ -491,23 +1008,27 @@ function runToolSelect(agent, mcpChanged, mode = "default") {
491
1008
  resolve(tools);
492
1009
  return;
493
1010
  }
494
- renderToolSelect(selected, cursor, agent, mcpChanged, mode);
1011
+ renderToolSelect(selected, cursor, clientsLabel, mcpChanged, mode);
495
1012
  };
496
1013
  process.stdin.on("data", onData);
497
1014
  });
498
1015
  }
499
1016
  /* ── Result screen ──────────────────────────────────────────────── */
500
- function printResult(agent, loginResult, mcpChanged, toolCount) {
1017
+ function printResult(clientsLabel, loginResult, configured, alreadyConfigured, toolCount) {
501
1018
  process.stdout.write("\x1b[2J\x1b[H");
502
1019
  printBanner();
503
1020
  printBadge();
504
1021
  w("");
505
- stepDone(`Agent \u2192 ${WHITE_BOLD}${agent}${RST}`);
1022
+ stepDone(`Clients \u2192 ${WHITE_BOLD}${clientsLabel}${RST}`);
506
1023
  w(BAR);
507
- stepDone(`MCP server ${mcpChanged ? "configured" : `${DIM}already configured${RST}`}`);
1024
+ stepDone(`Configured \u2192 ${configured.length > 0 ? configured.join(", ") : `${DIM}none${RST}`}`);
508
1025
  w(BAR);
509
- stepDone(`${BLUE}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed in ${DIM}~/.claude/settings.json${RST}`);
1026
+ stepDone(`Already configured \u2192 ${alreadyConfigured.length > 0 ? alreadyConfigured.join(", ") : `${DIM}none${RST}`}`);
510
1027
  w(BAR);
1028
+ if (toolCount > 0) {
1029
+ stepDone(`${BLUE}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed in ${DIM}~/.claude/settings.json${RST}`);
1030
+ w(BAR);
1031
+ }
511
1032
  stepDone(loginLabel(loginResult));
512
1033
  w(BAR);
513
1034
  stepDone(`${BLUE}Setup complete${RST}`);
@@ -519,37 +1040,95 @@ function printResult(agent, loginResult, mcpChanged, toolCount) {
519
1040
  return ` ${BLUE_DIM}\u2502${RST}${content}${" ".repeat(padding)}${BLUE_DIM}\u2502${RST}`;
520
1041
  };
521
1042
  const empty = row("");
1043
+ const nextSteps = getNextSteps(clientsLabel);
522
1044
  w(` ${BLUE_DIM}\u256d${"─".repeat(innerW)}\u256e${RST}`);
523
1045
  w(empty);
524
1046
  w(row(` ${WHITE_BOLD}Next steps${RST}`));
525
1047
  w(empty);
526
- w(row(` ${BLUE}1.${RST} Type ${WHITE_BOLD}claude${RST} to start Claude Code`));
527
- w(row(` ${BLUE}2.${RST} Say ${BLUE}"I want to contribute/explore the founders-inc wiki"${RST}`));
1048
+ for (let i = 0; i < nextSteps.length; i++) {
1049
+ w(row(` ${BLUE}${i + 1}.${RST} ${nextSteps[i]}`));
1050
+ }
528
1051
  w(empty);
529
1052
  w(` ${BLUE_DIM}\u2570${"─".repeat(innerW)}\u256f${RST}`);
530
1053
  w("");
531
1054
  }
1055
+ function getNextSteps(clientsLabel) {
1056
+ if (clientsLabel === "Claude Code") {
1057
+ return [
1058
+ `Type ${WHITE_BOLD}claude${RST} to start Claude Code`,
1059
+ `Say ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
1060
+ ];
1061
+ }
1062
+ if (clientsLabel === "Codex") {
1063
+ return [
1064
+ `Type ${WHITE_BOLD}codex${RST} to start Codex`,
1065
+ `Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
1066
+ ];
1067
+ }
1068
+ if (clientsLabel === "Cursor") {
1069
+ return [
1070
+ `Open ${WHITE_BOLD}Cursor${RST} in your project`,
1071
+ `Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
1072
+ ];
1073
+ }
1074
+ if (clientsLabel === "OpenCode") {
1075
+ return [
1076
+ `Type ${WHITE_BOLD}opencode${RST} to start OpenCode`,
1077
+ `Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
1078
+ ];
1079
+ }
1080
+ if (clientsLabel === "Windsurf") {
1081
+ return [
1082
+ `Open ${WHITE_BOLD}Windsurf${RST} in your project`,
1083
+ `Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
1084
+ ];
1085
+ }
1086
+ if (clientsLabel === "Claude Desktop") {
1087
+ return [
1088
+ `Open ${WHITE_BOLD}Claude Desktop${RST}`,
1089
+ `Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
1090
+ ];
1091
+ }
1092
+ return [
1093
+ `Open one of your configured agents in this project`,
1094
+ `Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
1095
+ ];
1096
+ }
532
1097
  /* ── Entry point ────────────────────────────────────────────────── */
533
1098
  export async function runSetup() {
534
- const skipTui = process.argv.includes("--yes") || process.argv.includes("-y");
535
- const interactive = process.stdin.isTTY && !skipTui;
536
- let agent = "Claude Code";
537
- if (interactive) {
538
- agent = await runAgentSelect();
1099
+ const options = parseSetupArgs(process.argv.slice(3));
1100
+ let clients = resolveClients(options);
1101
+ if (options.print || options.dryRun) {
1102
+ printSetupPlan(clients, options);
1103
+ process.exit(0);
539
1104
  }
540
- const mcpChanged = configureMcp();
541
- let tools;
542
- if (interactive) {
543
- tools = await runToolSelect(agent, mcpChanged);
1105
+ if (clients.length === 0) {
1106
+ printSetupPlan(clients, options);
1107
+ process.exit(0);
544
1108
  }
545
- else {
546
- tools = TOOL_GROUPS.flatMap((g) => g.tools);
1109
+ const skipTui = options.yes;
1110
+ const interactive = process.stdin.isTTY && !skipTui;
1111
+ if (interactive && options.clients.length === 0) {
1112
+ clients = await runClientSelect(clients);
547
1113
  }
548
- const count = configurePermissions(tools);
549
- // Login step (last — so when they return from browser, everything is done)
1114
+ const clientsLabel = clients.map((client) => client.name).join(", ");
1115
+ const setupSummary = applyClientSetup(clients, "apply");
1116
+ const permissionClient = clients.find((client) => client.supportsPermissions);
1117
+ let tools = [];
1118
+ if (permissionClient) {
1119
+ const mcpChanged = setupSummary.configured.includes(permissionClient.name);
1120
+ if (interactive) {
1121
+ tools = await runToolSelect(clientsLabel, mcpChanged);
1122
+ }
1123
+ else {
1124
+ tools = TOOL_GROUPS.flatMap((g) => g.tools);
1125
+ }
1126
+ }
1127
+ const count = tools.length > 0 ? configurePermissions(tools) : 0;
1128
+ const permissionCount = permissionClient ? count : null;
550
1129
  let loginResult;
551
1130
  if (interactive) {
552
- loginResult = await runLoginStep(agent, mcpChanged, count);
1131
+ loginResult = await runLoginStep(clientsLabel, setupSummary.configured.length > 0, permissionCount);
553
1132
  }
554
1133
  else {
555
1134
  try {
@@ -563,7 +1142,7 @@ export async function runSetup() {
563
1142
  loginResult = { status: "skipped" };
564
1143
  }
565
1144
  }
566
- printResult(agent, loginResult, mcpChanged, count);
1145
+ printResult(clientsLabel, loginResult, setupSummary.configured, setupSummary.alreadyConfigured, count);
567
1146
  process.exit(0);
568
1147
  }
569
1148
  /* ── Skill installation ────────────────────────────────────────── */
@@ -629,7 +1208,8 @@ export async function runRedditSetup() {
629
1208
  if (interactive) {
630
1209
  agent = await runAgentSelect("reddit");
631
1210
  }
632
- const mcpChanged = configureMcp();
1211
+ const claudeSetup = SUPPORTED_CLIENTS["claude-code"].configure("apply");
1212
+ const mcpChanged = claudeSetup.changed;
633
1213
  let tools;
634
1214
  if (interactive) {
635
1215
  tools = await runToolSelect(agent, mcpChanged, "reddit");