specdown-cli 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +167 -0
  2. package/dist/index.js +597 -0
  3. package/package.json +46 -0
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # SpecDown CLI
2
+
3
+ <p align="center">
4
+ <img src="https://img.shields.io/npm/v/specdown-cli?color=blue" alt="npm version" />
5
+ <img src="https://img.shields.io/node/v/specdown-cli" alt="node" />
6
+ <img src="https://img.shields.io/npm/dm/specdown-cli" alt="downloads" />
7
+ </p>
8
+
9
+ **CLI for [SpecDown](https://specdown.app)** — manage your Markdown spec docs from the terminal.
10
+ Read, write, push, pull, and search spec documents. Works great in CI/CD pipelines and AI automation scripts.
11
+
12
+ ---
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ # Run without installing (recommended):
18
+ npx specdown-cli --help
19
+
20
+ # Or install globally:
21
+ npm install -g specdown-cli
22
+ ```
23
+
24
+ **Requirements:** Node.js ≥ 18
25
+
26
+ ---
27
+
28
+ ## Quick Start
29
+
30
+ ```bash
31
+ # 1. Login
32
+ specdown login
33
+
34
+ # 2. Switch to a project
35
+ specdown use my-project-slug
36
+
37
+ # 3. List documents
38
+ specdown ls
39
+
40
+ # 4. Read a document
41
+ specdown read /README.md
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Commands
47
+
48
+ ### Authentication
49
+
50
+ ```bash
51
+ specdown login # Sign in with email + password
52
+ specdown logout # Sign out and clear credentials
53
+ specdown whoami # Show current user and active project
54
+ ```
55
+
56
+ ### Projects
57
+
58
+ ```bash
59
+ specdown projects # List all projects you have access to
60
+ specdown use <slug> # Switch active project
61
+ ```
62
+
63
+ ### Documents
64
+
65
+ ```bash
66
+ specdown ls # List documents in active project
67
+ specdown read <path> # Print document content to stdout
68
+ specdown read <path> --from 10 --to 50 # Print lines 10–50
69
+ specdown read <path> -n # Print with line numbers
70
+ ```
71
+
72
+ ### Search
73
+
74
+ ```bash
75
+ specdown search "authentication flow"
76
+ specdown search "api" --files "design,api-spec" # Restrict to specific docs
77
+ specdown search "TODO" -C 5 # 5 lines of context
78
+ ```
79
+
80
+ ### Create & Edit
81
+
82
+ ```bash
83
+ specdown new "API Design" # Create a new document
84
+ specdown new "Design" --folder # Create a folder
85
+ specdown new "Auth" -p /design # Create inside a folder
86
+ ```
87
+
88
+ ### Sync
89
+
90
+ ```bash
91
+ specdown push ./local-file.md /remote/path.md # Upload local file to SpecDown
92
+ specdown pull /remote/path.md # Print remote doc to stdout
93
+ specdown pull /remote/path.md out.md # Save to local file
94
+ ```
95
+
96
+ ### Delete
97
+
98
+ ```bash
99
+ specdown rm /path/to/doc.md # Delete a document (with confirmation)
100
+ specdown rm /path/to/doc.md --force # Skip confirmation
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Configuration
106
+
107
+ Credentials are stored in `~/.specdown/config.json` after login. No environment variables required for standard use.
108
+
109
+ ```json
110
+ {
111
+ "access_token": "...",
112
+ "refresh_token": "...",
113
+ "user_email": "you@example.com",
114
+ "user_id": "...",
115
+ "current_project_slug": "my-project",
116
+ "current_project_name": "My Project"
117
+ }
118
+ ```
119
+
120
+ ---
121
+
122
+ ## CI/CD Usage
123
+
124
+ Use environment variables for non-interactive environments:
125
+
126
+ ```bash
127
+ # In CI: set token directly, skip login prompt
128
+ SPECDOWN_ACCESS_TOKEN=<token> specdown ls
129
+ ```
130
+
131
+ Or use the CLI in automation scripts:
132
+
133
+ ```bash
134
+ # Pull latest spec and pass to AI
135
+ specdown pull /api-spec.md | claude "suggest improvements"
136
+
137
+ # Auto-publish docs from CI
138
+ specdown push ./docs/openapi.md /api/openapi.md
139
+ ```
140
+
141
+ ---
142
+
143
+ ## AI Usage (MCP)
144
+
145
+ Pair the CLI with the [SpecDown MCP Server](https://github.com/specdown-app/mcp-server) to give Claude, Cursor, and other AI assistants direct access to your specs:
146
+
147
+ ```bash
148
+ # Install MCP server
149
+ npm install -g specdown-mcp
150
+
151
+ # Your API key is in ~/.specdown/config.json after login
152
+ cat ~/.specdown/config.json
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Links
158
+
159
+ - [SpecDown](https://specdown.app) — Spec-as-Code platform
160
+ - [Docs](https://specdown.app/docs)
161
+ - [MCP Server](https://github.com/specdown-app/mcp-server) — AI integration
162
+ - [GitHub](https://github.com/specdown-app/cli)
163
+ - [Report issue](https://github.com/specdown-app/cli/issues)
164
+
165
+ ## License
166
+
167
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,597 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/login.ts
7
+ import chalk from "chalk";
8
+ import ora from "ora";
9
+
10
+ // src/lib/api.ts
11
+ import { createClient } from "@supabase/supabase-js";
12
+ var SUPABASE_URL = "https://zjvjdalqgrxdhefqqifd.supabase.co";
13
+ var SUPABASE_ANON_KEY = "sb_publishable_y-O0ly4bNH0G4KJii3m25g_WXujFZDo";
14
+ function getClient(cfg) {
15
+ const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
16
+ auth: { persistSession: false }
17
+ });
18
+ client.auth.setSession({
19
+ access_token: cfg.access_token,
20
+ refresh_token: cfg.refresh_token
21
+ });
22
+ return client;
23
+ }
24
+ function anonClient() {
25
+ return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
26
+ auth: { persistSession: false }
27
+ });
28
+ }
29
+
30
+ // src/lib/config.ts
31
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
32
+ import { homedir } from "os";
33
+ import { join } from "path";
34
+ var CONFIG_DIR = join(homedir(), ".specdown");
35
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
36
+ function readConfig() {
37
+ if (!existsSync(CONFIG_FILE)) return null;
38
+ try {
39
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ function writeConfig(config) {
45
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
46
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
47
+ }
48
+ function clearConfig() {
49
+ if (existsSync(CONFIG_FILE)) writeFileSync(CONFIG_FILE, "{}", "utf-8");
50
+ }
51
+ function requireAuth() {
52
+ const cfg = readConfig();
53
+ if (!cfg?.access_token) {
54
+ console.error("Not logged in. Run: specdown login");
55
+ process.exit(1);
56
+ }
57
+ return cfg;
58
+ }
59
+ function requireProject(cfg) {
60
+ if (!cfg.current_project_id || !cfg.current_project_slug) {
61
+ console.error("No project selected. Run: specdown use <project-slug>");
62
+ process.exit(1);
63
+ }
64
+ return {
65
+ id: cfg.current_project_id,
66
+ slug: cfg.current_project_slug,
67
+ name: cfg.current_project_name ?? cfg.current_project_slug
68
+ };
69
+ }
70
+
71
+ // src/lib/prompt.ts
72
+ import { createInterface } from "readline";
73
+ function ask(question) {
74
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
75
+ return new Promise((resolve) => {
76
+ rl.question(question, (answer) => {
77
+ rl.close();
78
+ resolve(answer.trim());
79
+ });
80
+ });
81
+ }
82
+ function askPassword(question) {
83
+ return new Promise((resolve) => {
84
+ process.stdout.write(question);
85
+ const stdin = process.stdin;
86
+ const chunks = [];
87
+ stdin.setRawMode(true);
88
+ stdin.resume();
89
+ stdin.on("data", function handler(chunk) {
90
+ const char = chunk.toString();
91
+ if (char === "\r" || char === "\n") {
92
+ stdin.setRawMode(false);
93
+ stdin.pause();
94
+ stdin.removeListener("data", handler);
95
+ process.stdout.write("\n");
96
+ resolve(Buffer.concat(chunks).toString());
97
+ } else if (char === "") {
98
+ process.exit();
99
+ } else if (char === "\x7F") {
100
+ if (chunks.length) chunks.pop();
101
+ } else {
102
+ chunks.push(chunk);
103
+ }
104
+ });
105
+ });
106
+ }
107
+
108
+ // src/commands/login.ts
109
+ async function login() {
110
+ console.log(chalk.bold("\nSpecDown Login\n"));
111
+ const email = await ask("Email: ");
112
+ const password = await askPassword("Password: ");
113
+ const spinner = ora("Signing in\u2026").start();
114
+ try {
115
+ const supabase = anonClient();
116
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password });
117
+ if (error || !data.session) {
118
+ spinner.fail(chalk.red("Login failed: " + (error?.message ?? "No session")));
119
+ process.exit(1);
120
+ }
121
+ const { session, user } = data;
122
+ writeConfig({
123
+ access_token: session.access_token,
124
+ refresh_token: session.refresh_token,
125
+ user_email: user.email ?? email,
126
+ user_id: user.id
127
+ });
128
+ spinner.succeed(chalk.green(`Logged in as ${chalk.bold(user.email)}`));
129
+ } catch (err) {
130
+ spinner.fail(chalk.red("Login failed"));
131
+ console.error(err);
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ // src/commands/logout.ts
137
+ import chalk2 from "chalk";
138
+ function logout() {
139
+ const cfg = readConfig();
140
+ if (!cfg?.access_token) {
141
+ console.log(chalk2.yellow("Not logged in."));
142
+ return;
143
+ }
144
+ clearConfig();
145
+ console.log(chalk2.green("Logged out."));
146
+ }
147
+
148
+ // src/commands/whoami.ts
149
+ import chalk3 from "chalk";
150
+ function whoami() {
151
+ const cfg = requireAuth();
152
+ console.log(chalk3.bold(cfg.user_email));
153
+ console.log(chalk3.gray("ID: ") + cfg.user_id);
154
+ if (cfg.current_project_name) {
155
+ console.log(chalk3.gray("Project: ") + cfg.current_project_name + chalk3.gray(` (${cfg.current_project_slug})`));
156
+ }
157
+ }
158
+
159
+ // src/commands/projects.ts
160
+ import chalk4 from "chalk";
161
+ import ora2 from "ora";
162
+ async function listProjects() {
163
+ const cfg = requireAuth();
164
+ const supabase = getClient(cfg);
165
+ const spinner = ora2("Fetching projects\u2026").start();
166
+ try {
167
+ const { data, error } = await supabase.from("projects").select("id, name, slug, updated_at").order("updated_at", { ascending: false });
168
+ if (error) throw error;
169
+ spinner.stop();
170
+ if (!data?.length) {
171
+ console.log(chalk4.gray("No projects found."));
172
+ return;
173
+ }
174
+ for (const p of data) {
175
+ const isCurrent = p.slug === cfg.current_project_slug;
176
+ const marker = isCurrent ? chalk4.green("* ") : " ";
177
+ const name = isCurrent ? chalk4.bold.green(p.name) : chalk4.bold(p.name);
178
+ console.log(`${marker}${name} ${chalk4.gray(p.slug)}`);
179
+ }
180
+ } catch (err) {
181
+ spinner.fail(chalk4.red("Failed to fetch projects"));
182
+ console.error(err);
183
+ process.exit(1);
184
+ }
185
+ }
186
+
187
+ // src/commands/use.ts
188
+ import chalk5 from "chalk";
189
+ import ora3 from "ora";
190
+ async function useProject(slug) {
191
+ const cfg = requireAuth();
192
+ const supabase = getClient(cfg);
193
+ const spinner = ora3(`Switching to project "${slug}"\u2026`).start();
194
+ try {
195
+ const { data, error } = await supabase.from("projects").select("id, name, slug").eq("slug", slug).single();
196
+ if (error || !data) {
197
+ spinner.fail(chalk5.red(`Project "${slug}" not found.`));
198
+ process.exit(1);
199
+ }
200
+ const current = readConfig() ?? cfg;
201
+ writeConfig({
202
+ ...current,
203
+ current_project_id: data.id,
204
+ current_project_slug: data.slug,
205
+ current_project_name: data.name
206
+ });
207
+ spinner.succeed(chalk5.green(`Now using project ${chalk5.bold(data.name)}`));
208
+ } catch (err) {
209
+ spinner.fail(chalk5.red("Failed to switch project"));
210
+ console.error(err);
211
+ process.exit(1);
212
+ }
213
+ }
214
+
215
+ // src/commands/ls.ts
216
+ import chalk6 from "chalk";
217
+ import ora4 from "ora";
218
+ function buildTree(docs) {
219
+ const map = /* @__PURE__ */ new Map();
220
+ for (const d of docs) map.set(d.id, { ...d, children: [] });
221
+ const roots = [];
222
+ for (const node of map.values()) {
223
+ if (node.parent_id && map.has(node.parent_id)) {
224
+ map.get(node.parent_id).children.push(node);
225
+ } else {
226
+ roots.push(node);
227
+ }
228
+ }
229
+ const sort = (nodes) => {
230
+ nodes.sort((a, b) => {
231
+ if (a.is_folder !== b.is_folder) return a.is_folder ? -1 : 1;
232
+ if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
233
+ return a.title.localeCompare(b.title);
234
+ });
235
+ for (const n of nodes) sort(n.children);
236
+ };
237
+ sort(roots);
238
+ return roots;
239
+ }
240
+ function printTree(nodes, prefix = "") {
241
+ for (let i = 0; i < nodes.length; i++) {
242
+ const node = nodes[i];
243
+ const isLast = i === nodes.length - 1;
244
+ const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
245
+ const childPrefix = prefix + (isLast ? " " : "\u2502 ");
246
+ if (node.is_folder) {
247
+ console.log(prefix + connector + chalk6.bold.blue("\u{1F4C1} " + node.title));
248
+ } else {
249
+ console.log(prefix + connector + chalk6.white("\u{1F4C4} " + node.title) + chalk6.gray(" " + node.path));
250
+ }
251
+ if (node.children.length) printTree(node.children, childPrefix);
252
+ }
253
+ }
254
+ async function ls() {
255
+ const cfg = requireAuth();
256
+ const project = requireProject(cfg);
257
+ const supabase = getClient(cfg);
258
+ const spinner = ora4("Loading documents\u2026").start();
259
+ try {
260
+ const { data, error } = await supabase.from("documents").select("id, title, slug, path, full_path, is_folder, parent_id, sort_order, updated_at").eq("project_id", project.id).is("deleted_at", null).order("is_folder", { ascending: false }).order("sort_order").order("title");
261
+ if (error) throw error;
262
+ spinner.stop();
263
+ console.log(chalk6.bold(`
264
+ ${project.name}
265
+ `));
266
+ if (!data?.length) {
267
+ console.log(chalk6.gray(" (empty)"));
268
+ return;
269
+ }
270
+ const tree = buildTree(data);
271
+ printTree(tree);
272
+ console.log();
273
+ } catch (err) {
274
+ spinner.fail(chalk6.red("Failed to list documents"));
275
+ console.error(err);
276
+ process.exit(1);
277
+ }
278
+ }
279
+
280
+ // src/commands/read.ts
281
+ import chalk7 from "chalk";
282
+ import ora5 from "ora";
283
+ function normalizePath(p) {
284
+ return p.startsWith("/") ? p : `/${p}`;
285
+ }
286
+ async function readDoc(pathArg, opts) {
287
+ const cfg = requireAuth();
288
+ const project = requireProject(cfg);
289
+ const supabase = getClient(cfg);
290
+ const spinner = ora5("Fetching document\u2026").start();
291
+ try {
292
+ const { data, error } = await supabase.from("documents").select("title, full_path, content, updated_at").eq("project_id", project.id).eq("is_folder", false).is("deleted_at", null).eq("full_path", normalizePath(pathArg)).single();
293
+ if (error || !data) {
294
+ spinner.fail(chalk7.red(`Document not found: ${pathArg}`));
295
+ process.exit(1);
296
+ }
297
+ spinner.stop();
298
+ const rawLines = (data.content ?? "").split("\n");
299
+ const totalLines = rawLines.length;
300
+ const fromLine = opts.from ? Math.max(1, parseInt(opts.from, 10)) : 1;
301
+ const toLine = opts.to ? Math.min(totalLines, parseInt(opts.to, 10)) : totalLines;
302
+ if (fromLine > toLine) {
303
+ console.error(chalk7.red(`Invalid range: --from ${fromLine} is after --to ${toLine}`));
304
+ process.exit(1);
305
+ }
306
+ const selectedLines = rawLines.slice(fromLine - 1, toLine);
307
+ const rangeLabel = fromLine === 1 && toLine === totalLines ? `${totalLines} lines` : `lines ${fromLine}\u2013${toLine} of ${totalLines}`;
308
+ console.log(chalk7.gray(`# ${data.full_path} (${rangeLabel}, updated ${new Date(data.updated_at).toLocaleString()})
309
+ `));
310
+ if (opts.lineNumbers) {
311
+ const padWidth = String(toLine).length;
312
+ selectedLines.forEach((line, i) => {
313
+ console.log(chalk7.gray(String(fromLine + i).padStart(padWidth) + " ") + line);
314
+ });
315
+ } else {
316
+ console.log(selectedLines.join("\n"));
317
+ }
318
+ } catch {
319
+ spinner.fail(chalk7.red("Failed to read document"));
320
+ process.exit(1);
321
+ }
322
+ }
323
+
324
+ // src/commands/new.ts
325
+ import chalk8 from "chalk";
326
+ import ora6 from "ora";
327
+ function normalizePath2(p) {
328
+ return p.startsWith("/") ? p : `/${p}`;
329
+ }
330
+ async function newDoc(title, opts) {
331
+ const cfg = requireAuth();
332
+ const project = requireProject(cfg);
333
+ const supabase = getClient(cfg);
334
+ const spinner = ora6("Creating\u2026").start();
335
+ try {
336
+ const slug = title.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
337
+ const docSlug = opts.folder ? slug : `${slug}.md`;
338
+ let parentId = null;
339
+ let dirPath = "/";
340
+ if (opts.parent) {
341
+ const parentPath = normalizePath2(opts.parent);
342
+ const { data: parentDoc } = await supabase.from("documents").select("id, full_path").eq("project_id", project.id).eq("is_folder", true).eq("full_path", parentPath).single();
343
+ if (!parentDoc) {
344
+ spinner.fail(chalk8.red(`Parent folder not found: ${opts.parent}`));
345
+ process.exit(1);
346
+ }
347
+ parentId = parentDoc.id;
348
+ dirPath = (parentDoc.full_path ?? "") + "/";
349
+ }
350
+ const fullPath = `${dirPath}${docSlug}`;
351
+ const { data, error } = await supabase.from("documents").insert({
352
+ project_id: project.id,
353
+ created_by: cfg.user_id,
354
+ title,
355
+ slug: docSlug,
356
+ path: dirPath,
357
+ full_path: fullPath,
358
+ is_folder: opts.folder ?? false,
359
+ parent_id: parentId,
360
+ sort_order: 9999,
361
+ content: opts.folder ? null : `# ${title}
362
+ `
363
+ }).select("id, full_path").single();
364
+ if (error) throw error;
365
+ spinner.succeed(chalk8.green(`Created: ${data.full_path}`));
366
+ } catch {
367
+ spinner.fail(chalk8.red("Failed to create document"));
368
+ process.exit(1);
369
+ }
370
+ }
371
+
372
+ // src/commands/push.ts
373
+ import { readFileSync as readFileSync2, existsSync as existsSync2, statSync } from "fs";
374
+ import chalk9 from "chalk";
375
+ import ora7 from "ora";
376
+ var MAX_FILE_BYTES = 10 * 1024 * 1024;
377
+ function normalizePath3(p) {
378
+ return p.startsWith("/") ? p : `/${p}`;
379
+ }
380
+ async function push(filePath, docPath) {
381
+ if (!existsSync2(filePath)) {
382
+ console.error(chalk9.red(`File not found: ${filePath}`));
383
+ process.exit(1);
384
+ }
385
+ const stat = statSync(filePath);
386
+ if (stat.size > MAX_FILE_BYTES) {
387
+ console.error(chalk9.red(`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Max 10 MB.`));
388
+ process.exit(1);
389
+ }
390
+ const content = readFileSync2(filePath, "utf-8");
391
+ const cfg = requireAuth();
392
+ const project = requireProject(cfg);
393
+ const supabase = getClient(cfg);
394
+ const fullPath = normalizePath3(docPath);
395
+ const spinner = ora7(`Pushing ${filePath} \u2192 ${fullPath}\u2026`).start();
396
+ try {
397
+ const { data: existing } = await supabase.from("documents").select("id").eq("project_id", project.id).eq("is_folder", false).is("deleted_at", null).eq("full_path", fullPath).single();
398
+ if (existing) {
399
+ const { error } = await supabase.from("documents").update({ content, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", existing.id);
400
+ if (error) throw error;
401
+ spinner.succeed(chalk9.green(`Updated: ${fullPath}`));
402
+ } else {
403
+ const parts = fullPath.split("/");
404
+ const filename = parts.pop() ?? "doc.md";
405
+ const dirPath = parts.length > 1 ? parts.join("/") + "/" : "/";
406
+ const slug = filename.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9.-]/g, "");
407
+ const { error } = await supabase.from("documents").insert({
408
+ project_id: project.id,
409
+ created_by: cfg.user_id,
410
+ title: slug.replace(/\.md$/, ""),
411
+ slug,
412
+ path: dirPath,
413
+ full_path: fullPath,
414
+ is_folder: false,
415
+ parent_id: null,
416
+ sort_order: 9999,
417
+ content
418
+ });
419
+ if (error) throw error;
420
+ spinner.succeed(chalk9.green(`Created: ${fullPath}`));
421
+ }
422
+ } catch {
423
+ spinner.fail(chalk9.red("Push failed"));
424
+ process.exit(1);
425
+ }
426
+ }
427
+
428
+ // src/commands/pull.ts
429
+ import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
430
+ import { dirname } from "path";
431
+ import chalk10 from "chalk";
432
+ import ora8 from "ora";
433
+ function normalizePath4(p) {
434
+ return p.startsWith("/") ? p : `/${p}`;
435
+ }
436
+ async function pull(docPath, outFile) {
437
+ const cfg = requireAuth();
438
+ const project = requireProject(cfg);
439
+ const supabase = getClient(cfg);
440
+ const spinner = ora8(`Pulling ${docPath}\u2026`).start();
441
+ try {
442
+ const { data, error } = await supabase.from("documents").select("title, full_path, content").eq("project_id", project.id).eq("is_folder", false).is("deleted_at", null).eq("full_path", normalizePath4(docPath)).single();
443
+ if (error || !data) {
444
+ spinner.fail(chalk10.red(`Document not found: ${docPath}`));
445
+ process.exit(1);
446
+ }
447
+ const content = data.content ?? "";
448
+ if (outFile) {
449
+ const dir = dirname(outFile);
450
+ if (dir !== ".") mkdirSync2(dir, { recursive: true });
451
+ writeFileSync2(outFile, content, "utf-8");
452
+ spinner.succeed(chalk10.green(`Saved to ${outFile}`));
453
+ } else {
454
+ spinner.stop();
455
+ process.stdout.write(content);
456
+ }
457
+ } catch {
458
+ spinner.fail(chalk10.red("Pull failed"));
459
+ process.exit(1);
460
+ }
461
+ }
462
+
463
+ // src/commands/rm.ts
464
+ import chalk11 from "chalk";
465
+ import ora9 from "ora";
466
+ function normalizePath5(p) {
467
+ return p.startsWith("/") ? p : `/${p}`;
468
+ }
469
+ async function rm(docPath, opts) {
470
+ const cfg = requireAuth();
471
+ const project = requireProject(cfg);
472
+ const supabase = getClient(cfg);
473
+ const fullPath = normalizePath5(docPath);
474
+ const spinner = ora9(`Looking up ${fullPath}\u2026`).start();
475
+ try {
476
+ const { data, error } = await supabase.from("documents").select("id, title, is_folder").eq("project_id", project.id).is("deleted_at", null).eq("full_path", fullPath).single();
477
+ if (error || !data) {
478
+ spinner.fail(chalk11.red(`Document not found: ${fullPath}`));
479
+ process.exit(1);
480
+ }
481
+ spinner.stop();
482
+ if (!opts.force) {
483
+ const answer = await ask(
484
+ chalk11.yellow(`Delete "${data.title}"${data.is_folder ? " (folder + all children)" : ""}? [y/N] `)
485
+ );
486
+ if (answer.toLowerCase() !== "y") {
487
+ console.log("Aborted.");
488
+ return;
489
+ }
490
+ }
491
+ const deleteSpinner = ora9("Deleting\u2026").start();
492
+ const { error: delErr } = await supabase.from("documents").update({ deleted_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", data.id);
493
+ if (delErr) throw delErr;
494
+ deleteSpinner.succeed(chalk11.green(`Deleted: ${fullPath}`));
495
+ } catch {
496
+ spinner.fail(chalk11.red("Delete failed"));
497
+ process.exit(1);
498
+ }
499
+ }
500
+
501
+ // src/commands/search.ts
502
+ import chalk12 from "chalk";
503
+ import ora10 from "ora";
504
+ function searchInContent(content, query, contextLines) {
505
+ const lines = content.split("\n");
506
+ const lower = query.toLowerCase();
507
+ const results = [];
508
+ for (let i = 0; i < lines.length; i++) {
509
+ if (lines[i].toLowerCase().includes(lower)) {
510
+ results.push({
511
+ lineNo: i + 1,
512
+ line: lines[i],
513
+ before: lines.slice(Math.max(0, i - contextLines), i),
514
+ after: lines.slice(i + 1, Math.min(lines.length, i + 1 + contextLines))
515
+ });
516
+ }
517
+ }
518
+ return results;
519
+ }
520
+ function highlight(line, query) {
521
+ const idx = line.toLowerCase().indexOf(query.toLowerCase());
522
+ if (idx === -1) return line;
523
+ return line.slice(0, idx) + chalk12.bgYellow.black(line.slice(idx, idx + query.length)) + line.slice(idx + query.length);
524
+ }
525
+ function printContextLine(lineNo, line) {
526
+ console.log(chalk12.gray(String(lineNo).padStart(4) + " \u2502 ") + chalk12.dim(line));
527
+ }
528
+ async function search(query, opts) {
529
+ const cfg = requireAuth();
530
+ const project = requireProject(cfg);
531
+ const supabase = getClient(cfg);
532
+ const contextLines = Math.min(parseInt(opts.context ?? "2", 10), 10);
533
+ const spinner = ora10("Searching\u2026").start();
534
+ try {
535
+ let q = supabase.from("documents").select("full_path, slug, content").eq("project_id", project.id).eq("is_folder", false).is("deleted_at", null).ilike("content", `%${query}%`).order("full_path").limit(100);
536
+ if (opts.files) {
537
+ const paths = opts.files.split(",").map((s) => s.trim()).filter(Boolean);
538
+ if (paths.length > 0) {
539
+ q = q.in("full_path", paths.map((p) => p.startsWith("/") ? p : `/${p}`));
540
+ }
541
+ }
542
+ const { data, error } = await q;
543
+ if (error) throw error;
544
+ spinner.stop();
545
+ const allMatches = [];
546
+ for (const doc of data ?? []) {
547
+ if (!doc.content) continue;
548
+ const hits = searchInContent(doc.content, query, contextLines);
549
+ for (const h of hits) {
550
+ allMatches.push({ docPath: doc.full_path ?? doc.slug, ...h });
551
+ }
552
+ }
553
+ if (!allMatches.length) {
554
+ console.log(chalk12.yellow(`No results for "${query}" in ${project.name}`));
555
+ return;
556
+ }
557
+ let currentFile = "";
558
+ for (const m of allMatches) {
559
+ if (m.docPath !== currentFile) {
560
+ if (currentFile) console.log();
561
+ currentFile = m.docPath;
562
+ console.log(chalk12.bold.cyan(`
563
+ \u{1F4C4} ${m.docPath}`));
564
+ console.log(chalk12.gray("\u2500".repeat(50)));
565
+ }
566
+ m.before.forEach((l, i) => printContextLine(m.lineNo - m.before.length + i, l));
567
+ console.log(chalk12.green(String(m.lineNo).padStart(4) + " \u2502 ") + highlight(m.line, query));
568
+ m.after.forEach((l, i) => printContextLine(m.lineNo + 1 + i, l));
569
+ if (m.after.length || m.before.length) console.log(chalk12.gray(" \xB7"));
570
+ }
571
+ const fileCount = new Set(allMatches.map((m) => m.docPath)).size;
572
+ console.log();
573
+ console.log(
574
+ chalk12.bold(`${allMatches.length} match${allMatches.length === 1 ? "" : "es"}`) + chalk12.gray(` across ${fileCount} file${fileCount === 1 ? "" : "s"} in `) + chalk12.bold(project.name)
575
+ );
576
+ } catch {
577
+ spinner.fail(chalk12.red("Search failed"));
578
+ process.exit(1);
579
+ }
580
+ }
581
+
582
+ // src/index.ts
583
+ var program = new Command();
584
+ program.name("specdown").description("Manage SpecDown docs from your terminal").version("0.1.0");
585
+ program.command("login").description("Log in to SpecDown").action(login);
586
+ program.command("logout").description("Log out").action(logout);
587
+ program.command("whoami").description("Show current user and active project").action(whoami);
588
+ program.command("projects").description("List all projects you have access to").action(listProjects);
589
+ program.command("use <slug>").description("Switch active project by slug").action(useProject);
590
+ program.command("ls").description("List documents in the active project").action(ls);
591
+ program.command("read <path>").description("Print a document to stdout").option("--from <line>", "Start line (1-based, inclusive)").option("--to <line>", "End line (1-based, inclusive)").option("-n, --line-numbers", "Show line numbers").action(readDoc);
592
+ program.command("search <query>").description("Search text across the whole project or specific files").option("--files <paths>", 'Comma-separated doc paths to restrict search (e.g. "api,docs/guide")').option("-C, --context <n>", "Lines of context around each match", "2").action(search);
593
+ program.command("new <title>").description("Create a new document (or folder with --folder)").option("-f, --folder", "Create a folder instead of a document").option("-p, --parent <path>", "Parent folder path").action(newDoc);
594
+ program.command("push <file> <doc-path>").description("Upload a local file to a document path in SpecDown").action(push);
595
+ program.command("pull <doc-path> [out-file]").description("Download a document from SpecDown (or print to stdout)").action(pull);
596
+ program.command("rm <path>").description("Delete a document or folder (soft delete)").option("-f, --force", "Skip confirmation prompt").action(rm);
597
+ program.parseAsync(process.argv);
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "specdown-cli",
3
+ "version": "0.1.1",
4
+ "description": "CLI for SpecDown — manage spec docs from your terminal",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "bin": {
10
+ "specdown": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsup",
17
+ "dev": "tsx src/index.ts",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "specdown",
22
+ "cli",
23
+ "docs",
24
+ "markdown",
25
+ "spec"
26
+ ],
27
+ "author": "SpecDown",
28
+ "license": "MIT",
29
+ "homepage": "https://specdown.app",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/specdown-app/cli"
33
+ },
34
+ "dependencies": {
35
+ "@supabase/supabase-js": "^2.97.0",
36
+ "chalk": "^5.4.1",
37
+ "commander": "^12.1.0",
38
+ "ora": "^8.1.1"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^20.11.0",
42
+ "tsup": "^8.3.0",
43
+ "tsx": "^4.7.0",
44
+ "typescript": "^5.3.0"
45
+ }
46
+ }