@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/README.md +245 -0
- package/package.json +69 -0
- package/src/api.ts +520 -0
- package/src/attachments/index.ts +174 -0
- package/src/auth.ts +160 -0
- package/src/cli.ts +952 -0
- package/src/config.ts +49 -0
- package/src/drafts/editor.ts +105 -0
- package/src/drafts/index.ts +208 -0
- package/src/notes/index.ts +130 -0
- package/src/styles/index.ts +45 -0
- package/src/suggestions/diff.ts +33 -0
- package/src/suggestions/index.ts +205 -0
- package/src/suggestions/parser.ts +83 -0
- package/src/theme.ts +23 -0
- package/src/tools/renderer.ts +104 -0
- package/src/types/marked-terminal.d.ts +31 -0
- package/src/types.ts +170 -0
- package/src/workspaces/index.ts +55 -0
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);
|