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/README.md CHANGED
@@ -11,6 +11,7 @@ sfora new "Acme" # create a project
11
11
  sfora post plan.md --project acme # push a markdown plan as a post
12
12
  sfora task spec.md --project acme # …or a task on the board
13
13
  sfora cat /projects/acme/board/01-todo/0042-fix-login.md
14
+ sfora url /projects/acme/docs/kickoff.md # where it lives on the web
14
15
  ```
15
16
 
16
17
  Under the hood it maps a Unix view onto sfora's `/v1/fs` HTTP API (a sandboxed
@@ -211,6 +212,179 @@ backend) are the public exports, alongside the lower-level `SforaApiClient`.
211
212
 
212
213
  ## Supported operations
213
214
 
215
+ ## Blocks — write one paragraph, not the file
216
+
217
+ A markdown body is addressable: every top-level block has an id that is a
218
+ fingerprint over its bytes, so `sfora blocks` lists them and
219
+ `sfora put --block <id>` replaces exactly one and leaves every other byte alone.
220
+
221
+ ```bash
222
+ $ sfora blocks /projects/acme/docs/kickoff.md
223
+ kfrontmat yaml id: k17e8c0... read-only
224
+ k7f3a2cx heading # Kickoff read-only
225
+ kq8w1zzp paragraph The hill chart is the one view that a…
226
+ 3 blocks · 1 writable
227
+ https://www.sfora.ai/org/acme/notes/k17e8c0...
228
+
229
+ $ echo "A rewritten paragraph." | sfora put /projects/acme/docs/kickoff.md --block kq8w1zzp
230
+ ✓ Wrote block kq8w1zzp of /v1/fs/projects/acme/library/documents/kickoff.md
231
+ changed · 3 of 4 block ids kept · 1 moved
232
+ https://www.sfora.ai/org/acme/notes/k17e8c0...
233
+ you are visible as editing this document
234
+ ```
235
+
236
+ `read-only` blocks are real lines you can see in the file — the frontmatter
237
+ fence, the title heading — that the write door does not store, so their ids
238
+ address nothing. Aim at a writable one.
239
+
240
+ **Every write prints what it did.** sfora's write door splices: a PUT of bytes
241
+ that parse the same as the stored ones stores nothing, so `no change — the
242
+ stored bytes already matched` is a real and frequent answer to a
243
+ read-edit-write loop that reformatted more than it meant to. When bytes did
244
+ move, the line says how many block ids survived it.
245
+
246
+ **A stale id is not an error, it is a re-aim.** Block ids are derived from
247
+ content, so "this id resolves to nothing" means somebody changed that block
248
+ since you read it. The server sends the document's current blocks with the
249
+ refusal, and the CLI prints them:
250
+
251
+ ```
252
+ that block is gone — somebody changed it since you read it
253
+ Block kq8w1zzp is not in this document any more.
254
+
255
+ the document has these blocks now:
256
+ k7f3a2cx line 1 ## Agenda
257
+ kt2p9lmx line 3 A rewritten paragraph.
258
+ kb91xz4q line 5 ```json
259
+
260
+ re-aim with: sfora put /projects/acme/docs/kickoff.md --block <id>
261
+ ```
262
+
263
+ The columns differ from `sfora blocks` on purpose. That listing describes what
264
+ a READ serves — frontmatter, the title heading, and `writable` to say which of
265
+ those the write door can reach. This one is what the write door FOUND, which is
266
+ the document's stored body: every id here already resolves, so there is no
267
+ read-only row to mark, and `line` is where in the file to look.
268
+
269
+ `blocks`, `put` and `url` are also commands **inside the shell**, so the body
270
+ can come from a pipeline:
271
+
272
+ ```text
273
+ sfora:/$ blocks /projects/acme/docs/kickoff.md | grep paragraph
274
+ sfora:/$ sed 's/hill chart/hill/' draft.md | put /projects/acme/docs/kickoff.md --block kq8w1zzp
275
+ ```
276
+
277
+ Writing a document also makes you **visible in it** — the app's avatar stack
278
+ shows you editing, with the block you aimed at. The CLI says so once per run.
279
+
280
+ ## Watch — a document as a channel
281
+
282
+ `sfora watch` long-polls the workspace and prints every write as it lands. Point
283
+ it at one document, or at a whole project.
284
+
285
+ ```bash
286
+ $ sfora watch /projects/acme/docs/kickoff.md
287
+ watching /projects/acme/docs/kickoff.md (not your own writes) — ^C to stop
288
+ 14:22:07 · Ada Lovelace · Kickoff · 4 of 5 block ids kept · https://www.sfora.ai/org/acme/notes/k17e8c0...
289
+ 14:24:19 · Dogfood Bot · Kickoff · edited · https://www.sfora.ai/org/acme/notes/k17e8c0...
290
+
291
+ $ sfora watch acme --json | jq -r 'select(.docType == "note") | .title'
292
+ ```
293
+
294
+ * `--json` writes **NDJSON** — one event per line (schema below).
295
+ * Your own writes are excluded by default; `--self` includes them.
296
+ * `--wait <secs>` sets the per-request long-poll budget (0–50, default 25).
297
+ * Watching a document makes you **visible in it** as a viewer; ^C aborts the
298
+ open long-poll and retracts you on the way out, rather than leaving a ghost
299
+ in the avatar stack for 90 seconds. It lands immediately — it does not wait
300
+ out the `--wait` budget. A second ^C gives up on the retraction and exits
301
+ now.
302
+ * A dropped connection reconnects with exponential backoff to 30s and resumes
303
+ from the cursor already reached — no replayed pings, no lost ones.
304
+
305
+ ### The NDJSON schema
306
+
307
+ Each line is the server's `/v1/events` object, **verbatim** — the CLI reshapes
308
+ nothing, so one parser reads the CLI's stream and the HTTP door's pages alike.
309
+
310
+ | field | |
311
+ |---|---|
312
+ | `type` | `"doc.write"` or `"doc.delete"` |
313
+ | `ts` | epoch ms — also the cursor value |
314
+ | `docType` | `"note"` · `"post"` · `"card"` |
315
+ | `docId` | the entity id |
316
+ | `projectId` | the project it lives in |
317
+ | `title`, `path` | the document's title and fs path |
318
+ | `url` | the page it can be read on |
319
+ | `author`, `authorId`, `authorType` | who wrote |
320
+ | `changed` | always `true` — a write that changed nothing never pings |
321
+ | `blockIds` | `{ rebound, orphaned, total }` for the version before this write |
322
+ | `deleted` | `doc.delete` only; `blockIds` is then absent |
323
+ | `restricted` | the ping is real, its pointer was withheld — see below |
324
+
325
+ A **restricted** ping has no `title`, `path` or `url`: the document is somebody's
326
+ unpublished draft. The event is still delivered, because a watch that went
327
+ silent would look connected and be deaf.
328
+
329
+ Warnings (reconnects) go to stderr, so `--json` stdout stays parseable.
330
+
331
+ ## Where — who's in which document
332
+
333
+ `sfora watch` tells you when a document moves. `sfora where` tells you where
334
+ people *are* — the reverse of the presence roster, and the answer to "the doc
335
+ I'm looking at" when nobody sent a link.
336
+
337
+ ```bash
338
+ $ sfora where
339
+ Thijs is editing test-document.md — https://www.sfora.ai/org/acme/notes/k17e8c0...
340
+ Dogfood Bot is editing test-document.md (block k7f3a2cx) — https://www.sfora.ai/org/acme/notes/k17e8c0...
341
+
342
+ $ sfora where Thijs
343
+ Thijs is editing test-document.md — https://www.sfora.ai/org/acme/notes/k17e8c0...
344
+
345
+ $ sfora where --json | jq -r 'select(.type == "human") | .path'
346
+ ```
347
+
348
+ * The name is optional and takes a member name, an id, or `self`. A name nobody
349
+ in the workspace answers to is an error, not an empty list — "who?" and
350
+ "nowhere" are different answers.
351
+ * The block id appears only when somebody claimed one. Agents address blocks
352
+ natively; humans are present at document level today.
353
+ * **Asking declares nothing.** Unlike `watch` and every write, `where` is a
354
+ plain `GET` — running it never puts you in a document.
355
+ * You see what your key can open, and nothing else: presence carries a title
356
+ and a link, so a document you're not on the project for never appears.
357
+ * `--as <agent>` composes — the answer is then that agent's view.
358
+ * `--json` writes **NDJSON**, one record per person-in-a-document (flat, so
359
+ each line stands alone): `memberId`, `name`, `type`, `kind`, `block`,
360
+ `lastSeenAt`, `ttlSeconds`, `docId`, `title`, `filename`, `path`, `project`,
361
+ `url`.
362
+
363
+ An entry is live while `lastSeenAt` is inside `ttlSeconds` (90) — the server
364
+ filters on it before answering, so nothing stale comes back.
365
+
366
+ ## Links — `sfora url` · `sfora open`
367
+
368
+ Every workspace thing has a page. The server says where; the CLI prints it.
369
+
370
+ ```bash
371
+ sfora url /projects/acme/library/documents/kickoff.md
372
+ # https://www.sfora.ai/org/acme/notes/k17e8c0...
373
+
374
+ sfora open /projects/acme/board/02-todo/0042-fix-login.md # …and opens it
375
+ sfora url /projects/acme/board --json # { "path": …, "url": … }
376
+ ```
377
+
378
+ The CLI holds **no route table and no hostname**. It asks for the path you gave
379
+ and reads the link off the answer — a markdown read carries it in the
380
+ `X-Sfora-Url` header, a JSON one in a `url` field — so links stay right when the
381
+ app's routes move, and a dev deployment hands out dev links.
382
+
383
+ `ls`, `cat` and the write verbs print the same link as one dim line **on
384
+ stderr**, so `sfora cat x.md > x.md` and `sfora ls | wc -l` stay byte-clean.
385
+ Paths with no page of their own (uploaded files, repository trees, your inbox)
386
+ print nothing, and `sfora url` on one says so.
387
+
214
388
  | op | behaviour |
215
389
  |----|-----------|
216
390
  | `ls`, `readdir` | directory listings via `/v1/fs/projects/…` (cached ~5s) |
package/dist/SforaFs.js CHANGED
@@ -413,12 +413,14 @@ export class SforaFs {
413
413
  const columns = meta.columns
414
414
  .slice()
415
415
  .sort((a, b) => a.position - b.position);
416
- const out = [
417
- `# ${project.name} roadmap`,
418
- "",
419
- `> https://www.sfora.ai/roadmap/${meta.publicSlug}`,
420
- "",
421
- ];
416
+ // The share link is the SERVER'S (`publicUrl` off `GET …/board`), not a
417
+ // hostname glued onto the slug here: which origin a deployment serves its
418
+ // pages from is the deployment's business, and a CLI pointed at a laptop or
419
+ // a preview would otherwise quote the production one. An older server that
420
+ // does not send it leaves the heading and the columns, minus the line.
421
+ const out = [`# ${project.name} roadmap`, ""];
422
+ if (meta.publicUrl)
423
+ out.push(`> ${meta.publicUrl}`, "");
422
424
  for (const col of columns) {
423
425
  out.push(`## ${col.name}`, "");
424
426
  const cards = await this.#listCards(slug, col.dirname);
@@ -24,10 +24,56 @@ export interface Entry {
24
24
  scheduledFor?: number;
25
25
  isDraft: boolean;
26
26
  }
27
+ /**
28
+ * How many block ids survived a write, from the server's rebinding ledger.
29
+ *
30
+ * Absent rather than zeroed when the door computed no correspondence — a
31
+ * create has no previous version to rebind from.
32
+ */
33
+ export interface RebindCounts {
34
+ rebound: number;
35
+ orphaned: number;
36
+ total: number;
37
+ }
38
+ /**
39
+ * The effect report every markdown write returns: did the stored bytes move,
40
+ * and what happened to the block ids if so.
41
+ *
42
+ * The reason a write reports this at all is that sfora's write door SPLICES —
43
+ * a PUT of bytes that parse the same as the stored ones stores nothing, so
44
+ * `changed: false` is a real and common answer, and a bare `201` would read as
45
+ * "saved" for a write that did nothing.
46
+ */
47
+ export interface WriteEffect {
48
+ changed: boolean;
49
+ blockIds?: RebindCounts;
50
+ }
51
+ /** What the last response said about itself. See `takeResponseInfo`. */
52
+ export interface ResponseInfo {
53
+ /** The absolute web page the response named, if any (card #335). */
54
+ url: string | null;
55
+ /** The effect report, when the response was a markdown write (card #333). */
56
+ effect: WriteEffect | null;
57
+ /**
58
+ * The write put the caller on the document's roster.
59
+ *
60
+ * Only document writes declare presence server-side — posts and cards have
61
+ * markdown bodies but nobody has one open in a document editor — so this
62
+ * comes off the response rather than being inferred from the path shape.
63
+ */
64
+ present: boolean;
65
+ }
66
+ /** The effect report in a parsed response body, or null when there is none. */
67
+ export declare function writeEffectFrom(body: unknown): WriteEffect | null;
27
68
  /** Result of a successful `PUT` (create/update). */
28
- export interface WriteResult {
69
+ export interface WriteResult extends Partial<WriteEffect> {
29
70
  filename: string;
30
71
  id: string;
72
+ /** The fs path the write landed at — canonical, which may differ from the URL. */
73
+ path?: string;
74
+ /** The web page it can be read on. */
75
+ url?: string;
76
+ created?: boolean;
31
77
  }
32
78
  /**
33
79
  * A note (doc) file entry. Mirrors a row of `GET …/docs`. Notes have stable
@@ -100,6 +146,14 @@ export interface BoardColumn {
100
146
  export interface BoardMeta {
101
147
  publicSlug: string | null;
102
148
  columns: BoardColumn[];
149
+ /**
150
+ * The shared roadmap's public page, or `null` when the board isn't shared.
151
+ *
152
+ * The slug next to it is the identifier; this is the LINK, and it comes from
153
+ * the server for the same reason every other one does — the hostname is the
154
+ * deployment's, not the CLI's.
155
+ */
156
+ publicUrl?: string | null;
103
157
  }
104
158
  /** A card file under `/projects/<slug>/board/<col>/`. `filename` is `NNNN-<slug>.md`. */
105
159
  export interface BoardCardEntry {
@@ -110,12 +164,126 @@ export interface BoardCardEntry {
110
164
  lastActivityAt: number;
111
165
  }
112
166
  /** Result of `PUT …/board/<col>/<filename>.md`. */
113
- export interface CardWriteResult {
167
+ export interface CardWriteResult extends Partial<WriteEffect> {
114
168
  filename: string;
115
169
  id: string;
116
170
  number: number;
117
171
  /** New column dirname — set when frontmatter `column:` or `status:` moved the card. */
118
172
  movedTo?: string;
173
+ path?: string;
174
+ /** The web page it can be read on. */
175
+ url?: string;
176
+ created?: boolean;
177
+ }
178
+ /** One member in a document right now. Mirrors a row of `here`. */
179
+ export interface DocPresenceMember {
180
+ memberId: string;
181
+ name: string;
182
+ type: "human" | "agent";
183
+ kind: "viewing" | "editing";
184
+ blockId?: string;
185
+ lastSeenAt: number;
186
+ }
187
+ /** `POST <doc>/_presence` — the roster, after your own declaration landed. */
188
+ export interface DocPresence {
189
+ document: string;
190
+ url?: string;
191
+ present: boolean;
192
+ block: string | null;
193
+ blockResolved: boolean | null;
194
+ here: DocPresenceMember[];
195
+ }
196
+ /** One occupant of a document, as `GET /v1/presence` reports them. */
197
+ export interface PresenceOccupant {
198
+ memberId: string;
199
+ name: string;
200
+ type: "human" | "agent";
201
+ kind: "viewing" | "editing";
202
+ /** The block they have claimed, or `null` for the document at large. */
203
+ block: string | null;
204
+ lastSeenAt: number;
205
+ }
206
+ /** One document somebody is in, with everyone who is in it. */
207
+ export interface PresenceDocument {
208
+ docId: string;
209
+ title: string;
210
+ filename: string;
211
+ /** The fs path — what `sfora cat` and `sfora put` take. */
212
+ path: string;
213
+ project: {
214
+ slug: string;
215
+ name: string;
216
+ };
217
+ url: string;
218
+ here: PresenceOccupant[];
219
+ }
220
+ /**
221
+ * `GET /v1/presence` — where everybody is, grouped by document.
222
+ *
223
+ * `ttlSeconds` is the server's own definition of "still here": an entry is
224
+ * live while `Date.now() - lastSeenAt` is inside it. Stated rather than
225
+ * assumed, so a consumer never has to hardcode the heartbeat window.
226
+ */
227
+ export interface PresenceView {
228
+ ttlSeconds: number;
229
+ /** The member asked about, when one was named; `null` for everybody. */
230
+ member: {
231
+ memberId: string;
232
+ name: string;
233
+ type: "human" | "agent";
234
+ } | null;
235
+ documents: PresenceDocument[];
236
+ }
237
+ /**
238
+ * One room, as `GET /api/rooms` lists it. `joined: false` rows appear only
239
+ * with `?all=1` — open rooms the caller can discover and self-join.
240
+ */
241
+ export interface Room {
242
+ _id: string;
243
+ name: string;
244
+ type: string;
245
+ description?: string | null;
246
+ /** The owning project's id (not slug), or null for a free-standing room. */
247
+ projectId: string | null;
248
+ joined: boolean;
249
+ }
250
+ /** Who wrote a chat message. Humans and agents are the same member model. */
251
+ export interface ChatAuthor {
252
+ _id: string;
253
+ name: string;
254
+ type: "human" | "agent";
255
+ }
256
+ /** One message, as a row of `GET /api/rooms/:id/messages`. Body is markdown. */
257
+ export interface ChatMessage {
258
+ _id: string;
259
+ body: string;
260
+ _creationTime: number;
261
+ /** Null when the author's member row is gone. */
262
+ author: ChatAuthor | null;
263
+ }
264
+ /** A page of messages, NEWEST FIRST — the server paginates backwards. */
265
+ export interface MessagesPage {
266
+ page: ChatMessage[];
267
+ continueCursor: string;
268
+ isDone: boolean;
269
+ }
270
+ /**
271
+ * One event off `/v1/events`.
272
+ *
273
+ * Deliberately loose beyond the fields the CLI prints: the feed carries room
274
+ * messages and ask state-changes as well as document writes, and a client that
275
+ * enumerated every field would have to be edited each time the server learns a
276
+ * new kind. `--json` forwards the object verbatim for exactly this reason.
277
+ */
278
+ export interface AgentEvent {
279
+ type: string;
280
+ ts: number;
281
+ [key: string]: unknown;
282
+ }
283
+ /** A page of the long-poll: what happened, and where to poll from next. */
284
+ export interface AgentEventsPage {
285
+ events: AgentEvent[];
286
+ cursor: number;
119
287
  }
120
288
  export interface SforaApiConfig {
121
289
  /** e.g. `https://your-sfora.com` or `http://localhost:2222`. Trailing slashes are trimmed. */
@@ -134,11 +302,101 @@ export interface SforaApiConfig {
134
302
  export declare class SforaApiError extends Error {
135
303
  readonly status: number;
136
304
  readonly code: string;
137
- constructor(status: number, code: string, message: string);
305
+ /**
306
+ * The parsed error body, when there was one.
307
+ *
308
+ * Some refusals are USEFUL, not merely informative: a 409 from a `?block=`
309
+ * write carries the document's current blocks so the caller can re-aim
310
+ * without a second round trip. Throwing away everything but the message
311
+ * would make the CLI ask for that page again. Untyped here because the fs
312
+ * error envelope is `{ error, message }` plus whatever the specific refusal
313
+ * adds; {@link blockConflictFrom} is the typed reader for the one shape the
314
+ * CLI acts on.
315
+ */
316
+ readonly data?: unknown;
317
+ constructor(status: number, code: string, message: string, data?: unknown);
318
+ }
319
+ /** One block, as `?view=blocks` describes it. */
320
+ export interface BlockView {
321
+ id: string;
322
+ writable: boolean;
323
+ type: string;
324
+ lines: [number, number];
325
+ text: string;
326
+ depth?: number;
327
+ lang?: string;
328
+ }
329
+ /** `GET …?view=blocks` — the addressable view of a document. */
330
+ export interface BlocksView {
331
+ /** The fs path these blocks are OF — what to GET for the document itself. */
332
+ canonical: string;
333
+ note: string;
334
+ document?: {
335
+ id?: string;
336
+ title?: string;
337
+ lastEditedAt?: string;
338
+ };
339
+ blocks: BlockView[];
340
+ /** Present when block extents could not be determined; `blocks` is then empty. */
341
+ unaddressable?: string;
342
+ /** The web page the document is read on (card #335). */
343
+ url?: string;
344
+ }
345
+ /**
346
+ * One block, as a 409 lists it.
347
+ *
348
+ * A DIFFERENT SHAPE from {@link BlockView}, and the difference is not an
349
+ * oversight on either side. `?view=blocks` projects what the read serves — the
350
+ * frontmatter envelope, the title heading, the mdast type of each node, and
351
+ * `writable` to say which of those the write door can actually resolve. The
352
+ * 409 lists what the write door FOUND, which is the document's stored body:
353
+ * every id in it resolves by construction, so there is no `writable` to report
354
+ * and no envelope block to report it about. Three fields, one line each in the
355
+ * re-aim table: which id, where it is, what it says.
356
+ */
357
+ export interface BlockSummary {
358
+ id: string;
359
+ /** 1-based line the block starts on, in the stored body. */
360
+ line: number;
361
+ /** The block's first non-empty line, clipped by the server. */
362
+ preview: string;
363
+ }
364
+ /**
365
+ * The recovery payload on a 409 from a `?block=` write: the id no longer
366
+ * resolves, and here is what the document has NOW so the caller can re-aim.
367
+ */
368
+ export interface BlockConflict {
369
+ message: string;
370
+ /** The id that was aimed at. */
371
+ block?: string;
372
+ blocks: BlockSummary[];
373
+ }
374
+ /** The 409 recovery payload inside an error, or null for any other failure. */
375
+ export declare function blockConflictFrom(error: unknown): BlockConflict | null;
376
+ /** Options every markdown write door takes. */
377
+ export interface WriteOptions {
378
+ /**
379
+ * Write ONE block instead of the whole file — the id from `?view=blocks`.
380
+ *
381
+ * The body is then that block's markdown, with no frontmatter and no title:
382
+ * a block write edits prose and never a document's state.
383
+ */
384
+ blockId?: string;
138
385
  }
139
386
  export declare class SforaApiClient {
140
387
  #private;
141
388
  constructor(config: SforaApiConfig);
389
+ /** What the last response said about itself, consumed. */
390
+ takeResponseInfo(): ResponseInfo;
391
+ /**
392
+ * The web page an fs path is read on, or null when it has none.
393
+ *
394
+ * One request, and the ONLY route knowledge involved is the server's: this
395
+ * reads the path the caller gave and reports the link the answer carried.
396
+ * A directory listing answers in JSON, a document in markdown with the link
397
+ * in a header, and {@link webUrlFromResponse} covers both.
398
+ */
399
+ resolveWebUrl(fsPath: string): Promise<string | null>;
142
400
  /** `GET /v1/fs/projects` */
143
401
  listProjects(): Promise<Project[]>;
144
402
  /** `POST /v1/fs/projects` — create a project; the caller becomes its lead. */
@@ -168,7 +426,23 @@ export declare class SforaApiClient {
168
426
  /** `GET …/posts/:filename.md` (or `…/drafts/…`). Returns the raw markdown body. */
169
427
  readPost(projectSlug: string, filename: string, kind?: PostKind): Promise<string>;
170
428
  /** Create a post or upsert a mutable draft from a Markdown file. */
171
- writePost(projectSlug: string, kind: PostKind, filename: string, markdown: string): Promise<WriteResult>;
429
+ writePost(projectSlug: string, kind: PostKind, filename: string, markdown: string, options?: WriteOptions): Promise<WriteResult>;
430
+ /**
431
+ * `GET …?view=blocks` on any fs path with a stored markdown body — the
432
+ * addressable view a `?block=` write aims into.
433
+ *
434
+ * The path is the caller's fs path (`/projects/acme/docs/x.md`), not a
435
+ * `/v1/fs` URL: the CLI does not build routes.
436
+ */
437
+ readBlocks(fsPath: string): Promise<BlocksView>;
438
+ /**
439
+ * `PUT <fs path>` — the generic write door, so the CLI can put to any path
440
+ * (and to a single block of it) without a per-entity method.
441
+ *
442
+ * A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
443
+ * server's recovery payload; read it with {@link blockConflictFrom}.
444
+ */
445
+ writePath(fsPath: string, markdown: string, options?: WriteOptions): Promise<WriteResult & Partial<CardWriteResult>>;
172
446
  /** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
173
447
  deletePost(projectSlug: string, kind: PostKind, filename: string): Promise<void>;
174
448
  /** `GET …/docs` — the project's notes, most-recently-edited first. */
@@ -236,6 +510,72 @@ export declare class SforaApiClient {
236
510
  content: string;
237
511
  reacted: boolean;
238
512
  }>;
513
+ /**
514
+ * `POST <path>/_presence` — say you are in a document.
515
+ *
516
+ * Returns `null` when the path has no roster. The CLI does not decide which
517
+ * paths those are: it asks, and a `422` is the server's named refusal
518
+ * ("posts and board cards have markdown bodies but nobody has one open in a
519
+ * document editor"). Every other failure is thrown as usual.
520
+ */
521
+ declarePresence(fsPath: string, options?: {
522
+ kind?: "viewing" | "editing";
523
+ block?: string;
524
+ leave?: boolean;
525
+ }): Promise<DocPresence | null>;
526
+ /**
527
+ * `GET /v1/presence` — where everybody is right now.
528
+ *
529
+ * The reverse of {@link declarePresence}, and a pure read: asking never
530
+ * enrols the asker in anything. `member` takes an id, a name, or `self`.
531
+ *
532
+ * Documents the key cannot open are absent from the answer — the server
533
+ * filters, the CLI prints. That is why this returns everything it is given
534
+ * rather than filtering again here.
535
+ */
536
+ listPresence(member?: string): Promise<PresenceView>;
537
+ /**
538
+ * `GET /v1/events` — the long-poll. Blocks server-side until something
539
+ * happens or the wait budget elapses, then answers with a cursor to poll
540
+ * from next.
541
+ *
542
+ * `signal` is not optional in spirit: this is the ONE request in the client
543
+ * that is designed to hang, so a caller that cannot cancel it cannot stop.
544
+ * `sfora watch` aborts it from its ^C handler, which is the difference
545
+ * between a terminal that says "^C to stop" and one that does.
546
+ */
547
+ pollEvents(params: {
548
+ since: number;
549
+ wait?: number;
550
+ doc?: string;
551
+ project?: string;
552
+ includeSelf?: boolean;
553
+ signal?: AbortSignal;
554
+ }): Promise<AgentEventsPage>;
555
+ /**
556
+ * The entity id behind an fs path — what `/v1/events?doc=` wants.
557
+ *
558
+ * Read off `?view=blocks`, which states the document's identity in its
559
+ * `document` field, rather than parsed out of the file's frontmatter: the
560
+ * projection is the server saying which row these bytes are.
561
+ */
562
+ resolveDocId(fsPath: string): Promise<string | null>;
563
+ /**
564
+ * `GET /api/rooms` — the caller's rooms. `all` adds open-but-unjoined rooms
565
+ * (`joined: false`), so the room can be found before it is joined.
566
+ */
567
+ listRooms(all?: boolean): Promise<Room[]>;
568
+ /** `POST /api/rooms/:id/join` — self-join an open room. Idempotent. */
569
+ joinRoom(roomId: string): Promise<{
570
+ joined: boolean;
571
+ already: boolean;
572
+ }>;
573
+ /** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
574
+ listRoomMessages(roomId: string, limit?: number): Promise<MessagesPage>;
575
+ /** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
576
+ sendRoomMessage(roomId: string, body: string): Promise<{
577
+ messageId: string;
578
+ }>;
239
579
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
240
580
  readInbox(): Promise<string>;
241
581
  /** `GET /v1/fs/me/api-key` — text identity (no key material). */