openalmanac 0.2.19 → 0.2.21

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,7 @@ 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(options?: {
13
+ signal?: AbortSignal;
14
+ forceNew?: boolean;
15
+ }): Promise<LoginResult>;
@@ -3,25 +3,174 @@ 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() {
11
- const existingKey = getApiKey();
12
- if (existingKey) {
13
- try {
14
- const resp = await fetch(`${API_BASE}/api/agents/me`, {
15
- headers: { Authorization: `Bearer ${existingKey}` },
16
- signal: AbortSignal.timeout(10_000),
17
- });
18
- if (resp.ok) {
19
- const data = (await resp.json());
20
- return { status: "already_logged_in", name: data.name ?? "unknown" };
156
+ export async function performLogin(options) {
157
+ const { signal, forceNew } = options ?? {};
158
+ if (!forceNew) {
159
+ const existingKey = getApiKey();
160
+ if (existingKey) {
161
+ try {
162
+ const resp = await fetch(`${API_BASE}/api/agents/me`, {
163
+ headers: { Authorization: `Bearer ${existingKey}` },
164
+ signal: AbortSignal.timeout(10_000),
165
+ });
166
+ if (resp.ok) {
167
+ const data = (await resp.json());
168
+ return { status: "already_logged_in", name: data.name ?? "unknown" };
169
+ }
170
+ }
171
+ catch {
172
+ // Key invalid or network error, continue to login
21
173
  }
22
- }
23
- catch {
24
- // Key invalid or network error, continue to login
25
174
  }
26
175
  }
27
176
  const { token, browserOpened } = await new Promise((resolve, reject) => {
@@ -36,14 +185,19 @@ export async function performLogin() {
36
185
  const callbackToken = url.searchParams.get("token");
37
186
  if (!callbackToken || !callbackToken.startsWith("oa_")) {
38
187
  res.writeHead(400, { "Content-Type": "text/html" });
39
- res.end("<h1>Invalid token</h1><p>Please return to OpenAlmanac and try again.</p>");
188
+ res.end(callbackPage(false));
40
189
  return;
41
190
  }
42
191
  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>");
192
+ res.end(callbackPage(true));
44
193
  resolve({ token: callbackToken, browserOpened: opened });
45
194
  httpServer.close();
46
195
  });
196
+ // Allow caller to abort (closes the server and rejects)
197
+ signal?.addEventListener("abort", () => {
198
+ httpServer.close();
199
+ reject(new Error("Login cancelled"));
200
+ }, { once: true });
47
201
  httpServer.listen(0, "127.0.0.1", () => {
48
202
  const addr = httpServer.address();
49
203
  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,261 @@ 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
+ let forceNew = false;
323
+ const auth = await getAuthStatus();
324
+ if (auth.loggedIn) {
325
+ const keepAccount = await runLoginChoice(auth.name);
326
+ if (keepAccount) {
327
+ return { status: "already", name: auth.name };
328
+ }
329
+ forceNew = true;
330
+ }
331
+ // Show prompt before opening browser
332
+ process.stdout.write("\x1b[2J\x1b[H");
333
+ printBanner();
334
+ printBadge();
144
335
  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}`);
336
+ priorSteps();
337
+ stepActive(`Login to Almanac`);
338
+ w(BAR);
339
+ w(` ${DIM}\u2502${RST} This will open ${WHITE_BOLD}almanac${RST} in your browser`);
340
+ w(` ${DIM}\u2502${RST} to connect your account.`);
341
+ w(BAR);
342
+ await waitForKey(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[enter]${RST} continue`);
343
+ // Show waiting state with cancel/retry hint
344
+ const renderWaiting = () => {
345
+ process.stdout.write("\x1b[2J\x1b[H");
346
+ printBanner();
347
+ printBadge();
348
+ w("");
349
+ priorSteps();
350
+ stepActive(`Waiting for login\u2026 ${DIM}complete in browser${RST}`);
351
+ w(BAR);
352
+ w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[r]${RST} retry ${DIM}[q] cancel setup${RST}`);
353
+ w("");
354
+ };
355
+ // Loop: attempt login, let user retry if it fails/times out
356
+ // eslint-disable-next-line no-constant-condition
357
+ while (true) {
358
+ renderWaiting();
359
+ // AbortController so we can kill the HTTP server on retry/cancel
360
+ const controller = new AbortController();
361
+ // Race login against keypress
362
+ const loginPromise = performLogin({ signal: controller.signal, forceNew }).then((result) => result.status === "already_logged_in"
363
+ ? { status: "already", name: result.name }
364
+ : { status: "done" }, () => ({ status: "skipped" }));
365
+ let keyOnData = null;
366
+ const keyPromise = new Promise((resolve) => {
367
+ process.stdin.setRawMode(true);
368
+ process.stdin.resume();
369
+ process.stdin.setEncoding("utf-8");
370
+ keyOnData = (key) => {
371
+ process.stdin.removeListener("data", keyOnData);
372
+ process.stdin.setRawMode(false);
373
+ process.stdin.pause();
374
+ resolve(key);
375
+ };
376
+ process.stdin.on("data", keyOnData);
377
+ });
378
+ const result = await Promise.race([
379
+ loginPromise.then((r) => ({ type: "login", result: r })),
380
+ keyPromise.then((k) => ({ type: "key", key: k })),
381
+ ]);
382
+ // Clean up stdin listener if login won
383
+ if (result.type === "login") {
384
+ if (keyOnData)
385
+ process.stdin.removeListener("data", keyOnData);
386
+ try {
387
+ process.stdin.setRawMode(false);
388
+ }
389
+ catch { /* already off */ }
390
+ process.stdin.pause();
391
+ return result.result;
392
+ }
393
+ // Key won — abort the login HTTP server
394
+ controller.abort();
395
+ // Handle keypress
396
+ if (result.key === "\x03" || result.key === "q") {
397
+ process.stdout.write("\x1b[2J\x1b[H");
398
+ console.log("\n Setup cancelled.\n");
399
+ process.exit(0);
400
+ }
401
+ if (result.key === "r") {
402
+ // Retry — loop continues
403
+ continue;
404
+ }
405
+ }
406
+ }
407
+ /* ── Tool permissions TUI ───────────────────────────────────────── */
408
+ const MAX_NAME = Math.max(...TOOL_GROUPS.map((g) => g.name.length));
409
+ function renderToolSelect(selected, cursor, agent, mcpChanged) {
410
+ process.stdout.write("\x1b[2J\x1b[H");
411
+ printBanner();
412
+ printBadge();
147
413
  w("");
414
+ stepDone(`Agent \u2192 ${WHITE_BOLD}${agent}${RST}`);
415
+ w(BAR);
416
+ stepDone(`MCP server ${mcpChanged ? `added to ${DIM}~/.claude.json${RST}` : `${DIM}already configured${RST}`}`);
417
+ w(BAR);
418
+ stepActive(`Select tool permissions ${DIM}deselect any you'd rather approve manually${RST}`);
419
+ w(BAR);
148
420
  for (let i = 0; i < TOOL_GROUPS.length; i++) {
149
- const arrow = i === cursor ? `${CYAN}❯${RST}` : " ";
150
- const check = selected[i] ? `${GREEN}✓${RST}` : " ";
421
+ const arrow = i === cursor ? `${BLUE}\u276f${RST}` : " ";
422
+ const check = selected[i] ? `${BLUE}\u2713${RST}` : " ";
151
423
  const pad = TOOL_GROUPS[i].name.padEnd(MAX_NAME + 2);
152
424
  const name = i === cursor ? `${BOLD}${pad}${RST}` : pad;
153
425
  const desc = `${DIM}${TOOL_GROUPS[i].description}${RST}`;
154
- w(` ${arrow} [${check}] ${name} ${desc}`);
426
+ w(` ${DIM}\u2502${RST} ${arrow} [${check}] ${name} ${desc}`);
155
427
  }
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`);
428
+ w(BAR);
429
+ 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
430
  w("");
159
431
  }
160
- function runTui(mcpLine) {
432
+ function runToolSelect(agent, mcpChanged) {
161
433
  return new Promise((resolve) => {
162
- const selected = TOOL_GROUPS.map(() => true); // all on by default
434
+ const selected = TOOL_GROUPS.map(() => true);
163
435
  let cursor = 0;
164
- render(selected, cursor, mcpLine);
436
+ renderToolSelect(selected, cursor, agent, mcpChanged);
165
437
  process.stdin.setRawMode(true);
166
438
  process.stdin.resume();
167
439
  process.stdin.setEncoding("utf-8");
@@ -171,7 +443,6 @@ function runTui(mcpLine) {
171
443
  process.stdin.pause();
172
444
  };
173
445
  const onData = (key) => {
174
- // Ctrl-C / q → cancel
175
446
  if (key === "\x03" || key === "q") {
176
447
  cleanup();
177
448
  process.stdout.write("\x1b[2J\x1b[H");
@@ -198,38 +469,78 @@ function runTui(mcpLine) {
198
469
  resolve(tools);
199
470
  return;
200
471
  }
201
- render(selected, cursor, mcpLine);
472
+ renderToolSelect(selected, cursor, agent, mcpChanged);
202
473
  };
203
474
  process.stdin.on("data", onData);
204
475
  });
205
476
  }
206
477
  /* ── Result screen ──────────────────────────────────────────────── */
207
- function printResult(mcpChanged, toolCount) {
478
+ function printResult(agent, loginResult, mcpChanged, toolCount) {
208
479
  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("");
480
+ printBanner();
481
+ printBadge();
482
+ w("");
483
+ stepDone(`Agent \u2192 ${WHITE_BOLD}${agent}${RST}`);
484
+ w(BAR);
485
+ stepDone(`MCP server ${mcpChanged ? `added to ${DIM}~/.claude.json${RST}` : `${DIM}already configured${RST}`}`);
486
+ w(BAR);
487
+ stepDone(`${BLUE}${toolCount}${RST} tool${toolCount !== 1 ? "s" : ""} allowed in ${DIM}~/.claude/settings.json${RST}`);
488
+ w(BAR);
489
+ stepDone(loginLabel(loginResult));
490
+ w(BAR);
491
+ stepDone(`${BLUE}Setup complete${RST}`);
492
+ w("");
493
+ // Next steps box
494
+ const innerW = 52;
495
+ const row = (content) => {
496
+ const padding = Math.max(0, innerW - vis(content));
497
+ return ` ${BLUE_DIM}\u2502${RST}${content}${" ".repeat(padding)}${BLUE_DIM}\u2502${RST}`;
498
+ };
499
+ const empty = row("");
500
+ w(` ${BLUE_DIM}\u256d${"─".repeat(innerW)}\u256e${RST}`);
501
+ w(empty);
502
+ w(row(` ${WHITE_BOLD}Next steps${RST}`));
503
+ w(empty);
504
+ w(row(` ${BLUE}1.${RST} Type ${WHITE_BOLD}claude${RST} to start Claude Code`));
505
+ w(row(` ${BLUE}2.${RST} Say ${BLUE}"Use almanac tools to write an article on <topic>"${RST}`));
506
+ w(empty);
507
+ w(` ${BLUE_DIM}\u2570${"─".repeat(innerW)}\u256f${RST}`);
508
+ w("");
219
509
  }
220
510
  /* ── Entry point ────────────────────────────────────────────────── */
221
511
  export async function runSetup() {
222
512
  const skipTui = process.argv.includes("--yes") || process.argv.includes("-y");
223
513
  const interactive = process.stdin.isTTY && !skipTui;
514
+ let agent = "Claude Code";
515
+ if (interactive) {
516
+ agent = await runAgentSelect();
517
+ }
224
518
  const mcpChanged = configureMcp();
225
- const mcpLine = `${GREEN}✓${RST} MCP server ${mcpChanged ? "added" : "already configured"}`;
226
519
  let tools;
227
520
  if (interactive) {
228
- tools = await runTui(mcpLine);
521
+ tools = await runToolSelect(agent, mcpChanged);
229
522
  }
230
523
  else {
231
524
  tools = TOOL_GROUPS.flatMap((g) => g.tools);
232
525
  }
233
526
  const count = configurePermissions(tools);
234
- printResult(mcpChanged, count);
527
+ // Login step (last — so when they return from browser, everything is done)
528
+ let loginResult;
529
+ if (interactive) {
530
+ loginResult = await runLoginStep(agent, mcpChanged, count);
531
+ }
532
+ else {
533
+ try {
534
+ const result = await performLogin();
535
+ loginResult =
536
+ result.status === "already_logged_in"
537
+ ? { status: "already", name: result.name }
538
+ : { status: "done" };
539
+ }
540
+ catch {
541
+ loginResult = { status: "skipped" };
542
+ }
543
+ }
544
+ printResult(agent, loginResult, mcpChanged, count);
545
+ process.exit(0);
235
546
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {