sfora-cli 0.10.0 → 0.12.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.
Files changed (72) hide show
  1. package/README.md +174 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +344 -4
  4. package/dist/api-client.js +289 -21
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/chat.d.ts +89 -0
  8. package/dist/chat.js +189 -0
  9. package/dist/cli-args.d.ts +32 -0
  10. package/dist/cli-args.js +88 -0
  11. package/dist/cli.js +530 -88
  12. package/dist/format/blockSplice.d.ts +135 -0
  13. package/dist/format/blockSplice.js +330 -0
  14. package/dist/format/blocks/dropClosure.d.ts +10 -1
  15. package/dist/format/blocks/dropClosure.js +11 -1
  16. package/dist/format/callout.d.ts +69 -7
  17. package/dist/format/callout.js +112 -15
  18. package/dist/format/checklist.js +11 -4
  19. package/dist/format/formatAxes.d.ts +228 -0
  20. package/dist/format/formatAxes.js +454 -0
  21. package/dist/format/index.d.ts +1 -0
  22. package/dist/format/index.js +4 -0
  23. package/dist/format/lineGeometry.d.ts +34 -4
  24. package/dist/format/lineGeometry.js +140 -40
  25. package/dist/format/lint/appliesTo.d.ts +92 -0
  26. package/dist/format/lint/appliesTo.js +369 -0
  27. package/dist/format/lint/config.d.ts +106 -0
  28. package/dist/format/lint/config.js +205 -0
  29. package/dist/format/lint/fixAll.d.ts +62 -0
  30. package/dist/format/lint/fixAll.js +107 -0
  31. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  32. package/dist/format/lint/frontmatterSchema.js +660 -0
  33. package/dist/format/lint/index.d.ts +34 -5
  34. package/dist/format/lint/index.js +34 -5
  35. package/dist/format/lint/lintSource.d.ts +27 -7
  36. package/dist/format/lint/lintSource.js +67 -33
  37. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  38. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  39. package/dist/format/lint/rules/index.d.ts +2 -1
  40. package/dist/format/lint/rules/index.js +7 -1
  41. package/dist/format/lint/rules/malformed-callout.js +25 -16
  42. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  43. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  44. package/dist/format/lint/severity.d.ts +15 -0
  45. package/dist/format/lint/severity.js +50 -0
  46. package/dist/format/lint/textEdits.d.ts +86 -0
  47. package/dist/format/lint/textEdits.js +162 -0
  48. package/dist/format/lint/types.d.ts +44 -8
  49. package/dist/format/markdown/slug.d.ts +28 -0
  50. package/dist/format/markdown/slug.js +63 -0
  51. package/dist/format/plaintext.js +13 -3
  52. package/dist/format/sheetCellSpans.d.ts +95 -0
  53. package/dist/format/sheetCellSpans.js +223 -0
  54. package/dist/format/sheetSelection.d.ts +136 -0
  55. package/dist/format/sheetSelection.js +282 -0
  56. package/dist/format/textStats.d.ts +23 -0
  57. package/dist/format/textStats.js +80 -0
  58. package/dist/format/wikiLinks.d.ts +60 -1
  59. package/dist/format/wikiLinks.js +195 -9
  60. package/dist/index.d.ts +26 -1
  61. package/dist/index.js +20 -3
  62. package/dist/opener.d.ts +23 -0
  63. package/dist/opener.js +26 -0
  64. package/dist/render.d.ts +162 -0
  65. package/dist/render.js +280 -0
  66. package/dist/shell-commands.d.ts +34 -0
  67. package/dist/shell-commands.js +108 -0
  68. package/dist/watch.d.ts +79 -0
  69. package/dist/watch.js +113 -0
  70. package/dist/web-url.d.ts +39 -0
  71. package/dist/web-url.js +63 -0
  72. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -11,83 +11,16 @@ import { spawn } from "node:child_process";
11
11
  import { readFile as readLocalFile } from "node:fs/promises";
12
12
  import { basename } from "node:path";
13
13
  import { taskUploadFilename } from "./format/taskUploadFilename.js";
14
- import { createSforaShell, createLocalShell, SforaApiClient } from "./index.js";
14
+ import { createSforaShell, createLocalShell, } from "./index.js";
15
+ import { openerCommand } from "./opener.js";
16
+ import { blocksCommand, presenceNotice, putCommand, } from "./block-commands.js";
17
+ import { colors, ndjson, presenceRecords, renderPresence, renderWriteEffect, urlLine, PRESENCE_NOTE, } from "./render.js";
18
+ import { parseWatchTarget, watchLoop } from "./watch.js";
15
19
  import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, } from "./local/workspace.js";
16
20
  import { runMcpServer } from "./mcp-server.js";
17
21
  import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
18
- const colors = {
19
- reset: "\x1b[0m",
20
- bold: "\x1b[1m",
21
- dim: "\x1b[2m",
22
- cyan: "\x1b[36m",
23
- green: "\x1b[32m",
24
- yellow: "\x1b[33m",
25
- blue: "\x1b[34m",
26
- red: "\x1b[31m",
27
- };
28
- function parseArgs(argv) {
29
- const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false };
30
- for (let i = 0; i < argv.length; i++) {
31
- const a = argv[i];
32
- if (a === "--mcp")
33
- args.mcp = true;
34
- else if (a === "--help" || a === "-h")
35
- args.help = true;
36
- else if (a === "--org")
37
- args.org = argv[++i];
38
- else if (a.startsWith("--org="))
39
- args.org = a.slice("--org=".length);
40
- else if (a === "--cwd")
41
- args.cwd = argv[++i] ?? "/";
42
- else if (a.startsWith("--cwd="))
43
- args.cwd = a.slice("--cwd=".length);
44
- else if (a === "--url")
45
- args.url = argv[++i];
46
- else if (a.startsWith("--url="))
47
- args.url = a.slice("--url=".length);
48
- else if (a === "--key")
49
- args.key = argv[++i];
50
- else if (a.startsWith("--key="))
51
- args.key = a.slice("--key=".length);
52
- else if (a === "--bot" || a === "--agent")
53
- args.bot = argv[++i];
54
- else if (a.startsWith("--bot="))
55
- args.bot = a.slice("--bot=".length);
56
- else if (a.startsWith("--agent="))
57
- args.bot = a.slice("--agent=".length);
58
- else if (a === "--as")
59
- args.as = argv[++i];
60
- else if (a.startsWith("--as="))
61
- args.as = a.slice("--as=".length);
62
- else if (a === "--web")
63
- args.web = argv[++i];
64
- else if (a.startsWith("--web="))
65
- args.web = a.slice("--web=".length);
66
- else if (a === "--project")
67
- args.project = argv[++i];
68
- else if (a.startsWith("--project="))
69
- args.project = a.slice("--project=".length);
70
- else if (a === "--column")
71
- args.column = argv[++i];
72
- else if (a.startsWith("--column="))
73
- args.column = a.slice("--column=".length);
74
- else if (a === "--draft")
75
- args.draft = true;
76
- else if (a === "--json")
77
- args.json = true;
78
- else if (a === "--local")
79
- args.local = true;
80
- else if (a === "--cloud")
81
- args.cloud = true;
82
- else if (!a.startsWith("-")) {
83
- if (!args.command)
84
- args.command = a;
85
- else
86
- args.rest.push(a);
87
- }
88
- }
89
- return args;
90
- }
22
+ import { parseArgs } from "./cli-args.js";
23
+ import { chatTailLoop, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
91
24
  const HELP = `sfora — the CLI for your sfora workspace
92
25
 
93
26
  Get started (no account needed):
@@ -121,11 +54,33 @@ Browse & read:
121
54
  sfora me Show who you're signed in as
122
55
  sfora ls [path] List a path (default /projects)
123
56
  sfora cat <path> Print a file's markdown
57
+ sfora url <path> Print the web URL for a path
58
+ sfora open <path> Open that URL in your browser
124
59
  sfora Open the interactive shell
125
60
 
126
61
  Add --json to any list command (projects/posts/tasks/ls/me) for
127
62
  machine-readable output and stable scripting.
128
63
 
64
+ Chat:
65
+ sfora rooms List rooms (● joined · ○ open to join)
66
+ sfora join <room> Join an open room
67
+ sfora chat <room> [-n <count>] Read the room, then type to talk —
68
+ new messages stream in live
69
+ sfora chat <room> -m "text" Send one message and exit (for scripts
70
+ and agents)
71
+
72
+ Write, and watch others write:
73
+ sfora blocks <path> List a document's addressable blocks
74
+ sfora put <path> <file.md> Write a file (add --block <id> for one block)
75
+ sfora put <path> --block <id> - …or pipe the block's markdown on stdin
76
+ sfora watch <path-or-project> Stream write pings (add --json for NDJSON)
77
+ sfora where [name] Who's in which document right now
78
+
79
+ Every write prints what it did — "changed" (with how many block ids
80
+ survived) or "no change". Watching and writing make you visible in the
81
+ document; watch --self also shows your own writes. "where" is a read only —
82
+ asking never puts you in a document.
83
+
129
84
  Agents & config:
130
85
  sfora --mcp [--org <slug>] Run as an MCP server (for agents)
131
86
  sfora mcp-config Print an MCP config snippet to paste
@@ -145,6 +100,11 @@ const SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— yo
145
100
  Standard tools work against your workspace:
146
101
  ls cat grep find head tail wc sed awk echo cd pwd
147
102
 
103
+ ${colors.dim}And three of sfora's own${colors.reset}
104
+ blocks <path> the blocks a --block write can aim at
105
+ put <path> [file|-] write a file (--block <id> writes one block)
106
+ url <path> where it lives on the web
107
+
148
108
  ${colors.dim}Where things live${colors.reset}
149
109
  /projects your projects
150
110
  /projects/<slug>/posts/<file>.md published posts
@@ -160,6 +120,8 @@ ${colors.dim}Try${colors.reset}
160
120
  find /projects/<slug>/library -type f
161
121
  grep -ri todo /projects
162
122
  echo "# Hello" > /projects/<slug>/posts/hello.md
123
+ blocks /projects/<slug>/docs/spec.md
124
+ echo "A rewritten paragraph." | put /projects/<slug>/docs/spec.md --block <id>
163
125
 
164
126
  ${colors.dim}Outside the shell${colors.reset}, sfora has verbs: post · task · doc · new · projects (run ${colors.cyan}sfora --help${colors.reset})
165
127
  Type ${colors.cyan}exit${colors.reset} to quit.
@@ -199,13 +161,9 @@ async function runInit(args) {
199
161
  }
200
162
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
201
163
  function openBrowser(url) {
202
- const cmd = process.platform === "darwin"
203
- ? "open"
204
- : process.platform === "win32"
205
- ? "start"
206
- : "xdg-open";
164
+ const { command, args } = openerCommand(process.platform, url);
207
165
  try {
208
- spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
166
+ spawn(command, args, { stdio: "ignore", detached: true }).unref();
209
167
  }
210
168
  catch {
211
169
  /* best effort — the URL is printed for manual opening */
@@ -384,6 +342,12 @@ const VERBS = new Set([
384
342
  "new",
385
343
  "ls",
386
344
  "cat",
345
+ "url",
346
+ "open",
347
+ "blocks",
348
+ "put",
349
+ "watch",
350
+ "where",
387
351
  "post",
388
352
  "task",
389
353
  "doc",
@@ -395,11 +359,35 @@ const VERBS = new Set([
395
359
  "whoami",
396
360
  "comment",
397
361
  "react",
362
+ "rooms",
363
+ "join",
364
+ "chat",
398
365
  ]);
399
366
  async function runVerb(args, fs, client) {
400
367
  const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
368
+ // The prose confirmation, suppressed under --json: a script parsing stdout
369
+ // must find JSON there and nothing else.
370
+ const said = (msg) => {
371
+ if (!args.json)
372
+ ok(msg);
373
+ };
401
374
  const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
402
375
  const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
376
+ /**
377
+ * The link the LAST request reported, as one dim line — on stderr.
378
+ *
379
+ * stderr and not stdout, deliberately: `sfora cat x.md > x.md` and
380
+ * `sfora ls | wc -l` are the reason those verbs exist, and a link mixed into
381
+ * stdout would corrupt the first and miscount the second. A human sees both
382
+ * streams; a pipe sees only the bytes it asked for. Suppressed under --json
383
+ * for the same reason.
384
+ */
385
+ const showUrl = () => {
386
+ const { url } = client.takeResponseInfo();
387
+ if (url && !args.json)
388
+ process.stderr.write(`${urlLine(url)}\n`);
389
+ return url;
390
+ };
403
391
  if (args.command === "projects") {
404
392
  const projects = await client.listProjects();
405
393
  if (args.json)
@@ -427,14 +415,115 @@ async function runVerb(args, fs, client) {
427
415
  if (args.json)
428
416
  return emitJson(entries);
429
417
  console.log(entries.join("\n"));
418
+ showUrl();
430
419
  return;
431
420
  }
432
421
  if (args.command === "cat") {
433
422
  if (!args.rest[0])
434
423
  throw new Error("usage: sfora cat <path>");
435
424
  process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
425
+ showUrl();
426
+ return;
427
+ }
428
+ // `url` and `open` are the same question — where does this live on the web —
429
+ // asked once and answered by the server. The CLI holds no route table.
430
+ if (args.command === "url" || args.command === "open") {
431
+ const path = args.rest[0];
432
+ if (!path)
433
+ throw new Error(`usage: sfora ${args.command} <path>`);
434
+ const url = await client.resolveWebUrl(path);
435
+ if (!url) {
436
+ throw new Error(`${path} has no page on the web — files, repositories and the derived ` +
437
+ "views (README, mentions) are read through the fs only");
438
+ }
439
+ if (args.json)
440
+ return emitJson({ path, url });
441
+ console.log(url);
442
+ if (args.command === "open")
443
+ openBrowser(url);
436
444
  return;
437
445
  }
446
+ // `blocks` and `put` are the shell's own commands, run without the shell.
447
+ // One implementation (block-commands.ts) so the two doors cannot drift.
448
+ if (args.command === "blocks") {
449
+ if (!args.rest[0])
450
+ throw new Error("usage: sfora blocks <path> [--json]");
451
+ return writeCommandOutput(await blocksCommand(client, args.rest[0], { json: args.json }));
452
+ }
453
+ if (args.command === "put") {
454
+ const path = args.rest[0];
455
+ if (!path) {
456
+ throw new Error("usage: sfora put <path> [<file.md>|-] [--block <id>] [--json]");
457
+ }
458
+ const source = args.rest[1];
459
+ const body = source && source !== "-"
460
+ ? await readLocalFile(source, "utf8")
461
+ : await readStdin();
462
+ return writeCommandOutput(await putCommand(client, path, body, {
463
+ blockId: args.block,
464
+ json: args.json,
465
+ presence: runPresence,
466
+ }));
467
+ }
468
+ if (args.command === "watch") {
469
+ if (!args.rest[0]) {
470
+ throw new Error("usage: sfora watch <path-or-project> [--json] [--self] [--wait <secs>]");
471
+ }
472
+ return runWatch(args, client, args.rest[0]);
473
+ }
474
+ // `where` — the reverse of presence. Card #345.
475
+ //
476
+ // Nothing is declared by running it: the server answers a query and the CLI
477
+ // prints it. That matters enough to be a property of the verb rather than a
478
+ // note about it — `sfora where` is the command an agent runs to find out
479
+ // where a human is working, and a version of it that joined the roster would
480
+ // make an agent appear in a document it had only asked about.
481
+ //
482
+ // The name is the whole argument list. `--as` composes for free (it is a
483
+ // header on the client, so `sfora where --as bot` asks as the bot and gets
484
+ // the bot's visibility), and no `--project` filter exists because the answer
485
+ // is already scoped to what this key can open.
486
+ if (args.command === "where") {
487
+ const view = await client.listPresence(args.rest[0]);
488
+ if (args.json) {
489
+ // NDJSON, matching `watch --json`: one record per line, no wrapper. A
490
+ // pretty-printed object would break `sfora where --json | jq -r .url`
491
+ // for the same reason it would there.
492
+ for (const record of presenceRecords(view)) {
493
+ process.stdout.write(`${ndjson(record)}\n`);
494
+ }
495
+ return;
496
+ }
497
+ console.log(renderPresence(view));
498
+ return;
499
+ }
500
+ // Chat — the same rooms the app shows, from the terminal. Humans and agents
501
+ // are the same member model, so this is how either sits in the conversation.
502
+ if (args.command === "rooms") {
503
+ const rooms = await client.listRooms(true);
504
+ if (args.json)
505
+ return emitJson(rooms);
506
+ console.log(renderRoomList(rooms));
507
+ return;
508
+ }
509
+ if (args.command === "join") {
510
+ if (!args.rest[0])
511
+ throw new Error("usage: sfora join <room>");
512
+ const room = await resolveRoomOrThrow(client, args.rest[0]);
513
+ const result = await client.joinRoom(room._id);
514
+ if (args.json)
515
+ return emitJson({ ...result, roomId: room._id, name: room.name });
516
+ ok(result.already
517
+ ? `Already in #${roomSlug(room.name)}`
518
+ : `Joined #${roomSlug(room.name)}`);
519
+ return;
520
+ }
521
+ if (args.command === "chat") {
522
+ if (!args.rest[0]) {
523
+ throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"]');
524
+ }
525
+ return runChat(args, client);
526
+ }
438
527
  if (args.command === "me" || args.command === "whoami") {
439
528
  const text = (await client.readMe()).trim();
440
529
  if (args.json) {
@@ -533,12 +622,14 @@ async function runVerb(args, fs, client) {
533
622
  if (args.command === "post") {
534
623
  const dir = args.draft ? "drafts" : "posts";
535
624
  await fs.writeFile(`/projects/${project}/${dir}/${name}`, md);
536
- ok(`${args.draft ? "Drafted" : "Posted"} to ${project} · ${name}`);
625
+ said(`${args.draft ? "Drafted" : "Posted"} to ${project} · ${name}`);
626
+ reportWrite(client, args.json);
537
627
  return;
538
628
  }
539
629
  if (args.command === "doc") {
540
630
  await fs.writeFile(`/projects/${project}/library/documents/${name}`, md);
541
- ok(`Doc saved to ${project} · ${name}`);
631
+ said(`Doc saved to ${project} · ${name}`);
632
+ reportWrite(client, args.json);
542
633
  return;
543
634
  }
544
635
  if (args.command === "task") {
@@ -557,9 +648,344 @@ async function runVerb(args, fs, client) {
557
648
  col = cols.find((c) => c.replace(/^\d+-/, "") === want) ?? col;
558
649
  }
559
650
  await fs.writeFile(`/projects/${project}/board/${col}/${name}`, md);
560
- ok(`Task created in ${project} / ${col} · ${name}`);
651
+ said(`Task created in ${project} / ${col} · ${name}`);
652
+ reportWrite(client, args.json);
653
+ return;
654
+ }
655
+ }
656
+ /**
657
+ * What a write DID — printed after every one of them (card #333).
658
+ *
659
+ * Three lines at most, all on stderr and all dim, in the order a reader needs
660
+ * them: the effect (did the bytes move, and did the block ids survive), the
661
+ * page, and — once per process — the fact that writing put you on the
662
+ * document's roster. `announcedPresence` is module-level rather than per-call
663
+ * because "once" means once per run, not once per document.
664
+ */
665
+ /**
666
+ * `sfora watch <target>` — the long-poll, wired to the real process.
667
+ *
668
+ * Everything decision-shaped lives in `watch.ts`; this resolves what is being
669
+ * watched, opens the roster, and arranges for ^C to close it. The two are
670
+ * separate so the loop can be tested without a signal handler or a socket.
671
+ */
672
+ async function runWatch(args, client, target) {
673
+ const parsed = parseWatchTarget(target);
674
+ const wait = Number.isFinite(args.wait) ? args.wait : undefined;
675
+ // A document watch needs the entity id `/v1/events?doc=` takes, and the
676
+ // server states it in `?view=blocks` — the CLI does not read it out of the
677
+ // file. A project watch needs only the slug it was given.
678
+ let docId;
679
+ if (parsed.kind === "path") {
680
+ const resolved = await client.resolveDocId(parsed.path);
681
+ if (!resolved) {
682
+ throw new Error(`${parsed.path} has no document behind it to watch — point watch at a ` +
683
+ "post, draft, document or board card, or at a project");
684
+ }
685
+ docId = resolved;
686
+ }
687
+ // Presence, if this path has a roster. Asked rather than inferred: a `422`
688
+ // is the server saying "no roster here", and posts and cards get one.
689
+ let canBeat = parsed.kind === "path";
690
+ const beat = async ({ leave }) => {
691
+ if (!canBeat || parsed.kind !== "path")
692
+ return;
693
+ const roster = await client.declarePresence(parsed.path, {
694
+ kind: "viewing",
695
+ leave,
696
+ });
697
+ if (roster === null)
698
+ canBeat = false;
699
+ };
700
+ // Start at NOW, not at the retained backlog: `watch` is a live channel, and
701
+ // opening one should not replay a day of history nobody was waiting for.
702
+ const since = Date.now();
703
+ let stop = false;
704
+ // ^C HAS TO REACH THE SOCKET. Registering a handler suppresses Node's own
705
+ // SIGINT exit, so a handler that only sets a flag turns ^C into "stop after
706
+ // the current long-poll finishes" — up to `--wait` seconds of a terminal
707
+ // that says "^C to stop" and does not, with further ^Cs inert because the
708
+ // default handler is gone. The user's remaining exit is ^\, which kills the
709
+ // process outright, skips the `finally`, and leaves exactly the roster ghost
710
+ // the watch exists to avoid. Aborting the in-flight request is what makes
711
+ // the first ^C land: the poll rejects at once, the loop sees `stopped()`,
712
+ // and the `finally` retracts presence on the way out.
713
+ const inFlight = new AbortController();
714
+ let signalled = false;
715
+ const onSignal = (signal) => {
716
+ stop = true;
717
+ inFlight.abort();
718
+ // A second one is the impatient case — the leave request is hanging too,
719
+ // or the network is gone. Stand down and let the default behaviour end the
720
+ // process; the ghost expires on its own in ~90 seconds.
721
+ if (signalled) {
722
+ process.off("SIGINT", onSignal);
723
+ process.off("SIGTERM", onSignal);
724
+ process.kill(process.pid, signal);
725
+ return;
726
+ }
727
+ signalled = true;
728
+ };
729
+ process.on("SIGINT", onSignal);
730
+ process.on("SIGTERM", onSignal);
731
+ if (!args.json) {
732
+ process.stderr.write(`${colors.dim}watching ${parsed.kind === "project" ? `project ${parsed.slug}` : parsed.path}${args.self ? "" : " (not your own writes)"} — ^C to stop${colors.reset}\n`);
733
+ }
734
+ try {
735
+ await watchLoop({
736
+ poll: (cursor) => client.pollEvents({
737
+ since: cursor,
738
+ wait,
739
+ doc: docId,
740
+ project: parsed.kind === "project" ? parsed.slug : undefined,
741
+ includeSelf: args.self,
742
+ signal: inFlight.signal,
743
+ }),
744
+ beat: parsed.kind === "path" ? beat : undefined,
745
+ write: (text) => process.stdout.write(text),
746
+ // Warnings on stderr so `--json` stdout stays parseable NDJSON.
747
+ warn: (text) => process.stderr.write(`${colors.dim}${text}${colors.reset}\n`),
748
+ sleep,
749
+ }, { json: args.json, since, stopped: () => stop });
750
+ }
751
+ finally {
752
+ process.off("SIGINT", onSignal);
753
+ process.off("SIGTERM", onSignal);
754
+ }
755
+ }
756
+ /**
757
+ * The room a reference names, or a thrown explanation.
758
+ *
759
+ * Resolution reads `?all=1` so an unjoined room can still be found — `sfora
760
+ * join dogfood` has to work before the caller is in it. Ambiguity throws with
761
+ * the candidates rather than guessing: sending into the wrong room is the
762
+ * failure worth a retype.
763
+ */
764
+ async function resolveRoomOrThrow(client, ref) {
765
+ const rooms = await client.listRooms(true);
766
+ const match = resolveRoomRef(rooms, ref);
767
+ if (match.kind === "match")
768
+ return match.room;
769
+ if (match.kind === "ambiguous") {
770
+ throw new Error(`'${ref}' matches several rooms — say which:\n` +
771
+ match.candidates.map((r) => ` ${roomSlug(r.name)}`).join("\n"));
772
+ }
773
+ throw new Error(`no room matches '${ref}' — run \`sfora rooms\` to see them`);
774
+ }
775
+ /**
776
+ * `sfora chat <room>` — the room in the terminal, wired to the real process.
777
+ *
778
+ * Everything decision-shaped (name resolution, formatting, the tail loop)
779
+ * lives in `chat.ts` where it is tested; this owns readline, ^C, and the two
780
+ * modes: `-m` sends one message and exits (what scripts and agents use), bare
781
+ * `chat` prints history and opens a prompt with a live tail above it.
782
+ */
783
+ async function runChat(args, client) {
784
+ const room = await resolveRoomOrThrow(client, args.rest[0]);
785
+ const tag = `#${roomSlug(room.name)}`;
786
+ const isTty = Boolean(process.stdin.isTTY);
787
+ if (!room.joined) {
788
+ // Offered inline on a terminal; stated as the fix everywhere else — a
789
+ // script cannot answer a question, so it gets the command instead.
790
+ if (args.message !== undefined || !isTty) {
791
+ throw new Error(`you have not joined ${tag} — run \`sfora join ${roomSlug(room.name)}\` first`);
792
+ }
793
+ const ask = readline.createInterface({
794
+ input: process.stdin,
795
+ output: process.stdout,
796
+ });
797
+ const answer = await new Promise((res) => ask.question(`You have not joined ${tag} — join? (y/n) `, res));
798
+ ask.close();
799
+ if (!/^y(es)?$/i.test(answer.trim()))
800
+ return;
801
+ await client.joinRoom(room._id);
802
+ console.log(`${colors.green}✓${colors.reset} Joined ${tag}`);
803
+ }
804
+ // One-shot send.
805
+ if (args.message !== undefined) {
806
+ const text = args.message.trim();
807
+ if (!text)
808
+ throw new Error('usage: sfora chat <room> -m "text"');
809
+ const { messageId } = await client.sendRoomMessage(room._id, text);
810
+ if (args.json) {
811
+ console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
812
+ return;
813
+ }
814
+ console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
561
815
  return;
562
816
  }
817
+ // History, oldest first — the page arrives newest-first.
818
+ const limit = Number.isFinite(args.limit) && args.limit > 0
819
+ ? Math.min(args.limit, 100)
820
+ : 30;
821
+ // Seed the seen set from as many messages as the tail's refetch pulls (20):
822
+ // with -n below that, the doorbell would otherwise "discover" older history
823
+ // and flood it as if it had just arrived. Print only the asked-for n.
824
+ const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
825
+ const seeded = history.page.slice().reverse();
826
+ const seen = new Set(seeded.map((m) => m._id));
827
+ const messages = seeded.slice(-limit);
828
+ if (messages.length === 0) {
829
+ console.log(`${colors.dim}(no messages yet)${colors.reset}`);
830
+ }
831
+ for (const msg of messages)
832
+ console.log(renderChatMessage(msg, Date.now()));
833
+ process.stderr.write(`${colors.dim}${tag} — type to send · /quit (or ^C) to leave${colors.reset}\n`);
834
+ // The prompt and the tail share one screen: an arriving message clears the
835
+ // prompt line, prints, and readline redraws the prompt with whatever was
836
+ // being typed. `readline.clearLine` over anything fancier — robust beats
837
+ // pretty at 80 columns.
838
+ const rl = readline.createInterface({
839
+ input: process.stdin,
840
+ output: process.stdout,
841
+ terminal: isTty,
842
+ });
843
+ rl.setPrompt(`${colors.cyan}${tag}${colors.reset} `);
844
+ const printAbove = (text) => {
845
+ if (isTty) {
846
+ readline.clearLine(process.stdout, 0);
847
+ readline.cursorTo(process.stdout, 0);
848
+ }
849
+ process.stdout.write(`${text}\n`);
850
+ if (isTty)
851
+ rl.prompt(true);
852
+ };
853
+ // ^C has to reach the socket, same as `watch`: the long-poll hangs for tens
854
+ // of seconds, and a flag alone would leave a terminal that says it stopped
855
+ // and hasn't. Closing readline ends the line iterator; aborting the request
856
+ // ends the poll; the loop sees `stopped()` and returns.
857
+ let stop = false;
858
+ const inFlight = new AbortController();
859
+ rl.on("SIGINT", () => rl.close());
860
+ rl.on("close", () => {
861
+ stop = true;
862
+ inFlight.abort();
863
+ });
864
+ const wait = Number.isFinite(args.wait) ? args.wait : undefined;
865
+ const tail = chatTailLoop({
866
+ poll: (since) => client.pollEvents({ since, wait, signal: inFlight.signal }),
867
+ fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
868
+ print: (msg) => printAbove(renderChatMessage(msg, Date.now())),
869
+ warn: (text) => printAbove(`${colors.dim}${text}${colors.reset}`),
870
+ sleep,
871
+ }, {
872
+ roomId: room._id,
873
+ seen,
874
+ // Cursor on SERVER time: the newest history message's _creationTime.
875
+ // A client clock running fast would otherwise open a blind window at
876
+ // startup; any overlap re-delivery is absorbed by the seen set.
877
+ since: history.page[0]?._creationTime ?? Date.now(),
878
+ stopped: () => stop,
879
+ });
880
+ if (isTty)
881
+ rl.prompt();
882
+ for await (const input of rl) {
883
+ const line = input.trim();
884
+ if (!line) {
885
+ if (isTty)
886
+ rl.prompt();
887
+ continue;
888
+ }
889
+ if (line === "/quit" || line === "/exit" || line === "/q")
890
+ break;
891
+ try {
892
+ // The send's own echo is the typed line still on screen; recording the
893
+ // id keeps the tail's refetch from printing it a second time.
894
+ const { messageId } = await client.sendRoomMessage(room._id, line);
895
+ seen.add(messageId);
896
+ }
897
+ catch (e) {
898
+ const msg = e instanceof Error ? e.message : String(e);
899
+ process.stderr.write(`${colors.red}error:${colors.reset} ${msg}\n`);
900
+ }
901
+ if (isTty)
902
+ rl.prompt();
903
+ }
904
+ stop = true;
905
+ inFlight.abort();
906
+ rl.close();
907
+ await tail.catch(() => { });
908
+ if (isTty)
909
+ console.log("");
910
+ }
911
+ /**
912
+ * A shared command's result, on the real streams.
913
+ *
914
+ * The exit code is honoured rather than thrown: a 409 re-aim table is a
915
+ * complete, useful answer that happens to mean "try again", and turning it back
916
+ * into an exception would put the CLI's generic `error:` prefix in front of a
917
+ * table that already explains itself.
918
+ */
919
+ function writeCommandOutput(out) {
920
+ if (out.stdout)
921
+ process.stdout.write(out.stdout);
922
+ if (out.stderr)
923
+ process.stderr.write(out.stderr);
924
+ if (out.exitCode !== 0)
925
+ process.exitCode = out.exitCode;
926
+ }
927
+ /** Everything piped in, as text. Empty when nothing was. */
928
+ async function readStdin() {
929
+ if (process.stdin.isTTY)
930
+ return "";
931
+ const chunks = [];
932
+ for await (const chunk of process.stdin) {
933
+ chunks.push(Buffer.from(chunk));
934
+ }
935
+ return Buffer.concat(chunks).toString("utf8");
936
+ }
937
+ /**
938
+ * The trailing dim lines after a shell line: what a write did, and where it
939
+ * lives.
940
+ *
941
+ * Gated on the command, not on "did the last request name anything", and that
942
+ * is deliberate. A `grep -ri todo /projects` reads dozens of files, and the
943
+ * link of whichever happened to be read LAST would be a link to a document the
944
+ * user never asked about. So the rule is narrow and stated: a write always
945
+ * reports its effect (that is card #333's whole point — a splice can store
946
+ * nothing, and silence would read as success), and a link is offered only
947
+ * after the two commands whose subject is one path.
948
+ *
949
+ * `put`, `blocks` and `url` consume the info themselves, so nothing here
950
+ * double-prints them.
951
+ */
952
+ function reportShellLine(line, client) {
953
+ const { url, effect, present } = client.takeResponseInfo();
954
+ const verb = line.trim().split(/\s+/)[0];
955
+ const effectLine = renderWriteEffect(effect);
956
+ if (effectLine)
957
+ process.stderr.write(` ${effectLine}\n`);
958
+ if (url && (effect || verb === "cat" || verb === "ls")) {
959
+ process.stderr.write(` ${urlLine(url)}\n`);
960
+ }
961
+ if (present && runPresence.claim()) {
962
+ process.stderr.write(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}\n`);
963
+ }
964
+ }
965
+ /**
966
+ * The run's one "you are visible" announcement.
967
+ *
968
+ * Module-level because a run IS this process, and shared with the shell's own
969
+ * `put` (it is handed to `createSforaShell`) because otherwise the interactive
970
+ * session has two latches — the shell command's and this one — and says the
971
+ * line twice for what is one fact.
972
+ */
973
+ const runPresence = presenceNotice();
974
+ function reportWrite(client, json) {
975
+ const { url, effect, present } = client.takeResponseInfo();
976
+ if (json) {
977
+ process.stdout.write(`${JSON.stringify({ url, present, ...(effect ?? {}) })}\n`);
978
+ return;
979
+ }
980
+ const line = renderWriteEffect(effect);
981
+ if (line)
982
+ process.stderr.write(` ${line}\n`);
983
+ if (url)
984
+ process.stderr.write(` ${urlLine(url)}\n`);
985
+ // Only a DOCUMENT write puts you on a roster, and the server is what says so.
986
+ if (present && runPresence.claim()) {
987
+ process.stderr.write(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}\n`);
988
+ }
563
989
  }
564
990
  // ─── Local mode (a .sfora/ directory — no server, no account) ─────
565
991
  const LOCAL_ONLY_HINT = "cloud command — run it with --cloud (after `sfora login`), or outside the .sfora/ repo";
@@ -749,13 +1175,17 @@ async function main() {
749
1175
  }
750
1176
  // Structured verbs — the workspace as a markdown filesystem.
751
1177
  if (args.command && VERBS.has(args.command)) {
752
- const { fs } = createSforaShell({
1178
+ // ONE client for the verb: `SforaFs` reads and writes through it, and the
1179
+ // verbs read back off it what the last response reported (the page, the
1180
+ // effect report). A second client would be watching a different
1181
+ // conversation and would always answer "nothing".
1182
+ const { fs, client } = createSforaShell({
753
1183
  baseUrl,
754
1184
  apiKey,
755
1185
  org: settings.org ?? "",
756
1186
  actAs: args.as,
1187
+ presence: runPresence,
757
1188
  });
758
- const client = new SforaApiClient({ baseUrl, apiKey, actAs: args.as });
759
1189
  try {
760
1190
  await runVerb(args, fs, client);
761
1191
  }
@@ -783,7 +1213,13 @@ async function main() {
783
1213
  return;
784
1214
  }
785
1215
  const org = settings.org;
786
- const { bash, fs } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
1216
+ const { bash, fs, client } = createSforaShell({
1217
+ baseUrl,
1218
+ apiKey,
1219
+ org,
1220
+ cwd: args.cwd,
1221
+ presence: runPresence,
1222
+ });
787
1223
  // Pre-flight: confirm auth + connectivity and greet with the resolved identity.
788
1224
  let identity = "";
789
1225
  try {
@@ -804,9 +1240,11 @@ async function main() {
804
1240
  console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
805
1241
  }
806
1242
  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`);
807
- await runShell(bash, fs, SHELL_HELP, args.cwd);
1243
+ await runShell(bash, fs, SHELL_HELP, args.cwd, client);
808
1244
  }
809
- async function runShell(bash, fs, helpText, initialCwd) {
1245
+ async function runShell(bash, fs, helpText, initialCwd,
1246
+ /** Absent in local mode — there is no server, so nothing reports a link. */
1247
+ client) {
810
1248
  // Shell state threaded across exec() calls — just-bash does not persist cwd/env
811
1249
  // between separate exec()s, so we carry them forward ourselves.
812
1250
  let cwd = initialCwd;
@@ -825,6 +1263,8 @@ async function runShell(bash, fs, helpText, initialCwd) {
825
1263
  "ls", "cat", "cd", "pwd", "grep", "find", "echo", "head", "tail", "wc",
826
1264
  "sed", "awk", "sort", "uniq", "mkdir", "rm", "mv", "cp", "touch", "help",
827
1265
  "exit",
1266
+ // sfora's own, registered on the cloud shell (see `sforaShellCommands`).
1267
+ "blocks", "put", "url",
828
1268
  ];
829
1269
  async function completePath(token) {
830
1270
  const slash = token.lastIndexOf("/");
@@ -888,6 +1328,8 @@ async function runShell(bash, fs, helpText, initialCwd) {
888
1328
  // reads as native — the user never typed `bash`.
889
1329
  if (res.stderr)
890
1330
  process.stderr.write(res.stderr.replace(/^bash:/gm, "sfora:"));
1331
+ if (client)
1332
+ reportShellLine(line, client);
891
1333
  env = res.env;
892
1334
  cwd = res.env?.PWD ?? cwd;
893
1335
  }