sfora-cli 0.10.0 → 0.11.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 (68) hide show
  1. package/README.md +139 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +243 -4
  4. package/dist/api-client.js +248 -20
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +317 -26
  8. package/dist/format/blockSplice.d.ts +135 -0
  9. package/dist/format/blockSplice.js +330 -0
  10. package/dist/format/blocks/dropClosure.d.ts +10 -1
  11. package/dist/format/blocks/dropClosure.js +11 -1
  12. package/dist/format/callout.d.ts +69 -7
  13. package/dist/format/callout.js +112 -15
  14. package/dist/format/checklist.js +11 -4
  15. package/dist/format/formatAxes.d.ts +228 -0
  16. package/dist/format/formatAxes.js +454 -0
  17. package/dist/format/index.d.ts +1 -0
  18. package/dist/format/index.js +4 -0
  19. package/dist/format/lineGeometry.d.ts +34 -4
  20. package/dist/format/lineGeometry.js +140 -40
  21. package/dist/format/lint/appliesTo.d.ts +92 -0
  22. package/dist/format/lint/appliesTo.js +369 -0
  23. package/dist/format/lint/config.d.ts +106 -0
  24. package/dist/format/lint/config.js +205 -0
  25. package/dist/format/lint/fixAll.d.ts +62 -0
  26. package/dist/format/lint/fixAll.js +107 -0
  27. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  28. package/dist/format/lint/frontmatterSchema.js +660 -0
  29. package/dist/format/lint/index.d.ts +34 -5
  30. package/dist/format/lint/index.js +34 -5
  31. package/dist/format/lint/lintSource.d.ts +27 -7
  32. package/dist/format/lint/lintSource.js +67 -33
  33. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  34. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  35. package/dist/format/lint/rules/index.d.ts +2 -1
  36. package/dist/format/lint/rules/index.js +7 -1
  37. package/dist/format/lint/rules/malformed-callout.js +25 -16
  38. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  39. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  40. package/dist/format/lint/severity.d.ts +15 -0
  41. package/dist/format/lint/severity.js +50 -0
  42. package/dist/format/lint/textEdits.d.ts +86 -0
  43. package/dist/format/lint/textEdits.js +162 -0
  44. package/dist/format/lint/types.d.ts +44 -8
  45. package/dist/format/markdown/slug.d.ts +28 -0
  46. package/dist/format/markdown/slug.js +63 -0
  47. package/dist/format/plaintext.js +13 -3
  48. package/dist/format/sheetCellSpans.d.ts +95 -0
  49. package/dist/format/sheetCellSpans.js +223 -0
  50. package/dist/format/sheetSelection.d.ts +136 -0
  51. package/dist/format/sheetSelection.js +282 -0
  52. package/dist/format/textStats.d.ts +23 -0
  53. package/dist/format/textStats.js +80 -0
  54. package/dist/format/wikiLinks.d.ts +60 -1
  55. package/dist/format/wikiLinks.js +195 -9
  56. package/dist/index.d.ts +26 -1
  57. package/dist/index.js +20 -3
  58. package/dist/opener.d.ts +23 -0
  59. package/dist/opener.js +26 -0
  60. package/dist/render.d.ts +132 -0
  61. package/dist/render.js +208 -0
  62. package/dist/shell-commands.d.ts +34 -0
  63. package/dist/shell-commands.js +108 -0
  64. package/dist/watch.d.ts +79 -0
  65. package/dist/watch.js +113 -0
  66. package/dist/web-url.d.ts +39 -0
  67. package/dist/web-url.js +63 -0
  68. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -11,22 +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, 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
22
  function parseArgs(argv) {
29
- const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false };
23
+ const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false };
30
24
  for (let i = 0; i < argv.length; i++) {
31
25
  const a = argv[i];
32
26
  if (a === "--mcp")
@@ -79,6 +73,16 @@ function parseArgs(argv) {
79
73
  args.local = true;
80
74
  else if (a === "--cloud")
81
75
  args.cloud = true;
76
+ else if (a === "--block")
77
+ args.block = argv[++i];
78
+ else if (a.startsWith("--block="))
79
+ args.block = a.slice("--block=".length);
80
+ else if (a === "--self")
81
+ args.self = true;
82
+ else if (a === "--wait")
83
+ args.wait = Number.parseInt(argv[++i] ?? "", 10);
84
+ else if (a.startsWith("--wait="))
85
+ args.wait = Number.parseInt(a.slice("--wait=".length), 10);
82
86
  else if (!a.startsWith("-")) {
83
87
  if (!args.command)
84
88
  args.command = a;
@@ -121,11 +125,23 @@ Browse & read:
121
125
  sfora me Show who you're signed in as
122
126
  sfora ls [path] List a path (default /projects)
123
127
  sfora cat <path> Print a file's markdown
128
+ sfora url <path> Print the web URL for a path
129
+ sfora open <path> Open that URL in your browser
124
130
  sfora Open the interactive shell
125
131
 
126
132
  Add --json to any list command (projects/posts/tasks/ls/me) for
127
133
  machine-readable output and stable scripting.
128
134
 
135
+ Write, and watch others write:
136
+ sfora blocks <path> List a document's addressable blocks
137
+ sfora put <path> <file.md> Write a file (add --block <id> for one block)
138
+ sfora put <path> --block <id> - …or pipe the block's markdown on stdin
139
+ sfora watch <path-or-project> Stream write pings (add --json for NDJSON)
140
+
141
+ Every write prints what it did — "changed" (with how many block ids
142
+ survived) or "no change". Watching and writing make you visible in the
143
+ document; watch --self also shows your own writes.
144
+
129
145
  Agents & config:
130
146
  sfora --mcp [--org <slug>] Run as an MCP server (for agents)
131
147
  sfora mcp-config Print an MCP config snippet to paste
@@ -145,6 +161,11 @@ const SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— yo
145
161
  Standard tools work against your workspace:
146
162
  ls cat grep find head tail wc sed awk echo cd pwd
147
163
 
164
+ ${colors.dim}And three of sfora's own${colors.reset}
165
+ blocks <path> the blocks a --block write can aim at
166
+ put <path> [file|-] write a file (--block <id> writes one block)
167
+ url <path> where it lives on the web
168
+
148
169
  ${colors.dim}Where things live${colors.reset}
149
170
  /projects your projects
150
171
  /projects/<slug>/posts/<file>.md published posts
@@ -160,6 +181,8 @@ ${colors.dim}Try${colors.reset}
160
181
  find /projects/<slug>/library -type f
161
182
  grep -ri todo /projects
162
183
  echo "# Hello" > /projects/<slug>/posts/hello.md
184
+ blocks /projects/<slug>/docs/spec.md
185
+ echo "A rewritten paragraph." | put /projects/<slug>/docs/spec.md --block <id>
163
186
 
164
187
  ${colors.dim}Outside the shell${colors.reset}, sfora has verbs: post · task · doc · new · projects (run ${colors.cyan}sfora --help${colors.reset})
165
188
  Type ${colors.cyan}exit${colors.reset} to quit.
@@ -199,13 +222,9 @@ async function runInit(args) {
199
222
  }
200
223
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
201
224
  function openBrowser(url) {
202
- const cmd = process.platform === "darwin"
203
- ? "open"
204
- : process.platform === "win32"
205
- ? "start"
206
- : "xdg-open";
225
+ const { command, args } = openerCommand(process.platform, url);
207
226
  try {
208
- spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
227
+ spawn(command, args, { stdio: "ignore", detached: true }).unref();
209
228
  }
210
229
  catch {
211
230
  /* best effort — the URL is printed for manual opening */
@@ -384,6 +403,11 @@ const VERBS = new Set([
384
403
  "new",
385
404
  "ls",
386
405
  "cat",
406
+ "url",
407
+ "open",
408
+ "blocks",
409
+ "put",
410
+ "watch",
387
411
  "post",
388
412
  "task",
389
413
  "doc",
@@ -398,8 +422,29 @@ const VERBS = new Set([
398
422
  ]);
399
423
  async function runVerb(args, fs, client) {
400
424
  const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
425
+ // The prose confirmation, suppressed under --json: a script parsing stdout
426
+ // must find JSON there and nothing else.
427
+ const said = (msg) => {
428
+ if (!args.json)
429
+ ok(msg);
430
+ };
401
431
  const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
402
432
  const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
433
+ /**
434
+ * The link the LAST request reported, as one dim line — on stderr.
435
+ *
436
+ * stderr and not stdout, deliberately: `sfora cat x.md > x.md` and
437
+ * `sfora ls | wc -l` are the reason those verbs exist, and a link mixed into
438
+ * stdout would corrupt the first and miscount the second. A human sees both
439
+ * streams; a pipe sees only the bytes it asked for. Suppressed under --json
440
+ * for the same reason.
441
+ */
442
+ const showUrl = () => {
443
+ const { url } = client.takeResponseInfo();
444
+ if (url && !args.json)
445
+ process.stderr.write(`${urlLine(url)}\n`);
446
+ return url;
447
+ };
403
448
  if (args.command === "projects") {
404
449
  const projects = await client.listProjects();
405
450
  if (args.json)
@@ -427,14 +472,62 @@ async function runVerb(args, fs, client) {
427
472
  if (args.json)
428
473
  return emitJson(entries);
429
474
  console.log(entries.join("\n"));
475
+ showUrl();
430
476
  return;
431
477
  }
432
478
  if (args.command === "cat") {
433
479
  if (!args.rest[0])
434
480
  throw new Error("usage: sfora cat <path>");
435
481
  process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
482
+ showUrl();
483
+ return;
484
+ }
485
+ // `url` and `open` are the same question — where does this live on the web —
486
+ // asked once and answered by the server. The CLI holds no route table.
487
+ if (args.command === "url" || args.command === "open") {
488
+ const path = args.rest[0];
489
+ if (!path)
490
+ throw new Error(`usage: sfora ${args.command} <path>`);
491
+ const url = await client.resolveWebUrl(path);
492
+ if (!url) {
493
+ throw new Error(`${path} has no page on the web — files, repositories and the derived ` +
494
+ "views (README, mentions) are read through the fs only");
495
+ }
496
+ if (args.json)
497
+ return emitJson({ path, url });
498
+ console.log(url);
499
+ if (args.command === "open")
500
+ openBrowser(url);
436
501
  return;
437
502
  }
503
+ // `blocks` and `put` are the shell's own commands, run without the shell.
504
+ // One implementation (block-commands.ts) so the two doors cannot drift.
505
+ if (args.command === "blocks") {
506
+ if (!args.rest[0])
507
+ throw new Error("usage: sfora blocks <path> [--json]");
508
+ return writeCommandOutput(await blocksCommand(client, args.rest[0], { json: args.json }));
509
+ }
510
+ if (args.command === "put") {
511
+ const path = args.rest[0];
512
+ if (!path) {
513
+ throw new Error("usage: sfora put <path> [<file.md>|-] [--block <id>] [--json]");
514
+ }
515
+ const source = args.rest[1];
516
+ const body = source && source !== "-"
517
+ ? await readLocalFile(source, "utf8")
518
+ : await readStdin();
519
+ return writeCommandOutput(await putCommand(client, path, body, {
520
+ blockId: args.block,
521
+ json: args.json,
522
+ presence: runPresence,
523
+ }));
524
+ }
525
+ if (args.command === "watch") {
526
+ if (!args.rest[0]) {
527
+ throw new Error("usage: sfora watch <path-or-project> [--json] [--self] [--wait <secs>]");
528
+ }
529
+ return runWatch(args, client, args.rest[0]);
530
+ }
438
531
  if (args.command === "me" || args.command === "whoami") {
439
532
  const text = (await client.readMe()).trim();
440
533
  if (args.json) {
@@ -533,12 +626,14 @@ async function runVerb(args, fs, client) {
533
626
  if (args.command === "post") {
534
627
  const dir = args.draft ? "drafts" : "posts";
535
628
  await fs.writeFile(`/projects/${project}/${dir}/${name}`, md);
536
- ok(`${args.draft ? "Drafted" : "Posted"} to ${project} · ${name}`);
629
+ said(`${args.draft ? "Drafted" : "Posted"} to ${project} · ${name}`);
630
+ reportWrite(client, args.json);
537
631
  return;
538
632
  }
539
633
  if (args.command === "doc") {
540
634
  await fs.writeFile(`/projects/${project}/library/documents/${name}`, md);
541
- ok(`Doc saved to ${project} · ${name}`);
635
+ said(`Doc saved to ${project} · ${name}`);
636
+ reportWrite(client, args.json);
542
637
  return;
543
638
  }
544
639
  if (args.command === "task") {
@@ -557,10 +652,190 @@ async function runVerb(args, fs, client) {
557
652
  col = cols.find((c) => c.replace(/^\d+-/, "") === want) ?? col;
558
653
  }
559
654
  await fs.writeFile(`/projects/${project}/board/${col}/${name}`, md);
560
- ok(`Task created in ${project} / ${col} · ${name}`);
655
+ said(`Task created in ${project} / ${col} · ${name}`);
656
+ reportWrite(client, args.json);
561
657
  return;
562
658
  }
563
659
  }
660
+ /**
661
+ * What a write DID — printed after every one of them (card #333).
662
+ *
663
+ * Three lines at most, all on stderr and all dim, in the order a reader needs
664
+ * them: the effect (did the bytes move, and did the block ids survive), the
665
+ * page, and — once per process — the fact that writing put you on the
666
+ * document's roster. `announcedPresence` is module-level rather than per-call
667
+ * because "once" means once per run, not once per document.
668
+ */
669
+ /**
670
+ * `sfora watch <target>` — the long-poll, wired to the real process.
671
+ *
672
+ * Everything decision-shaped lives in `watch.ts`; this resolves what is being
673
+ * watched, opens the roster, and arranges for ^C to close it. The two are
674
+ * separate so the loop can be tested without a signal handler or a socket.
675
+ */
676
+ async function runWatch(args, client, target) {
677
+ const parsed = parseWatchTarget(target);
678
+ const wait = Number.isFinite(args.wait) ? args.wait : undefined;
679
+ // A document watch needs the entity id `/v1/events?doc=` takes, and the
680
+ // server states it in `?view=blocks` — the CLI does not read it out of the
681
+ // file. A project watch needs only the slug it was given.
682
+ let docId;
683
+ if (parsed.kind === "path") {
684
+ const resolved = await client.resolveDocId(parsed.path);
685
+ if (!resolved) {
686
+ throw new Error(`${parsed.path} has no document behind it to watch — point watch at a ` +
687
+ "post, draft, document or board card, or at a project");
688
+ }
689
+ docId = resolved;
690
+ }
691
+ // Presence, if this path has a roster. Asked rather than inferred: a `422`
692
+ // is the server saying "no roster here", and posts and cards get one.
693
+ let canBeat = parsed.kind === "path";
694
+ const beat = async ({ leave }) => {
695
+ if (!canBeat || parsed.kind !== "path")
696
+ return;
697
+ const roster = await client.declarePresence(parsed.path, {
698
+ kind: "viewing",
699
+ leave,
700
+ });
701
+ if (roster === null)
702
+ canBeat = false;
703
+ };
704
+ // Start at NOW, not at the retained backlog: `watch` is a live channel, and
705
+ // opening one should not replay a day of history nobody was waiting for.
706
+ const since = Date.now();
707
+ let stop = false;
708
+ // ^C HAS TO REACH THE SOCKET. Registering a handler suppresses Node's own
709
+ // SIGINT exit, so a handler that only sets a flag turns ^C into "stop after
710
+ // the current long-poll finishes" — up to `--wait` seconds of a terminal
711
+ // that says "^C to stop" and does not, with further ^Cs inert because the
712
+ // default handler is gone. The user's remaining exit is ^\, which kills the
713
+ // process outright, skips the `finally`, and leaves exactly the roster ghost
714
+ // the watch exists to avoid. Aborting the in-flight request is what makes
715
+ // the first ^C land: the poll rejects at once, the loop sees `stopped()`,
716
+ // and the `finally` retracts presence on the way out.
717
+ const inFlight = new AbortController();
718
+ let signalled = false;
719
+ const onSignal = (signal) => {
720
+ stop = true;
721
+ inFlight.abort();
722
+ // A second one is the impatient case — the leave request is hanging too,
723
+ // or the network is gone. Stand down and let the default behaviour end the
724
+ // process; the ghost expires on its own in ~90 seconds.
725
+ if (signalled) {
726
+ process.off("SIGINT", onSignal);
727
+ process.off("SIGTERM", onSignal);
728
+ process.kill(process.pid, signal);
729
+ return;
730
+ }
731
+ signalled = true;
732
+ };
733
+ process.on("SIGINT", onSignal);
734
+ process.on("SIGTERM", onSignal);
735
+ if (!args.json) {
736
+ 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`);
737
+ }
738
+ try {
739
+ await watchLoop({
740
+ poll: (cursor) => client.pollEvents({
741
+ since: cursor,
742
+ wait,
743
+ doc: docId,
744
+ project: parsed.kind === "project" ? parsed.slug : undefined,
745
+ includeSelf: args.self,
746
+ signal: inFlight.signal,
747
+ }),
748
+ beat: parsed.kind === "path" ? beat : undefined,
749
+ write: (text) => process.stdout.write(text),
750
+ // Warnings on stderr so `--json` stdout stays parseable NDJSON.
751
+ warn: (text) => process.stderr.write(`${colors.dim}${text}${colors.reset}\n`),
752
+ sleep,
753
+ }, { json: args.json, since, stopped: () => stop });
754
+ }
755
+ finally {
756
+ process.off("SIGINT", onSignal);
757
+ process.off("SIGTERM", onSignal);
758
+ }
759
+ }
760
+ /**
761
+ * A shared command's result, on the real streams.
762
+ *
763
+ * The exit code is honoured rather than thrown: a 409 re-aim table is a
764
+ * complete, useful answer that happens to mean "try again", and turning it back
765
+ * into an exception would put the CLI's generic `error:` prefix in front of a
766
+ * table that already explains itself.
767
+ */
768
+ function writeCommandOutput(out) {
769
+ if (out.stdout)
770
+ process.stdout.write(out.stdout);
771
+ if (out.stderr)
772
+ process.stderr.write(out.stderr);
773
+ if (out.exitCode !== 0)
774
+ process.exitCode = out.exitCode;
775
+ }
776
+ /** Everything piped in, as text. Empty when nothing was. */
777
+ async function readStdin() {
778
+ if (process.stdin.isTTY)
779
+ return "";
780
+ const chunks = [];
781
+ for await (const chunk of process.stdin) {
782
+ chunks.push(Buffer.from(chunk));
783
+ }
784
+ return Buffer.concat(chunks).toString("utf8");
785
+ }
786
+ /**
787
+ * The trailing dim lines after a shell line: what a write did, and where it
788
+ * lives.
789
+ *
790
+ * Gated on the command, not on "did the last request name anything", and that
791
+ * is deliberate. A `grep -ri todo /projects` reads dozens of files, and the
792
+ * link of whichever happened to be read LAST would be a link to a document the
793
+ * user never asked about. So the rule is narrow and stated: a write always
794
+ * reports its effect (that is card #333's whole point — a splice can store
795
+ * nothing, and silence would read as success), and a link is offered only
796
+ * after the two commands whose subject is one path.
797
+ *
798
+ * `put`, `blocks` and `url` consume the info themselves, so nothing here
799
+ * double-prints them.
800
+ */
801
+ function reportShellLine(line, client) {
802
+ const { url, effect, present } = client.takeResponseInfo();
803
+ const verb = line.trim().split(/\s+/)[0];
804
+ const effectLine = renderWriteEffect(effect);
805
+ if (effectLine)
806
+ process.stderr.write(` ${effectLine}\n`);
807
+ if (url && (effect || verb === "cat" || verb === "ls")) {
808
+ process.stderr.write(` ${urlLine(url)}\n`);
809
+ }
810
+ if (present && runPresence.claim()) {
811
+ process.stderr.write(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}\n`);
812
+ }
813
+ }
814
+ /**
815
+ * The run's one "you are visible" announcement.
816
+ *
817
+ * Module-level because a run IS this process, and shared with the shell's own
818
+ * `put` (it is handed to `createSforaShell`) because otherwise the interactive
819
+ * session has two latches — the shell command's and this one — and says the
820
+ * line twice for what is one fact.
821
+ */
822
+ const runPresence = presenceNotice();
823
+ function reportWrite(client, json) {
824
+ const { url, effect, present } = client.takeResponseInfo();
825
+ if (json) {
826
+ process.stdout.write(`${JSON.stringify({ url, present, ...(effect ?? {}) })}\n`);
827
+ return;
828
+ }
829
+ const line = renderWriteEffect(effect);
830
+ if (line)
831
+ process.stderr.write(` ${line}\n`);
832
+ if (url)
833
+ process.stderr.write(` ${urlLine(url)}\n`);
834
+ // Only a DOCUMENT write puts you on a roster, and the server is what says so.
835
+ if (present && runPresence.claim()) {
836
+ process.stderr.write(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}\n`);
837
+ }
838
+ }
564
839
  // ─── Local mode (a .sfora/ directory — no server, no account) ─────
565
840
  const LOCAL_ONLY_HINT = "cloud command — run it with --cloud (after `sfora login`), or outside the .sfora/ repo";
566
841
  async function runLocalVerb(args, root) {
@@ -749,13 +1024,17 @@ async function main() {
749
1024
  }
750
1025
  // Structured verbs — the workspace as a markdown filesystem.
751
1026
  if (args.command && VERBS.has(args.command)) {
752
- const { fs } = createSforaShell({
1027
+ // ONE client for the verb: `SforaFs` reads and writes through it, and the
1028
+ // verbs read back off it what the last response reported (the page, the
1029
+ // effect report). A second client would be watching a different
1030
+ // conversation and would always answer "nothing".
1031
+ const { fs, client } = createSforaShell({
753
1032
  baseUrl,
754
1033
  apiKey,
755
1034
  org: settings.org ?? "",
756
1035
  actAs: args.as,
1036
+ presence: runPresence,
757
1037
  });
758
- const client = new SforaApiClient({ baseUrl, apiKey, actAs: args.as });
759
1038
  try {
760
1039
  await runVerb(args, fs, client);
761
1040
  }
@@ -783,7 +1062,13 @@ async function main() {
783
1062
  return;
784
1063
  }
785
1064
  const org = settings.org;
786
- const { bash, fs } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
1065
+ const { bash, fs, client } = createSforaShell({
1066
+ baseUrl,
1067
+ apiKey,
1068
+ org,
1069
+ cwd: args.cwd,
1070
+ presence: runPresence,
1071
+ });
787
1072
  // Pre-flight: confirm auth + connectivity and greet with the resolved identity.
788
1073
  let identity = "";
789
1074
  try {
@@ -804,9 +1089,11 @@ async function main() {
804
1089
  console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
805
1090
  }
806
1091
  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);
1092
+ await runShell(bash, fs, SHELL_HELP, args.cwd, client);
808
1093
  }
809
- async function runShell(bash, fs, helpText, initialCwd) {
1094
+ async function runShell(bash, fs, helpText, initialCwd,
1095
+ /** Absent in local mode — there is no server, so nothing reports a link. */
1096
+ client) {
810
1097
  // Shell state threaded across exec() calls — just-bash does not persist cwd/env
811
1098
  // between separate exec()s, so we carry them forward ourselves.
812
1099
  let cwd = initialCwd;
@@ -825,6 +1112,8 @@ async function runShell(bash, fs, helpText, initialCwd) {
825
1112
  "ls", "cat", "cd", "pwd", "grep", "find", "echo", "head", "tail", "wc",
826
1113
  "sed", "awk", "sort", "uniq", "mkdir", "rm", "mv", "cp", "touch", "help",
827
1114
  "exit",
1115
+ // sfora's own, registered on the cloud shell (see `sforaShellCommands`).
1116
+ "blocks", "put", "url",
828
1117
  ];
829
1118
  async function completePath(token) {
830
1119
  const slash = token.lastIndexOf("/");
@@ -888,6 +1177,8 @@ async function runShell(bash, fs, helpText, initialCwd) {
888
1177
  // reads as native — the user never typed `bash`.
889
1178
  if (res.stderr)
890
1179
  process.stderr.write(res.stderr.replace(/^bash:/gm, "sfora:"));
1180
+ if (client)
1181
+ reportShellLine(line, client);
891
1182
  env = res.env;
892
1183
  cwd = res.env?.PWD ?? cwd;
893
1184
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * One block of a source string: its byte range, and its identity.
3
+ *
4
+ * `key` is what "unchanged" means. Two blocks with the same key are the same
5
+ * block for splice purposes and the old one's bytes win. A caller that keys by
6
+ * raw text gets a byte-exact diff (and no canonicalization softening at all);
7
+ * a caller that keys by a position-stripped parse gets the semantic diff this
8
+ * module exists to serve.
9
+ */
10
+ export interface SourceBlock {
11
+ /** Byte offset of the block's first byte. */
12
+ start: number;
13
+ /** One past the block's last byte. */
14
+ end: number;
15
+ /** Identity under whatever equality the caller decided on. */
16
+ key: string;
17
+ }
18
+ /** Why a splice gave up and handed back the new source whole. */
19
+ export type BlockSpliceFallback = "none"
20
+ /** One side has no blocks at all — there is nothing to preserve or to keep. */
21
+ | "no-blocks"
22
+ /** A block list was out of order, overlapping, or out of bounds. */
23
+ | "unusable-blocks";
24
+ export interface BlockSpliceResult {
25
+ /** The bytes to store. */
26
+ source: string;
27
+ /** Old blocks that kept their bytes. */
28
+ kept: number;
29
+ /** Blocks written from the new source. */
30
+ rewritten: number;
31
+ /** Set when the result is just `newSource`. */
32
+ fallback: BlockSpliceFallback;
33
+ }
34
+ /**
35
+ * Above this many DP cells the middle region is treated as one changed run
36
+ * instead of being aligned properly.
37
+ *
38
+ * The prefix/suffix trim below already handles the shape a real edit has — one
39
+ * changed region, everything before and after it identical — in linear time,
40
+ * so the quadratic pass only ever runs on a genuinely scattered diff. 250k
41
+ * cells is a 500-block-by-500-block middle, which is a document nobody has;
42
+ * past it the splice degrades to exactly what open-knowledge's
43
+ * `map-driven-splice.ts` does at every size (one over-wide contiguous splice),
44
+ * which is correct, merely less preserving.
45
+ *
46
+ * Exported so the test that pins the boundary can build the block lists either
47
+ * side of it from this number rather than from a second copy of it: a budget
48
+ * quietly raised past a hard-coded 500×500 would leave a test that still
49
+ * passes while asserting nothing about the fallback.
50
+ */
51
+ export declare const ALIGNMENT_CELL_BUDGET = 250000;
52
+ /**
53
+ * A run of blocks that is either kept from old or written from new.
54
+ *
55
+ * `kept: true` means every block in the range keyed identically on both sides,
56
+ * one for one, so `oldTo - oldFrom === newTo - newFrom` and the i-th old block
57
+ * IS the i-th new block. `kept: false` means the two ranges are the region the
58
+ * alignment could not explain: they may be different lengths, and nothing here
59
+ * says which old block became which new one — that judgement belongs to the
60
+ * consumer, because the splice does not need it and a rebinding ledger does.
61
+ */
62
+ export interface BlockRun {
63
+ kept: boolean;
64
+ /** Half-open index range into the old block list. */
65
+ oldFrom: number;
66
+ oldTo: number;
67
+ /** Half-open index range into the new block list. */
68
+ newFrom: number;
69
+ newTo: number;
70
+ }
71
+ /**
72
+ * How much of the correspondence was actually computed.
73
+ *
74
+ * `"exact"` is a real alignment: every block that survives unchanged is inside
75
+ * a kept run. `"budget"` says the middle region was past
76
+ * `ALIGNMENT_CELL_BUDGET` and was handed back as one changed run without being
77
+ * looked at, so blocks that did survive are sitting inside it unrecognised.
78
+ * The splice does not care — an over-wide changed run is merely less
79
+ * preserving — but a consumer that reads a changed run as "these blocks were
80
+ * edited" would be stating something the alignment never checked, so the
81
+ * degradation is reported rather than inferred from the run shapes.
82
+ */
83
+ export type BlockAlignmentQuality = "exact" | "budget";
84
+ /** The old↔new block correspondence, and how far it was actually worked out. */
85
+ export interface BlockAlignment {
86
+ /**
87
+ * Alternating kept/changed runs covering both lists end to end: every index
88
+ * of `oldBlocks` appears in exactly one run's old range, every index of
89
+ * `newBlocks` in exactly one run's new range, both in ascending order.
90
+ */
91
+ runs: BlockRun[];
92
+ quality: BlockAlignmentQuality;
93
+ }
94
+ /**
95
+ * Assemble the bytes to store from `oldSource` and `newSource`.
96
+ *
97
+ * The contract, in order of how much it matters:
98
+ *
99
+ * 1. If every key matches, the result is `oldSource` byte-for-byte. Not
100
+ * "equivalent to" — identical, including its trailing newline or lack of
101
+ * one. A save that changed nothing must reach the store as no change at
102
+ * all, or the whole exercise is decorative.
103
+ * 2. A block whose key changed contributes its NEW bytes, together with the
104
+ * gap bytes on either side of it, because a new or deleted block has to be
105
+ * able to bring its own blank lines.
106
+ * 3. A block whose key did not change contributes its OLD bytes, and so do
107
+ * the gaps between two such blocks.
108
+ * 4. The bytes outside every block — leading whitespace, the trailing
109
+ * newline — belong to no block, so they follow the block nearest them: old
110
+ * if that first/last block was kept, new if it was rewritten. Kept-at-the-
111
+ * edge is what makes rule 1 exact; rewritten-at-the-edge is what lets a
112
+ * document that really was replaced arrive with its own final newline
113
+ * instead of inheriting the absence of one.
114
+ */
115
+ export declare function spliceBlocks(oldSource: string, newSource: string, oldBlocks: readonly SourceBlock[], newBlocks: readonly SourceBlock[]): BlockSpliceResult;
116
+ /**
117
+ * Split both block lists into alternating kept/changed runs — the old↔new
118
+ * correspondence, on its own, with no bytes involved.
119
+ *
120
+ * Common prefix and suffix first — that is the entire diff for a normal edit
121
+ * and it costs one pass. What is left in the middle gets a longest-common-
122
+ * subsequence alignment so that two edits with untouched blocks between them
123
+ * keep those blocks' bytes; open-knowledge's version collapses that case into
124
+ * one over-wide splice, and it is the one thing here worth doing better than
125
+ * the reference, because "find and replace in three places" is an ordinary
126
+ * afternoon and it should not rewrite the paragraphs in between.
127
+ *
128
+ * Public because it is most of a rebinding ledger and it already runs on every
129
+ * write. `spliceBlocks` reads it for bytes; a caller that has derived an
130
+ * identity from the same keys reads it for what became what. Only `key` is
131
+ * consulted, so a caller holding keys and no offsets can ask this directly —
132
+ * the offset sanity `spliceBlocks` insists on is a slicing concern, not an
133
+ * alignment one.
134
+ */
135
+ export declare function alignBlockRuns(oldBlocks: readonly SourceBlock[], newBlocks: readonly SourceBlock[]): BlockAlignment;