@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/config.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Local configuration store using `conf` package
|
|
2
|
+
import Conf from "conf";
|
|
3
|
+
|
|
4
|
+
// Draft stored locally (for tracking unsaved changes)
|
|
5
|
+
export interface LocalDraft {
|
|
6
|
+
id: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
localContent?: string; // Unsaved local changes
|
|
10
|
+
lastSyncedAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Note item for scratchpad
|
|
14
|
+
export interface NoteItem {
|
|
15
|
+
id: string;
|
|
16
|
+
content: string;
|
|
17
|
+
feedback?: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// User preferences
|
|
22
|
+
export interface SpiralPreferences {
|
|
23
|
+
editor?: string; // Override $EDITOR
|
|
24
|
+
diffTool?: "inline" | "side-by-side";
|
|
25
|
+
toolCallVerbosity?: "minimal" | "normal" | "verbose";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Main config schema
|
|
29
|
+
export interface SpiralConfig {
|
|
30
|
+
currentWorkspaceId?: string;
|
|
31
|
+
currentStyleId?: string;
|
|
32
|
+
defaultModel?: string;
|
|
33
|
+
drafts: Record<string, LocalDraft>;
|
|
34
|
+
notes: NoteItem[];
|
|
35
|
+
preferences: SpiralPreferences;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Initialize conf with defaults
|
|
39
|
+
export const config = new Conf<SpiralConfig>({
|
|
40
|
+
projectName: "spiral-cli",
|
|
41
|
+
defaults: {
|
|
42
|
+
drafts: {},
|
|
43
|
+
notes: [],
|
|
44
|
+
preferences: {
|
|
45
|
+
diffTool: "inline",
|
|
46
|
+
toolCallVerbosity: "normal",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// $EDITOR integration with security mitigations
|
|
2
|
+
// See plan: Security review identified command injection and temp file risks
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { chmodSync, lstatSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
// SECURITY: Allowlist of safe editors
|
|
10
|
+
const ALLOWED_EDITORS = new Set([
|
|
11
|
+
"vi",
|
|
12
|
+
"vim",
|
|
13
|
+
"nvim",
|
|
14
|
+
"nano",
|
|
15
|
+
"emacs",
|
|
16
|
+
"code",
|
|
17
|
+
"subl",
|
|
18
|
+
"atom",
|
|
19
|
+
"micro",
|
|
20
|
+
"helix",
|
|
21
|
+
"joe",
|
|
22
|
+
"pico",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get a secure editor command
|
|
27
|
+
* @security Validates against allowlist and rejects shell metacharacters
|
|
28
|
+
*/
|
|
29
|
+
function getSecureEditor(): string {
|
|
30
|
+
const rawEditor = process.env.EDITOR || process.env.VISUAL || "vi";
|
|
31
|
+
const firstPart = rawEditor.split(" ")[0] || "vi";
|
|
32
|
+
const editorName = basename(firstPart);
|
|
33
|
+
|
|
34
|
+
if (!ALLOWED_EDITORS.has(editorName)) {
|
|
35
|
+
console.warn(`Warning: $EDITOR '${rawEditor}' not in allowlist, using 'vi'`);
|
|
36
|
+
return "vi";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// SECURITY: Reject shell metacharacters
|
|
40
|
+
if (/[;&|`$]/.test(rawEditor)) {
|
|
41
|
+
throw new Error("$EDITOR contains shell metacharacters");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return rawEditor;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface EditorResult {
|
|
48
|
+
content: string;
|
|
49
|
+
changed: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Open content in $EDITOR for editing
|
|
54
|
+
* @security Uses secure temp paths, restrictive permissions, and secure cleanup
|
|
55
|
+
*/
|
|
56
|
+
export async function editInEditor(
|
|
57
|
+
initialContent: string,
|
|
58
|
+
options: { extension?: string; title?: string } = {},
|
|
59
|
+
): Promise<EditorResult> {
|
|
60
|
+
const { extension = ".md" } = options;
|
|
61
|
+
|
|
62
|
+
// SECURITY: Unpredictable temp path
|
|
63
|
+
const tempPath = join(tmpdir(), `spiral-${randomUUID()}${extension}`);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// SECURITY: Write with restrictive permissions (owner read/write only)
|
|
67
|
+
writeFileSync(tempPath, initialContent, { mode: 0o600 });
|
|
68
|
+
chmodSync(tempPath, 0o600);
|
|
69
|
+
|
|
70
|
+
const editor = getSecureEditor();
|
|
71
|
+
|
|
72
|
+
// Get mtime before edit for change detection
|
|
73
|
+
const statBefore = lstatSync(tempPath);
|
|
74
|
+
const mtimeBefore = statBefore.mtimeMs;
|
|
75
|
+
|
|
76
|
+
// Split editor command (handles "vim -O" style editors)
|
|
77
|
+
const editorParts = editor.split(" ");
|
|
78
|
+
const editorPath = editorParts[0] || "vi";
|
|
79
|
+
const editorArgs = [...editorParts.slice(1), tempPath];
|
|
80
|
+
|
|
81
|
+
// Use Bun.spawn with stdio inheritance for interactive editing
|
|
82
|
+
const proc = Bun.spawn([editorPath, ...editorArgs], {
|
|
83
|
+
stdin: "inherit",
|
|
84
|
+
stdout: "inherit",
|
|
85
|
+
stderr: "inherit",
|
|
86
|
+
});
|
|
87
|
+
await proc.exited;
|
|
88
|
+
|
|
89
|
+
// Fast change detection via mtime
|
|
90
|
+
const statAfter = lstatSync(tempPath);
|
|
91
|
+
const changed = statAfter.mtimeMs !== mtimeBefore;
|
|
92
|
+
const content = changed ? await Bun.file(tempPath).text() : initialContent;
|
|
93
|
+
|
|
94
|
+
return { content, changed };
|
|
95
|
+
} finally {
|
|
96
|
+
// SECURITY: Secure cleanup - overwrite before deletion
|
|
97
|
+
try {
|
|
98
|
+
const size = initialContent.length;
|
|
99
|
+
writeFileSync(tempPath, Buffer.alloc(size, 0));
|
|
100
|
+
unlinkSync(tempPath);
|
|
101
|
+
} catch {
|
|
102
|
+
/* best effort cleanup */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Draft management commands
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import {
|
|
5
|
+
fetchDraft,
|
|
6
|
+
fetchDraftVersions,
|
|
7
|
+
fetchSessionDrafts,
|
|
8
|
+
restoreDraftVersion,
|
|
9
|
+
updateDraft,
|
|
10
|
+
} from "../api";
|
|
11
|
+
import { theme } from "../theme";
|
|
12
|
+
import { editInEditor } from "./editor";
|
|
13
|
+
|
|
14
|
+
export interface DraftCommandOptions {
|
|
15
|
+
json?: boolean;
|
|
16
|
+
limit?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* List all drafts in a session
|
|
21
|
+
*/
|
|
22
|
+
export async function listDrafts(sessionId: string, options: DraftCommandOptions): Promise<void> {
|
|
23
|
+
const spinner = ora("Fetching drafts...").start();
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const drafts = await fetchSessionDrafts(sessionId);
|
|
27
|
+
spinner.succeed("Drafts loaded");
|
|
28
|
+
|
|
29
|
+
if (options.json) {
|
|
30
|
+
console.log(JSON.stringify({ success: true, drafts }));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (drafts.length === 0) {
|
|
35
|
+
console.log(theme.dim("No drafts in this session."));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(theme.heading("\nDrafts:\n"));
|
|
40
|
+
for (const draft of drafts) {
|
|
41
|
+
const preview = draft.content?.slice(0, 60) || "";
|
|
42
|
+
console.log(`${theme.dim(draft.id.slice(0, 8))} ${chalk.white(draft.title || "Untitled")}`);
|
|
43
|
+
console.log(` ${theme.dim(preview)}${preview.length >= 60 ? "..." : ""}\n`);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
spinner.fail("Failed to fetch drafts");
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* View a single draft
|
|
53
|
+
*/
|
|
54
|
+
export async function viewDraft(
|
|
55
|
+
sessionId: string,
|
|
56
|
+
draftId: string,
|
|
57
|
+
options: DraftCommandOptions,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
const spinner = ora("Loading draft...").start();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const draft = await fetchDraft(sessionId, draftId);
|
|
63
|
+
spinner.succeed("Draft loaded");
|
|
64
|
+
|
|
65
|
+
if (options.json) {
|
|
66
|
+
console.log(JSON.stringify({ success: true, draft }));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(theme.heading(`\n${draft.title || "Untitled Draft"}\n`));
|
|
71
|
+
console.log(draft.content);
|
|
72
|
+
console.log(theme.dim(`\nID: ${draft.id}`));
|
|
73
|
+
console.log(theme.dim(`Updated: ${new Date(draft.updated_at).toLocaleString()}`));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
spinner.fail("Failed to load draft");
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Edit a draft in $EDITOR
|
|
82
|
+
*/
|
|
83
|
+
export async function editDraft(
|
|
84
|
+
sessionId: string,
|
|
85
|
+
draftId: string,
|
|
86
|
+
options: DraftCommandOptions,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const spinner = ora("Loading draft for editing...").start();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const draft = await fetchDraft(sessionId, draftId);
|
|
92
|
+
spinner.stop();
|
|
93
|
+
|
|
94
|
+
console.log(theme.info(`Opening ${draft.title || "draft"} in editor...\n`));
|
|
95
|
+
|
|
96
|
+
const { content, changed } = await editInEditor(draft.content, {
|
|
97
|
+
title: draft.title || "draft",
|
|
98
|
+
extension: ".md",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!changed) {
|
|
102
|
+
console.log(theme.dim("No changes made."));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const saveSpinner = ora("Saving changes...").start();
|
|
107
|
+
const updated = await updateDraft(sessionId, draftId, content);
|
|
108
|
+
saveSpinner.succeed("Draft saved");
|
|
109
|
+
|
|
110
|
+
if (options.json) {
|
|
111
|
+
console.log(JSON.stringify({ success: true, draft: updated }));
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
spinner.fail("Failed to edit draft");
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Update draft content directly (agent-native, bypasses $EDITOR)
|
|
121
|
+
*/
|
|
122
|
+
export async function updateDraftContent(
|
|
123
|
+
sessionId: string,
|
|
124
|
+
draftId: string,
|
|
125
|
+
content: string,
|
|
126
|
+
options: DraftCommandOptions & { title?: string },
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const spinner = ora("Updating draft...").start();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const updated = await updateDraft(sessionId, draftId, content, options.title);
|
|
132
|
+
spinner.succeed("Draft updated");
|
|
133
|
+
|
|
134
|
+
if (options.json) {
|
|
135
|
+
console.log(JSON.stringify({ success: true, draft: updated }));
|
|
136
|
+
} else {
|
|
137
|
+
console.log(theme.success(`Updated draft ${draftId.slice(0, 8)}`));
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
spinner.fail("Failed to update draft");
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List version history for a draft
|
|
147
|
+
*/
|
|
148
|
+
export async function listVersions(
|
|
149
|
+
sessionId: string,
|
|
150
|
+
draftId: string,
|
|
151
|
+
options: DraftCommandOptions,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const spinner = ora("Fetching version history...").start();
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const versions = await fetchDraftVersions(sessionId, draftId);
|
|
157
|
+
spinner.succeed("Versions loaded");
|
|
158
|
+
|
|
159
|
+
if (options.json) {
|
|
160
|
+
console.log(JSON.stringify({ success: true, versions }));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log(theme.heading(`\nVersion History (${versions.length} versions):\n`));
|
|
165
|
+
|
|
166
|
+
const limit = options.limit || 10;
|
|
167
|
+
for (const version of versions.slice(0, limit)) {
|
|
168
|
+
const date = new Date(version.created_at).toLocaleString();
|
|
169
|
+
const sizeKb = (version.content.length / 1024).toFixed(1);
|
|
170
|
+
const by = version.created_by === "llm" ? theme.assistant("AI") : theme.user("You");
|
|
171
|
+
|
|
172
|
+
console.log(`${theme.dim(version.id.slice(0, 8))} ${by} - ${date} (${sizeKb}kb)`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (versions.length > limit) {
|
|
176
|
+
console.log(theme.dim(`\n... and ${versions.length - limit} more`));
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
spinner.fail("Failed to fetch versions");
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Restore a draft to a specific version
|
|
186
|
+
*/
|
|
187
|
+
export async function restoreVersion(
|
|
188
|
+
sessionId: string,
|
|
189
|
+
draftId: string,
|
|
190
|
+
versionId: string,
|
|
191
|
+
options: DraftCommandOptions,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
const spinner = ora("Restoring version...").start();
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const draft = await restoreDraftVersion(sessionId, draftId, versionId);
|
|
197
|
+
spinner.succeed("Version restored");
|
|
198
|
+
|
|
199
|
+
if (options.json) {
|
|
200
|
+
console.log(JSON.stringify({ success: true, draft }));
|
|
201
|
+
} else {
|
|
202
|
+
console.log(theme.success(`Restored to version ${versionId.slice(0, 8)}`));
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
spinner.fail("Failed to restore version");
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Scratchpad/notes management with local persistence
|
|
2
|
+
import { confirm } from "@inquirer/prompts";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { type NoteItem, config } from "../config";
|
|
5
|
+
import { theme } from "../theme";
|
|
6
|
+
|
|
7
|
+
export interface NoteCommandOptions {
|
|
8
|
+
json?: boolean;
|
|
9
|
+
force?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Add a new note
|
|
14
|
+
*/
|
|
15
|
+
export function addNote(
|
|
16
|
+
content: string,
|
|
17
|
+
feedback?: string,
|
|
18
|
+
options: NoteCommandOptions = {},
|
|
19
|
+
): void {
|
|
20
|
+
const notes = config.get("notes") || [];
|
|
21
|
+
|
|
22
|
+
const note: NoteItem = {
|
|
23
|
+
id: `note-${Date.now()}`,
|
|
24
|
+
content,
|
|
25
|
+
feedback,
|
|
26
|
+
createdAt: new Date().toISOString(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
notes.push(note);
|
|
30
|
+
config.set("notes", notes);
|
|
31
|
+
|
|
32
|
+
if (options.json) {
|
|
33
|
+
console.log(JSON.stringify({ success: true, note }));
|
|
34
|
+
} else {
|
|
35
|
+
console.log(
|
|
36
|
+
theme.success(`Note added: "${content.slice(0, 40)}${content.length > 40 ? "..." : ""}"`),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* List all notes
|
|
43
|
+
*/
|
|
44
|
+
export function listNotes(options: NoteCommandOptions): void {
|
|
45
|
+
const notes = config.get("notes") || [];
|
|
46
|
+
|
|
47
|
+
if (options.json) {
|
|
48
|
+
console.log(JSON.stringify({ success: true, notes }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (notes.length === 0) {
|
|
53
|
+
console.log(theme.dim("No notes. Use /note <text> to add one."));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log(theme.heading(`\nNotes (${notes.length}):\n`));
|
|
58
|
+
|
|
59
|
+
for (const note of notes) {
|
|
60
|
+
const date = new Date(note.createdAt).toLocaleString();
|
|
61
|
+
console.log(`${theme.dim(note.id.slice(0, 12))} ${chalk.white(note.content)}`);
|
|
62
|
+
if (note.feedback) {
|
|
63
|
+
console.log(` ${theme.info("Feedback:")} ${note.feedback}`);
|
|
64
|
+
}
|
|
65
|
+
console.log(` ${theme.dim(date)}\n`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Clear all notes
|
|
71
|
+
*/
|
|
72
|
+
export async function clearNotes(options: NoteCommandOptions): Promise<void> {
|
|
73
|
+
const notes = config.get("notes") || [];
|
|
74
|
+
|
|
75
|
+
if (notes.length === 0) {
|
|
76
|
+
console.log(theme.dim("No notes to clear."));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!options.force && !options.json) {
|
|
81
|
+
const proceed = await confirm({
|
|
82
|
+
message: `Delete all ${notes.length} notes?`,
|
|
83
|
+
default: false,
|
|
84
|
+
});
|
|
85
|
+
if (!proceed) {
|
|
86
|
+
console.log(theme.dim("Cancelled."));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
config.set("notes", []);
|
|
92
|
+
|
|
93
|
+
if (options.json) {
|
|
94
|
+
console.log(JSON.stringify({ success: true, cleared: notes.length }));
|
|
95
|
+
} else {
|
|
96
|
+
console.log(theme.success(`Cleared ${notes.length} notes.`));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Remove a specific note by ID
|
|
102
|
+
*/
|
|
103
|
+
export function removeNote(noteId: string, options: NoteCommandOptions): void {
|
|
104
|
+
const notes = config.get("notes") || [];
|
|
105
|
+
const index = notes.findIndex((n: NoteItem) => n.id === noteId || n.id.startsWith(noteId));
|
|
106
|
+
|
|
107
|
+
if (index === -1) {
|
|
108
|
+
throw new Error(`Note not found: ${noteId}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const [removed] = notes.splice(index, 1);
|
|
112
|
+
config.set("notes", notes);
|
|
113
|
+
|
|
114
|
+
if (options.json) {
|
|
115
|
+
console.log(JSON.stringify({ success: true, removed }));
|
|
116
|
+
} else if (removed) {
|
|
117
|
+
console.log(theme.dim(`Removed note: "${removed.content.slice(0, 30)}..."`));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get notes formatted for API submission
|
|
123
|
+
*/
|
|
124
|
+
export function getNotesForApi(): Array<{ content: string; feedback: string }> {
|
|
125
|
+
const notes = config.get("notes") || [];
|
|
126
|
+
return notes.map((n: NoteItem) => ({
|
|
127
|
+
content: n.content,
|
|
128
|
+
feedback: n.feedback || "",
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Writing styles management
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { fetchWritingStyles } from "../api";
|
|
5
|
+
import { theme } from "../theme";
|
|
6
|
+
|
|
7
|
+
export interface StyleCommandOptions {
|
|
8
|
+
json?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* List all available writing styles
|
|
13
|
+
*/
|
|
14
|
+
export async function listStyles(options: StyleCommandOptions): Promise<void> {
|
|
15
|
+
const spinner = ora("Fetching writing styles...").start();
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const styles = await fetchWritingStyles();
|
|
19
|
+
spinner.succeed("Styles loaded");
|
|
20
|
+
|
|
21
|
+
if (options.json) {
|
|
22
|
+
console.log(JSON.stringify({ success: true, styles }));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (styles.length === 0) {
|
|
27
|
+
console.log(theme.dim("No writing styles found. Create one in the web app."));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(theme.heading("\nWriting Styles:\n"));
|
|
32
|
+
for (const style of styles) {
|
|
33
|
+
console.log(`${theme.dim(style.id.slice(0, 8))} ${chalk.white(style.name)}`);
|
|
34
|
+
if (style.description) {
|
|
35
|
+
console.log(
|
|
36
|
+
` ${theme.dim(style.description.slice(0, 80))}${style.description.length > 80 ? "..." : ""}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
console.log();
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
spinner.fail("Failed to fetch styles");
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Diff display utilities
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { theme } from "../theme";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Display inline diff (before/after stacked)
|
|
7
|
+
*/
|
|
8
|
+
export function displayInlineDiff(before: string, after: string): void {
|
|
9
|
+
console.log(theme.heading("\n--- Before ---"));
|
|
10
|
+
console.log(chalk.red(before));
|
|
11
|
+
console.log(theme.heading("\n--- After ---"));
|
|
12
|
+
console.log(chalk.green(after));
|
|
13
|
+
console.log();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Display side-by-side diff
|
|
18
|
+
*/
|
|
19
|
+
export function displaySideBySideDiff(before: string, after: string, width = 40): void {
|
|
20
|
+
const beforeLines = before.split("\n");
|
|
21
|
+
const afterLines = after.split("\n");
|
|
22
|
+
const maxLines = Math.max(beforeLines.length, afterLines.length);
|
|
23
|
+
|
|
24
|
+
console.log(theme.heading(`\n${"Before".padEnd(width)} | After`));
|
|
25
|
+
console.log(`${"-".repeat(width)} | ${"-".repeat(width)}`);
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < maxLines; i++) {
|
|
28
|
+
const b = (beforeLines[i] || "").slice(0, width).padEnd(width);
|
|
29
|
+
const a = (afterLines[i] || "").slice(0, width);
|
|
30
|
+
console.log(`${chalk.red(b)} | ${chalk.green(a)}`);
|
|
31
|
+
}
|
|
32
|
+
console.log();
|
|
33
|
+
}
|