@every-env/spiral-cli 0.1.0 → 1.0.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 CHANGED
@@ -1,952 +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 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";
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";
13
10
  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
- }
11
+ SpiralCliError,
12
+ AuthenticationError,
13
+ ApiError,
14
+ type GenerateRequest,
15
+ type GenerateResponse,
16
+ } from "./types.js";
74
17
 
75
- // Store pending attachments for REPL mode
76
- let pendingAttachments: Attachment[] = [];
18
+ const VERSION = "1.0.0";
77
19
 
78
- async function main(): Promise<void> {
79
- const command = positionals[0];
20
+ const HELP = `
21
+ ${theme.heading("spiral")} write with Spiral from your terminal
80
22
 
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
- }
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
+ };
130
63
  }
131
64
 
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);
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,
189
83
  });
190
84
 
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
- );
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
+ }
241
110
 
242
- const content = chunks.join("");
111
+ // ── Commands ──
243
112
 
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
- }
113
+ async function cmdWrite(args: ParsedArgs): Promise<void> {
114
+ // Read prompt from positional args or stdin
115
+ let prompt = args.positional.join(" ").trim();
249
116
 
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
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);
262
122
  }
263
-
264
- process.exit(EXIT_CODES.SUCCESS);
265
- } catch (error) {
266
- spinner?.stop();
267
- cleanupToolSpinners();
268
- handleError(error as Error);
123
+ prompt = Buffer.concat(chunks).toString("utf-8").trim();
269
124
  }
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
125
 
278
- if (process.env.DEBUG) {
279
- 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
+ );
280
131
  }
281
132
 
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();
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
+ };
293
141
 
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
- }
142
+ let resp: GenerateResponse = await output.withSpinner("Writing...", () =>
143
+ api.generate(req),
144
+ );
303
145
 
304
- if (!trimmed) {
305
- 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);
306
151
  return;
307
152
  }
308
153
 
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
- }
154
+ // Show questions
155
+ output.formatGenerateResponse(resp);
369
156
 
370
- currentController = null;
371
- pendingAttachments = []; // Clear attachments after use
372
- rl.prompt();
373
- };
157
+ // Prompt for answers
158
+ const answer = await input({ message: "Your response:" });
374
159
 
375
- rl.on("line", (input) => {
376
- handleInput(input).catch((error) => {
377
- console.error(theme.error("Error:"), sanitizeError(error));
378
- rl.prompt();
379
- });
380
- });
160
+ resp = await output.withSpinner("Writing...", () =>
161
+ api.generate({
162
+ session_id: resp.session_id,
163
+ responses: answer,
164
+ }),
165
+ );
166
+ }
381
167
 
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
- });
168
+ if (args.flags.json) {
169
+ output.outputJson(resp);
170
+ } else {
171
+ output.formatGenerateResponse(resp);
172
+ }
173
+ }
394
174
 
395
- 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
+ }
396
184
  }
397
185
 
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}`));
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);
591
194
  }
592
- return undefined;
593
195
  }
594
196
 
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);
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);
603
205
  }
604
- await listDrafts(sessionId, { json: values.json });
605
206
  }
606
207
 
607
- /**
608
- * Draft subcommand handler
609
- */
610
- async function draftCommand(action?: string, draftId?: string): Promise<void> {
611
- const sessionId = values.session;
208
+ async function cmdDrafts(args: ParsedArgs): Promise<void> {
209
+ const sessionId = args.flags.session;
612
210
  if (!sessionId) {
613
- console.error(theme.error("--session required for draft commands"));
614
- process.exit(EXIT_CODES.INVALID_ARGS);
211
+ throw new SpiralCliError(
212
+ "Usage: spiral drafts --session <id>",
213
+ EXIT_CODES.INVALID_ARGS,
214
+ );
615
215
  }
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);
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);
620
223
  }
224
+ }
621
225
 
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);
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);
655
234
  }
656
235
  }
657
236
 
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);
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);
689
245
  }
690
246
  }
691
247
 
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);
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);
710
253
  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);
254
+ case "status":
255
+ auth.showStatus();
721
256
  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);
257
+ case "logout":
258
+ auth.logout();
728
259
  break;
729
260
  default:
730
- console.error(theme.error(`Unknown suggestions action: ${action}`));
731
- process.exit(EXIT_CODES.INVALID_ARGS);
261
+ console.log("Usage: spiral auth login|status|logout");
262
+ break;
732
263
  }
733
264
  }
734
265
 
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
- }
266
+ // ── Main ──
759
267
 
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
- }
268
+ async function main(): Promise<void> {
269
+ const args = parse(process.argv);
768
270
 
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);
271
+ if (args.flags.version) {
272
+ console.log(VERSION);
273
+ return;
776
274
  }
777
275
 
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);
276
+ if (args.flags.help || !args.command) {
277
+ console.log(HELP);
278
+ return;
801
279
  }
802
- }
803
280
 
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
- }
281
+ switch (args.command) {
282
+ case "write":
283
+ await cmdWrite(args);
284
+ break;
285
+ case "styles":
286
+ await cmdStyles(args);
287
+ break;
288
+ case "workspaces":
289
+ await cmdWorkspaces(args);
838
290
  break;
839
- case "clear":
840
- clearTokenCache();
841
- console.log(
842
- theme.info("Token cache cleared. Re-login by visiting https://app.writewithspiral.com"),
843
- );
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);
844
305
  break;
845
306
  default:
846
- console.log(`
847
- ${theme.heading("Auth Commands:")}
848
- spiral auth status Check authentication status
849
- spiral auth clear Clear cached token
850
- `);
307
+ console.error(theme.error(`Unknown command: ${args.command}`));
308
+ console.log(HELP);
309
+ process.exit(EXIT_CODES.INVALID_ARGS);
851
310
  }
852
311
  }
853
312
 
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);
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);
939
326
  }
940
327
 
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);
328
+ const msg = sanitizeError(err);
329
+ console.error(theme.error(`Unexpected error: ${msg}`));
330
+ if (process.argv.includes("--debug")) {
331
+ console.error(err);
947
332
  }
948
333
  process.exit(EXIT_CODES.GENERAL_ERROR);
949
- }
950
-
951
- // Run
952
- main().catch(handleError);
334
+ });