@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.
@@ -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
+ }