openalmanac 0.2.18 → 0.2.20

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.
@@ -9,4 +9,4 @@ export type LoginResult = {
9
9
  * Core login flow shared by CLI and MCP tool.
10
10
  * Checks for existing valid key, otherwise opens browser for auth.
11
11
  */
12
- export declare function performLogin(): Promise<LoginResult>;
12
+ export declare function performLogin(signal?: AbortSignal): Promise<LoginResult>;
@@ -3,11 +3,157 @@ import { getApiKey, saveApiKey, API_BASE } from "./auth.js";
3
3
  import { openBrowser } from "./browser.js";
4
4
  const CONNECT_URL_BASE = "https://openalmanac.org/contribute/connect";
5
5
  const LOGIN_TIMEOUT_MS = 120_000;
6
+ function callbackPage(success) {
7
+ // These values are hardcoded — never interpolate user input here.
8
+ const title = success ? "You\u2019re connected" : "Something went wrong";
9
+ const messageLine1 = success
10
+ ? "Your agent is registered and ready to go."
11
+ : "Invalid token. Please return to your terminal and try again.";
12
+ const messageLine2 = success
13
+ ? "You can close this tab and return to your terminal."
14
+ : "";
15
+ const statusIcon = success
16
+ ? `<svg width="20" height="20" fill="none" viewBox="0 0 24 24" style="position:absolute;bottom:-2px;right:-2px;background:#fff;border-radius:50%;padding:2px"><circle cx="12" cy="12" r="10" fill="#437a50"/><path stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" d="M8 12l3 3 5-5"/></svg>`
17
+ : `<svg width="20" height="20" fill="none" viewBox="0 0 24 24" style="position:absolute;bottom:-2px;right:-2px;background:#fff;border-radius:50%;padding:2px"><circle cx="12" cy="12" r="10" fill="#bb4444"/><path stroke="#fff" stroke-width="2" stroke-linecap="round" d="M9 9l6 6M15 9l-6 6"/></svg>`;
18
+ return `<!DOCTYPE html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="utf-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1">
23
+ <title>${title} \u2014 Almanac</title>
24
+ <link rel="icon" href="https://openalmanac.org/icon-32.png" sizes="32x32" type="image/png">
25
+ <style>
26
+ @import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@400;600&display=swap');
27
+ * { margin: 0; padding: 0; box-sizing: border-box; }
28
+ body {
29
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
30
+ background: #fcfbfa;
31
+ color: #1c1a19;
32
+ display: flex;
33
+ align-items: center;
34
+ justify-content: center;
35
+ min-height: 100vh;
36
+ }
37
+ .card {
38
+ display: flex;
39
+ flex-direction: column;
40
+ align-items: center;
41
+ text-align: center;
42
+ max-width: 420px;
43
+ padding: 48px 44px;
44
+ background: #fff;
45
+ border: 1px solid #e6e4df;
46
+ border-radius: 16px;
47
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
48
+ }
49
+ .logo-wrap {
50
+ position: relative;
51
+ margin-bottom: 24px;
52
+ }
53
+ .logo-wrap img {
54
+ width: 56px;
55
+ height: 56px;
56
+ border-radius: 12px;
57
+ }
58
+ h1 {
59
+ font-family: 'Source Serif 4', Georgia, serif;
60
+ font-size: 24px;
61
+ font-weight: 600;
62
+ margin-bottom: 8px;
63
+ color: #1c1a19;
64
+ }
65
+ p {
66
+ font-size: 15px;
67
+ color: #5c5855;
68
+ line-height: 1.6;
69
+ }
70
+ .divider {
71
+ width: 40px;
72
+ height: 1px;
73
+ background: #e6e4df;
74
+ margin: 24px 0;
75
+ }
76
+ .terminal {
77
+ width: 100%;
78
+ background: #1c1a19;
79
+ border-radius: 10px;
80
+ padding: 16px 18px;
81
+ text-align: left;
82
+ font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
83
+ font-size: 13px;
84
+ line-height: 1.7;
85
+ color: #a8a4a0;
86
+ overflow: hidden;
87
+ }
88
+ .terminal .prompt { color: #5c5855; }
89
+ .terminal .cmd { color: #e8e6e3; }
90
+ .terminal .cursor {
91
+ display: inline-block;
92
+ width: 7px;
93
+ height: 15px;
94
+ background: #e8e6e3;
95
+ vertical-align: text-bottom;
96
+ animation: blink 1s step-end infinite;
97
+ }
98
+ @keyframes blink {
99
+ 50% { opacity: 0; }
100
+ }
101
+ .line { min-height: 22px; }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <div class="card">
106
+ <div class="logo-wrap">
107
+ <img src="https://openalmanac.org/icon-192.png" alt="Almanac">
108
+ ${statusIcon}
109
+ </div>
110
+ <h1>${title}</h1>
111
+ <p>${messageLine1}${messageLine2 ? `<br>${messageLine2}` : ""}</p>${success ? `
112
+ <div class="divider"></div>
113
+ <div class="terminal">
114
+ <div class="line" id="line1"></div>
115
+ <div class="line" id="line2" style="display:none"></div>
116
+ </div>
117
+ <script>
118
+ const steps = [
119
+ { target: 'line1', prefix: '<span class="prompt">$ </span>', text: 'claude', delay: 600 },
120
+ { target: 'line2', prefix: '<span class="prompt">&gt; </span>', text: 'Use almanac tools to write an article on black holes', delay: 400 },
121
+ ];
122
+ function type(el, prefix, text, speed, cb) {
123
+ el.style.display = '';
124
+ el.innerHTML = prefix + '<span class="cmd"></span><span class="cursor"></span>';
125
+ const cmd = el.querySelector('.cmd');
126
+ let i = 0;
127
+ function next() {
128
+ if (i < text.length) {
129
+ cmd.textContent += text[i++];
130
+ setTimeout(next, speed + Math.random() * 40);
131
+ } else if (cb) {
132
+ setTimeout(cb, 600);
133
+ }
134
+ }
135
+ setTimeout(next, speed);
136
+ }
137
+ function run(idx) {
138
+ if (idx >= steps.length) return;
139
+ const s = steps[idx];
140
+ const prev = idx > 0 ? document.getElementById(steps[idx-1].target) : null;
141
+ if (prev) { const c = prev.querySelector('.cursor'); if (c) c.remove(); }
142
+ setTimeout(function() {
143
+ type(document.getElementById(s.target), s.prefix, s.text, 60, function() { run(idx+1); });
144
+ }, s.delay);
145
+ }
146
+ run(0);
147
+ </script>` : ""}
148
+ </div>
149
+ </body>
150
+ </html>`;
151
+ }
6
152
  /**
7
153
  * Core login flow shared by CLI and MCP tool.
8
154
  * Checks for existing valid key, otherwise opens browser for auth.
9
155
  */
10
- export async function performLogin() {
156
+ export async function performLogin(signal) {
11
157
  const existingKey = getApiKey();
12
158
  if (existingKey) {
13
159
  try {
@@ -36,14 +182,19 @@ export async function performLogin() {
36
182
  const callbackToken = url.searchParams.get("token");
37
183
  if (!callbackToken || !callbackToken.startsWith("oa_")) {
38
184
  res.writeHead(400, { "Content-Type": "text/html" });
39
- res.end("<h1>Invalid token</h1><p>Please return to OpenAlmanac and try again.</p>");
185
+ res.end(callbackPage(false));
40
186
  return;
41
187
  }
42
188
  res.writeHead(200, { "Content-Type": "text/html" });
43
- res.end("<h1>Connected</h1><p>Your OpenAlmanac agent is registered. You can close this tab.</p>");
189
+ res.end(callbackPage(true));
44
190
  resolve({ token: callbackToken, browserOpened: opened });
45
191
  httpServer.close();
46
192
  });
193
+ // Allow caller to abort (closes the server and rejects)
194
+ signal?.addEventListener("abort", () => {
195
+ httpServer.close();
196
+ reject(new Error("Login cancelled"));
197
+ }, { once: true });
47
198
  httpServer.listen(0, "127.0.0.1", () => {
48
199
  const addr = httpServer.address();
49
200
  if (!addr || typeof addr === "string") {
package/dist/setup.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
+ import { performLogin } from "./login-core.js";
5
+ import { getAuthStatus } from "./auth.js";
4
6
  const TOOL_GROUPS = [
5
7
  {
6
8
  name: "Search & Read",
@@ -60,20 +62,71 @@ const TOOL_GROUPS = [
60
62
  "Edit(~/.openalmanac/**)",
61
63
  ],
62
64
  },
65
+ {
66
+ name: "Web Access",
67
+ description: "web search & fetch used during research",
68
+ tools: ["WebSearch", "WebFetch"],
69
+ },
70
+ ];
71
+ const AGENTS = [
72
+ { name: "Claude Code", supported: true },
73
+ { name: "Codex", supported: false },
74
+ { name: "Cursor", supported: false },
75
+ { name: "Windsurf", supported: false },
63
76
  ];
64
77
  /* ── ANSI helpers ───────────────────────────────────────────────── */
65
78
  const RST = "\x1b[0m";
66
79
  const BOLD = "\x1b[1m";
67
80
  const DIM = "\x1b[2m";
68
- const GREEN = "\x1b[32m";
69
- const CYAN = "\x1b[36m";
70
- const RED = "\x1b[31m";
71
- const YELLOW = "\x1b[33m";
72
- const MAGENTA = "\x1b[35m";
73
81
  const WHITE_BOLD = "\x1b[1;37m";
82
+ const BLUE = "\x1b[38;5;75m"; // blue for accents
83
+ const BLUE_DIM = "\x1b[38;5;69m"; // slightly deeper blue for boxes
84
+ // Interactive TUI accent
85
+ const ACCENT = "\x1b[38;5;252m"; // silver
86
+ const ACCENT_BG = "\x1b[48;5;252m\x1b[38;5;16m"; // badge: silver bg, black text
87
+ // Banner gradient: white → silver
88
+ const GRADIENT = [
89
+ "\x1b[38;5;255m",
90
+ "\x1b[38;5;253m",
91
+ "\x1b[38;5;251m",
92
+ "\x1b[38;5;249m",
93
+ "\x1b[38;5;246m",
94
+ "\x1b[38;5;243m",
95
+ ];
96
+ /* ── ASCII banner ───────────────────────────────────────────────── */
97
+ const LOGO_LINES = [
98
+ " \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557",
99
+ "\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d",
100
+ "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 ",
101
+ "\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u255a\u2588\u2588\u2554\u255d\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u255a\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551 ",
102
+ "\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u255a\u2550\u255d \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2557",
103
+ "\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u2550\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d",
104
+ ];
105
+ function printBanner() {
106
+ process.stdout.write("\n");
107
+ for (let i = 0; i < LOGO_LINES.length; i++) {
108
+ process.stdout.write(`${GRADIENT[i]}${LOGO_LINES[i]}${RST}\n`);
109
+ }
110
+ process.stdout.write(`\n${DIM} Write and publish articles with your AI agent${RST}\n`);
111
+ }
112
+ function printBadge() {
113
+ process.stdout.write(`\n ${ACCENT_BG} almanac ${RST}\n`);
114
+ }
115
+ /* ── Step indicators ────────────────────────────────────────────── */
116
+ const BAR = ` ${DIM}\u2502${RST}`;
117
+ function stepDone(msg) {
118
+ process.stdout.write(` ${BLUE}\u25c7${RST} ${msg}\n`);
119
+ }
120
+ function stepActive(msg) {
121
+ process.stdout.write(` ${BLUE}\u25c6${RST} ${msg}\n`);
122
+ }
123
+ /* ── Helpers ────────────────────────────────────────────────────── */
124
+ // Strip ANSI codes to measure visible length
125
+ const vis = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
126
+ const w = (s) => process.stdout.write(s + "\n");
74
127
  /* ── File helpers ───────────────────────────────────────────────── */
75
128
  const CLAUDE_DIR = join(homedir(), ".claude");
76
- const MCP_JSON = join(CLAUDE_DIR, "mcp.json");
129
+ const CLAUDE_JSON = join(homedir(), ".claude.json");
77
130
  const SETTINGS_JSON = join(CLAUDE_DIR, "settings.json");
78
131
  function ensureDir(dir) {
79
132
  if (!existsSync(dir))
@@ -92,9 +145,8 @@ function writeJson(path, data) {
92
145
  }
93
146
  /* ── Step 1 — MCP server ───────────────────────────────────────── */
94
147
  function configureMcp() {
95
- ensureDir(CLAUDE_DIR);
96
148
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
- const cfg = readJson(MCP_JSON);
149
+ const cfg = readJson(CLAUDE_JSON);
98
150
  if (!cfg.mcpServers)
99
151
  cfg.mcpServers = {};
100
152
  const cur = cfg.mcpServers.almanac;
@@ -103,7 +155,7 @@ function configureMcp() {
103
155
  return false; // already set
104
156
  }
105
157
  cfg.mcpServers.almanac = { command: "npx", args: ["-y", "openalmanac"] };
106
- writeJson(MCP_JSON, cfg);
158
+ writeJson(CLAUDE_JSON, cfg);
107
159
  return true;
108
160
  }
109
161
  /* ── Step 2 — Permissions ──────────────────────────────────────── */
@@ -127,37 +179,259 @@ function configurePermissions(tools) {
127
179
  writeJson(SETTINGS_JSON, settings);
128
180
  return tools.length;
129
181
  }
130
- /* ── TUI ────────────────────────────────────────────────────────── */
131
- const MAX_NAME = Math.max(...TOOL_GROUPS.map((g) => g.name.length));
132
- function render(selected, cursor, mcpLine) {
133
- process.stdout.write("\x1b[2J\x1b[H"); // clear + home
134
- const w = (s) => process.stdout.write(s + "\n");
182
+ /* ── Agent selection screen ─────────────────────────────────────── */
183
+ function renderAgentSelect(_cursor) {
184
+ process.stdout.write("\x1b[2J\x1b[H");
185
+ printBanner();
186
+ printBadge();
135
187
  w("");
136
- w(` ${WHITE_BOLD}OpenAlmanac${RST} Claude Code Setup`);
137
- w(` ${DIM}${"─".repeat(36)}${RST}`);
188
+ stepActive(`Select your agent`);
189
+ w(BAR);
190
+ for (const agent of AGENTS) {
191
+ if (agent.supported) {
192
+ w(` ${DIM}\u2502${RST} ${BLUE}\u276f${RST} ${BLUE}\u25cf${RST} ${BOLD}${agent.name}${RST}`);
193
+ }
194
+ else {
195
+ w(` ${DIM}\u2502${RST} ${DIM}\u25cb ${agent.name}${" "}coming soon${RST}`);
196
+ }
197
+ }
198
+ w(BAR);
199
+ w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[enter]${RST} confirm ${DIM}[q] quit${RST}`);
138
200
  w("");
139
- w(` ${mcpLine}`);
201
+ }
202
+ function runAgentSelect() {
203
+ return new Promise((resolve) => {
204
+ renderAgentSelect(0);
205
+ process.stdin.setRawMode(true);
206
+ process.stdin.resume();
207
+ process.stdin.setEncoding("utf-8");
208
+ const cleanup = () => {
209
+ process.stdin.removeListener("data", onData);
210
+ process.stdin.setRawMode(false);
211
+ process.stdin.pause();
212
+ };
213
+ const onData = (key) => {
214
+ if (key === "\x03" || key === "q") {
215
+ cleanup();
216
+ process.stdout.write("\x1b[2J\x1b[H");
217
+ console.log("\n Setup cancelled.\n");
218
+ process.exit(0);
219
+ }
220
+ if (key === "\r" || key === "\n") {
221
+ cleanup();
222
+ const supported = AGENTS.find((a) => a.supported);
223
+ resolve(supported.name);
224
+ return;
225
+ }
226
+ };
227
+ process.stdin.on("data", onData);
228
+ });
229
+ }
230
+ /* ── Login step ─────────────────────────────────────────────────── */
231
+ function loginLabel(result) {
232
+ if (result.status === "already")
233
+ return `Logged in as ${WHITE_BOLD}${result.name}${RST}`;
234
+ if (result.status === "done")
235
+ return `Logged in`;
236
+ return `Login ${DIM}skipped${RST}`;
237
+ }
238
+ function waitForKey(prompt) {
239
+ return new Promise((resolve) => {
240
+ process.stdin.setRawMode(true);
241
+ process.stdin.resume();
242
+ process.stdin.setEncoding("utf-8");
243
+ w(prompt);
244
+ const onData = (key) => {
245
+ process.stdin.removeListener("data", onData);
246
+ process.stdin.setRawMode(false);
247
+ process.stdin.pause();
248
+ if (key === "\x03") {
249
+ process.stdout.write("\x1b[2J\x1b[H");
250
+ console.log("\n Setup cancelled.\n");
251
+ process.exit(0);
252
+ }
253
+ resolve(key);
254
+ };
255
+ process.stdin.on("data", onData);
256
+ });
257
+ }
258
+ async function runLoginStep(agent, mcpChanged, toolCount) {
259
+ const priorSteps = () => {
260
+ stepDone(`Agent \u2192 ${WHITE_BOLD}${agent}${RST}`);
261
+ w(BAR);
262
+ stepDone(`MCP server ${mcpChanged ? `added to ${DIM}~/.claude.json${RST}` : `${DIM}already configured${RST}`}`);
263
+ w(BAR);
264
+ stepDone(`${BLUE}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed`);
265
+ w(BAR);
266
+ };
267
+ function renderLoginChoice(name, cursor) {
268
+ process.stdout.write("\x1b[2J\x1b[H");
269
+ printBanner();
270
+ printBadge();
271
+ w("");
272
+ priorSteps();
273
+ stepActive(`Already logged in as ${WHITE_BOLD}${name}${RST}`);
274
+ w(BAR);
275
+ const options = [
276
+ `Continue as ${BOLD}${name}${RST}`,
277
+ `Login with a different account`,
278
+ ];
279
+ for (let i = 0; i < options.length; i++) {
280
+ const arrow = i === cursor ? `${BLUE}\u276f${RST}` : " ";
281
+ const label = i === cursor ? options[i] : `${DIM}${options[i]}${RST}`;
282
+ w(` ${DIM}\u2502${RST} ${arrow} ${label}`);
283
+ }
284
+ w(BAR);
285
+ w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[\u2191\u2193]${RST} move ${BLUE}${BOLD}[enter]${RST} confirm`);
286
+ w("");
287
+ }
288
+ function runLoginChoice(name) {
289
+ return new Promise((resolve) => {
290
+ let cursor = 0;
291
+ renderLoginChoice(name, cursor);
292
+ process.stdin.setRawMode(true);
293
+ process.stdin.resume();
294
+ process.stdin.setEncoding("utf-8");
295
+ const cleanup = () => {
296
+ process.stdin.removeListener("data", onData);
297
+ process.stdin.setRawMode(false);
298
+ process.stdin.pause();
299
+ };
300
+ const onData = (key) => {
301
+ if (key === "\x03" || key === "q") {
302
+ cleanup();
303
+ process.stdout.write("\x1b[2J\x1b[H");
304
+ console.log("\n Setup cancelled.\n");
305
+ process.exit(0);
306
+ }
307
+ if (key === "\x1b[A" || key === "k")
308
+ cursor = cursor === 0 ? 1 : 0;
309
+ else if (key === "\x1b[B" || key === "j")
310
+ cursor = cursor === 0 ? 1 : 0;
311
+ else if (key === "\r" || key === "\n") {
312
+ cleanup();
313
+ resolve(cursor === 0); // 0 = keep, 1 = new account
314
+ return;
315
+ }
316
+ renderLoginChoice(name, cursor);
317
+ };
318
+ process.stdin.on("data", onData);
319
+ });
320
+ }
321
+ // Check if already logged in
322
+ const auth = await getAuthStatus();
323
+ if (auth.loggedIn) {
324
+ const keepAccount = await runLoginChoice(auth.name);
325
+ if (keepAccount) {
326
+ return { status: "already", name: auth.name };
327
+ }
328
+ }
329
+ // Show prompt before opening browser
330
+ process.stdout.write("\x1b[2J\x1b[H");
331
+ printBanner();
332
+ printBadge();
140
333
  w("");
141
- w(` Allow all tools for a ${WHITE_BOLD}seamless${RST} writing experience?`);
142
- w(` ${DIM}Deselect any you'd rather approve manually.${RST}`);
334
+ priorSteps();
335
+ stepActive(`Login to Almanac`);
336
+ w(BAR);
337
+ w(` ${DIM}\u2502${RST} This will open ${WHITE_BOLD}almanac${RST} in your browser`);
338
+ w(` ${DIM}\u2502${RST} to connect your account.`);
339
+ w(BAR);
340
+ await waitForKey(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[enter]${RST} continue`);
341
+ // Show waiting state with cancel/retry hint
342
+ const renderWaiting = () => {
343
+ process.stdout.write("\x1b[2J\x1b[H");
344
+ printBanner();
345
+ printBadge();
346
+ w("");
347
+ priorSteps();
348
+ stepActive(`Waiting for login\u2026 ${DIM}complete in browser${RST}`);
349
+ w(BAR);
350
+ w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[r]${RST} retry ${DIM}[q] cancel setup${RST}`);
351
+ w("");
352
+ };
353
+ // Loop: attempt login, let user retry if it fails/times out
354
+ // eslint-disable-next-line no-constant-condition
355
+ while (true) {
356
+ renderWaiting();
357
+ // AbortController so we can kill the HTTP server on retry/cancel
358
+ const controller = new AbortController();
359
+ // Race login against keypress
360
+ const loginPromise = performLogin(controller.signal).then((result) => result.status === "already_logged_in"
361
+ ? { status: "already", name: result.name }
362
+ : { status: "done" }, () => ({ status: "skipped" }));
363
+ let keyOnData = null;
364
+ const keyPromise = new Promise((resolve) => {
365
+ process.stdin.setRawMode(true);
366
+ process.stdin.resume();
367
+ process.stdin.setEncoding("utf-8");
368
+ keyOnData = (key) => {
369
+ process.stdin.removeListener("data", keyOnData);
370
+ process.stdin.setRawMode(false);
371
+ process.stdin.pause();
372
+ resolve(key);
373
+ };
374
+ process.stdin.on("data", keyOnData);
375
+ });
376
+ const result = await Promise.race([
377
+ loginPromise.then((r) => ({ type: "login", result: r })),
378
+ keyPromise.then((k) => ({ type: "key", key: k })),
379
+ ]);
380
+ // Clean up stdin listener if login won
381
+ if (result.type === "login") {
382
+ if (keyOnData)
383
+ process.stdin.removeListener("data", keyOnData);
384
+ try {
385
+ process.stdin.setRawMode(false);
386
+ }
387
+ catch { /* already off */ }
388
+ process.stdin.pause();
389
+ return result.result;
390
+ }
391
+ // Key won — abort the login HTTP server
392
+ controller.abort();
393
+ // Handle keypress
394
+ if (result.key === "\x03" || result.key === "q") {
395
+ process.stdout.write("\x1b[2J\x1b[H");
396
+ console.log("\n Setup cancelled.\n");
397
+ process.exit(0);
398
+ }
399
+ if (result.key === "r") {
400
+ // Retry — loop continues
401
+ continue;
402
+ }
403
+ }
404
+ }
405
+ /* ── Tool permissions TUI ───────────────────────────────────────── */
406
+ const MAX_NAME = Math.max(...TOOL_GROUPS.map((g) => g.name.length));
407
+ function renderToolSelect(selected, cursor, agent, mcpChanged) {
408
+ process.stdout.write("\x1b[2J\x1b[H");
409
+ printBanner();
410
+ printBadge();
143
411
  w("");
412
+ stepDone(`Agent \u2192 ${WHITE_BOLD}${agent}${RST}`);
413
+ w(BAR);
414
+ stepDone(`MCP server ${mcpChanged ? `added to ${DIM}~/.claude.json${RST}` : `${DIM}already configured${RST}`}`);
415
+ w(BAR);
416
+ stepActive(`Select tool permissions ${DIM}deselect any you'd rather approve manually${RST}`);
417
+ w(BAR);
144
418
  for (let i = 0; i < TOOL_GROUPS.length; i++) {
145
- const arrow = i === cursor ? `${CYAN}❯${RST}` : " ";
146
- const check = selected[i] ? `${GREEN}✓${RST}` : " ";
419
+ const arrow = i === cursor ? `${BLUE}\u276f${RST}` : " ";
420
+ const check = selected[i] ? `${BLUE}\u2713${RST}` : " ";
147
421
  const pad = TOOL_GROUPS[i].name.padEnd(MAX_NAME + 2);
148
422
  const name = i === cursor ? `${BOLD}${pad}${RST}` : pad;
149
423
  const desc = `${DIM}${TOOL_GROUPS[i].description}${RST}`;
150
- w(` ${arrow} [${check}] ${name} ${desc}`);
424
+ w(` ${DIM}\u2502${RST} ${arrow} [${check}] ${name} ${desc}`);
151
425
  }
152
- w("");
153
- w(` ${GREEN}${BOLD}[enter]${RST} confirm ${CYAN}${BOLD}[space]${RST} toggle ${CYAN}${BOLD}[↑↓]${RST} move ${CYAN}${BOLD}[a]${RST} all ${CYAN}${BOLD}[q]${RST} quit`);
426
+ w(BAR);
427
+ 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}`);
154
428
  w("");
155
429
  }
156
- function runTui(mcpLine) {
430
+ function runToolSelect(agent, mcpChanged) {
157
431
  return new Promise((resolve) => {
158
- const selected = TOOL_GROUPS.map(() => true); // all on by default
432
+ const selected = TOOL_GROUPS.map(() => true);
159
433
  let cursor = 0;
160
- render(selected, cursor, mcpLine);
434
+ renderToolSelect(selected, cursor, agent, mcpChanged);
161
435
  process.stdin.setRawMode(true);
162
436
  process.stdin.resume();
163
437
  process.stdin.setEncoding("utf-8");
@@ -167,7 +441,6 @@ function runTui(mcpLine) {
167
441
  process.stdin.pause();
168
442
  };
169
443
  const onData = (key) => {
170
- // Ctrl-C / q → cancel
171
444
  if (key === "\x03" || key === "q") {
172
445
  cleanup();
173
446
  process.stdout.write("\x1b[2J\x1b[H");
@@ -194,38 +467,78 @@ function runTui(mcpLine) {
194
467
  resolve(tools);
195
468
  return;
196
469
  }
197
- render(selected, cursor, mcpLine);
470
+ renderToolSelect(selected, cursor, agent, mcpChanged);
198
471
  };
199
472
  process.stdin.on("data", onData);
200
473
  });
201
474
  }
202
475
  /* ── Result screen ──────────────────────────────────────────────── */
203
- function printResult(mcpChanged, toolCount) {
476
+ function printResult(agent, loginResult, mcpChanged, toolCount) {
204
477
  process.stdout.write("\x1b[2J\x1b[H");
205
- console.log("");
206
- console.log(` ${WHITE_BOLD}OpenAlmanac${RST} — Claude Code Setup`);
207
- console.log(` ${DIM}${"".repeat(36)}${RST}`);
208
- console.log("");
209
- console.log(` ${GREEN}✓${RST} MCP server ${mcpChanged ? "added to" : "already in"} ${DIM}~/.claude/mcp.json${RST}`);
210
- console.log(` ${GREEN}✓${RST} ${CYAN}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed in ${DIM}~/.claude/settings.json${RST}`);
211
- console.log("");
212
- console.log(` ${YELLOW}Restart Claude Code${RST} to apply changes.`);
213
- console.log(` Then try: ${MAGENTA}"Write an article about quantum computing"${RST}`);
214
- console.log("");
478
+ printBanner();
479
+ printBadge();
480
+ w("");
481
+ stepDone(`Agent \u2192 ${WHITE_BOLD}${agent}${RST}`);
482
+ w(BAR);
483
+ stepDone(`MCP server ${mcpChanged ? `added to ${DIM}~/.claude.json${RST}` : `${DIM}already configured${RST}`}`);
484
+ w(BAR);
485
+ stepDone(`${BLUE}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed in ${DIM}~/.claude/settings.json${RST}`);
486
+ w(BAR);
487
+ stepDone(loginLabel(loginResult));
488
+ w(BAR);
489
+ stepDone(`${BLUE}Setup complete${RST}`);
490
+ w("");
491
+ // Next steps box
492
+ const innerW = 52;
493
+ const row = (content) => {
494
+ const padding = Math.max(0, innerW - vis(content));
495
+ return ` ${BLUE_DIM}\u2502${RST}${content}${" ".repeat(padding)}${BLUE_DIM}\u2502${RST}`;
496
+ };
497
+ const empty = row("");
498
+ w(` ${BLUE_DIM}\u256d${"─".repeat(innerW)}\u256e${RST}`);
499
+ w(empty);
500
+ w(row(` ${WHITE_BOLD}Next steps${RST}`));
501
+ w(empty);
502
+ w(row(` ${BLUE}1.${RST} Type ${WHITE_BOLD}claude${RST} to start Claude Code`));
503
+ w(row(` ${BLUE}2.${RST} Say ${BLUE}"Use almanac tools to write an article on <topic>"${RST}`));
504
+ w(empty);
505
+ w(` ${BLUE_DIM}\u2570${"─".repeat(innerW)}\u256f${RST}`);
506
+ w("");
215
507
  }
216
508
  /* ── Entry point ────────────────────────────────────────────────── */
217
509
  export async function runSetup() {
218
510
  const skipTui = process.argv.includes("--yes") || process.argv.includes("-y");
219
511
  const interactive = process.stdin.isTTY && !skipTui;
512
+ let agent = "Claude Code";
513
+ if (interactive) {
514
+ agent = await runAgentSelect();
515
+ }
220
516
  const mcpChanged = configureMcp();
221
- const mcpLine = `${GREEN}✓${RST} MCP server ${mcpChanged ? "added" : "already configured"}`;
222
517
  let tools;
223
518
  if (interactive) {
224
- tools = await runTui(mcpLine);
519
+ tools = await runToolSelect(agent, mcpChanged);
225
520
  }
226
521
  else {
227
522
  tools = TOOL_GROUPS.flatMap((g) => g.tools);
228
523
  }
229
524
  const count = configurePermissions(tools);
230
- printResult(mcpChanged, count);
525
+ // Login step (last — so when they return from browser, everything is done)
526
+ let loginResult;
527
+ if (interactive) {
528
+ loginResult = await runLoginStep(agent, mcpChanged, count);
529
+ }
530
+ else {
531
+ try {
532
+ const result = await performLogin();
533
+ loginResult =
534
+ result.status === "already_logged_in"
535
+ ? { status: "already", name: result.name }
536
+ : { status: "done" };
537
+ }
538
+ catch {
539
+ loginResult = { status: "skipped" };
540
+ }
541
+ }
542
+ printResult(agent, loginResult, mcpChanged, count);
543
+ process.exit(0);
231
544
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {