@every-env/spiral-cli 0.2.0 → 1.0.1

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/src/cli.ts CHANGED
@@ -1,1018 +1,334 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import * as readline from "node:readline";
4
- import { parseArgs } from "node:util";
5
- import { password } from "@inquirer/prompts";
6
- import chalk from "chalk";
7
- import { marked } from "marked";
8
- import { markedTerminal } from "marked-terminal";
9
- import ora from "ora";
10
- import { fetchConversations, fetchMessages, getApiBaseUrl, streamChat } from "./api";
11
- import { formatAttachmentsSummary, processAttachments } from "./attachments";
3
+ import { parseArgs } from "util";
4
+ import { input } from "@inquirer/prompts";
5
+ import { theme, EXIT_CODES } from "./theme.js";
6
+ import { sanitizeError } from "./auth.js";
7
+ import * as auth from "./auth.js";
8
+ import * as api from "./api.js";
9
+ import * as output from "./output.js";
12
10
  import {
13
- clearStoredPAT,
14
- clearTokenCache,
15
- getAuthStatus,
16
- getAuthToken,
17
- sanitizeError,
18
- storePAT,
19
- } from "./auth";
20
- import { config } from "./config";
21
- import {
22
- editDraft,
23
- listDrafts,
24
- listVersions,
25
- restoreVersion,
26
- updateDraftContent,
27
- viewDraft,
28
- } from "./drafts";
29
- import { addNote, clearNotes, listNotes, removeNote } from "./notes";
30
- import { listStyles } from "./styles";
31
- import {
32
- applySuggestion,
33
- dismissSuggestion,
34
- listPendingSuggestions,
35
- previewSuggestion,
36
- registerSuggestions,
37
- } from "./suggestions";
38
- import { EXIT_CODES, theme } from "./theme";
39
- import { cleanupToolSpinners, handleToolCallUpdate } from "./tools/renderer";
40
- import { ApiError, type Attachment, AuthenticationError, SpiralCliError } from "./types";
41
- import { listWorkspaces } from "./workspaces";
42
-
43
- // Configure marked for terminal output
44
- marked.use(
45
- markedTerminal({
46
- heading: chalk.cyan.bold,
47
- code: chalk.yellow,
48
- codespan: chalk.bgBlack.yellow,
49
- }),
50
- );
51
-
52
- const VERSION = "0.2.0";
53
-
54
- // Parse CLI arguments
55
- const { values, positionals } = parseArgs({
56
- args: Bun.argv.slice(2),
57
- options: {
58
- help: { type: "boolean", short: "h" },
59
- version: { type: "boolean", short: "v" },
60
- session: { type: "string", short: "s" },
61
- json: { type: "boolean" },
62
- quiet: { type: "boolean", short: "q" },
63
- new: { type: "boolean", short: "n" },
64
- limit: { type: "string" },
65
- debug: { type: "boolean", short: "d" },
66
- // New options for extended features
67
- attach: { type: "string", multiple: true, short: "a" },
68
- style: { type: "string" },
69
- workspace: { type: "string" },
70
- force: { type: "boolean", short: "f" },
71
- content: { type: "string" }, // For agent-native draft updates
72
- title: { type: "string" }, // For draft title
73
- versionId: { type: "string" }, // For version restore
74
- },
75
- allowPositionals: true,
76
- });
11
+ SpiralCliError,
12
+ AuthenticationError,
13
+ ApiError,
14
+ type GenerateRequest,
15
+ type GenerateResponse,
16
+ } from "./types.js";
77
17
 
78
- // Enable debug mode via flag
79
- if (values.debug) {
80
- process.env.DEBUG = "1";
81
- }
82
-
83
- // Store pending attachments for REPL mode
84
- let pendingAttachments: Attachment[] = [];
18
+ const VERSION = "1.0.1";
85
19
 
86
- async function main(): Promise<void> {
87
- const command = positionals[0];
20
+ const HELP = `
21
+ ${theme.heading("spiral")} write with Spiral from your terminal
88
22
 
89
- if (values.version) {
90
- console.log(`spiral-cli v${VERSION}`);
91
- process.exit(EXIT_CODES.SUCCESS);
92
- }
93
-
94
- if (values.help || !command) {
95
- showHelp();
96
- process.exit(EXIT_CODES.SUCCESS);
97
- }
98
-
99
- switch (command) {
100
- case "send":
101
- await sendCommand(positionals.slice(1).join(" "));
102
- break;
103
- case "chat":
104
- await chatCommand();
105
- break;
106
- case "sessions":
107
- await sessionsCommand();
108
- break;
109
- case "history":
110
- await historyCommand(positionals[1]);
111
- break;
112
- case "auth":
113
- await authCommand(positionals[1]);
114
- break;
115
- // New commands
116
- case "styles":
117
- await listStyles({ json: values.json });
118
- break;
119
- case "workspaces":
120
- await listWorkspaces({ json: values.json });
121
- break;
122
- case "drafts":
123
- await draftsCommand();
124
- break;
125
- case "draft":
126
- await draftCommand(positionals[1], positionals[2]);
127
- break;
128
- case "notes":
129
- await notesCommand(positionals[1], positionals.slice(2).join(" "));
130
- break;
131
- case "suggestions":
132
- await suggestionsCommand(positionals[1], positionals[2]);
133
- break;
134
- default:
135
- console.error(theme.error(`Unknown command: ${command}`));
136
- process.exit(EXIT_CODES.INVALID_ARGS);
137
- }
23
+ ${theme.heading("Usage:")}
24
+ spiral write "a blog post about X" Create content
25
+ spiral write "refine tone" --session <id> Refine existing drafts
26
+ spiral styles List writing styles
27
+ spiral workspaces List workspaces
28
+ spiral sessions List conversations
29
+ spiral drafts --session <id> List drafts in a session
30
+ spiral quota Session quota info
31
+ spiral prime Agent discovery document
32
+ spiral auth login|status|logout Manage authentication
33
+
34
+ ${theme.heading("Flags:")}
35
+ --json Machine-readable JSON output
36
+ --session <id> Session ID for multi-turn / refinement
37
+ --style <id> Writing style ID
38
+ --workspace <id> Workspace ID
39
+ --instant Skip clarifying questions
40
+ --num-drafts <n> Number of drafts (1-5, default 1)
41
+ --token <key> API key for auth login
42
+ --debug Show debug info on errors
43
+ --help, -h Show this help
44
+ --version, -v Show version
45
+ `;
46
+
47
+ interface ParsedArgs {
48
+ command: string;
49
+ positional: string[];
50
+ flags: {
51
+ json: boolean;
52
+ session?: string;
53
+ style?: string;
54
+ workspace?: string;
55
+ instant: boolean;
56
+ numDrafts?: number;
57
+ token?: string;
58
+ debug: boolean;
59
+ help: boolean;
60
+ version: boolean;
61
+ limit?: number;
62
+ };
138
63
  }
139
64
 
140
- /**
141
- * Non-interactive send command (Agent-Native)
142
- * Usage: spiral send "Your message here" [--session id] [--json] [--attach file...]
143
- */
144
- async function sendCommand(initialMessage: string): Promise<void> {
145
- let message = initialMessage;
146
- if (!message) {
147
- // Check for stdin piping
148
- if (!process.stdin.isTTY) {
149
- const chunks: string[] = [];
150
- for await (const chunk of process.stdin) {
151
- chunks.push(chunk.toString());
152
- }
153
- message = chunks.join("");
154
- } else {
155
- console.error(theme.error('Usage: spiral send "Your message"'));
156
- process.exit(EXIT_CODES.INVALID_ARGS);
157
- }
158
- }
159
-
160
- const sessionId = values.session || null;
161
- const outputJson = values.json;
162
- const quiet = values.quiet;
163
- const spinner = quiet ? null : ora("Sending...").start();
164
-
165
- // Process attachments if provided
166
- let attachments: Attachment[] = [];
167
- if (values.attach && values.attach.length > 0) {
168
- try {
169
- attachments = await processAttachments(values.attach, { quiet });
170
- if (!quiet && !outputJson) {
171
- console.log(theme.info(`Attached: ${formatAttachmentsSummary(attachments)}`));
172
- }
173
- } catch (error) {
174
- console.error(theme.error((error as Error).message));
175
- process.exit(EXIT_CODES.INVALID_ARGS);
176
- }
177
- }
178
-
179
- // Performance: Use array accumulation (O(n) vs O(n²) string concat)
180
- const chunks: string[] = [];
181
- const thinkingChunks: string[] = [];
182
- let finalSessionId = sessionId || "";
183
-
184
- const controller = new AbortController();
185
-
186
- // Handle Ctrl+C
187
- process.on("SIGINT", () => {
188
- controller.abort();
189
- spinner?.stop();
190
- cleanupToolSpinners();
191
- if (outputJson) {
192
- console.log(JSON.stringify({ status: "cancelled", partial: chunks.join("") }));
193
- } else {
194
- console.log(theme.warning("\n(Cancelled)"));
195
- }
196
- process.exit(EXIT_CODES.GENERAL_ERROR);
65
+ function parse(argv: string[]): ParsedArgs {
66
+ const { values, positionals } = parseArgs({
67
+ args: argv.slice(2),
68
+ options: {
69
+ json: { type: "boolean", default: false },
70
+ session: { type: "string" },
71
+ style: { type: "string" },
72
+ workspace: { type: "string" },
73
+ instant: { type: "boolean", default: false },
74
+ "num-drafts": { type: "string" },
75
+ token: { type: "string" },
76
+ debug: { type: "boolean", default: false },
77
+ help: { type: "boolean", short: "h", default: false },
78
+ version: { type: "boolean", short: "v", default: false },
79
+ limit: { type: "string" },
80
+ },
81
+ allowPositionals: true,
82
+ strict: false,
197
83
  });
198
84
 
199
- try {
200
- await streamChat(
201
- message,
202
- sessionId,
203
- {
204
- onChunk: (chunk) => {
205
- chunks.push(chunk);
206
- if (!outputJson && !quiet) {
207
- spinner?.stop();
208
- process.stdout.write(chunk);
209
- }
210
- },
211
- onThinking: (thought) => {
212
- thinkingChunks.push(thought);
213
- if (spinner) spinner.text = `Thinking: ${thought.slice(0, 40)}...`;
214
- },
215
- onToolCall: (tool) => {
216
- if (spinner && tool.status === "started") {
217
- spinner.text = `Using ${tool.tool_name}...`;
218
- }
219
- // Enhanced tool call visualization
220
- handleToolCallUpdate({
221
- callId: tool.tool_call_id || tool.call_id || `tool-${Date.now()}`,
222
- toolName: tool.tool_name,
223
- status: tool.status,
224
- result: tool.result,
225
- error: tool.error,
226
- });
227
- },
228
- onSessionName: () => {},
229
- onRetry: (info) => {
230
- if (spinner) spinner.text = `Retrying (${info.attempt}/${info.max})...`;
231
- },
232
- onModelDowngrade: (from, to) => {
233
- if (!quiet && !outputJson) {
234
- console.log(theme.warning(`\nNote: Model downgraded from ${from} to ${to}`));
235
- }
236
- },
237
- onComplete: (id) => {
238
- finalSessionId = id;
239
- },
240
- onError: (error) => {
241
- throw error;
242
- },
243
- },
244
- controller.signal,
245
- {
246
- attachments: attachments.length > 0 ? attachments : undefined,
247
- },
248
- );
85
+ const command = positionals[0] ?? "";
86
+ const rest = positionals.slice(1);
87
+
88
+ return {
89
+ command,
90
+ positional: rest,
91
+ flags: {
92
+ json: values.json as boolean,
93
+ session: values.session as string | undefined,
94
+ style: values.style as string | undefined,
95
+ workspace: values.workspace as string | undefined,
96
+ instant: values.instant as boolean,
97
+ numDrafts: values["num-drafts"]
98
+ ? Number.parseInt(values["num-drafts"] as string, 10)
99
+ : undefined,
100
+ token: values.token as string | undefined,
101
+ debug: values.debug as boolean,
102
+ help: values.help as boolean,
103
+ version: values.version as boolean,
104
+ limit: values.limit
105
+ ? Number.parseInt(values.limit as string, 10)
106
+ : undefined,
107
+ },
108
+ };
109
+ }
249
110
 
250
- const content = chunks.join("");
111
+ // ── Commands ──
251
112
 
252
- // Register any suggestions from the response
253
- const sugCount = registerSuggestions(content);
254
- if (sugCount > 0 && !quiet && !outputJson) {
255
- console.log(theme.info(`\n${sugCount} suggestion(s) parsed. Use /suggestions to view.`));
256
- }
113
+ async function cmdWrite(args: ParsedArgs): Promise<void> {
114
+ // Read prompt from positional args or stdin
115
+ let prompt = args.positional.join(" ").trim();
257
116
 
258
- if (outputJson) {
259
- console.log(
260
- JSON.stringify({
261
- status: "success",
262
- session_id: finalSessionId,
263
- content,
264
- thinking: thinkingChunks.join(""),
265
- suggestions_count: sugCount,
266
- }),
267
- );
268
- } else {
269
- console.log(); // Newline after streaming
117
+ if (!prompt && !process.stdin.isTTY) {
118
+ // Read from piped stdin
119
+ const chunks: Buffer[] = [];
120
+ for await (const chunk of process.stdin) {
121
+ chunks.push(chunk as Buffer);
270
122
  }
271
-
272
- process.exit(EXIT_CODES.SUCCESS);
273
- } catch (error) {
274
- spinner?.stop();
275
- cleanupToolSpinners();
276
- handleError(error as Error);
123
+ prompt = Buffer.concat(chunks).toString("utf-8").trim();
277
124
  }
278
- }
279
-
280
- /**
281
- * Interactive REPL command
282
- */
283
- async function chatCommand(): Promise<void> {
284
- console.log(`${theme.heading("Spiral Chat")} (type /help for commands)\n`);
285
125
 
286
- if (process.env.DEBUG) {
287
- console.log(theme.dim(`API: ${getApiBaseUrl()}\n`));
126
+ if (!prompt && !args.flags.session) {
127
+ throw new SpiralCliError(
128
+ 'Usage: spiral write "your prompt here"',
129
+ EXIT_CODES.INVALID_ARGS,
130
+ );
288
131
  }
289
132
 
290
- const rl = readline.createInterface({
291
- input: process.stdin,
292
- output: process.stdout,
293
- prompt: theme.user("You: "),
294
- });
295
-
296
- let sessionId = values.session || null;
297
- let currentController: AbortController | null = null;
133
+ const req: GenerateRequest = {
134
+ prompt: prompt || undefined,
135
+ session_id: args.flags.session,
136
+ style_id: args.flags.style,
137
+ workspace_id: args.flags.workspace,
138
+ mode: args.flags.instant ? "instant" : "interactive",
139
+ num_drafts: args.flags.numDrafts,
140
+ };
298
141
 
299
- const handleInput = async (input: string): Promise<void> => {
300
- const trimmed = input.trim();
142
+ let resp: GenerateResponse = await output.withSpinner("Writing...", () =>
143
+ api.generate(req),
144
+ );
301
145
 
302
- // Handle REPL commands
303
- if (trimmed.startsWith("/")) {
304
- const newSession = await handleReplCommand(trimmed, rl, sessionId);
305
- if (newSession !== undefined) {
306
- sessionId = newSession;
307
- }
308
- rl.prompt();
146
+ // Multi-turn loop: handle needs_input
147
+ while (resp.status === "needs_input") {
148
+ if (args.flags.json || !process.stdin.isTTY) {
149
+ // Non-interactive: return the needs_input response for caller to handle
150
+ output.outputJson(resp);
309
151
  return;
310
152
  }
311
153
 
312
- if (!trimmed) {
313
- rl.prompt();
314
- return;
315
- }
154
+ // Show questions
155
+ output.formatGenerateResponse(resp);
316
156
 
317
- const spinner = ora("Thinking...").start();
318
- const chunks: string[] = [];
319
- currentController = new AbortController();
320
-
321
- try {
322
- await streamChat(
323
- trimmed,
324
- sessionId,
325
- {
326
- onChunk: (chunk) => {
327
- if (spinner.isSpinning) {
328
- spinner.stop();
329
- process.stdout.write(theme.assistant("Spiral: "));
330
- }
331
- chunks.push(chunk);
332
- process.stdout.write(chunk);
333
- },
334
- onThinking: (thought) => {
335
- spinner.text = theme.thinking(`Thinking: ${thought.slice(0, 40)}...`);
336
- },
337
- onToolCall: (tool) => {
338
- if (tool.status === "started") {
339
- spinner.text = `Using ${tool.tool_name}...`;
340
- }
341
- },
342
- onSessionName: (name) => {
343
- // Could update terminal title here
344
- if (process.env.DEBUG) {
345
- console.log(theme.dim(`\n[Session: ${name}]`));
346
- }
347
- },
348
- onRetry: (info) => {
349
- spinner.text = theme.warning(`Retrying (${info.attempt}/${info.max})...`);
350
- },
351
- onModelDowngrade: (from, to) => {
352
- console.log(theme.warning(`\nNote: Model changed from ${from} to ${to}`));
353
- },
354
- onComplete: (id) => {
355
- sessionId = id;
356
- },
357
- onError: (error) => {
358
- throw error;
359
- },
360
- },
361
- currentController.signal,
362
- );
363
-
364
- // Register suggestions from response
365
- const content = chunks.join("");
366
- const sugCount = registerSuggestions(content);
367
- if (sugCount > 0) {
368
- console.log(theme.info(`\n${sugCount} suggestion(s) parsed. Use /suggestions to view.`));
369
- }
370
-
371
- console.log("\n"); // Newline after response
372
- } catch (error) {
373
- if (!currentController.signal.aborted) {
374
- spinner.fail(sanitizeError(error as Error));
375
- }
376
- }
377
-
378
- currentController = null;
379
- pendingAttachments = []; // Clear attachments after use
380
- rl.prompt();
381
- };
157
+ // Prompt for answers
158
+ const answer = await input({ message: "Your response:" });
382
159
 
383
- rl.on("line", (input) => {
384
- handleInput(input).catch((error) => {
385
- console.error(theme.error("Error:"), sanitizeError(error));
386
- rl.prompt();
387
- });
388
- });
160
+ resp = await output.withSpinner("Writing...", () =>
161
+ api.generate({
162
+ session_id: resp.session_id,
163
+ responses: answer,
164
+ }),
165
+ );
166
+ }
389
167
 
390
- // Handle Ctrl+C: first cancels stream, second exits
391
- rl.on("SIGINT", () => {
392
- if (currentController) {
393
- currentController.abort();
394
- currentController = null;
395
- console.log(theme.warning("\n(Stream cancelled)"));
396
- rl.prompt();
397
- } else {
398
- console.log(theme.dim("\nGoodbye!"));
399
- process.exit(EXIT_CODES.SUCCESS);
400
- }
401
- });
168
+ if (args.flags.json) {
169
+ output.outputJson(resp);
170
+ } else {
171
+ output.formatGenerateResponse(resp);
172
+ }
173
+ }
402
174
 
403
- rl.prompt();
175
+ async function cmdStyles(args: ParsedArgs): Promise<void> {
176
+ const styles = await output.withSpinner("Loading styles...", () =>
177
+ api.getStyles(),
178
+ );
179
+ if (args.flags.json) {
180
+ output.outputJson(styles);
181
+ } else {
182
+ output.formatStyles(styles);
183
+ }
404
184
  }
405
185
 
406
- /**
407
- * Handle REPL slash commands
408
- * Returns new session ID if changed, undefined otherwise
409
- */
410
- async function handleReplCommand(
411
- cmd: string,
412
- _rl: readline.Interface,
413
- sessionId: string | null,
414
- ): Promise<string | null | undefined> {
415
- const parts = cmd.slice(1).split(" ");
416
- const command = parts[0];
417
- const args = parts.slice(1);
418
-
419
- switch (command) {
420
- case "help":
421
- console.log(`
422
- ${theme.heading("Commands:")}
423
- /help Show this help
424
- /exit Exit the chat
425
- /clear Clear the screen
426
- /history Show current session history
427
- /sessions List all sessions
428
- /new Start a new session
429
- /session <id> Switch to different session
430
- /debug Toggle debug mode
431
-
432
- ${theme.heading("Content Management:")}
433
- /drafts List drafts in current session
434
- /draft view <id> View a draft
435
- /draft edit <id> Edit a draft in $EDITOR
436
- /draft versions <id> Show version history
437
- /styles List writing styles
438
- /style <id> Set writing style for messages
439
- /workspaces List workspaces
440
- /workspace <id> Set workspace for new sessions
441
-
442
- ${theme.heading("Notes & Suggestions:")}
443
- /note <text> Add a note to scratchpad
444
- /notes List all notes
445
- /notes clear Clear all notes
446
- /suggestions List pending suggestions
447
- /apply <id> Apply a suggestion
448
- /dismiss <id> Dismiss a suggestion
449
-
450
- ${theme.heading("Attachments:")}
451
- /attach <files...> Queue files for next message
452
- `);
453
- break;
454
- case "exit":
455
- case "quit":
456
- console.log(theme.dim("Goodbye!"));
457
- return process.exit(EXIT_CODES.SUCCESS);
458
- case "clear":
459
- console.clear();
460
- break;
461
- case "history":
462
- if (sessionId) {
463
- await historyCommand(sessionId);
464
- } else {
465
- console.log(theme.warning("No active session. Start a conversation first."));
466
- }
467
- break;
468
- case "sessions":
469
- await sessionsCommand();
470
- break;
471
- case "new":
472
- console.log(theme.info("Starting new session..."));
473
- return null; // Clear session ID
474
- case "session":
475
- if (args[0]) {
476
- console.log(theme.info(`Switching to session ${args[0]}...`));
477
- return args[0];
478
- }
479
- console.log(theme.error("Usage: /session <id>"));
480
- break;
481
- case "debug":
482
- process.env.DEBUG = process.env.DEBUG ? "" : "1";
483
- console.log(theme.info(`Debug mode: ${process.env.DEBUG ? "ON" : "OFF"}`));
484
- break;
485
- // New commands
486
- case "drafts":
487
- if (sessionId) {
488
- await listDrafts(sessionId, { json: false });
489
- } else {
490
- console.log(theme.warning("No active session."));
491
- }
492
- break;
493
- case "draft": {
494
- if (!sessionId) {
495
- console.log(theme.warning("No active session."));
496
- break;
497
- }
498
- const [draftAction, draftArg] = args;
499
- switch (draftAction) {
500
- case "view":
501
- if (draftArg) await viewDraft(sessionId, draftArg, { json: false });
502
- else console.log(theme.error("Usage: /draft view <id>"));
503
- break;
504
- case "edit":
505
- if (draftArg) await editDraft(sessionId, draftArg, { json: false });
506
- else console.log(theme.error("Usage: /draft edit <id>"));
507
- break;
508
- case "versions":
509
- if (draftArg) await listVersions(sessionId, draftArg, { json: false });
510
- else console.log(theme.error("Usage: /draft versions <id>"));
511
- break;
512
- default:
513
- console.log(theme.error("Usage: /draft [view|edit|versions] <id>"));
514
- }
515
- break;
516
- }
517
- case "styles":
518
- await listStyles({ json: false });
519
- break;
520
- case "style":
521
- if (args[0]) {
522
- config.set("currentStyleId", args[0]);
523
- console.log(theme.info(`Style set to ${args[0]}`));
524
- } else {
525
- const current = config.get("currentStyleId");
526
- if (current) {
527
- console.log(theme.info(`Current style: ${current}`));
528
- } else {
529
- console.log(theme.error("Usage: /style <id>"));
530
- }
531
- }
532
- break;
533
- case "workspaces":
534
- await listWorkspaces({ json: false });
535
- break;
536
- case "workspace":
537
- if (args[0]) {
538
- config.set("currentWorkspaceId", args[0]);
539
- console.log(theme.info(`Workspace set to ${args[0]}`));
540
- console.log(theme.warning("Note: Start a /new session to use this workspace."));
541
- } else {
542
- const current = config.get("currentWorkspaceId");
543
- if (current) {
544
- console.log(theme.info(`Current workspace: ${current}`));
545
- } else {
546
- console.log(theme.error("Usage: /workspace <id>"));
547
- }
548
- }
549
- break;
550
- case "note":
551
- if (args.length > 0) {
552
- addNote(args.join(" "));
553
- } else {
554
- console.log(theme.error("Usage: /note <text>"));
555
- }
556
- break;
557
- case "notes":
558
- if (args[0] === "clear") {
559
- await clearNotes({ json: false });
560
- } else {
561
- listNotes({ json: false });
562
- }
563
- break;
564
- case "suggestions":
565
- listPendingSuggestions({ json: false });
566
- break;
567
- case "apply":
568
- if (args[0] && sessionId) {
569
- await applySuggestion(sessionId, args[0], { json: false });
570
- } else if (!sessionId) {
571
- console.log(theme.warning("No active session."));
572
- } else {
573
- console.log(theme.error("Usage: /apply <suggestion-id>"));
574
- }
575
- break;
576
- case "dismiss":
577
- if (args[0]) {
578
- dismissSuggestion(args[0], { json: false });
579
- } else {
580
- console.log(theme.error("Usage: /dismiss <suggestion-id>"));
581
- }
582
- break;
583
- case "attach":
584
- if (args.length > 0) {
585
- try {
586
- pendingAttachments = await processAttachments(args);
587
- console.log(
588
- theme.success(`Queued ${pendingAttachments.length} file(s) for next message.`),
589
- );
590
- } catch (error) {
591
- console.log(theme.error((error as Error).message));
592
- }
593
- } else {
594
- console.log(theme.error("Usage: /attach <file1> [file2] ..."));
595
- }
596
- break;
597
- default:
598
- console.log(theme.error(`Unknown command: ${command}`));
186
+ async function cmdWorkspaces(args: ParsedArgs): Promise<void> {
187
+ const workspaces = await output.withSpinner("Loading workspaces...", () =>
188
+ api.getWorkspaces(),
189
+ );
190
+ if (args.flags.json) {
191
+ output.outputJson(workspaces);
192
+ } else {
193
+ output.formatWorkspaces(workspaces);
599
194
  }
600
- return undefined;
601
195
  }
602
196
 
603
- /**
604
- * Drafts command handler
605
- */
606
- async function draftsCommand(): Promise<void> {
607
- const sessionId = values.session;
608
- if (!sessionId) {
609
- console.error(theme.error("Usage: spiral drafts --session <id>"));
610
- process.exit(EXIT_CODES.INVALID_ARGS);
197
+ async function cmdSessions(args: ParsedArgs): Promise<void> {
198
+ const data = await output.withSpinner("Loading sessions...", () =>
199
+ api.getConversations(),
200
+ );
201
+ if (args.flags.json) {
202
+ output.outputJson(data.sessions);
203
+ } else {
204
+ output.formatConversations(data.sessions);
611
205
  }
612
- await listDrafts(sessionId, { json: values.json });
613
206
  }
614
207
 
615
- /**
616
- * Draft subcommand handler
617
- */
618
- async function draftCommand(action?: string, draftId?: string): Promise<void> {
619
- const sessionId = values.session;
208
+ async function cmdDrafts(args: ParsedArgs): Promise<void> {
209
+ const sessionId = args.flags.session;
620
210
  if (!sessionId) {
621
- console.error(theme.error("--session required for draft commands"));
622
- process.exit(EXIT_CODES.INVALID_ARGS);
211
+ throw new SpiralCliError(
212
+ "Usage: spiral drafts --session <id>",
213
+ EXIT_CODES.INVALID_ARGS,
214
+ );
623
215
  }
624
-
625
- if (!action || !draftId) {
626
- console.error(theme.error("Usage: spiral draft [view|edit|update|versions|restore] <id>"));
627
- process.exit(EXIT_CODES.INVALID_ARGS);
216
+ const drafts = await output.withSpinner("Loading drafts...", () =>
217
+ api.getSessionDrafts(sessionId),
218
+ );
219
+ if (args.flags.json) {
220
+ output.outputJson(drafts);
221
+ } else {
222
+ output.formatSessionDrafts(drafts);
628
223
  }
224
+ }
629
225
 
630
- const limit = values.limit ? Number.parseInt(values.limit, 10) : undefined;
631
-
632
- switch (action) {
633
- case "view":
634
- await viewDraft(sessionId, draftId, { json: values.json });
635
- break;
636
- case "edit":
637
- await editDraft(sessionId, draftId, { json: values.json });
638
- break;
639
- case "update":
640
- // Agent-native update with --content flag
641
- if (!values.content) {
642
- console.error(theme.error("--content required for update"));
643
- process.exit(EXIT_CODES.INVALID_ARGS);
644
- }
645
- await updateDraftContent(sessionId, draftId, values.content, {
646
- json: values.json,
647
- title: values.title,
648
- });
649
- break;
650
- case "versions":
651
- await listVersions(sessionId, draftId, { json: values.json, limit });
652
- break;
653
- case "restore":
654
- if (!values.versionId) {
655
- console.error(theme.error("--versionId required for restore"));
656
- process.exit(EXIT_CODES.INVALID_ARGS);
657
- }
658
- await restoreVersion(sessionId, draftId, values.versionId, { json: values.json });
659
- break;
660
- default:
661
- console.error(theme.error(`Unknown draft action: ${action}`));
662
- process.exit(EXIT_CODES.INVALID_ARGS);
226
+ async function cmdQuota(args: ParsedArgs): Promise<void> {
227
+ const quota = await output.withSpinner("Loading quota...", () =>
228
+ api.getQuota(),
229
+ );
230
+ if (args.flags.json) {
231
+ output.outputJson(quota);
232
+ } else {
233
+ output.formatQuota(quota);
663
234
  }
664
235
  }
665
236
 
666
- /**
667
- * Notes command handler
668
- */
669
- async function notesCommand(action?: string, content?: string): Promise<void> {
670
- const opts = { json: values.json, force: values.force };
671
-
672
- switch (action) {
673
- case "list":
674
- case undefined:
675
- listNotes(opts);
676
- break;
677
- case "add":
678
- if (!content) {
679
- console.error(theme.error("Usage: spiral notes add <content>"));
680
- process.exit(EXIT_CODES.INVALID_ARGS);
681
- }
682
- addNote(content, undefined, opts);
683
- break;
684
- case "clear":
685
- await clearNotes(opts);
686
- break;
687
- case "remove":
688
- if (!content) {
689
- console.error(theme.error("Usage: spiral notes remove <id>"));
690
- process.exit(EXIT_CODES.INVALID_ARGS);
691
- }
692
- removeNote(content, opts);
693
- break;
694
- default:
695
- // Treat as adding a note if no recognized action
696
- addNote([action, content].filter(Boolean).join(" "), undefined, opts);
237
+ async function cmdPrime(args: ParsedArgs): Promise<void> {
238
+ const doc = await output.withSpinner("Loading prime...", () =>
239
+ api.getPrime(),
240
+ );
241
+ if (args.flags.json) {
242
+ output.outputJson({ content: doc });
243
+ } else {
244
+ output.outputMarkdown(doc);
697
245
  }
698
246
  }
699
247
 
700
- /**
701
- * Suggestions command handler
702
- */
703
- async function suggestionsCommand(action?: string, suggestionId?: string): Promise<void> {
704
- const opts = { json: values.json, force: values.force };
705
- const sessionId = values.session;
706
-
707
- switch (action) {
708
- case "list":
709
- case undefined:
710
- listPendingSuggestions(opts);
248
+ async function cmdAuth(args: ParsedArgs): Promise<void> {
249
+ const sub = args.positional[0];
250
+ switch (sub) {
251
+ case "login":
252
+ await auth.login(args.flags.token);
711
253
  break;
712
- case "preview":
713
- if (!suggestionId) {
714
- console.error(theme.error("Usage: spiral suggestions preview <id>"));
715
- process.exit(EXIT_CODES.INVALID_ARGS);
716
- }
717
- await previewSuggestion(suggestionId, opts);
254
+ case "status":
255
+ auth.showStatus();
718
256
  break;
719
- case "apply":
720
- if (!suggestionId) {
721
- console.error(theme.error("Usage: spiral suggestions apply <id>"));
722
- process.exit(EXIT_CODES.INVALID_ARGS);
723
- }
724
- if (!sessionId) {
725
- console.error(theme.error("--session required for apply"));
726
- process.exit(EXIT_CODES.INVALID_ARGS);
727
- }
728
- await applySuggestion(sessionId, suggestionId, opts);
729
- break;
730
- case "dismiss":
731
- if (!suggestionId) {
732
- console.error(theme.error("Usage: spiral suggestions dismiss <id>"));
733
- process.exit(EXIT_CODES.INVALID_ARGS);
734
- }
735
- dismissSuggestion(suggestionId, opts);
257
+ case "logout":
258
+ auth.logout();
736
259
  break;
737
260
  default:
738
- console.error(theme.error(`Unknown suggestions action: ${action}`));
739
- process.exit(EXIT_CODES.INVALID_ARGS);
261
+ console.log("Usage: spiral auth login|status|logout");
262
+ break;
740
263
  }
741
264
  }
742
265
 
743
- /**
744
- * List sessions command (supports --json)
745
- */
746
- async function sessionsCommand(): Promise<void> {
747
- const spinner = values.quiet ? null : ora("Fetching sessions...").start();
748
-
749
- try {
750
- const conversations = await fetchConversations();
751
- spinner?.succeed("Sessions loaded");
752
-
753
- if (values.json) {
754
- console.log(JSON.stringify(conversations));
755
- return;
756
- }
757
-
758
- console.log(theme.heading("\nYour Sessions:\n"));
759
-
760
- const limit = Number.parseInt(values.limit || "20", 10);
761
- for (const conv of conversations.slice(0, limit)) {
762
- const date = new Date(conv.created_at).toLocaleDateString();
763
- console.log(
764
- `${theme.dim(conv.session_id.slice(0, 8))} ${chalk.white(conv.session_name || "Untitled")} ${theme.dim(`(${date})`)}`,
765
- );
766
- }
266
+ // ── Main ──
767
267
 
768
- if (conversations.length === 0) {
769
- console.log(theme.dim("No sessions found."));
770
- }
771
- } catch (error) {
772
- spinner?.stop();
773
- handleError(error as Error);
774
- }
775
- }
268
+ async function main(): Promise<void> {
269
+ const args = parse(process.argv);
776
270
 
777
- /**
778
- * View history command (supports --json, --limit)
779
- */
780
- async function historyCommand(conversationId?: string): Promise<void> {
781
- if (!conversationId) {
782
- console.error(theme.error("Usage: spiral history <session-id>"));
783
- process.exit(EXIT_CODES.INVALID_ARGS);
271
+ if (args.flags.version) {
272
+ console.log(VERSION);
273
+ return;
784
274
  }
785
275
 
786
- const spinner = values.quiet ? null : ora("Fetching history...").start();
787
-
788
- try {
789
- const messages = await fetchMessages(conversationId);
790
- spinner?.succeed("History loaded");
791
-
792
- if (values.json) {
793
- console.log(JSON.stringify(messages));
794
- return;
795
- }
796
-
797
- const limit = Number.parseInt(values.limit || "50", 10);
798
- console.log(theme.heading(`\nSession History (${messages.length} messages):\n`));
799
-
800
- for (const msg of messages.slice(-limit)) {
801
- const role = msg.role === "user" ? theme.user("You") : theme.assistant("Spiral");
802
- const content = msg.content || "";
803
- const preview = content.slice(0, 200);
804
- console.log(`${role}: ${preview}${content.length > 200 ? "..." : ""}\n`);
805
- }
806
- } catch (error) {
807
- spinner?.stop();
808
- handleError(error as Error);
276
+ if (args.flags.help || !args.command) {
277
+ console.log(HELP);
278
+ return;
809
279
  }
810
- }
811
280
 
812
- /**
813
- * Auth command
814
- */
815
- async function authCommand(action?: string): Promise<void> {
816
- switch (action) {
817
- case "login": {
818
- console.log(theme.heading("\nSpiral CLI Login\n"));
819
- console.log(
820
- theme.dim("Get your API key at: https://app.writewithspiral.com → Account → API Keys\n"),
821
- );
822
-
823
- try {
824
- const apiKey = await password({
825
- message: "Enter your API key:",
826
- mask: "•",
827
- });
828
-
829
- if (!apiKey || !apiKey.trim()) {
830
- console.log(theme.error("No API key provided"));
831
- process.exit(EXIT_CODES.AUTH_ERROR);
832
- }
833
-
834
- storePAT(apiKey.trim());
835
- console.log(theme.success("\n✓ Logged in successfully!"));
836
- console.log(theme.dim("Your API key has been saved securely.\n"));
837
- } catch (error) {
838
- if ((error as Error).message?.includes("spiral_sk_")) {
839
- console.log(theme.error((error as Error).message));
840
- } else {
841
- console.log(theme.error("Login cancelled"));
842
- }
843
- process.exit(EXIT_CODES.AUTH_ERROR);
844
- }
281
+ switch (args.command) {
282
+ case "write":
283
+ await cmdWrite(args);
845
284
  break;
846
- }
847
-
848
- case "logout": {
849
- const status = getAuthStatus();
850
- if (status.method === "pat") {
851
- clearStoredPAT();
852
- console.log(theme.success("Logged out. API key removed."));
853
- } else if (status.method === "env") {
854
- console.log(
855
- theme.warning("Using SPIRAL_TOKEN environment variable. Unset it to log out."),
856
- );
857
- } else {
858
- console.log(theme.info("Not logged in with an API key."));
859
- }
285
+ case "styles":
286
+ await cmdStyles(args);
860
287
  break;
861
- }
862
-
863
- case "status": {
864
- const status = getAuthStatus();
865
-
866
- if (values.json) {
867
- console.log(JSON.stringify(status));
868
- return;
869
- }
870
-
871
- switch (status.method) {
872
- case "env":
873
- console.log(theme.success("Authenticated via SPIRAL_TOKEN environment variable"));
874
- console.log(theme.dim(`Token: ${status.tokenPrefix}`));
875
- break;
876
- case "pat":
877
- console.log(theme.success("Authenticated with API key"));
878
- console.log(theme.dim(`Key: ${status.tokenPrefix}`));
879
- if (status.createdAt) {
880
- console.log(theme.dim(`Saved: ${new Date(status.createdAt).toLocaleDateString()}`));
881
- }
882
- break;
883
- case "none":
884
- // Try browser fallback
885
- try {
886
- const token = await getAuthToken();
887
- console.log(theme.success("Authenticated via browser session"));
888
- console.log(theme.dim(`Token: ${token.slice(0, 20)}...`));
889
- } catch {
890
- console.log(theme.warning("Not authenticated"));
891
- console.log(
892
- theme.dim("\nRun `spiral auth login` to authenticate with an API key."),
893
- );
894
- }
895
- break;
896
- }
288
+ case "workspaces":
289
+ await cmdWorkspaces(args);
897
290
  break;
898
- }
899
-
900
- case "clear":
901
- clearStoredPAT();
902
- clearTokenCache();
903
- console.log(theme.info("All credentials cleared."));
291
+ case "sessions":
292
+ await cmdSessions(args);
293
+ break;
294
+ case "drafts":
295
+ await cmdDrafts(args);
296
+ break;
297
+ case "quota":
298
+ await cmdQuota(args);
299
+ break;
300
+ case "prime":
301
+ await cmdPrime(args);
302
+ break;
303
+ case "auth":
304
+ await cmdAuth(args);
904
305
  break;
905
-
906
306
  default:
907
- console.log(`
908
- ${theme.heading("Auth Commands:")}
909
- spiral auth login Login with API key (recommended)
910
- spiral auth logout Remove stored API key
911
- spiral auth status Check authentication status
912
- spiral auth clear Clear all stored credentials
913
-
914
- ${theme.dim("Get your API key at: https://app.writewithspiral.com → Account → API Keys")}
915
- `);
307
+ console.error(theme.error(`Unknown command: ${args.command}`));
308
+ console.log(HELP);
309
+ process.exit(EXIT_CODES.INVALID_ARGS);
916
310
  }
917
311
  }
918
312
 
919
- /**
920
- * Show help
921
- */
922
- function showHelp(): void {
923
- console.log(`
924
- ${theme.heading("spiral-cli")} - Interact with Spiral API from the terminal
925
-
926
- ${theme.heading("Usage:")}
927
- spiral send <message> [options] Send single message (agent-native)
928
- spiral chat [--session <id>] [--new] Start interactive chat
929
- spiral sessions [--json] [--limit N] List sessions
930
- spiral history <session-id> [--json] View session history
931
- spiral auth login Login with API key
932
- spiral auth [status|logout|clear] Manage authentication
933
-
934
- ${theme.heading("Content Management:")}
935
- spiral styles [--json] List writing styles
936
- spiral workspaces [--json] List workspaces
937
- spiral drafts --session <id> [--json] List drafts in a session
938
- spiral draft <action> <id> --session <id> [--json]
939
- view View draft content
940
- edit Edit in $EDITOR
941
- update Update with --content (agent-native)
942
- versions View version history
943
- restore Restore with --versionId
944
-
945
- ${theme.heading("Notes & Suggestions:")}
946
- spiral notes [list|add|clear|remove] Manage local notes
947
- spiral suggestions [list|preview|apply|dismiss] <id>
948
-
949
- ${theme.heading("Send Options:")}
950
- --attach, -a <files...> Attach files to message
951
- --style <id> Use writing style
952
- --workspace <id> Use workspace context
953
-
954
- ${theme.heading("General Options:")}
955
- --help, -h Show this help
956
- --version, -v Show version
957
- --session, -s Session ID to resume
958
- --json Output as JSON (for scripting/agents)
959
- --quiet, -q Suppress spinners and progress
960
- --new, -n Start new session
961
- --limit Limit results
962
- --debug, -d Enable debug output
963
- --force, -f Skip confirmations
964
-
965
- ${theme.heading("Examples:")}
966
- spiral send "Write a haiku" --json
967
- spiral send "Analyze this" --attach data.csv
968
- spiral chat --session abc123
969
- spiral drafts --session abc123
970
- spiral draft edit draft-id --session abc123
971
- spiral draft update draft-id --session abc123 --content "New content"
972
- spiral notes add "Remember to check X"
973
- spiral suggestions apply sug-123 --session abc123
974
-
975
- ${theme.heading("Exit Codes:")}
976
- 0 Success
977
- 1 General error
978
- 2 Authentication failed
979
- 3 API error
980
- 4 Network error
981
- 5 Invalid arguments
982
-
983
- ${theme.heading("Environment Variables:")}
984
- SPIRAL_API_URL Override API endpoint
985
- SPIRAL_TOKEN Provide auth token directly
986
- EDITOR Editor for draft editing (default: vi)
987
- DEBUG Enable verbose debug logging
988
-
989
- ${theme.heading("Authentication:")}
990
- spiral-cli extracts your session from Safari automatically.
991
- Make sure you're logged in at ${theme.info("https://app.writewithspiral.com")}
992
- `);
993
- }
994
-
995
- /**
996
- * Handle errors with proper exit codes
997
- */
998
- function handleError(error: Error): never {
999
- const message = sanitizeError(error);
1000
-
1001
- if (values.json) {
1002
- console.log(JSON.stringify({ status: "error", error: message }));
1003
- } else {
1004
- console.error(theme.error("Error:"), message);
313
+ main().catch((err) => {
314
+ if (err instanceof SpiralCliError) {
315
+ if (!(err instanceof AuthenticationError) || !process.argv.includes("--json")) {
316
+ console.error(theme.error(err.message));
317
+ }
318
+ if (process.argv.includes("--json")) {
319
+ output.outputJson({
320
+ error: err.name,
321
+ message: err.message,
322
+ ...(err instanceof ApiError ? { statusCode: err.statusCode } : {}),
323
+ });
324
+ }
325
+ process.exit(err.exitCode);
1005
326
  }
1006
327
 
1007
- if (error instanceof AuthenticationError) {
1008
- process.exit(EXIT_CODES.AUTH_ERROR);
1009
- } else if (error instanceof ApiError) {
1010
- process.exit(EXIT_CODES.API_ERROR);
1011
- } else if (error instanceof SpiralCliError) {
1012
- process.exit(error.exitCode);
328
+ const msg = sanitizeError(err);
329
+ console.error(theme.error(`Unexpected error: ${msg}`));
330
+ if (process.argv.includes("--debug")) {
331
+ console.error(err);
1013
332
  }
1014
333
  process.exit(EXIT_CODES.GENERAL_ERROR);
1015
- }
1016
-
1017
- // Run
1018
- main().catch(handleError);
334
+ });