@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Suggestion management commands
|
|
2
|
+
// See plan: Performance review identified unbounded storage risk
|
|
3
|
+
|
|
4
|
+
import { confirm } from "@inquirer/prompts";
|
|
5
|
+
import ora from "ora";
|
|
6
|
+
import { applyDraftPatch } from "../api";
|
|
7
|
+
import { theme } from "../theme";
|
|
8
|
+
import type { Suggestion } from "../types";
|
|
9
|
+
import { displayInlineDiff } from "./diff";
|
|
10
|
+
import { parseSuggestions } from "./parser";
|
|
11
|
+
|
|
12
|
+
// LRU cache for suggestions with TTL
|
|
13
|
+
const MAX_SUGGESTIONS = 50;
|
|
14
|
+
const SUGGESTION_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
15
|
+
|
|
16
|
+
interface CachedSuggestion {
|
|
17
|
+
suggestion: Suggestion;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Store pending suggestions with LRU eviction
|
|
22
|
+
const suggestionCache: Map<string, CachedSuggestion> = new Map();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Clean expired suggestions and enforce max size
|
|
26
|
+
*/
|
|
27
|
+
function cleanupCache(): void {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const entries = Array.from(suggestionCache.entries());
|
|
30
|
+
|
|
31
|
+
// Remove expired
|
|
32
|
+
for (const [id, cached] of entries) {
|
|
33
|
+
if (now - cached.timestamp > SUGGESTION_TTL_MS) {
|
|
34
|
+
suggestionCache.delete(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// LRU eviction if over limit
|
|
39
|
+
if (suggestionCache.size > MAX_SUGGESTIONS) {
|
|
40
|
+
const sorted = entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
41
|
+
const toRemove = sorted.slice(0, suggestionCache.size - MAX_SUGGESTIONS);
|
|
42
|
+
for (const [id] of toRemove) {
|
|
43
|
+
suggestionCache.delete(id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register suggestions from AI response content
|
|
50
|
+
*/
|
|
51
|
+
export function registerSuggestions(content: string): number {
|
|
52
|
+
const suggestions = parseSuggestions(content);
|
|
53
|
+
for (const sug of suggestions) {
|
|
54
|
+
suggestionCache.set(sug.id, {
|
|
55
|
+
suggestion: sug,
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
cleanupCache();
|
|
60
|
+
return suggestions.length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get a suggestion by ID
|
|
65
|
+
*/
|
|
66
|
+
function getSuggestion(id: string): Suggestion | undefined {
|
|
67
|
+
const cached = suggestionCache.get(id);
|
|
68
|
+
return cached?.suggestion;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* List all pending suggestions
|
|
73
|
+
*/
|
|
74
|
+
export function listPendingSuggestions(options: { json?: boolean }): void {
|
|
75
|
+
cleanupCache();
|
|
76
|
+
const suggestions = Array.from(suggestionCache.values())
|
|
77
|
+
.map((c) => c.suggestion)
|
|
78
|
+
.filter((s) => s.status === "pending");
|
|
79
|
+
|
|
80
|
+
if (options.json) {
|
|
81
|
+
console.log(JSON.stringify({ success: true, suggestions }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (suggestions.length === 0) {
|
|
86
|
+
console.log(theme.dim("No pending suggestions."));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(theme.heading(`\nPending Suggestions (${suggestions.length}):\n`));
|
|
91
|
+
|
|
92
|
+
for (const sug of suggestions) {
|
|
93
|
+
console.log(`${theme.dim(sug.id)} -> draft ${theme.dim(sug.targetDraftId.slice(0, 8))}`);
|
|
94
|
+
console.log(` ${theme.dim("Before:")} ${sug.before.slice(0, 40)}...`);
|
|
95
|
+
console.log(` ${theme.success("After:")} ${sug.after.slice(0, 40)}...`);
|
|
96
|
+
if (sug.rationale) {
|
|
97
|
+
console.log(` ${theme.info("Why:")} ${sug.rationale}`);
|
|
98
|
+
}
|
|
99
|
+
console.log();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Preview a suggestion with diff display
|
|
105
|
+
*/
|
|
106
|
+
export async function previewSuggestion(
|
|
107
|
+
suggestionId: string,
|
|
108
|
+
options: { json?: boolean },
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const suggestion = getSuggestion(suggestionId);
|
|
111
|
+
|
|
112
|
+
if (!suggestion) {
|
|
113
|
+
throw new Error(`Suggestion not found: ${suggestionId}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (options.json) {
|
|
117
|
+
console.log(JSON.stringify({ success: true, suggestion }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(theme.heading("\nSuggestion Preview"));
|
|
122
|
+
console.log(theme.dim(`Target: draft ${suggestion.targetDraftId.slice(0, 8)}`));
|
|
123
|
+
|
|
124
|
+
if (suggestion.rationale) {
|
|
125
|
+
console.log(theme.info(`\nRationale: ${suggestion.rationale}`));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
displayInlineDiff(suggestion.before, suggestion.after);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Apply a suggestion to its target draft
|
|
133
|
+
*/
|
|
134
|
+
export async function applySuggestion(
|
|
135
|
+
sessionId: string,
|
|
136
|
+
suggestionId: string,
|
|
137
|
+
options: { json?: boolean; force?: boolean },
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const suggestion = getSuggestion(suggestionId);
|
|
140
|
+
|
|
141
|
+
if (!suggestion) {
|
|
142
|
+
throw new Error(`Suggestion not found: ${suggestionId}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (suggestion.status !== "pending") {
|
|
146
|
+
throw new Error(`Suggestion already ${suggestion.status}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Show preview and confirm (unless --force)
|
|
150
|
+
if (!options.force && !options.json) {
|
|
151
|
+
await previewSuggestion(suggestionId, { json: false });
|
|
152
|
+
const proceed = await confirm({
|
|
153
|
+
message: "Apply this suggestion?",
|
|
154
|
+
default: true,
|
|
155
|
+
});
|
|
156
|
+
if (!proceed) {
|
|
157
|
+
console.log(theme.dim("Cancelled."));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const spinner = ora("Applying suggestion...").start();
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
await applyDraftPatch(sessionId, suggestion.targetDraftId, [
|
|
166
|
+
{
|
|
167
|
+
type: "replace",
|
|
168
|
+
range: suggestion.range,
|
|
169
|
+
content: suggestion.after,
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
suggestion.status = "applied";
|
|
174
|
+
spinner.succeed("Suggestion applied");
|
|
175
|
+
|
|
176
|
+
if (options.json) {
|
|
177
|
+
console.log(JSON.stringify({ success: true, suggestion }));
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
spinner.fail("Failed to apply suggestion");
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Dismiss a suggestion without applying
|
|
187
|
+
*/
|
|
188
|
+
export function dismissSuggestion(suggestionId: string, options: { json?: boolean }): void {
|
|
189
|
+
const suggestion = getSuggestion(suggestionId);
|
|
190
|
+
|
|
191
|
+
if (!suggestion) {
|
|
192
|
+
throw new Error(`Suggestion not found: ${suggestionId}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
suggestion.status = "dismissed";
|
|
196
|
+
|
|
197
|
+
if (options.json) {
|
|
198
|
+
console.log(JSON.stringify({ success: true, dismissed: suggestionId }));
|
|
199
|
+
} else {
|
|
200
|
+
console.log(theme.dim(`Dismissed suggestion ${suggestionId}`));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Re-export parser functions
|
|
205
|
+
export { hasSuggestions, parseSuggestions } from "./parser";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Suggestion parser with type-safe JSON handling
|
|
2
|
+
// See plan: TypeScript review identified need for type guards
|
|
3
|
+
|
|
4
|
+
import type { Suggestion } from "../types";
|
|
5
|
+
|
|
6
|
+
// Suggestion syntax in AI responses:
|
|
7
|
+
// ```suggestion[target=draft-id]
|
|
8
|
+
// {"before": "old text", "after": "new text", "rationale": "why"}
|
|
9
|
+
// ```
|
|
10
|
+
|
|
11
|
+
const SUGGESTION_REGEX = /```suggestion\[target=([^\]]+)\]\n([\s\S]*?)```/g;
|
|
12
|
+
|
|
13
|
+
// TYPESCRIPT: Type guard for safe JSON parsing
|
|
14
|
+
interface SuggestionPayload {
|
|
15
|
+
before?: string;
|
|
16
|
+
after?: string;
|
|
17
|
+
rationale?: string;
|
|
18
|
+
range?: [number, number];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isSuggestionPayload(value: unknown): value is SuggestionPayload {
|
|
22
|
+
return (
|
|
23
|
+
typeof value === "object" &&
|
|
24
|
+
value !== null &&
|
|
25
|
+
(!("before" in value) || typeof (value as SuggestionPayload).before === "string") &&
|
|
26
|
+
(!("after" in value) || typeof (value as SuggestionPayload).after === "string")
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse suggestions from AI response content
|
|
32
|
+
*/
|
|
33
|
+
export function parseSuggestions(content: string): Suggestion[] {
|
|
34
|
+
const suggestions: Suggestion[] = [];
|
|
35
|
+
// Create fresh regex each call to avoid state issues with global flag
|
|
36
|
+
const regex = new RegExp(SUGGESTION_REGEX.source, "g");
|
|
37
|
+
let match: RegExpExecArray | null = regex.exec(content);
|
|
38
|
+
|
|
39
|
+
while (match !== null) {
|
|
40
|
+
const targetDraftId = match[1];
|
|
41
|
+
const jsonContent = match[2]?.trim();
|
|
42
|
+
|
|
43
|
+
// Guard against undefined captures
|
|
44
|
+
if (!targetDraftId || !jsonContent) continue;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(jsonContent);
|
|
48
|
+
|
|
49
|
+
// TYPESCRIPT: Validate shape before use
|
|
50
|
+
if (!isSuggestionPayload(parsed)) {
|
|
51
|
+
if (process.env.DEBUG) {
|
|
52
|
+
console.debug("Invalid suggestion format");
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
suggestions.push({
|
|
58
|
+
id: `sug-${Date.now()}-${suggestions.length}`,
|
|
59
|
+
targetDraftId,
|
|
60
|
+
before: parsed.before ?? "",
|
|
61
|
+
after: parsed.after ?? "",
|
|
62
|
+
rationale: parsed.rationale,
|
|
63
|
+
range: parsed.range,
|
|
64
|
+
status: "pending",
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
// Skip malformed suggestions
|
|
68
|
+
if (process.env.DEBUG) {
|
|
69
|
+
console.debug("Skipping malformed suggestion:", jsonContent.slice(0, 100));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
match = regex.exec(content);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return suggestions;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if content contains suggestions
|
|
80
|
+
*/
|
|
81
|
+
export function hasSuggestions(content: string): boolean {
|
|
82
|
+
return new RegExp(SUGGESTION_REGEX.source).test(content);
|
|
83
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export const theme = {
|
|
4
|
+
error: chalk.bold.red,
|
|
5
|
+
warning: chalk.hex("#FFA500"),
|
|
6
|
+
success: chalk.green,
|
|
7
|
+
info: chalk.blue,
|
|
8
|
+
dim: chalk.dim,
|
|
9
|
+
heading: chalk.bold.cyan,
|
|
10
|
+
code: chalk.yellow,
|
|
11
|
+
user: chalk.blue,
|
|
12
|
+
assistant: chalk.green,
|
|
13
|
+
thinking: chalk.dim.italic,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const EXIT_CODES = {
|
|
17
|
+
SUCCESS: 0,
|
|
18
|
+
GENERAL_ERROR: 1,
|
|
19
|
+
AUTH_ERROR: 2,
|
|
20
|
+
API_ERROR: 3,
|
|
21
|
+
NETWORK_ERROR: 4,
|
|
22
|
+
INVALID_ARGS: 5,
|
|
23
|
+
} as const;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Tool call visualization with spinner management
|
|
2
|
+
import type { Ora } from "ora";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { theme } from "../theme";
|
|
5
|
+
import type { ToolCallUpdate } from "../types";
|
|
6
|
+
|
|
7
|
+
// Tool icons for visual identification
|
|
8
|
+
const TOOL_ICONS: Record<string, string> = {
|
|
9
|
+
search_web: "S",
|
|
10
|
+
read_file: "R",
|
|
11
|
+
create_draft: "D",
|
|
12
|
+
replace_draft_content: "E",
|
|
13
|
+
analyze: "A",
|
|
14
|
+
default: "T",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Track active tool spinners
|
|
18
|
+
const toolSpinners: Map<string, Ora> = new Map();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render tool call start
|
|
22
|
+
*/
|
|
23
|
+
export function renderToolStart(callId: string, toolName: string): void {
|
|
24
|
+
const icon = TOOL_ICONS[toolName] || TOOL_ICONS.default;
|
|
25
|
+
const spinner = ora({
|
|
26
|
+
text: `[${icon}] ${toolName}`,
|
|
27
|
+
prefixText: theme.dim(`[${callId.slice(0, 6)}]`),
|
|
28
|
+
}).start();
|
|
29
|
+
toolSpinners.set(callId, spinner);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render tool call completion
|
|
34
|
+
*/
|
|
35
|
+
export function renderToolComplete(
|
|
36
|
+
callId: string,
|
|
37
|
+
toolName: string,
|
|
38
|
+
result?: unknown,
|
|
39
|
+
error?: string,
|
|
40
|
+
): void {
|
|
41
|
+
const spinner = toolSpinners.get(callId);
|
|
42
|
+
const icon = TOOL_ICONS[toolName] || TOOL_ICONS.default;
|
|
43
|
+
|
|
44
|
+
if (error) {
|
|
45
|
+
spinner?.fail(`[${icon}] ${toolName}: ${theme.error(error.slice(0, 50))}`);
|
|
46
|
+
} else {
|
|
47
|
+
const summary = formatResultSummary(result);
|
|
48
|
+
spinner?.succeed(`[${icon}] ${toolName}${summary ? `: ${summary}` : ""}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
toolSpinners.delete(callId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handle tool call update event
|
|
56
|
+
*/
|
|
57
|
+
export function handleToolCallUpdate(update: ToolCallUpdate): void {
|
|
58
|
+
switch (update.status) {
|
|
59
|
+
case "started":
|
|
60
|
+
renderToolStart(update.callId, update.toolName);
|
|
61
|
+
break;
|
|
62
|
+
case "completed":
|
|
63
|
+
renderToolComplete(update.callId, update.toolName, update.result);
|
|
64
|
+
break;
|
|
65
|
+
case "error":
|
|
66
|
+
renderToolComplete(update.callId, update.toolName, undefined, update.error);
|
|
67
|
+
break;
|
|
68
|
+
case "arguments_delta":
|
|
69
|
+
case "arguments_done":
|
|
70
|
+
// Update spinner text if needed
|
|
71
|
+
{
|
|
72
|
+
const spinner = toolSpinners.get(update.callId);
|
|
73
|
+
if (spinner) {
|
|
74
|
+
const icon = TOOL_ICONS[update.toolName] || TOOL_ICONS.default;
|
|
75
|
+
spinner.text = `[${icon}] ${update.toolName}...`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Format result for summary display
|
|
84
|
+
*/
|
|
85
|
+
function formatResultSummary(result: unknown): string {
|
|
86
|
+
if (!result) return "";
|
|
87
|
+
if (typeof result === "string") return result.slice(0, 50);
|
|
88
|
+
if (Array.isArray(result)) return `${result.length} items`;
|
|
89
|
+
if (typeof result === "object") {
|
|
90
|
+
const keys = Object.keys(result);
|
|
91
|
+
return `{${keys.slice(0, 3).join(", ")}${keys.length > 3 ? "..." : ""}}`;
|
|
92
|
+
}
|
|
93
|
+
return String(result).slice(0, 50);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Clean up all active tool spinners
|
|
98
|
+
*/
|
|
99
|
+
export function cleanupToolSpinners(): void {
|
|
100
|
+
for (const spinner of toolSpinners.values()) {
|
|
101
|
+
spinner.stop();
|
|
102
|
+
}
|
|
103
|
+
toolSpinners.clear();
|
|
104
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
declare module "marked-terminal" {
|
|
2
|
+
import type { MarkedExtension } from "marked";
|
|
3
|
+
|
|
4
|
+
interface MarkedTerminalOptions {
|
|
5
|
+
heading?: (text: string) => string;
|
|
6
|
+
code?: (text: string) => string;
|
|
7
|
+
codespan?: (text: string) => string;
|
|
8
|
+
blockquote?: (text: string) => string;
|
|
9
|
+
list?: (text: string) => string;
|
|
10
|
+
listitem?: (text: string) => string;
|
|
11
|
+
link?: (href: string, title: string | null, text: string) => string;
|
|
12
|
+
strong?: (text: string) => string;
|
|
13
|
+
em?: (text: string) => string;
|
|
14
|
+
del?: (text: string) => string;
|
|
15
|
+
paragraph?: (text: string) => string;
|
|
16
|
+
table?: (header: string, body: string) => string;
|
|
17
|
+
tablerow?: (content: string) => string;
|
|
18
|
+
tablecell?: (content: string, flags: unknown) => string;
|
|
19
|
+
hr?: () => string;
|
|
20
|
+
html?: (html: string) => string;
|
|
21
|
+
image?: (href: string, title: string | null, text: string) => string;
|
|
22
|
+
firstHeading?: boolean;
|
|
23
|
+
showSectionPrefix?: boolean;
|
|
24
|
+
unescape?: boolean;
|
|
25
|
+
width?: number;
|
|
26
|
+
reflowText?: boolean;
|
|
27
|
+
tab?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function markedTerminal(options?: MarkedTerminalOptions): MarkedExtension;
|
|
31
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Error types for proper error handling
|
|
2
|
+
export class SpiralCliError extends Error {
|
|
3
|
+
constructor(
|
|
4
|
+
message: string,
|
|
5
|
+
public readonly exitCode: number = 1,
|
|
6
|
+
) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "SpiralCliError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class AuthenticationError extends SpiralCliError {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message, 2); // Exit code 2 for auth errors
|
|
15
|
+
this.name = "AuthenticationError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ApiError extends SpiralCliError {
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
public readonly status: number,
|
|
23
|
+
public readonly body?: unknown,
|
|
24
|
+
) {
|
|
25
|
+
super(message, 3); // Exit code 3 for API errors
|
|
26
|
+
this.name = "ApiError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class NetworkError extends SpiralCliError {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message, 4); // Exit code 4 for network errors
|
|
33
|
+
this.name = "NetworkError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// SSE Event types (aligned with apps/web/src/types/sse.ts)
|
|
38
|
+
export interface ToolCall {
|
|
39
|
+
tool_name: string;
|
|
40
|
+
tool_call_id?: string;
|
|
41
|
+
call_id?: string;
|
|
42
|
+
tool_args?: Record<string, unknown>;
|
|
43
|
+
result?: unknown;
|
|
44
|
+
error?: string;
|
|
45
|
+
status: "started" | "arguments_delta" | "arguments_done" | "completed";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface StreamEvent {
|
|
49
|
+
type: "content" | "thinking" | "tool_call" | "session_name" | "retry" | "complete" | "error";
|
|
50
|
+
content?: string;
|
|
51
|
+
thinking?: string;
|
|
52
|
+
toolCall?: ToolCall;
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
sessionName?: string;
|
|
55
|
+
retryInfo?: { attempt: number; maxRetries: number; message: string };
|
|
56
|
+
model?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Output formatters for agent-native support
|
|
60
|
+
export interface OutputFormatter {
|
|
61
|
+
content(text: string): void;
|
|
62
|
+
thinking(text: string): void;
|
|
63
|
+
error(error: Error): void;
|
|
64
|
+
complete(sessionId: string): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Conversation types from API
|
|
68
|
+
export interface Conversation {
|
|
69
|
+
session_id: string;
|
|
70
|
+
user_id: string;
|
|
71
|
+
session_name: string | null;
|
|
72
|
+
workspace_id: string | null;
|
|
73
|
+
created_at: string;
|
|
74
|
+
updated_at: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Message {
|
|
78
|
+
id: string;
|
|
79
|
+
role: string;
|
|
80
|
+
content: string;
|
|
81
|
+
thinking?: string;
|
|
82
|
+
created_at: string;
|
|
83
|
+
updated_at?: string;
|
|
84
|
+
is_complete?: boolean;
|
|
85
|
+
model?: string;
|
|
86
|
+
provider?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Draft types (from backend)
|
|
90
|
+
export interface Draft {
|
|
91
|
+
id: string;
|
|
92
|
+
session_id: string;
|
|
93
|
+
user_id: string;
|
|
94
|
+
title?: string;
|
|
95
|
+
content: string;
|
|
96
|
+
content_hash: string;
|
|
97
|
+
current_version_id?: string;
|
|
98
|
+
created_at: string;
|
|
99
|
+
updated_at: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface DraftVersion {
|
|
103
|
+
id: string;
|
|
104
|
+
draft_id: string;
|
|
105
|
+
content: string;
|
|
106
|
+
content_hash: string;
|
|
107
|
+
created_by: "user" | "llm";
|
|
108
|
+
parent_version_id?: string;
|
|
109
|
+
created_at: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Suggestion types (parsed from responses)
|
|
113
|
+
export interface Suggestion {
|
|
114
|
+
id: string;
|
|
115
|
+
targetDraftId: string;
|
|
116
|
+
before: string;
|
|
117
|
+
after: string;
|
|
118
|
+
rationale?: string;
|
|
119
|
+
range?: [number, number];
|
|
120
|
+
status: "pending" | "applied" | "dismissed";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Writing style types
|
|
124
|
+
export interface WritingStyle {
|
|
125
|
+
id: string;
|
|
126
|
+
name: string;
|
|
127
|
+
description?: string;
|
|
128
|
+
usage_context?: string;
|
|
129
|
+
guide?: string;
|
|
130
|
+
summary?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Workspace types
|
|
134
|
+
export interface Workspace {
|
|
135
|
+
id: string;
|
|
136
|
+
name: string;
|
|
137
|
+
icon?: string;
|
|
138
|
+
icon_color?: string;
|
|
139
|
+
description?: string;
|
|
140
|
+
workspace_type: "PERSONAL" | "TEAM";
|
|
141
|
+
owner_id: string;
|
|
142
|
+
team_id?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Attachment types
|
|
146
|
+
export interface Attachment {
|
|
147
|
+
name: string;
|
|
148
|
+
content: string; // Text content or base64 for binary
|
|
149
|
+
type: "text" | "binary";
|
|
150
|
+
size: number;
|
|
151
|
+
mimeType?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Patch operation for draft updates
|
|
155
|
+
export interface PatchOperation {
|
|
156
|
+
type: "replace" | "insert" | "delete";
|
|
157
|
+
range?: [number, number];
|
|
158
|
+
content?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Tool call update for visualization
|
|
162
|
+
export interface ToolCallUpdate {
|
|
163
|
+
callId: string;
|
|
164
|
+
toolName: string;
|
|
165
|
+
status: "started" | "arguments_delta" | "arguments_done" | "completed" | "error";
|
|
166
|
+
args?: Record<string, unknown>;
|
|
167
|
+
result?: unknown;
|
|
168
|
+
error?: string;
|
|
169
|
+
formattedCalls?: string[];
|
|
170
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Workspace management
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { fetchWorkspaces } from "../api";
|
|
5
|
+
import { theme } from "../theme";
|
|
6
|
+
|
|
7
|
+
export interface WorkspaceCommandOptions {
|
|
8
|
+
json?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* List all available workspaces
|
|
13
|
+
*/
|
|
14
|
+
export async function listWorkspaces(options: WorkspaceCommandOptions): Promise<void> {
|
|
15
|
+
const spinner = ora("Fetching workspaces...").start();
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const workspaces = await fetchWorkspaces();
|
|
19
|
+
spinner.succeed("Workspaces loaded");
|
|
20
|
+
|
|
21
|
+
if (options.json) {
|
|
22
|
+
console.log(JSON.stringify({ success: true, workspaces }));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (workspaces.length === 0) {
|
|
27
|
+
console.log(theme.dim("No workspaces found."));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(theme.heading("\nWorkspaces:\n"));
|
|
32
|
+
|
|
33
|
+
// Group by type (case-insensitive comparison)
|
|
34
|
+
const personal = workspaces.filter((w) => w.workspace_type?.toLowerCase() === "personal");
|
|
35
|
+
const team = workspaces.filter((w) => w.workspace_type?.toLowerCase() === "team");
|
|
36
|
+
|
|
37
|
+
if (personal.length > 0) {
|
|
38
|
+
console.log(theme.info("Personal:"));
|
|
39
|
+
for (const ws of personal) {
|
|
40
|
+
console.log(` ${theme.dim(ws.id.slice(0, 8))} ${chalk.white(ws.name)}`);
|
|
41
|
+
}
|
|
42
|
+
console.log();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (team.length > 0) {
|
|
46
|
+
console.log(theme.info("Team:"));
|
|
47
|
+
for (const ws of team) {
|
|
48
|
+
console.log(` ${theme.dim(ws.id.slice(0, 8))} ${chalk.white(ws.name)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
spinner.fail("Failed to fetch workspaces");
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|