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
@@ -0,0 +1,162 @@
1
+ /**
2
+ * What the CLI prints — as pure functions, so it can be tested.
3
+ *
4
+ * Everything here takes data and returns a string. No I/O, no process, no
5
+ * colours decided by a TTY check: the caller writes the result. That is what
6
+ * lets `render.test.ts` assert on a 409 re-aim table or a blocks listing
7
+ * without a server, a terminal, or a snapshot of the whole command.
8
+ *
9
+ * The rule these follow, from the mission's binding: THE CLI PRINTS WHAT THE
10
+ * SERVER SENDS. Nothing here computes a URL, a route, or a block id — each
11
+ * comes off a response and is formatted.
12
+ */
13
+ import type { BlockConflict, BlockView, BlocksView, PresenceView, WriteEffect } from "./api-client.js";
14
+ export declare const colors: {
15
+ reset: string;
16
+ bold: string;
17
+ dim: string;
18
+ cyan: string;
19
+ green: string;
20
+ yellow: string;
21
+ blue: string;
22
+ red: string;
23
+ };
24
+ /**
25
+ * A link, as one quiet line.
26
+ *
27
+ * Dim and unadorned because it is an offer, not an instruction: the line a
28
+ * reader's eye skips until the moment they want it, and a terminal that
29
+ * hyperlinks URLs makes it clickable without any markup from us.
30
+ */
31
+ export declare function urlLine(url: string): string;
32
+ /** The first line of a block's text, collapsed and clipped for one row. */
33
+ export declare function blockSummary(block: BlockView, width?: number): string;
34
+ /**
35
+ * `sfora blocks <path>` — the addressable view, one row per block.
36
+ *
37
+ * The id comes FIRST because it is the only column anyone retypes: the next
38
+ * command is `sfora put <path> --block <id>`, and a listing that buries the id
39
+ * makes the reader hunt for it.
40
+ *
41
+ * `writable: false` is stated, not omitted, and it is stated as "read-only"
42
+ * rather than by leaving the row out. Those blocks are real — the frontmatter
43
+ * fence, the title heading — they occupy lines the reader can see in the file,
44
+ * and hiding them would make the listing disagree with `cat`.
45
+ */
46
+ export declare function renderBlocks(view: BlocksView): string;
47
+ /**
48
+ * The 409 a `?block=` write gets: the id named nothing, here is what the
49
+ * document has now.
50
+ *
51
+ * A table and not a message, because the recovery IS the payload: the server
52
+ * already sent the current blocks, so the fix is one glance and one retry
53
+ * rather than a second `sfora blocks` round trip. The retry line is spelled
54
+ * out with the real path for the same reason.
55
+ *
56
+ * THE COLUMNS ARE THE PAYLOAD'S — id, line, preview — and not `sfora blocks`'s.
57
+ * The two lists describe different bytes (see {@link BlockSummary}): every id
58
+ * here already resolves at the write door, so there is no writable/read-only
59
+ * distinction to draw and no node type in the payload to draw one with. An
60
+ * earlier version of this function reached for both anyway and printed
61
+ * `undefined read-only` on every row, which inverted the one fact the table
62
+ * exists to convey. `line` earns the column it took: it is where in the file
63
+ * the caller is holding to look, and it is the field that tells two blocks with
64
+ * the same opening words apart.
65
+ */
66
+ export declare function renderBlockConflict(conflict: BlockConflict, fsPath: string): string;
67
+ /**
68
+ * The effect report, as one line — printed after EVERY write.
69
+ *
70
+ * A bare "saved" is a lie the CLI used to tell: sfora's write door splices, so
71
+ * a PUT of bytes that parse the same as the stored ones stores nothing and
72
+ * `changed: false` is a normal answer. Saying so is the difference between an
73
+ * agent that knows its edit landed and one that assumes it did.
74
+ *
75
+ * The rebind counts describe the blocks the document had BEFORE this write:
76
+ * `total` of them, of which `rebound` are the same block under a new id and
77
+ * `orphaned` are blocks nothing in the new document can be shown to be. The
78
+ * rest kept the id they had, which is the number worth leading with — it is
79
+ * what tells a reader whether their block addresses still work.
80
+ */
81
+ export declare function renderWriteEffect(effect: WriteEffect | null): string | null;
82
+ /**
83
+ * The one-time "you are visible" note.
84
+ *
85
+ * Writing DECLARES PRESENCE server-side — the document's roster shows the
86
+ * writer, and a human with it open sees them. That is a fact about the user's
87
+ * visibility to other people, so it is said out loud rather than left to be
88
+ * discovered in the UI; once per run, because said on every write it would be
89
+ * noise and the second one teaches nothing.
90
+ */
91
+ export declare const PRESENCE_NOTE = "you are visible as editing this document";
92
+ /**
93
+ * `sfora where` — one sentence per person, and a link they can be met at.
94
+ *
95
+ * A SENTENCE, not a table. The answer to "where is Thijs" is read once and
96
+ * acted on immediately ("open that document"), so it is written the way it
97
+ * would be said: *Thijs is editing test-document.md — <url>*. A columnar
98
+ * listing would be denser and would make the reader assemble the sentence
99
+ * themselves.
100
+ *
101
+ * The block id rides in parentheses only when somebody claimed one. Humans
102
+ * are present at document level today (the editor has no cheap source-offset
103
+ * mapping), so an always-present column would be empty on most rows and read
104
+ * as a fault rather than as an absence of claim.
105
+ *
106
+ * Grouped runs, no headers: two people in one document print as two adjacent
107
+ * lines naming the same file. The repetition is the grouping, and it costs
108
+ * nothing to scan — where a header would cost a line per document and break
109
+ * `sfora where | grep` into a two-line lookup.
110
+ */
111
+ export declare function renderPresence(view: PresenceView): string;
112
+ /**
113
+ * `sfora where --json` — one flat record per person-in-a-document, per line.
114
+ *
115
+ * FLAT, where the wire shape is grouped. NDJSON's contract is that a line is a
116
+ * record, and the record a consumer of this wants is "who is where": grouping
117
+ * would make a line's shape depend on how many people happened to be in one
118
+ * file, and `jq -r .url` would stop working. The document's fields are copied
119
+ * onto each line rather than referenced, so no line needs another to be read.
120
+ */
121
+ export declare function presenceRecords(view: PresenceView): unknown[];
122
+ /** One `doc.write` / `doc.delete` ping from `/v1/events`, as one line. */
123
+ export interface DocPing {
124
+ type: "doc.write" | "doc.delete";
125
+ ts: number;
126
+ docType?: string;
127
+ docId?: string;
128
+ title?: string;
129
+ path?: string;
130
+ url?: string;
131
+ author?: string;
132
+ authorType?: "human" | "agent";
133
+ changed?: boolean;
134
+ deleted?: boolean;
135
+ restricted?: boolean;
136
+ blockIds?: {
137
+ rebound: number;
138
+ orphaned: number;
139
+ total: number;
140
+ };
141
+ }
142
+ /** `HH:MM:SS` in the reader's own timezone — a ping is read as it lands. */
143
+ export declare function pingTime(ts: number): string;
144
+ /**
145
+ * `time · author · doc · what changed · url` — one ping, one line.
146
+ *
147
+ * A RESTRICTED ping keeps its shape and loses its pointer: the server
148
+ * delivered the fact (a document you are watching moved, by whom) and withheld
149
+ * the title and the link because they belong to somebody's draft. Printing
150
+ * "(restricted)" rather than dropping the line is the honest half — a watch
151
+ * that went silent would look broken.
152
+ */
153
+ export declare function renderPing(ping: DocPing): string;
154
+ /**
155
+ * The NDJSON line for one ping — the machine half of `sfora watch --json`.
156
+ *
157
+ * The SERVER'S event object, verbatim, plus nothing. A CLI that reshaped it
158
+ * would become a second schema to keep in step with `/v1/events`, and the one
159
+ * consumer that matters here (an agent piping this into a program) is better
160
+ * served by the wire shape it can also get from the HTTP door directly.
161
+ */
162
+ export declare function ndjson(event: unknown): string;
package/dist/render.js ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * What the CLI prints — as pure functions, so it can be tested.
3
+ *
4
+ * Everything here takes data and returns a string. No I/O, no process, no
5
+ * colours decided by a TTY check: the caller writes the result. That is what
6
+ * lets `render.test.ts` assert on a 409 re-aim table or a blocks listing
7
+ * without a server, a terminal, or a snapshot of the whole command.
8
+ *
9
+ * The rule these follow, from the mission's binding: THE CLI PRINTS WHAT THE
10
+ * SERVER SENDS. Nothing here computes a URL, a route, or a block id — each
11
+ * comes off a response and is formatted.
12
+ */
13
+ export const colors = {
14
+ reset: "\x1b[0m",
15
+ bold: "\x1b[1m",
16
+ dim: "\x1b[2m",
17
+ cyan: "\x1b[36m",
18
+ green: "\x1b[32m",
19
+ yellow: "\x1b[33m",
20
+ blue: "\x1b[34m",
21
+ red: "\x1b[31m",
22
+ };
23
+ const dim = (text) => `${colors.dim}${text}${colors.reset}`;
24
+ /**
25
+ * A link, as one quiet line.
26
+ *
27
+ * Dim and unadorned because it is an offer, not an instruction: the line a
28
+ * reader's eye skips until the moment they want it, and a terminal that
29
+ * hyperlinks URLs makes it clickable without any markup from us.
30
+ */
31
+ export function urlLine(url) {
32
+ return dim(url);
33
+ }
34
+ /** One line of text, collapsed and clipped to a column. */
35
+ function oneLine(text, width) {
36
+ const firstLine = text.split("\n").find((l) => l.trim()) ?? "";
37
+ const flat = firstLine.replace(/\s+/g, " ").trim();
38
+ return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat;
39
+ }
40
+ /** The first line of a block's text, collapsed and clipped for one row. */
41
+ export function blockSummary(block, width = 56) {
42
+ return oneLine(block.text ?? "", width);
43
+ }
44
+ function pad(text, width) {
45
+ return text.length >= width ? text : text + " ".repeat(width - text.length);
46
+ }
47
+ /**
48
+ * `sfora blocks <path>` — the addressable view, one row per block.
49
+ *
50
+ * The id comes FIRST because it is the only column anyone retypes: the next
51
+ * command is `sfora put <path> --block <id>`, and a listing that buries the id
52
+ * makes the reader hunt for it.
53
+ *
54
+ * `writable: false` is stated, not omitted, and it is stated as "read-only"
55
+ * rather than by leaving the row out. Those blocks are real — the frontmatter
56
+ * fence, the title heading — they occupy lines the reader can see in the file,
57
+ * and hiding them would make the listing disagree with `cat`.
58
+ */
59
+ export function renderBlocks(view) {
60
+ const lines = [];
61
+ if (view.unaddressable) {
62
+ return [
63
+ `${colors.yellow}no addressable blocks${colors.reset}`,
64
+ dim(view.unaddressable),
65
+ ...(view.url ? [urlLine(view.url)] : []),
66
+ ].join("\n");
67
+ }
68
+ if (view.blocks.length === 0) {
69
+ lines.push(dim("(no blocks)"));
70
+ }
71
+ else {
72
+ const idWidth = Math.max(...view.blocks.map((b) => b.id.length));
73
+ const typeWidth = Math.max(...view.blocks.map((b) => b.type.length));
74
+ for (const block of view.blocks) {
75
+ const row = `${pad(block.id, idWidth)} ${dim(pad(block.type, typeWidth))} ${blockSummary(block)}`;
76
+ lines.push(block.writable ? row : `${dim(row)} ${dim("read-only")}`);
77
+ }
78
+ const writable = view.blocks.filter((b) => b.writable).length;
79
+ lines.push(dim(`${view.blocks.length} block${view.blocks.length === 1 ? "" : "s"} · ${writable} writable`));
80
+ }
81
+ if (view.url)
82
+ lines.push(urlLine(view.url));
83
+ return lines.join("\n");
84
+ }
85
+ /**
86
+ * The 409 a `?block=` write gets: the id named nothing, here is what the
87
+ * document has now.
88
+ *
89
+ * A table and not a message, because the recovery IS the payload: the server
90
+ * already sent the current blocks, so the fix is one glance and one retry
91
+ * rather than a second `sfora blocks` round trip. The retry line is spelled
92
+ * out with the real path for the same reason.
93
+ *
94
+ * THE COLUMNS ARE THE PAYLOAD'S — id, line, preview — and not `sfora blocks`'s.
95
+ * The two lists describe different bytes (see {@link BlockSummary}): every id
96
+ * here already resolves at the write door, so there is no writable/read-only
97
+ * distinction to draw and no node type in the payload to draw one with. An
98
+ * earlier version of this function reached for both anyway and printed
99
+ * `undefined read-only` on every row, which inverted the one fact the table
100
+ * exists to convey. `line` earns the column it took: it is where in the file
101
+ * the caller is holding to look, and it is the field that tells two blocks with
102
+ * the same opening words apart.
103
+ */
104
+ export function renderBlockConflict(conflict, fsPath) {
105
+ const lines = [
106
+ `${colors.yellow}that block is gone${colors.reset} ${dim("— somebody changed it since you read it")}`,
107
+ dim(conflict.message),
108
+ "",
109
+ ];
110
+ if (conflict.blocks.length === 0) {
111
+ lines.push(dim("(the document has no addressable blocks now)"));
112
+ }
113
+ else {
114
+ lines.push(dim("the document has these blocks now:"));
115
+ const idWidth = Math.max(...conflict.blocks.map((b) => b.id.length));
116
+ const lineWidth = Math.max(...conflict.blocks.map((b) => `${b.line}`.length));
117
+ for (const block of conflict.blocks) {
118
+ const at = `${" ".repeat(lineWidth - `${block.line}`.length)}${block.line}`;
119
+ lines.push(` ${pad(block.id, idWidth)} ${dim(`line ${at}`)} ${oneLine(block.preview, 48)}`);
120
+ }
121
+ lines.push("", dim(`re-aim with: sfora put ${fsPath} --block <id>`));
122
+ }
123
+ return lines.join("\n");
124
+ }
125
+ /**
126
+ * The effect report, as one line — printed after EVERY write.
127
+ *
128
+ * A bare "saved" is a lie the CLI used to tell: sfora's write door splices, so
129
+ * a PUT of bytes that parse the same as the stored ones stores nothing and
130
+ * `changed: false` is a normal answer. Saying so is the difference between an
131
+ * agent that knows its edit landed and one that assumes it did.
132
+ *
133
+ * The rebind counts describe the blocks the document had BEFORE this write:
134
+ * `total` of them, of which `rebound` are the same block under a new id and
135
+ * `orphaned` are blocks nothing in the new document can be shown to be. The
136
+ * rest kept the id they had, which is the number worth leading with — it is
137
+ * what tells a reader whether their block addresses still work.
138
+ */
139
+ export function renderWriteEffect(effect) {
140
+ if (!effect)
141
+ return null;
142
+ if (!effect.changed) {
143
+ return dim("no change — the stored bytes already matched");
144
+ }
145
+ const counts = effect.blockIds;
146
+ if (!counts || counts.total === 0)
147
+ return dim("changed");
148
+ const kept = counts.total - counts.rebound - counts.orphaned;
149
+ const parts = [`${kept} of ${counts.total} block ids kept`];
150
+ if (counts.rebound)
151
+ parts.push(`${counts.rebound} moved`);
152
+ if (counts.orphaned)
153
+ parts.push(`${counts.orphaned} orphaned`);
154
+ return dim(`changed · ${parts.join(" · ")}`);
155
+ }
156
+ /**
157
+ * The one-time "you are visible" note.
158
+ *
159
+ * Writing DECLARES PRESENCE server-side — the document's roster shows the
160
+ * writer, and a human with it open sees them. That is a fact about the user's
161
+ * visibility to other people, so it is said out loud rather than left to be
162
+ * discovered in the UI; once per run, because said on every write it would be
163
+ * noise and the second one teaches nothing.
164
+ */
165
+ export const PRESENCE_NOTE = "you are visible as editing this document";
166
+ /**
167
+ * `sfora where` — one sentence per person, and a link they can be met at.
168
+ *
169
+ * A SENTENCE, not a table. The answer to "where is Thijs" is read once and
170
+ * acted on immediately ("open that document"), so it is written the way it
171
+ * would be said: *Thijs is editing test-document.md — <url>*. A columnar
172
+ * listing would be denser and would make the reader assemble the sentence
173
+ * themselves.
174
+ *
175
+ * The block id rides in parentheses only when somebody claimed one. Humans
176
+ * are present at document level today (the editor has no cheap source-offset
177
+ * mapping), so an always-present column would be empty on most rows and read
178
+ * as a fault rather than as an absence of claim.
179
+ *
180
+ * Grouped runs, no headers: two people in one document print as two adjacent
181
+ * lines naming the same file. The repetition is the grouping, and it costs
182
+ * nothing to scan — where a header would cost a line per document and break
183
+ * `sfora where | grep` into a two-line lookup.
184
+ */
185
+ export function renderPresence(view) {
186
+ const lines = [];
187
+ const totalHere = view.documents.reduce((n, d) => n + d.here.length, 0);
188
+ if (totalHere === 0) {
189
+ return dim(view.member
190
+ ? `${view.member.name} isn't in a document right now`
191
+ : "nobody is in a document right now");
192
+ }
193
+ for (const doc of view.documents) {
194
+ for (const who of doc.here) {
195
+ lines.push(presenceLine(doc, who));
196
+ }
197
+ }
198
+ return lines.join("\n");
199
+ }
200
+ function presenceLine(doc, who) {
201
+ const where = who.block
202
+ ? `${doc.filename} ${dim(`(block ${who.block})`)}`
203
+ : doc.filename;
204
+ return `${who.name} is ${who.kind} ${where} ${dim("—")} ${urlLine(doc.url)}`;
205
+ }
206
+ /**
207
+ * `sfora where --json` — one flat record per person-in-a-document, per line.
208
+ *
209
+ * FLAT, where the wire shape is grouped. NDJSON's contract is that a line is a
210
+ * record, and the record a consumer of this wants is "who is where": grouping
211
+ * would make a line's shape depend on how many people happened to be in one
212
+ * file, and `jq -r .url` would stop working. The document's fields are copied
213
+ * onto each line rather than referenced, so no line needs another to be read.
214
+ */
215
+ export function presenceRecords(view) {
216
+ const records = [];
217
+ for (const doc of view.documents) {
218
+ for (const who of doc.here) {
219
+ records.push({
220
+ memberId: who.memberId,
221
+ name: who.name,
222
+ type: who.type,
223
+ kind: who.kind,
224
+ block: who.block,
225
+ lastSeenAt: who.lastSeenAt,
226
+ ttlSeconds: view.ttlSeconds,
227
+ docId: doc.docId,
228
+ title: doc.title,
229
+ filename: doc.filename,
230
+ path: doc.path,
231
+ project: doc.project.slug,
232
+ url: doc.url,
233
+ });
234
+ }
235
+ }
236
+ return records;
237
+ }
238
+ /** `HH:MM:SS` in the reader's own timezone — a ping is read as it lands. */
239
+ export function pingTime(ts) {
240
+ return new Date(ts).toTimeString().slice(0, 8);
241
+ }
242
+ /**
243
+ * `time · author · doc · what changed · url` — one ping, one line.
244
+ *
245
+ * A RESTRICTED ping keeps its shape and loses its pointer: the server
246
+ * delivered the fact (a document you are watching moved, by whom) and withheld
247
+ * the title and the link because they belong to somebody's draft. Printing
248
+ * "(restricted)" rather than dropping the line is the honest half — a watch
249
+ * that went silent would look broken.
250
+ */
251
+ export function renderPing(ping) {
252
+ const who = ping.author ?? "someone";
253
+ const what = ping.restricted
254
+ ? dim("(a document you can't open)")
255
+ : (ping.title ?? ping.path ?? ping.docId ?? "a document");
256
+ const parts = [dim(pingTime(ping.ts)), who, what];
257
+ if (ping.type === "doc.delete") {
258
+ parts.push(`${colors.yellow}deleted${colors.reset}`);
259
+ }
260
+ else {
261
+ const counts = ping.blockIds;
262
+ parts.push(dim(counts && counts.total
263
+ ? `${counts.total - counts.rebound - counts.orphaned} of ${counts.total} block ids kept`
264
+ : "edited"));
265
+ }
266
+ if (ping.url)
267
+ parts.push(urlLine(ping.url));
268
+ return parts.join(dim(" · "));
269
+ }
270
+ /**
271
+ * The NDJSON line for one ping — the machine half of `sfora watch --json`.
272
+ *
273
+ * The SERVER'S event object, verbatim, plus nothing. A CLI that reshaped it
274
+ * would become a second schema to keep in step with `/v1/events`, and the one
275
+ * consumer that matters here (an agent piping this into a program) is better
276
+ * served by the wire shape it can also get from the HTTP door directly.
277
+ */
278
+ export function ndjson(event) {
279
+ return JSON.stringify(event);
280
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The sfora-native commands inside the shell: `blocks`, `put`, `url`.
3
+ *
4
+ * Card #333's "and the fs shell equivalent". The shell already writes whole
5
+ * files (`echo '# Title' > /projects/x/docs/y.md` is a PUT), but it had no way
6
+ * to aim at ONE block, and no way to see what a write did. These three close
7
+ * that gap without teaching the shell anything new about routes: they are the
8
+ * same functions the CLI verbs call.
9
+ *
10
+ * `put` reads its body from a file argument or from STDIN, which is what makes
11
+ * it shell-shaped — `sed 's/foo/bar/' <(…) | put /projects/x/docs/y.md --block
12
+ * k7f3a2cx` is the loop this exists for.
13
+ */
14
+ import type { Command } from "just-bash";
15
+ import type { SforaApiClient } from "./api-client.js";
16
+ import { type PresenceNotice } from "./block-commands.js";
17
+ /** Flags these commands understand, split from the positional arguments. */
18
+ interface ParsedArgs {
19
+ positional: string[];
20
+ blockId?: string;
21
+ json: boolean;
22
+ }
23
+ export declare function parseShellArgs(args: string[]): ParsedArgs;
24
+ /**
25
+ * The three commands, bound to the client the shell's fs already uses.
26
+ *
27
+ * `presence` is the RUN's latch, not the shell's: an interactive session
28
+ * prints the presence note from two places (a `put` command, and the trailing
29
+ * report after any line that wrote), and both must count as the same "once".
30
+ * A caller that passes none gets a fresh one, so a standalone shell still says
31
+ * it exactly once.
32
+ */
33
+ export declare function sforaShellCommands(client: SforaApiClient, presence?: PresenceNotice): Command[];
34
+ export {};
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The sfora-native commands inside the shell: `blocks`, `put`, `url`.
3
+ *
4
+ * Card #333's "and the fs shell equivalent". The shell already writes whole
5
+ * files (`echo '# Title' > /projects/x/docs/y.md` is a PUT), but it had no way
6
+ * to aim at ONE block, and no way to see what a write did. These three close
7
+ * that gap without teaching the shell anything new about routes: they are the
8
+ * same functions the CLI verbs call.
9
+ *
10
+ * `put` reads its body from a file argument or from STDIN, which is what makes
11
+ * it shell-shaped — `sed 's/foo/bar/' <(…) | put /projects/x/docs/y.md --block
12
+ * k7f3a2cx` is the loop this exists for.
13
+ */
14
+ import { decodeBytesToUtf8 } from "just-bash";
15
+ import { blocksCommand, presenceNotice, putCommand, resolveFsPath, urlCommand, } from "./block-commands.js";
16
+ export function parseShellArgs(args) {
17
+ const parsed = { positional: [], json: false };
18
+ for (let i = 0; i < args.length; i++) {
19
+ const a = args[i];
20
+ if (a === "--json")
21
+ parsed.json = true;
22
+ else if (a === "--block")
23
+ parsed.blockId = args[++i];
24
+ else if (a.startsWith("--block=")) {
25
+ parsed.blockId = a.slice("--block=".length);
26
+ }
27
+ else
28
+ parsed.positional.push(a);
29
+ }
30
+ return parsed;
31
+ }
32
+ const toExec = (out) => ({
33
+ stdout: out.stdout,
34
+ stderr: out.stderr,
35
+ exitCode: out.exitCode,
36
+ });
37
+ const usage = (text) => ({
38
+ stdout: "",
39
+ stderr: `${text}\n`,
40
+ exitCode: 2,
41
+ });
42
+ /**
43
+ * The body of a `put`: a file argument read through the shell's own fs, or
44
+ * whatever arrived on stdin.
45
+ *
46
+ * `-` is spelled out as "stdin" because a pipeline that produced nothing and a
47
+ * caller that forgot the body are the same empty string, and the write door
48
+ * refuses an empty `--block` body with a message about truncated pipes. Being
49
+ * explicit at the call site is how the shell keeps that refusal meaningful.
50
+ */
51
+ async function putBody(ctx, fileArg, stdin) {
52
+ if (fileArg && fileArg !== "-") {
53
+ const path = resolveFsPath(ctx.cwd, fileArg);
54
+ const content = await ctx.fs.readFile(path, "utf8");
55
+ return typeof content === "string"
56
+ ? content
57
+ : new TextDecoder().decode(content);
58
+ }
59
+ return stdin;
60
+ }
61
+ /**
62
+ * The three commands, bound to the client the shell's fs already uses.
63
+ *
64
+ * `presence` is the RUN's latch, not the shell's: an interactive session
65
+ * prints the presence note from two places (a `put` command, and the trailing
66
+ * report after any line that wrote), and both must count as the same "once".
67
+ * A caller that passes none gets a fresh one, so a standalone shell still says
68
+ * it exactly once.
69
+ */
70
+ export function sforaShellCommands(client, presence = presenceNotice()) {
71
+ return [
72
+ {
73
+ name: "blocks",
74
+ async execute(args, ctx) {
75
+ const { positional, json } = parseShellArgs(args);
76
+ if (!positional[0])
77
+ return usage("usage: blocks <path> [--json]");
78
+ return toExec(await blocksCommand(client, resolveFsPath(ctx.cwd, positional[0]), {
79
+ json,
80
+ }));
81
+ },
82
+ },
83
+ {
84
+ name: "put",
85
+ async execute(args, ctx) {
86
+ const { positional, blockId, json } = parseShellArgs(args);
87
+ if (!positional[0]) {
88
+ return usage("usage: put <path> [<file>|-] [--block <id>] [--json] (body from stdin when no file)");
89
+ }
90
+ // `ctx.stdin` is just-bash's opaque byte string, not a buffer: the
91
+ // engine's own decoder is the only correct way to read it as text.
92
+ const body = await putBody(ctx, positional[1], decodeBytesToUtf8(ctx.stdin));
93
+ return toExec(await putCommand(client, resolveFsPath(ctx.cwd, positional[0]), body, { blockId, json, presence }));
94
+ },
95
+ },
96
+ {
97
+ name: "url",
98
+ async execute(args, ctx) {
99
+ const { positional, json } = parseShellArgs(args);
100
+ if (!positional[0])
101
+ return usage("usage: url <path> [--json]");
102
+ return toExec(await urlCommand(client, resolveFsPath(ctx.cwd, positional[0]), {
103
+ json,
104
+ }));
105
+ },
106
+ },
107
+ ];
108
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `sfora watch` — a document as a channel.
3
+ *
4
+ * Card #332. `/v1/events` has been the agent wake-up transport since 2026-07;
5
+ * card #328 taught it `doc.write`, and this is the consumer that makes a write
6
+ * into a ping somebody actually sees. Point it at a document and every edit
7
+ * anybody makes to it arrives as a line; point it at a project and every
8
+ * document in the project does.
9
+ *
10
+ * FOUR THINGS THIS OWNS, and each is here rather than in `cli.ts` because each
11
+ * is a rule with a reason:
12
+ *
13
+ * 1. **The cursor survives reconnects.** A dropped connection is normal on a
14
+ * long-poll; losing the cursor with it would replay pings already printed,
15
+ * or (worse, with `since=0`) dump a day of backlog. It is a variable in
16
+ * this loop and it only ever moves forward.
17
+ * 2. **Backoff on drops, reset on success.** A server that is down must not be
18
+ * hammered by a watcher that reconnects instantly, and a watcher that
19
+ * backed off must not stay slow once the server is back.
20
+ * 3. **Presence while watching.** Reading a document IS being in it, so the
21
+ * watch declares `viewing` and re-declares on each poll — the roster row
22
+ * expires ~90s after the last beat and the poll cadence is well inside
23
+ * that. Paths with no roster (a post, a card, a project) simply get no
24
+ * declaration; the server says which those are, not the CLI.
25
+ * 4. **Leaving on exit.** A watch that ended and left a row behind would show
26
+ * a ghost in somebody's avatar stack for a minute and a half.
27
+ *
28
+ * The loop takes its I/O as arguments so a test can drive it with canned pages
29
+ * and a fake clock. Nothing here touches `process`.
30
+ */
31
+ import type { AgentEventsPage } from "./api-client.js";
32
+ /** Everything the loop needs from the outside world. */
33
+ export interface WatchDeps {
34
+ /** One long-poll. Rejects on a dropped connection — the loop backs off. */
35
+ poll(since: number): Promise<AgentEventsPage>;
36
+ /** Say "viewing" (or retract). Absent when the target has no roster. */
37
+ beat?(options: {
38
+ leave?: boolean;
39
+ }): Promise<void>;
40
+ /** Where a printed line goes. */
41
+ write(text: string): void;
42
+ /** Where a warning goes — never mixed into `--json` output. */
43
+ warn(text: string): void;
44
+ sleep(ms: number): Promise<void>;
45
+ }
46
+ export interface WatchOptions {
47
+ /** NDJSON instead of prose: one event object per line, the server's shape. */
48
+ json?: boolean;
49
+ /** Where to start. 0 replays the retained backlog; the CLI starts at "now". */
50
+ since?: number;
51
+ /** Stop when this returns true — a signal, or a counter in a test. */
52
+ stopped?: () => boolean;
53
+ /** First backoff step, doubling to {@link MAX_BACKOFF_MS}. */
54
+ backoffMs?: number;
55
+ }
56
+ /** A watcher that has been unreachable this long is not polling any faster. */
57
+ export declare const MAX_BACKOFF_MS = 30000;
58
+ /**
59
+ * Poll until stopped, printing what arrives.
60
+ *
61
+ * Returns the cursor it reached, which is what makes the loop testable: a test
62
+ * asserts that a reconnect resumed from it rather than from zero.
63
+ */
64
+ export declare function watchLoop(deps: WatchDeps, options?: WatchOptions): Promise<number>;
65
+ /**
66
+ * What `sfora watch <target>` is watching: one document, or a project.
67
+ *
68
+ * A path names a document (`/projects/acme/docs/x.md`); a bare word names a
69
+ * project, and so does the project's own directory, since `/projects/acme` is
70
+ * how the fs spells it.
71
+ */
72
+ export type WatchTarget = {
73
+ kind: "project";
74
+ slug: string;
75
+ } | {
76
+ kind: "path";
77
+ path: string;
78
+ };
79
+ export declare function parseWatchTarget(target: string): WatchTarget;