@every-env/spiral-cli 0.1.0

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