openalmanac 0.2.19 → 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",
@@ -66,16 +68,62 @@ const TOOL_GROUPS = [
66
68
  tools: ["WebSearch", "WebFetch"],
67
69
  },
68
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 },
76
+ ];
69
77
  /* ── ANSI helpers ───────────────────────────────────────────────── */
70
78
  const RST = "\x1b[0m";
71
79
  const BOLD = "\x1b[1m";
72
80
  const DIM = "\x1b[2m";
73
- const GREEN = "\x1b[32m";
74
- const CYAN = "\x1b[36m";
75
- const RED = "\x1b[31m";
76
- const YELLOW = "\x1b[33m";
77
- const MAGENTA = "\x1b[35m";
78
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");
79
127
  /* ── File helpers ───────────────────────────────────────────────── */
80
128
  const CLAUDE_DIR = join(homedir(), ".claude");
81
129
  const CLAUDE_JSON = join(homedir(), ".claude.json");
@@ -131,37 +179,259 @@ function configurePermissions(tools) {
131
179
  writeJson(SETTINGS_JSON, settings);
132
180
  return tools.length;
133
181
  }
134
- /* ── TUI ────────────────────────────────────────────────────────── */
135
- const MAX_NAME = Math.max(...TOOL_GROUPS.map((g) => g.name.length));
136
- function render(selected, cursor, mcpLine) {
137
- process.stdout.write("\x1b[2J\x1b[H"); // clear + home
138
- 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();
139
187
  w("");
140
- w(` ${WHITE_BOLD}OpenAlmanac${RST} Claude Code Setup`);
141
- 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}`);
142
200
  w("");
143
- 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();
144
333
  w("");
145
- w(` Allow all tools for a ${WHITE_BOLD}seamless${RST} writing experience?`);
146
- 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();
147
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);
148
418
  for (let i = 0; i < TOOL_GROUPS.length; i++) {
149
- const arrow = i === cursor ? `${CYAN}❯${RST}` : " ";
150
- const check = selected[i] ? `${GREEN}✓${RST}` : " ";
419
+ const arrow = i === cursor ? `${BLUE}\u276f${RST}` : " ";
420
+ const check = selected[i] ? `${BLUE}\u2713${RST}` : " ";
151
421
  const pad = TOOL_GROUPS[i].name.padEnd(MAX_NAME + 2);
152
422
  const name = i === cursor ? `${BOLD}${pad}${RST}` : pad;
153
423
  const desc = `${DIM}${TOOL_GROUPS[i].description}${RST}`;
154
- w(` ${arrow} [${check}] ${name} ${desc}`);
424
+ w(` ${DIM}\u2502${RST} ${arrow} [${check}] ${name} ${desc}`);
155
425
  }
156
- w("");
157
- 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}`);
158
428
  w("");
159
429
  }
160
- function runTui(mcpLine) {
430
+ function runToolSelect(agent, mcpChanged) {
161
431
  return new Promise((resolve) => {
162
- const selected = TOOL_GROUPS.map(() => true); // all on by default
432
+ const selected = TOOL_GROUPS.map(() => true);
163
433
  let cursor = 0;
164
- render(selected, cursor, mcpLine);
434
+ renderToolSelect(selected, cursor, agent, mcpChanged);
165
435
  process.stdin.setRawMode(true);
166
436
  process.stdin.resume();
167
437
  process.stdin.setEncoding("utf-8");
@@ -171,7 +441,6 @@ function runTui(mcpLine) {
171
441
  process.stdin.pause();
172
442
  };
173
443
  const onData = (key) => {
174
- // Ctrl-C / q → cancel
175
444
  if (key === "\x03" || key === "q") {
176
445
  cleanup();
177
446
  process.stdout.write("\x1b[2J\x1b[H");
@@ -198,38 +467,78 @@ function runTui(mcpLine) {
198
467
  resolve(tools);
199
468
  return;
200
469
  }
201
- render(selected, cursor, mcpLine);
470
+ renderToolSelect(selected, cursor, agent, mcpChanged);
202
471
  };
203
472
  process.stdin.on("data", onData);
204
473
  });
205
474
  }
206
475
  /* ── Result screen ──────────────────────────────────────────────── */
207
- function printResult(mcpChanged, toolCount) {
476
+ function printResult(agent, loginResult, mcpChanged, toolCount) {
208
477
  process.stdout.write("\x1b[2J\x1b[H");
209
- console.log("");
210
- console.log(` ${WHITE_BOLD}OpenAlmanac${RST} — Claude Code Setup`);
211
- console.log(` ${DIM}${"".repeat(36)}${RST}`);
212
- console.log("");
213
- console.log(` ${GREEN}✓${RST} MCP server ${mcpChanged ? "added to" : "already in"} ${DIM}~/.claude/mcp.json${RST}`);
214
- console.log(` ${GREEN}✓${RST} ${CYAN}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed in ${DIM}~/.claude/settings.json${RST}`);
215
- console.log("");
216
- console.log(` ${YELLOW}Restart Claude Code${RST} to apply changes.`);
217
- console.log(` Then try: ${MAGENTA}"Write an article about quantum computing"${RST}`);
218
- 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("");
219
507
  }
220
508
  /* ── Entry point ────────────────────────────────────────────────── */
221
509
  export async function runSetup() {
222
510
  const skipTui = process.argv.includes("--yes") || process.argv.includes("-y");
223
511
  const interactive = process.stdin.isTTY && !skipTui;
512
+ let agent = "Claude Code";
513
+ if (interactive) {
514
+ agent = await runAgentSelect();
515
+ }
224
516
  const mcpChanged = configureMcp();
225
- const mcpLine = `${GREEN}✓${RST} MCP server ${mcpChanged ? "added" : "already configured"}`;
226
517
  let tools;
227
518
  if (interactive) {
228
- tools = await runTui(mcpLine);
519
+ tools = await runToolSelect(agent, mcpChanged);
229
520
  }
230
521
  else {
231
522
  tools = TOOL_GROUPS.flatMap((g) => g.tools);
232
523
  }
233
524
  const count = configurePermissions(tools);
234
- 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);
235
544
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.2.19",
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": {