sfora-cli 0.6.0 → 0.8.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/dist/cli.js CHANGED
@@ -10,9 +10,10 @@ import * as readline from "node:readline";
10
10
  import { spawn } from "node:child_process";
11
11
  import { readFile as readLocalFile } from "node:fs/promises";
12
12
  import { basename } from "node:path";
13
- import { createSforaShell, SforaApiClient } from "./index.js";
13
+ import { createSforaShell, createLocalShell, SforaApiClient } from "./index.js";
14
+ import { LocalWorkspace, initWorkspace, findWorkspace, } from "./local/workspace.js";
14
15
  import { runMcpServer } from "./mcp-server.js";
15
- import { readConfig, writeConfig, resolveSettings, DEFAULT_URL, } from "./config.js";
16
+ import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
16
17
  const colors = {
17
18
  reset: "\x1b[0m",
18
19
  bold: "\x1b[1m",
@@ -24,7 +25,7 @@ const colors = {
24
25
  red: "\x1b[31m",
25
26
  };
26
27
  function parseArgs(argv) {
27
- const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false };
28
+ const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false };
28
29
  for (let i = 0; i < argv.length; i++) {
29
30
  const a = argv[i];
30
31
  if (a === "--mcp")
@@ -53,6 +54,10 @@ function parseArgs(argv) {
53
54
  args.bot = a.slice("--bot=".length);
54
55
  else if (a.startsWith("--agent="))
55
56
  args.bot = a.slice("--agent=".length);
57
+ else if (a === "--as")
58
+ args.as = argv[++i];
59
+ else if (a.startsWith("--as="))
60
+ args.as = a.slice("--as=".length);
56
61
  else if (a === "--web")
57
62
  args.web = argv[++i];
58
63
  else if (a.startsWith("--web="))
@@ -67,6 +72,12 @@ function parseArgs(argv) {
67
72
  args.column = a.slice("--column=".length);
68
73
  else if (a === "--draft")
69
74
  args.draft = true;
75
+ else if (a === "--json")
76
+ args.json = true;
77
+ else if (a === "--local")
78
+ args.local = true;
79
+ else if (a === "--cloud")
80
+ args.cloud = true;
70
81
  else if (!a.startsWith("-")) {
71
82
  if (!args.command)
72
83
  args.command = a;
@@ -78,34 +89,79 @@ function parseArgs(argv) {
78
89
  }
79
90
  const HELP = `sfora — the CLI for your sfora workspace
80
91
 
81
- Get started:
82
- sfora login Authorize as you (saves your key)
92
+ Get started (no account needed):
93
+ sfora init --local Create a .sfora/ workspace in this repo —
94
+ tasks/posts/docs as plain markdown files,
95
+ versioned with your code
96
+ sfora login Authorize with sfora cloud (saves your key)
83
97
  sfora login --bot <name> Authorize as a named bot (its own key)
84
98
 
85
- Run as a bot: add --bot <name> to any command (default identity is you).
99
+ Inside a .sfora/ repo the CLI works on the local files; pass --cloud (or
100
+ --org/--url) to target sfora cloud instead. Run as a bot: add --bot <name>.
86
101
 
87
102
  Push markdown straight to sfora:
88
103
  sfora post <file.md> [--project <slug>] [--draft] Publish (or draft) a post
89
104
  sfora task <file.md> [--project <slug>] [--column <name>] Create a task
90
105
  sfora doc <file.md> [--project <slug>] Create a doc
91
106
 
107
+ Act on a post:
108
+ sfora comment <post> "<message>" Comment on a post
109
+ sfora react <post> <emoji> Toggle a reaction (👍 🚀 …)
110
+ (<post> is a post file path or its id)
111
+
92
112
  Projects:
93
113
  sfora new "<name>" Create a project
94
114
  sfora projects List your projects
95
115
 
96
- Browse:
116
+ Browse & read:
117
+ sfora posts <project> List a project's posts
118
+ sfora tasks <project> List a project's board, by column
119
+ sfora inbox Show your unread mentions
120
+ sfora me Show who you're signed in as
97
121
  sfora ls [path] List a path (default /projects)
98
122
  sfora cat <path> Print a file's markdown
99
123
  sfora Open the interactive shell
100
124
 
125
+ Add --json to any list command (projects/posts/tasks/ls/me) for
126
+ machine-readable output and stable scripting.
127
+
101
128
  Agents & config:
102
129
  sfora --mcp [--org <slug>] Run as an MCP server (for agents)
103
130
  sfora mcp-config Print an MCP config snippet to paste
131
+ sfora contexts List saved deployment+org keys (● = active)
104
132
  sfora init Save URL + API key + org manually
105
133
 
106
134
  Config resolution: flags > env (SFORA_API_KEY / SFORA_URL / SFORA_ORG) >
107
135
  ~/.sfora/config.json > default (URL ${DEFAULT_URL}).
108
136
  `;
137
+ // In-shell `help` (and `?`). The underlying engine (just-bash) ships its own
138
+ // `help` builtin that prints "just-bash shell builtins" — internal branding we
139
+ // never want users to see, and it can't be overridden via the command registry
140
+ // (it's a hardcoded interpreter builtin). So the REPL intercepts `help` and
141
+ // prints this sfora-native cheat sheet instead.
142
+ const SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— your workspace as a markdown filesystem${colors.reset}
143
+
144
+ Standard tools work against your workspace:
145
+ ls cat grep find head tail wc sed awk echo cd pwd
146
+
147
+ ${colors.dim}Where things live${colors.reset}
148
+ /projects your projects
149
+ /projects/<slug>/posts/<file>.md published posts
150
+ /projects/<slug>/drafts/ drafts
151
+ /projects/<slug>/board/<col>/ board tasks
152
+ /projects/<slug>/docs/ docs
153
+ /inbox/mentions.md your mentions
154
+ /me/api-key who you're signed in as
155
+
156
+ ${colors.dim}Try${colors.reset}
157
+ ls /projects
158
+ cat /projects/<slug>/posts/<file>.md
159
+ grep -ri todo /projects
160
+ echo "# Hello" > /projects/<slug>/posts/hello.md
161
+
162
+ ${colors.dim}Outside the shell${colors.reset}, sfora has verbs: post · task · doc · new · projects (run ${colors.cyan}sfora --help${colors.reset})
163
+ Type ${colors.cyan}exit${colors.reset} to quit.
164
+ `;
109
165
  // `sfora init` — persist url/apiKey/org to ~/.sfora/config.json. Uses flags
110
166
  // when given, otherwise prompts (on a TTY), defaulting to any existing config.
111
167
  async function runInit(args) {
@@ -131,8 +187,13 @@ async function runInit(args) {
131
187
  process.exitCode = 1;
132
188
  return;
133
189
  }
134
- const path = await writeConfig({ url, apiKey: key, org: org || cfg.org });
135
- console.log(`${colors.green}✓${colors.reset} Saved ${path}`);
190
+ const { cfg: next, key: profile } = upsertProfile(cfg, {
191
+ url,
192
+ org: org || cfg.org,
193
+ apiKey: key,
194
+ });
195
+ const path = await writeConfig(next);
196
+ console.log(`${colors.green}✓${colors.reset} Saved ${path} ${colors.dim}(${profile})${colors.reset}`);
136
197
  }
137
198
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
138
199
  function openBrowser(url) {
@@ -195,17 +256,27 @@ async function runLogin(args, baseUrl) {
195
256
  }
196
257
  if (data.status === "approved" && data.apiKey) {
197
258
  const cfg = await readConfig();
198
- const next = { ...cfg, url: base };
259
+ let next;
199
260
  if (args.bot) {
200
261
  // Save under the bot's slot; keep the user's personal key intact.
201
- next.bots = {
202
- ...cfg.bots,
203
- [args.bot]: { apiKey: data.apiKey, org: data.orgSlug ?? undefined },
262
+ next = {
263
+ ...cfg,
264
+ url: base,
265
+ bots: {
266
+ ...cfg.bots,
267
+ [args.bot]: { apiKey: data.apiKey, org: data.orgSlug ?? undefined },
268
+ },
204
269
  };
205
270
  }
206
271
  else {
207
- next.apiKey = data.apiKey;
208
- next.org = data.orgSlug ?? cfg.org;
272
+ // Add/update this deployment+org as its own profile instead of
273
+ // overwriting one shared key — so logging in here never invalidates the
274
+ // key you hold for another deployment.
275
+ next = upsertProfile(cfg, {
276
+ url: base,
277
+ org: data.orgSlug ?? undefined,
278
+ apiKey: data.apiKey,
279
+ }).cfg;
209
280
  }
210
281
  const path = await writeConfig(next);
211
282
  console.log(`\n${colors.green}✓${colors.reset} Logged in as ${data.name ?? "you"}${data.orgSlug ? ` ${colors.dim}(${data.orgSlug})${colors.reset}` : ""}${args.bot ? ` ${colors.dim}[bot: ${args.bot}]${colors.reset}` : ""} — saved ${path}`);
@@ -237,6 +308,37 @@ function printMcpConfig(s) {
237
308
  };
238
309
  process.stdout.write(`${JSON.stringify(snippet, null, 2)}\n`);
239
310
  }
311
+ // `sfora contexts` — list the deployment+org identities saved in the config and
312
+ // mark the one the current flags/env resolve to. Local-only (no network), so
313
+ // you can always see which key you're about to use.
314
+ function printContexts(cfg, resolved) {
315
+ const mask = (k) => k ? `${k.slice(0, 12)}…` : `${colors.dim}(no key)${colors.reset}`;
316
+ const entries = Object.entries(effectiveProfiles(cfg));
317
+ if (entries.length === 0) {
318
+ console.log(`${colors.dim}No saved contexts. Run \`sfora login\`.${colors.reset}`);
319
+ }
320
+ else {
321
+ console.log(`${colors.bold}Contexts${colors.reset} ${colors.dim}(~/.sfora/config.json)${colors.reset}`);
322
+ for (const [key, p] of entries) {
323
+ const isResolved = resolved.apiKey != null && p.apiKey === resolved.apiKey;
324
+ const isActive = key === cfg.activeProfile;
325
+ const marker = isResolved
326
+ ? `${colors.green}●${colors.reset}`
327
+ : isActive
328
+ ? `${colors.cyan}○${colors.reset}`
329
+ : " ";
330
+ console.log(` ${marker} ${key.padEnd(28)} ${colors.dim}${p.url}${p.org ? ` · ${p.org}` : ""} · ${mask(p.apiKey)}${colors.reset}`);
331
+ }
332
+ }
333
+ const bots = Object.entries(cfg.bots ?? {});
334
+ if (bots.length) {
335
+ console.log(`${colors.bold}Bots${colors.reset}`);
336
+ for (const [name, b] of bots) {
337
+ console.log(` ${colors.dim}${name.padEnd(28)} ${b.org ?? ""} · ${mask(b.apiKey)}${colors.reset}`);
338
+ }
339
+ }
340
+ console.log(`\n${colors.dim}Resolved now → ${resolved.url}${resolved.org ? ` (org: ${resolved.org})` : ""} ${colors.green}●${colors.reset}${colors.dim} = active key${colors.reset}`);
341
+ }
240
342
  // ─── Structured verbs (post / task / doc / ls / cat / projects) ──
241
343
  // These talk to the workspace over the same /v1/fs the app + agents use, so the
242
344
  // CLI is a real product interface — not a generic shell.
@@ -263,6 +365,18 @@ async function resolveProject(fs, flag, md) {
263
365
  return slugs[0];
264
366
  throw new Error(`specify --project <slug>${slugs.length ? ` (yours: ${slugs.join(", ")})` : ""}`);
265
367
  }
368
+ // A post reference can be a file path (we read its `id:` frontmatter) or a bare
369
+ // post id. So `sfora comment /projects/x/posts/y.md "hi"` and `sfora comment
370
+ // <id> "hi"` both work.
371
+ async function resolvePostId(fs, ref) {
372
+ if (!ref.includes("/"))
373
+ return ref;
374
+ const md = await fs.readFile(ref, "utf8");
375
+ const id = frontmatterValue(md, "id");
376
+ if (!id)
377
+ throw new Error(`no post id found in ${ref} (is it a post file?)`);
378
+ return id;
379
+ }
266
380
  const VERBS = new Set([
267
381
  "projects",
268
382
  "new",
@@ -271,12 +385,31 @@ const VERBS = new Set([
271
385
  "post",
272
386
  "task",
273
387
  "doc",
388
+ "posts",
389
+ "tasks",
390
+ "inbox",
391
+ "mentions",
392
+ "me",
393
+ "whoami",
394
+ "comment",
395
+ "react",
274
396
  ]);
275
397
  async function runVerb(args, fs, client) {
276
398
  const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
399
+ const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
400
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
277
401
  if (args.command === "projects") {
278
- const slugs = await fs.readdir("/projects");
279
- console.log(slugs.length ? slugs.join("\n") : "(no projects)");
402
+ const projects = await client.listProjects();
403
+ if (args.json)
404
+ return emitJson(projects);
405
+ if (projects.length === 0) {
406
+ console.log(`${colors.dim}(no projects)${colors.reset}`);
407
+ return;
408
+ }
409
+ const width = Math.max(...projects.map((p) => p.name.length));
410
+ for (const p of projects) {
411
+ console.log(`${p.name.padEnd(width)} ${colors.dim}${p.slug} · ${plural(p.postCount, "post")}${colors.reset}`);
412
+ }
280
413
  return;
281
414
  }
282
415
  if (args.command === "new") {
@@ -289,6 +422,8 @@ async function runVerb(args, fs, client) {
289
422
  }
290
423
  if (args.command === "ls") {
291
424
  const entries = await fs.readdir(args.rest[0] ?? "/projects");
425
+ if (args.json)
426
+ return emitJson(entries);
292
427
  console.log(entries.join("\n"));
293
428
  return;
294
429
  }
@@ -298,6 +433,91 @@ async function runVerb(args, fs, client) {
298
433
  process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
299
434
  return;
300
435
  }
436
+ if (args.command === "me" || args.command === "whoami") {
437
+ const text = (await client.readMe()).trim();
438
+ if (args.json) {
439
+ const obj = {};
440
+ for (const ln of text.split("\n")) {
441
+ const m = /^([a-zA-Z][\w-]*):\s*(.*)$/.exec(ln);
442
+ if (m)
443
+ obj[m[1]] = m[2];
444
+ }
445
+ return emitJson(obj);
446
+ }
447
+ console.log(text);
448
+ return;
449
+ }
450
+ if (args.command === "inbox" || args.command === "mentions") {
451
+ const md = (await client.readInbox()).trim();
452
+ if (args.json)
453
+ return emitJson({ markdown: md });
454
+ console.log(md || `${colors.dim}(no unread mentions)${colors.reset}`);
455
+ return;
456
+ }
457
+ if (args.command === "posts") {
458
+ const slug = args.project ?? args.rest[0];
459
+ if (!slug)
460
+ throw new Error("usage: sfora posts <project> [--json]");
461
+ const entries = await client.listPosts(slug);
462
+ if (args.json)
463
+ return emitJson(entries);
464
+ if (entries.length === 0) {
465
+ console.log(`${colors.dim}(no posts)${colors.reset}`);
466
+ return;
467
+ }
468
+ for (const e of entries) {
469
+ const flag = e.isDraft ? `${colors.yellow}draft${colors.reset} ` : "";
470
+ console.log(`${flag}${e.title} ${colors.dim}${e.filename}${colors.reset}`);
471
+ }
472
+ return;
473
+ }
474
+ if (args.command === "tasks") {
475
+ const slug = args.project ?? args.rest[0];
476
+ if (!slug)
477
+ throw new Error("usage: sfora tasks <project> [--json]");
478
+ const columns = await client.listColumns(slug);
479
+ const board = await Promise.all(columns.map(async (col) => ({
480
+ column: col.name,
481
+ dirname: col.dirname,
482
+ cards: await client.listCards(slug, col.dirname),
483
+ })));
484
+ if (args.json)
485
+ return emitJson(board);
486
+ for (const group of board) {
487
+ console.log(`${colors.bold}${group.column}${colors.reset} ${colors.dim}(${group.cards.length})${colors.reset}`);
488
+ for (const c of group.cards) {
489
+ const dot = c.status === "closed"
490
+ ? colors.green
491
+ : c.status === "active"
492
+ ? colors.cyan
493
+ : colors.dim;
494
+ console.log(` ${dot}●${colors.reset} ${colors.dim}#${c.number}${colors.reset} ${c.title}`);
495
+ }
496
+ if (group.cards.length === 0)
497
+ console.log(` ${colors.dim}(empty)${colors.reset}`);
498
+ }
499
+ return;
500
+ }
501
+ if (args.command === "comment") {
502
+ const ref = args.rest[0];
503
+ const text = args.rest.slice(1).join(" ").trim();
504
+ if (!ref || !text)
505
+ throw new Error('usage: sfora comment <post> "<message>"');
506
+ const postId = await resolvePostId(fs, ref);
507
+ await client.createComment(postId, text);
508
+ ok(`Commented on ${ref}`);
509
+ return;
510
+ }
511
+ if (args.command === "react") {
512
+ const ref = args.rest[0];
513
+ const emoji = args.rest[1];
514
+ if (!ref || !emoji)
515
+ throw new Error("usage: sfora react <post> <emoji>");
516
+ const postId = await resolvePostId(fs, ref);
517
+ const r = await client.reactToPost(postId, emoji);
518
+ ok(`${r.reacted ? "Reacted" : "Removed reaction"} ${emoji} on ${ref}`);
519
+ return;
520
+ }
301
521
  // post / task / doc — push a local markdown file into the workspace.
302
522
  const file = args.rest[0];
303
523
  if (!file) {
@@ -337,6 +557,106 @@ async function runVerb(args, fs, client) {
337
557
  return;
338
558
  }
339
559
  }
560
+ // ─── Local mode (a .sfora/ directory — no server, no account) ─────
561
+ const LOCAL_ONLY_HINT = "cloud command — run it with --cloud (after `sfora login`), or outside the .sfora/ repo";
562
+ async function runLocalVerb(args, root) {
563
+ const ws = new LocalWorkspace(root);
564
+ const { fs } = createLocalShell(root);
565
+ const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
566
+ const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
567
+ switch (args.command) {
568
+ case "ls": {
569
+ const entries = await fs.readdir(args.rest[0] ?? "/");
570
+ if (args.json)
571
+ return emitJson(entries);
572
+ console.log(entries.join("\n"));
573
+ return;
574
+ }
575
+ case "cat": {
576
+ if (!args.rest[0])
577
+ throw new Error("usage: sfora cat <path>");
578
+ process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
579
+ return;
580
+ }
581
+ case "tasks": {
582
+ const tasks = await ws.listTasks(args.column);
583
+ if (args.json)
584
+ return emitJson(tasks);
585
+ const columns = await ws.listColumns();
586
+ for (const col of args.column ? [] : columns) {
587
+ const inCol = tasks.filter((t) => t.column === col);
588
+ console.log(`${colors.bold}${col.replace(/^\d+-/, "")}${colors.reset} ${colors.dim}(${inCol.length})${colors.reset}`);
589
+ for (const t of inCol) {
590
+ const dot = t.status === "closed" ? colors.green : colors.cyan;
591
+ console.log(` ${dot}●${colors.reset} ${colors.dim}#${t.number ?? "?"}${colors.reset} ${t.title}`);
592
+ }
593
+ if (inCol.length === 0)
594
+ console.log(` ${colors.dim}(empty)${colors.reset}`);
595
+ }
596
+ if (args.column) {
597
+ for (const t of tasks)
598
+ console.log(`${colors.dim}#${t.number ?? "?"}${colors.reset} ${t.title}`);
599
+ }
600
+ return;
601
+ }
602
+ case "posts": {
603
+ const files = await ws.listPosts(args.draft ? "drafts" : "posts");
604
+ if (args.json)
605
+ return emitJson(files);
606
+ console.log(files.length ? files.join("\n") : `${colors.dim}(no posts)${colors.reset}`);
607
+ return;
608
+ }
609
+ case "me":
610
+ case "whoami": {
611
+ if (args.json)
612
+ return emitJson({ mode: "local", workspace: root });
613
+ console.log(`local workspace ${colors.dim}· ${root}${colors.reset}`);
614
+ return;
615
+ }
616
+ case "task":
617
+ case "post":
618
+ case "doc": {
619
+ const file = args.rest[0];
620
+ if (!file)
621
+ throw new Error(`usage: sfora ${args.command} <file.md>`);
622
+ const md = await readLocalFile(file, "utf8");
623
+ if (args.command === "task") {
624
+ const r = await ws.writeTask(md, { column: args.column });
625
+ ok(`Task #${r.number} created in ${r.column} · ${r.filename}`);
626
+ }
627
+ else if (args.command === "post") {
628
+ const r = await ws.writePost(md, { draft: args.draft });
629
+ ok(`${args.draft ? "Drafted" : "Posted"} · ${r.filename}`);
630
+ }
631
+ else {
632
+ const r = await ws.writeDoc(md);
633
+ ok(`Doc saved · ${r.filename}`);
634
+ }
635
+ return;
636
+ }
637
+ default:
638
+ throw new Error(`'${args.command}' is a ${LOCAL_ONLY_HINT}`);
639
+ }
640
+ }
641
+ const LOCAL_SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— local workspace (.sfora/), plain files on disk${colors.reset}
642
+
643
+ Standard tools work on the real files:
644
+ ls cat grep find head tail wc sed awk echo mv cd pwd
645
+
646
+ ${colors.dim}Where things live${colors.reset}
647
+ /board/<column>/NNNN-<slug>.md tasks — mv between columns moves a task
648
+ /posts/YYYY-MM-DD-<slug>.md posts
649
+ /docs/<slug>.md docs
650
+
651
+ ${colors.dim}Try${colors.reset}
652
+ ls /board/01-todo
653
+ grep -ri todo /board
654
+ echo "# Fix login" > /board/01-todo/fix-login.md
655
+ mv /board/01-todo/0003-*.md /board/03-done/
656
+
657
+ Everything is git-versioned with your repo. ${colors.dim}Connect a team later with${colors.reset} ${colors.cyan}sfora login${colors.reset}.
658
+ Type ${colors.cyan}exit${colors.reset} to quit.
659
+ `;
340
660
  async function main() {
341
661
  const args = parseArgs(process.argv.slice(2));
342
662
  if (args.help) {
@@ -344,6 +664,13 @@ async function main() {
344
664
  return;
345
665
  }
346
666
  if (args.command === "init") {
667
+ if (args.local) {
668
+ const root = await initWorkspace(process.cwd());
669
+ console.log(`${colors.green}✓${colors.reset} Local workspace ready · ${root}`);
670
+ console.log(`${colors.dim}Tasks, posts, and docs live there as plain markdown — versioned with your repo.\n` +
671
+ `Try: sfora task plan.md · sfora tasks · sfora (shell). Connect a team later with sfora login.${colors.reset}`);
672
+ return;
673
+ }
347
674
  await runInit(args);
348
675
  return;
349
676
  }
@@ -354,10 +681,51 @@ async function main() {
354
681
  printMcpConfig(settings);
355
682
  return;
356
683
  }
684
+ if (args.command === "contexts") {
685
+ printContexts(cfg, settings);
686
+ return;
687
+ }
357
688
  if (args.command === "login") {
358
689
  await runLogin(args, baseUrl);
359
690
  return;
360
691
  }
692
+ // Local mode — a .sfora/ workspace in (an ancestor of) cwd wins, unless the
693
+ // user explicitly targets the cloud (--cloud / --url / --org / --key / --bot).
694
+ const cloudIntent = args.cloud || !!args.url || !!args.org || !!args.key || !!args.bot;
695
+ const localRoot = cloudIntent
696
+ ? undefined
697
+ : await findWorkspace(process.cwd());
698
+ if (args.local && !localRoot) {
699
+ process.stderr.write(`${colors.red}error:${colors.reset} no .sfora/ workspace found — run \`sfora init --local\` first.\n`);
700
+ process.exitCode = 1;
701
+ return;
702
+ }
703
+ if (localRoot) {
704
+ if (args.command && VERBS.has(args.command)) {
705
+ try {
706
+ await runLocalVerb(args, localRoot);
707
+ }
708
+ catch (e) {
709
+ process.stderr.write(`${colors.red}error:${colors.reset} ${e instanceof Error ? e.message : String(e)}\n`);
710
+ process.exitCode = 1;
711
+ }
712
+ return;
713
+ }
714
+ if (args.mcp) {
715
+ await runMcpServer({
716
+ baseUrl,
717
+ apiKey: apiKey ?? "",
718
+ org: settings.org ?? "",
719
+ localRoot,
720
+ });
721
+ return;
722
+ }
723
+ const { bash, fs } = createLocalShell(localRoot, args.cwd);
724
+ console.log(`${colors.cyan}${colors.bold}sfora${colors.reset} ${colors.dim}— local workspace · ${localRoot}${colors.reset}`);
725
+ console.log(`${colors.dim}Plain markdown files, versioned with your repo. Try: ls /board · ${colors.reset}${colors.cyan}help${colors.reset}${colors.dim} for commands · ${colors.reset}${colors.cyan}exit${colors.reset}${colors.dim} to quit${colors.reset}\n`);
726
+ await runShell(bash, fs, LOCAL_SHELL_HELP, args.cwd);
727
+ return;
728
+ }
361
729
  if (!apiKey) {
362
730
  process.stderr.write(args.bot
363
731
  ? `${colors.red}error:${colors.reset} no key for bot '${args.bot}'. Run \`sfora login --bot ${args.bot}\`.\n`
@@ -372,13 +740,21 @@ async function main() {
372
740
  baseUrl,
373
741
  apiKey,
374
742
  org: settings.org ?? "",
743
+ actAs: args.as,
375
744
  });
376
- const client = new SforaApiClient({ baseUrl, apiKey });
745
+ const client = new SforaApiClient({ baseUrl, apiKey, actAs: args.as });
377
746
  try {
378
747
  await runVerb(args, fs, client);
379
748
  }
380
749
  catch (e) {
381
- process.stderr.write(`${colors.red}error:${colors.reset} ${e instanceof Error ? e.message : String(e)}\n`);
750
+ const msg = e instanceof Error ? e.message : String(e);
751
+ // A 401 surfaces as EACCES once SforaFs maps it to an errno — translate it
752
+ // back into an actionable hint instead of a cryptic permission error.
753
+ const authExpired = e?.status === 401 ||
754
+ /EACCES|permission denied|invalid api key|unauthorized/i.test(msg);
755
+ process.stderr.write(`${colors.red}error:${colors.reset} ${authExpired
756
+ ? `your key is invalid or expired — run \`sfora login\`${args.bot ? ` --bot ${args.bot}` : ""}`
757
+ : msg}\n`);
382
758
  process.exitCode = 1;
383
759
  }
384
760
  return;
@@ -394,15 +770,11 @@ async function main() {
394
770
  return;
395
771
  }
396
772
  const org = settings.org;
397
- const { bash } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
398
- // Shell state threaded across exec() calls — just-bash does not persist cwd/env
399
- // between separate exec()s, so we carry them forward ourselves.
400
- let cwd = args.cwd;
401
- let env;
773
+ const { bash, fs } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
402
774
  // Pre-flight: confirm auth + connectivity and greet with the resolved identity.
403
775
  let identity = "";
404
776
  try {
405
- identity = (await bash.exec("cat /me/api-key", { cwd, env })).stdout.trim();
777
+ identity = (await bash.exec("cat /me/api-key", { cwd: args.cwd })).stdout.trim();
406
778
  }
407
779
  catch {
408
780
  identity = "";
@@ -418,7 +790,14 @@ async function main() {
418
790
  else {
419
791
  console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
420
792
  }
421
- console.log(`${colors.dim}Try: ls /projects · cat /projects/<slug>/posts/<file>.md · cat /inbox/mentions.md · 'exit' to quit${colors.reset}\n`);
793
+ console.log(`${colors.dim}Try: ls /projects · cat /inbox/mentions.md · ${colors.reset}${colors.cyan}help${colors.reset}${colors.dim} for commands · ${colors.reset}${colors.cyan}exit${colors.reset}${colors.dim} to quit${colors.reset}\n`);
794
+ await runShell(bash, fs, SHELL_HELP, args.cwd);
795
+ }
796
+ async function runShell(bash, fs, helpText, initialCwd) {
797
+ // Shell state threaded across exec() calls — just-bash does not persist cwd/env
798
+ // between separate exec()s, so we carry them forward ourselves.
799
+ let cwd = initialCwd;
800
+ let env;
422
801
  // Iterate stdin as an async iterable. This pattern serializes lines even
423
802
  // when stdin is piped (buffered) — the `for await` body completes before the
424
803
  // next line is consumed, and EOF naturally falls through to process exit
@@ -427,10 +806,46 @@ async function main() {
427
806
  // readline fired 'close' (→ process.exit(0)) while the first exec's PUT was
428
807
  // still in flight.
429
808
  const isTty = Boolean(process.stdin.isTTY);
809
+ // Tab completion: command names on the first token, workspace paths after.
810
+ // Path hits read the live fs, so completing `/projects/<Tab>` lists projects.
811
+ const COMMANDS = [
812
+ "ls", "cat", "cd", "pwd", "grep", "find", "echo", "head", "tail", "wc",
813
+ "sed", "awk", "sort", "uniq", "mkdir", "rm", "mv", "cp", "touch", "help",
814
+ "exit",
815
+ ];
816
+ async function completePath(token) {
817
+ const slash = token.lastIndexOf("/");
818
+ const dir = slash >= 0 ? token.slice(0, slash + 1) : "";
819
+ const prefix = slash >= 0 ? token.slice(slash + 1) : token;
820
+ const base = dir.startsWith("/")
821
+ ? dir || "/"
822
+ : `${cwd.replace(/\/+$/, "")}/${dir}`;
823
+ let names = [];
824
+ try {
825
+ names = await fs.readdir(base || "/");
826
+ }
827
+ catch {
828
+ return [];
829
+ }
830
+ return names.filter((n) => n.startsWith(prefix)).map((n) => dir + n);
831
+ }
832
+ const completer = (line, cb) => {
833
+ const tokens = line.split(/\s+/);
834
+ const last = tokens[tokens.length - 1] ?? "";
835
+ if (tokens.length <= 1) {
836
+ const hits = COMMANDS.filter((c) => c.startsWith(last));
837
+ cb(null, [hits.length ? hits : COMMANDS, last]);
838
+ return;
839
+ }
840
+ completePath(last)
841
+ .then((hits) => cb(null, [hits, last]))
842
+ .catch(() => cb(null, [[], last]));
843
+ };
430
844
  const rl = readline.createInterface({
431
845
  input: process.stdin,
432
846
  output: process.stdout,
433
847
  terminal: isTty,
848
+ completer,
434
849
  });
435
850
  const promptText = () => `${colors.cyan}sfora${colors.reset}:${colors.blue}${cwd}${colors.reset}$ `;
436
851
  // Print the first prompt; subsequent ones are printed after each handled line.
@@ -445,12 +860,21 @@ async function main() {
445
860
  }
446
861
  if (line === "exit" || line === "quit")
447
862
  break;
863
+ // Show sfora's own help instead of just-bash's builtin help banner.
864
+ if (line === "help" || line === "?" || line.startsWith("help ")) {
865
+ process.stdout.write(helpText);
866
+ if (isTty)
867
+ process.stdout.write(promptText());
868
+ continue;
869
+ }
448
870
  try {
449
871
  const res = await bash.exec(line, { cwd, env });
450
872
  if (res.stdout)
451
873
  process.stdout.write(res.stdout);
874
+ // Rewrite the engine's "bash:" error prefix to "sfora:" so the shell
875
+ // reads as native — the user never typed `bash`.
452
876
  if (res.stderr)
453
- process.stderr.write(res.stderr);
877
+ process.stderr.write(res.stderr.replace(/^bash:/gm, "sfora:"));
454
878
  env = res.env;
455
879
  cwd = res.env?.PWD ?? cwd;
456
880
  }