sfora-cli 0.5.0 → 0.7.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 CHANGED
@@ -48,12 +48,14 @@ so a saved config means no env vars on every run.
48
48
  pnpm -F sfora build && node packages/sfora/dist/cli.js --help
49
49
  ```
50
50
 
51
- ### The `just-bash` dependency
51
+ ### The shell engine
52
52
 
53
- This package depends on `just-bash@^3.0.1` from npm no extra steps needed.
53
+ The sfora shell is a real POSIX-style shell mounted on your workspace. It's
54
+ powered by an embedded interpreter (the `just-bash` package, `^3.0.1`) — a
55
+ normal npm dependency, no extra steps needed.
54
56
 
55
- If you're working against an **unpublished** `just-bash` (e.g. the copy in
56
- `context/just-bash`), point the dependency at it and build it first:
57
+ If you're working against an **unpublished** build of the engine (e.g. the copy
58
+ in `context/just-bash`), point the dependency at it and build it first:
57
59
 
58
60
  ```jsonc
59
61
  // packages/sfora/package.json
@@ -65,11 +67,11 @@ cd context/just-bash && pnpm install && pnpm build # produces dist/bundle/*
65
67
  cd ../../ && pnpm install
66
68
  ```
67
69
 
68
- > Note: `createSforaShell` constructs the `Bash` instance with
69
- > `defenseInDepth: false`. just-bash's in-process hardening (which blocks globals
70
- > like `WeakRef`) defaults on and would break `fetch` (undici uses `WeakRef`
71
- > internally). sfora runs the user's/agent's own commands against their own
72
- > workspace over HTTPS, so that sandbox isn't needed here.
70
+ > Note: `createSforaShell` constructs the shell with `defenseInDepth: false`.
71
+ > The engine's in-process hardening (which blocks globals like `WeakRef`)
72
+ > defaults on and would break `fetch` (undici uses `WeakRef` internally). sfora
73
+ > runs the user's/agent's own commands against their own workspace over HTTPS,
74
+ > so that sandbox isn't needed here.
73
75
 
74
76
  ## Auth
75
77
 
@@ -187,7 +189,7 @@ backend) are the public exports, alongside the lower-level `SforaApiClient`.
187
189
  | `cat`, read | lazy `GET` of the markdown body (fetched only when read) |
188
190
  | `echo >`, write | `PUT` markdown → create/update a post or draft |
189
191
  | `rm`, unlink | `DELETE` → soft-delete (author or org admin/owner) |
190
- | `cd`, `pwd`, `grep`, `find`, pipes, … | all the just-bash builtins/commands |
192
+ | `cd`, `pwd`, `grep`, `find`, pipes, … | all the standard shell builtins/commands |
191
193
  | `mkdir` | no-op on known dirs; denied elsewhere (can't create projects) |
192
194
  | `chmod`, `utimes` | accepted no-ops (permissions/mtime are server-owned) |
193
195
  | symlinks, `cp`, `mv` | denied (`EPERM`) — no backend operation |
package/dist/SforaFs.js CHANGED
@@ -73,6 +73,9 @@ function classify(path) {
73
73
  if (seg.length === 2)
74
74
  return { kind: "projectDir", slug };
75
75
  const dir = seg[2];
76
+ if (dir === "links.md" && seg.length === 3) {
77
+ return { kind: "linksFile", slug };
78
+ }
76
79
  if (dir === "posts" || dir === "drafts") {
77
80
  if (seg.length === 3)
78
81
  return { kind: "postsDir", slug, dir };
@@ -276,6 +279,13 @@ export class SforaFs {
276
279
  catch (e) {
277
280
  throw fromApi(e, "open", normalize(path));
278
281
  }
282
+ case "linksFile":
283
+ try {
284
+ return await this.#client.getProjectLinks(loc.slug);
285
+ }
286
+ catch (e) {
287
+ throw fromApi(e, "open", normalize(path));
288
+ }
279
289
  case "root":
280
290
  case "projectsDir":
281
291
  case "inboxDir":
@@ -306,6 +316,15 @@ export class SforaFs {
306
316
  if (loc.kind === "inboxFile" || loc.kind === "meFile") {
307
317
  throw eacces("open", norm);
308
318
  }
319
+ if (loc.kind === "linksFile") {
320
+ try {
321
+ await this.#client.setProjectLinks(loc.slug, toText(content));
322
+ }
323
+ catch (e) {
324
+ throw fromApi(e, "open", norm);
325
+ }
326
+ return;
327
+ }
309
328
  if (loc.kind === "root" ||
310
329
  loc.kind === "projectsDir" ||
311
330
  loc.kind === "inboxDir" ||
@@ -416,6 +435,10 @@ export class SforaFs {
416
435
  case "inboxFile":
417
436
  case "meFile":
418
437
  return this.#fileStat(new Date());
438
+ case "linksFile":
439
+ if (!(await this.#projectExists(loc.slug)))
440
+ throw enoent("stat", norm);
441
+ return this.#fileStat(new Date());
419
442
  case "projectDir":
420
443
  case "postsDir":
421
444
  case "boardDir":
@@ -489,6 +512,7 @@ export class SforaFs {
489
512
  return [
490
513
  dirent("board", true),
491
514
  dirent("drafts", true),
515
+ dirent("links.md", false),
492
516
  dirent("posts", true),
493
517
  ];
494
518
  case "postsDir": {
@@ -75,6 +75,10 @@ export declare class SforaApiClient {
75
75
  slug: string;
76
76
  name: string;
77
77
  }>;
78
+ /** `GET …/links.md` — the project's external links as markdown. */
79
+ getProjectLinks(slug: string): Promise<string>;
80
+ /** `PUT …/links.md` — replace the project's links from a markdown list. */
81
+ setProjectLinks(slug: string, markdown: string): Promise<void>;
78
82
  /**
79
83
  * `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
80
84
  * (future) `scheduledFor` set.
@@ -116,6 +120,18 @@ export declare class SforaApiClient {
116
120
  * card across columns without changing its body. Used by `mv` in the shell.
117
121
  */
118
122
  moveCard(projectSlug: string, fromCol: string, filename: string, toCol: string): Promise<CardWriteResult>;
123
+ /** `POST /v1/posts/:postId/comments` — add a comment to a post. */
124
+ createComment(postId: string, body: string): Promise<{
125
+ id: string;
126
+ }>;
127
+ /**
128
+ * `POST /v1/posts/:postId/reactions` — toggle an emoji reaction on a post.
129
+ * Returns whether the reaction is now on (`reacted: true`) or off.
130
+ */
131
+ reactToPost(postId: string, emoji: string): Promise<{
132
+ content: string;
133
+ reacted: boolean;
134
+ }>;
119
135
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
120
136
  readInbox(): Promise<string>;
121
137
  /** `GET /v1/fs/me/api-key` — text identity (no key material). */
@@ -90,6 +90,14 @@ export class SforaApiClient {
90
90
  const res = await this.#request("POST", "/v1/fs/projects", JSON.stringify({ name }));
91
91
  return (await res.json());
92
92
  }
93
+ /** `GET …/links.md` — the project's external links as markdown. */
94
+ async getProjectLinks(slug) {
95
+ return this.#text(`/v1/fs/projects/${encodeURIComponent(slug)}/links.md`);
96
+ }
97
+ /** `PUT …/links.md` — replace the project's links from a markdown list. */
98
+ async setProjectLinks(slug, markdown) {
99
+ await this.#request("PUT", `/v1/fs/projects/${encodeURIComponent(slug)}/links.md`, markdown);
100
+ }
93
101
  /**
94
102
  * `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
95
103
  * (future) `scheduledFor` set.
@@ -231,6 +239,40 @@ export class SforaApiClient {
231
239
  throw await this.#toError(res);
232
240
  return (await res.json());
233
241
  }
242
+ // ─── Post actions (comment / react) ─────────────────────────────
243
+ /** `POST /v1/posts/:postId/comments` — add a comment to a post. */
244
+ async createComment(postId, body) {
245
+ const res = await fetch(`${this.#baseUrl}/v1/posts/${encodeURIComponent(postId)}/comments`, {
246
+ method: "POST",
247
+ headers: {
248
+ Authorization: `Bearer ${this.#apiKey}`,
249
+ "Content-Type": "application/json",
250
+ },
251
+ body: JSON.stringify({ body }),
252
+ });
253
+ if (!res.ok)
254
+ throw await this.#toError(res);
255
+ const data = (await res.json());
256
+ return { id: data.comment?._id ?? "" };
257
+ }
258
+ /**
259
+ * `POST /v1/posts/:postId/reactions` — toggle an emoji reaction on a post.
260
+ * Returns whether the reaction is now on (`reacted: true`) or off.
261
+ */
262
+ async reactToPost(postId, emoji) {
263
+ const res = await fetch(`${this.#baseUrl}/v1/posts/${encodeURIComponent(postId)}/reactions`, {
264
+ method: "POST",
265
+ headers: {
266
+ Authorization: `Bearer ${this.#apiKey}`,
267
+ "Content-Type": "application/json",
268
+ },
269
+ body: JSON.stringify({ emoji }),
270
+ });
271
+ if (!res.ok)
272
+ throw await this.#toError(res);
273
+ const data = (await res.json());
274
+ return data.reaction ?? { content: emoji, reacted: true };
275
+ }
234
276
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
235
277
  async readInbox() {
236
278
  return this.#text("/v1/fs/inbox/mentions.md");
package/dist/cli.js CHANGED
@@ -24,7 +24,7 @@ const colors = {
24
24
  red: "\x1b[31m",
25
25
  };
26
26
  function parseArgs(argv) {
27
- const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false };
27
+ const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false };
28
28
  for (let i = 0; i < argv.length; i++) {
29
29
  const a = argv[i];
30
30
  if (a === "--mcp")
@@ -67,6 +67,8 @@ function parseArgs(argv) {
67
67
  args.column = a.slice("--column=".length);
68
68
  else if (a === "--draft")
69
69
  args.draft = true;
70
+ else if (a === "--json")
71
+ args.json = true;
70
72
  else if (!a.startsWith("-")) {
71
73
  if (!args.command)
72
74
  args.command = a;
@@ -89,15 +91,27 @@ Push markdown straight to sfora:
89
91
  sfora task <file.md> [--project <slug>] [--column <name>] Create a task
90
92
  sfora doc <file.md> [--project <slug>] Create a doc
91
93
 
94
+ Act on a post:
95
+ sfora comment <post> "<message>" Comment on a post
96
+ sfora react <post> <emoji> Toggle a reaction (👍 🚀 …)
97
+ (<post> is a post file path or its id)
98
+
92
99
  Projects:
93
100
  sfora new "<name>" Create a project
94
101
  sfora projects List your projects
95
102
 
96
- Browse:
103
+ Browse & read:
104
+ sfora posts <project> List a project's posts
105
+ sfora tasks <project> List a project's board, by column
106
+ sfora inbox Show your unread mentions
107
+ sfora me Show who you're signed in as
97
108
  sfora ls [path] List a path (default /projects)
98
109
  sfora cat <path> Print a file's markdown
99
110
  sfora Open the interactive shell
100
111
 
112
+ Add --json to any list command (projects/posts/tasks/ls/me) for
113
+ machine-readable output and stable scripting.
114
+
101
115
  Agents & config:
102
116
  sfora --mcp [--org <slug>] Run as an MCP server (for agents)
103
117
  sfora mcp-config Print an MCP config snippet to paste
@@ -106,6 +120,34 @@ Agents & config:
106
120
  Config resolution: flags > env (SFORA_API_KEY / SFORA_URL / SFORA_ORG) >
107
121
  ~/.sfora/config.json > default (URL ${DEFAULT_URL}).
108
122
  `;
123
+ // In-shell `help` (and `?`). The underlying engine (just-bash) ships its own
124
+ // `help` builtin that prints "just-bash shell builtins" — internal branding we
125
+ // never want users to see, and it can't be overridden via the command registry
126
+ // (it's a hardcoded interpreter builtin). So the REPL intercepts `help` and
127
+ // prints this sfora-native cheat sheet instead.
128
+ const SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— your workspace as a markdown filesystem${colors.reset}
129
+
130
+ Standard tools work against your workspace:
131
+ ls cat grep find head tail wc sed awk echo cd pwd
132
+
133
+ ${colors.dim}Where things live${colors.reset}
134
+ /projects your projects
135
+ /projects/<slug>/posts/<file>.md published posts
136
+ /projects/<slug>/drafts/ drafts
137
+ /projects/<slug>/board/<col>/ board tasks
138
+ /projects/<slug>/docs/ docs
139
+ /inbox/mentions.md your mentions
140
+ /me/api-key who you're signed in as
141
+
142
+ ${colors.dim}Try${colors.reset}
143
+ ls /projects
144
+ cat /projects/<slug>/posts/<file>.md
145
+ grep -ri todo /projects
146
+ echo "# Hello" > /projects/<slug>/posts/hello.md
147
+
148
+ ${colors.dim}Outside the shell${colors.reset}, sfora has verbs: post · task · doc · new · projects (run ${colors.cyan}sfora --help${colors.reset})
149
+ Type ${colors.cyan}exit${colors.reset} to quit.
150
+ `;
109
151
  // `sfora init` — persist url/apiKey/org to ~/.sfora/config.json. Uses flags
110
152
  // when given, otherwise prompts (on a TTY), defaulting to any existing config.
111
153
  async function runInit(args) {
@@ -263,6 +305,18 @@ async function resolveProject(fs, flag, md) {
263
305
  return slugs[0];
264
306
  throw new Error(`specify --project <slug>${slugs.length ? ` (yours: ${slugs.join(", ")})` : ""}`);
265
307
  }
308
+ // A post reference can be a file path (we read its `id:` frontmatter) or a bare
309
+ // post id. So `sfora comment /projects/x/posts/y.md "hi"` and `sfora comment
310
+ // <id> "hi"` both work.
311
+ async function resolvePostId(fs, ref) {
312
+ if (!ref.includes("/"))
313
+ return ref;
314
+ const md = await fs.readFile(ref, "utf8");
315
+ const id = frontmatterValue(md, "id");
316
+ if (!id)
317
+ throw new Error(`no post id found in ${ref} (is it a post file?)`);
318
+ return id;
319
+ }
266
320
  const VERBS = new Set([
267
321
  "projects",
268
322
  "new",
@@ -271,12 +325,31 @@ const VERBS = new Set([
271
325
  "post",
272
326
  "task",
273
327
  "doc",
328
+ "posts",
329
+ "tasks",
330
+ "inbox",
331
+ "mentions",
332
+ "me",
333
+ "whoami",
334
+ "comment",
335
+ "react",
274
336
  ]);
275
337
  async function runVerb(args, fs, client) {
276
338
  const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
339
+ const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
340
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
277
341
  if (args.command === "projects") {
278
- const slugs = await fs.readdir("/projects");
279
- console.log(slugs.length ? slugs.join("\n") : "(no projects)");
342
+ const projects = await client.listProjects();
343
+ if (args.json)
344
+ return emitJson(projects);
345
+ if (projects.length === 0) {
346
+ console.log(`${colors.dim}(no projects)${colors.reset}`);
347
+ return;
348
+ }
349
+ const width = Math.max(...projects.map((p) => p.name.length));
350
+ for (const p of projects) {
351
+ console.log(`${p.name.padEnd(width)} ${colors.dim}${p.slug} · ${plural(p.postCount, "post")}${colors.reset}`);
352
+ }
280
353
  return;
281
354
  }
282
355
  if (args.command === "new") {
@@ -289,6 +362,8 @@ async function runVerb(args, fs, client) {
289
362
  }
290
363
  if (args.command === "ls") {
291
364
  const entries = await fs.readdir(args.rest[0] ?? "/projects");
365
+ if (args.json)
366
+ return emitJson(entries);
292
367
  console.log(entries.join("\n"));
293
368
  return;
294
369
  }
@@ -298,6 +373,91 @@ async function runVerb(args, fs, client) {
298
373
  process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
299
374
  return;
300
375
  }
376
+ if (args.command === "me" || args.command === "whoami") {
377
+ const text = (await client.readMe()).trim();
378
+ if (args.json) {
379
+ const obj = {};
380
+ for (const ln of text.split("\n")) {
381
+ const m = /^([a-zA-Z][\w-]*):\s*(.*)$/.exec(ln);
382
+ if (m)
383
+ obj[m[1]] = m[2];
384
+ }
385
+ return emitJson(obj);
386
+ }
387
+ console.log(text);
388
+ return;
389
+ }
390
+ if (args.command === "inbox" || args.command === "mentions") {
391
+ const md = (await client.readInbox()).trim();
392
+ if (args.json)
393
+ return emitJson({ markdown: md });
394
+ console.log(md || `${colors.dim}(no unread mentions)${colors.reset}`);
395
+ return;
396
+ }
397
+ if (args.command === "posts") {
398
+ const slug = args.project ?? args.rest[0];
399
+ if (!slug)
400
+ throw new Error("usage: sfora posts <project> [--json]");
401
+ const entries = await client.listPosts(slug);
402
+ if (args.json)
403
+ return emitJson(entries);
404
+ if (entries.length === 0) {
405
+ console.log(`${colors.dim}(no posts)${colors.reset}`);
406
+ return;
407
+ }
408
+ for (const e of entries) {
409
+ const flag = e.isDraft ? `${colors.yellow}draft${colors.reset} ` : "";
410
+ console.log(`${flag}${e.title} ${colors.dim}${e.filename}${colors.reset}`);
411
+ }
412
+ return;
413
+ }
414
+ if (args.command === "tasks") {
415
+ const slug = args.project ?? args.rest[0];
416
+ if (!slug)
417
+ throw new Error("usage: sfora tasks <project> [--json]");
418
+ const columns = await client.listColumns(slug);
419
+ const board = await Promise.all(columns.map(async (col) => ({
420
+ column: col.name,
421
+ dirname: col.dirname,
422
+ cards: await client.listCards(slug, col.dirname),
423
+ })));
424
+ if (args.json)
425
+ return emitJson(board);
426
+ for (const group of board) {
427
+ console.log(`${colors.bold}${group.column}${colors.reset} ${colors.dim}(${group.cards.length})${colors.reset}`);
428
+ for (const c of group.cards) {
429
+ const dot = c.status === "closed"
430
+ ? colors.green
431
+ : c.status === "active"
432
+ ? colors.cyan
433
+ : colors.dim;
434
+ console.log(` ${dot}●${colors.reset} ${colors.dim}#${c.number}${colors.reset} ${c.title}`);
435
+ }
436
+ if (group.cards.length === 0)
437
+ console.log(` ${colors.dim}(empty)${colors.reset}`);
438
+ }
439
+ return;
440
+ }
441
+ if (args.command === "comment") {
442
+ const ref = args.rest[0];
443
+ const text = args.rest.slice(1).join(" ").trim();
444
+ if (!ref || !text)
445
+ throw new Error('usage: sfora comment <post> "<message>"');
446
+ const postId = await resolvePostId(fs, ref);
447
+ await client.createComment(postId, text);
448
+ ok(`Commented on ${ref}`);
449
+ return;
450
+ }
451
+ if (args.command === "react") {
452
+ const ref = args.rest[0];
453
+ const emoji = args.rest[1];
454
+ if (!ref || !emoji)
455
+ throw new Error("usage: sfora react <post> <emoji>");
456
+ const postId = await resolvePostId(fs, ref);
457
+ const r = await client.reactToPost(postId, emoji);
458
+ ok(`${r.reacted ? "Reacted" : "Removed reaction"} ${emoji} on ${ref}`);
459
+ return;
460
+ }
301
461
  // post / task / doc — push a local markdown file into the workspace.
302
462
  const file = args.rest[0];
303
463
  if (!file) {
@@ -394,7 +554,7 @@ async function main() {
394
554
  return;
395
555
  }
396
556
  const org = settings.org;
397
- const { bash } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
557
+ const { bash, fs } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
398
558
  // Shell state threaded across exec() calls — just-bash does not persist cwd/env
399
559
  // between separate exec()s, so we carry them forward ourselves.
400
560
  let cwd = args.cwd;
@@ -418,7 +578,7 @@ async function main() {
418
578
  else {
419
579
  console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
420
580
  }
421
- console.log(`${colors.dim}Try: ls /projects · cat /projects/<slug>/posts/<file>.md · cat /inbox/mentions.md · 'exit' to quit${colors.reset}\n`);
581
+ 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`);
422
582
  // Iterate stdin as an async iterable. This pattern serializes lines even
423
583
  // when stdin is piped (buffered) — the `for await` body completes before the
424
584
  // next line is consumed, and EOF naturally falls through to process exit
@@ -427,10 +587,46 @@ async function main() {
427
587
  // readline fired 'close' (→ process.exit(0)) while the first exec's PUT was
428
588
  // still in flight.
429
589
  const isTty = Boolean(process.stdin.isTTY);
590
+ // Tab completion: command names on the first token, workspace paths after.
591
+ // Path hits read the live fs, so completing `/projects/<Tab>` lists projects.
592
+ const COMMANDS = [
593
+ "ls", "cat", "cd", "pwd", "grep", "find", "echo", "head", "tail", "wc",
594
+ "sed", "awk", "sort", "uniq", "mkdir", "rm", "mv", "cp", "touch", "help",
595
+ "exit",
596
+ ];
597
+ async function completePath(token) {
598
+ const slash = token.lastIndexOf("/");
599
+ const dir = slash >= 0 ? token.slice(0, slash + 1) : "";
600
+ const prefix = slash >= 0 ? token.slice(slash + 1) : token;
601
+ const base = dir.startsWith("/")
602
+ ? dir || "/"
603
+ : `${cwd.replace(/\/+$/, "")}/${dir}`;
604
+ let names = [];
605
+ try {
606
+ names = await fs.readdir(base || "/");
607
+ }
608
+ catch {
609
+ return [];
610
+ }
611
+ return names.filter((n) => n.startsWith(prefix)).map((n) => dir + n);
612
+ }
613
+ const completer = (line, cb) => {
614
+ const tokens = line.split(/\s+/);
615
+ const last = tokens[tokens.length - 1] ?? "";
616
+ if (tokens.length <= 1) {
617
+ const hits = COMMANDS.filter((c) => c.startsWith(last));
618
+ cb(null, [hits.length ? hits : COMMANDS, last]);
619
+ return;
620
+ }
621
+ completePath(last)
622
+ .then((hits) => cb(null, [hits, last]))
623
+ .catch(() => cb(null, [[], last]));
624
+ };
430
625
  const rl = readline.createInterface({
431
626
  input: process.stdin,
432
627
  output: process.stdout,
433
628
  terminal: isTty,
629
+ completer,
434
630
  });
435
631
  const promptText = () => `${colors.cyan}sfora${colors.reset}:${colors.blue}${cwd}${colors.reset}$ `;
436
632
  // Print the first prompt; subsequent ones are printed after each handled line.
@@ -445,12 +641,21 @@ async function main() {
445
641
  }
446
642
  if (line === "exit" || line === "quit")
447
643
  break;
644
+ // Show sfora's own help instead of just-bash's builtin help banner.
645
+ if (line === "help" || line === "?" || line.startsWith("help ")) {
646
+ process.stdout.write(SHELL_HELP);
647
+ if (isTty)
648
+ process.stdout.write(promptText());
649
+ continue;
650
+ }
448
651
  try {
449
652
  const res = await bash.exec(line, { cwd, env });
450
653
  if (res.stdout)
451
654
  process.stdout.write(res.stdout);
655
+ // Rewrite the engine's "bash:" error prefix to "sfora:" so the shell
656
+ // reads as native — the user never typed `bash`.
452
657
  if (res.stderr)
453
- process.stderr.write(res.stderr);
658
+ process.stderr.write(res.stderr.replace(/^bash:/gm, "sfora:"));
454
659
  env = res.env;
455
660
  cwd = res.env?.PWD ?? cwd;
456
661
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfora-cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Your sfora workspace as a markdown filesystem — a CLI + MCP server. Post/task/doc, ls/cat/grep, and a shell so agents operate sfora natively.",
6
6
  "keywords": [