sfora-cli 0.9.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 (108) hide show
  1. package/README.md +147 -6
  2. package/dist/SforaFs.js +278 -10
  3. package/dist/api-client.d.ts +290 -5
  4. package/dist/api-client.js +307 -22
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +323 -29
  8. package/dist/format/__tests__/byteStable.d.ts +5 -0
  9. package/dist/format/__tests__/byteStable.js +64 -0
  10. package/dist/format/blockSplice.d.ts +135 -0
  11. package/dist/format/blockSplice.js +330 -0
  12. package/dist/format/blocks/dropClosure.d.ts +81 -0
  13. package/dist/format/blocks/dropClosure.js +196 -0
  14. package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
  15. package/dist/format/blocks/markdown-block-catalog.js +162 -0
  16. package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
  17. package/dist/format/blocks/markdown-block-ids.mjs +25 -0
  18. package/dist/format/blocks/parsers.d.ts +105 -0
  19. package/dist/format/blocks/parsers.js +442 -0
  20. package/dist/format/blocks/structured-block-schema.d.ts +8 -0
  21. package/dist/format/blocks/structured-block-schema.js +30 -0
  22. package/dist/format/callout.d.ts +128 -0
  23. package/dist/format/callout.js +227 -0
  24. package/dist/format/cardMarkdown.d.ts +2 -0
  25. package/dist/format/cardMarkdown.js +10 -0
  26. package/dist/format/checklist.d.ts +34 -0
  27. package/dist/format/checklist.js +158 -0
  28. package/dist/format/formatAxes.d.ts +228 -0
  29. package/dist/format/formatAxes.js +454 -0
  30. package/dist/format/index.d.ts +19 -4
  31. package/dist/format/index.js +28 -4
  32. package/dist/format/lineGeometry.d.ts +100 -0
  33. package/dist/format/lineGeometry.js +424 -0
  34. package/dist/format/lint/appliesTo.d.ts +92 -0
  35. package/dist/format/lint/appliesTo.js +369 -0
  36. package/dist/format/lint/config.d.ts +106 -0
  37. package/dist/format/lint/config.js +205 -0
  38. package/dist/format/lint/fixAll.d.ts +62 -0
  39. package/dist/format/lint/fixAll.js +107 -0
  40. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  41. package/dist/format/lint/frontmatterSchema.js +660 -0
  42. package/dist/format/lint/index.d.ts +49 -0
  43. package/dist/format/lint/index.js +51 -0
  44. package/dist/format/lint/lintSource.d.ts +56 -0
  45. package/dist/format/lint/lintSource.js +188 -0
  46. package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
  47. package/dist/format/lint/rules/broken-wiki-link.js +45 -0
  48. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  49. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  50. package/dist/format/lint/rules/index.d.ts +11 -0
  51. package/dist/format/lint/rules/index.js +32 -0
  52. package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
  53. package/dist/format/lint/rules/malformed-callout.js +88 -0
  54. package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
  55. package/dist/format/lint/rules/malformed-checklist.js +65 -0
  56. package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
  57. package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
  58. package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
  59. package/dist/format/lint/rules/malformed-structured-block.js +134 -0
  60. package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
  61. package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
  62. package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
  63. package/dist/format/lint/rules/orphan-reference.js +87 -0
  64. package/dist/format/lint/severity.d.ts +15 -0
  65. package/dist/format/lint/severity.js +50 -0
  66. package/dist/format/lint/textEdits.d.ts +86 -0
  67. package/dist/format/lint/textEdits.js +162 -0
  68. package/dist/format/lint/types.d.ts +116 -0
  69. package/dist/format/lint/types.js +16 -0
  70. package/dist/format/markdown/dates.js +2 -0
  71. package/dist/format/markdown/document.js +2 -0
  72. package/dist/format/markdown/index.js +2 -0
  73. package/dist/format/markdown/mentions.js +2 -0
  74. package/dist/format/markdown/slug.d.ts +28 -0
  75. package/dist/format/markdown/slug.js +65 -0
  76. package/dist/format/markdown/yaml.js +2 -0
  77. package/dist/format/noteMarkdown.js +2 -0
  78. package/dist/format/parseWithFallback.d.ts +13 -0
  79. package/dist/format/parseWithFallback.js +98 -0
  80. package/dist/format/plaintext.d.ts +5 -0
  81. package/dist/format/plaintext.js +51 -0
  82. package/dist/format/postMarkdown.js +3 -1
  83. package/dist/format/sheetCellSpans.d.ts +95 -0
  84. package/dist/format/sheetCellSpans.js +223 -0
  85. package/dist/format/sheetSelection.d.ts +136 -0
  86. package/dist/format/sheetSelection.js +282 -0
  87. package/dist/format/taskUploadFilename.d.ts +6 -0
  88. package/dist/format/taskUploadFilename.js +13 -0
  89. package/dist/format/textStats.d.ts +23 -0
  90. package/dist/format/textStats.js +80 -0
  91. package/dist/format/wayfinder.d.ts +50 -0
  92. package/dist/format/wayfinder.js +203 -0
  93. package/dist/format/wikiLinks.d.ts +78 -0
  94. package/dist/format/wikiLinks.js +266 -0
  95. package/dist/index.d.ts +26 -1
  96. package/dist/index.js +20 -3
  97. package/dist/mcp-server.js +5 -2
  98. package/dist/opener.d.ts +23 -0
  99. package/dist/opener.js +26 -0
  100. package/dist/render.d.ts +132 -0
  101. package/dist/render.js +208 -0
  102. package/dist/shell-commands.d.ts +34 -0
  103. package/dist/shell-commands.js +108 -0
  104. package/dist/watch.d.ts +79 -0
  105. package/dist/watch.js +113 -0
  106. package/dist/web-url.d.ts +39 -0
  107. package/dist/web-url.js +63 -0
  108. package/package.json +7 -6
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
@@ -21,7 +22,9 @@ shell interpreter backs the interactive mode).
21
22
  ├── projects/<slug>/posts/<YYYY-MM-DD-title>.md # published posts (GET·PUT·DELETE)
22
23
  │ /drafts/<…>.md # your drafts (GET·PUT·DELETE)
23
24
  │ /board/<NN-col>/<NNNN-card>.md # tasks by column (GET·PUT·DELETE)
24
- │ /docs/<…>.md # docs / notes (GET·PUT·DELETE)
25
+ │ /library/documents/<…>.md # docs / notes (GET·PUT·DELETE)
26
+ │ /files/<…> # uploaded files (GET)
27
+ │ /repositories/<repo>/… # source trees (GET)
25
28
  ├── inbox/mentions.md # unread mentions (GET)
26
29
  └── me/api-key # your identity (GET)
27
30
  ```
@@ -154,11 +157,11 @@ sfora:/$ cd /projects/general/posts # cwd persists across commands
154
157
  sfora:/projects/general/posts$ exit
155
158
  ```
156
159
 
157
- Writing a file `PUT`s it (creating a post or, within the 5-minute edit window,
158
- updating one you authored). A bare `@Display Name` that matches an active member
159
- is rehydrated to a real mention server-side. `rm` soft-deletes (author or org
160
- admin/owner). Drafts live under `…/drafts/`; a `scheduledFor:` in the frontmatter
161
- schedules auto-publish.
160
+ Writing a post file publishes a new immutable record. Edit mutable work under
161
+ `…/drafts/` and publish only when it is ready; overwriting an existing published
162
+ filename is rejected. A bare `@Display Name` that matches an active member is
163
+ rehydrated to a real mention server-side. `rm` soft-deletes (author or org
164
+ admin/owner). A `scheduledFor:` in draft frontmatter schedules auto-publish.
162
165
 
163
166
  ## MCP server (Claude Desktop / Cursor)
164
167
 
@@ -209,6 +212,144 @@ backend) are the public exports, alongside the lower-level `SforaApiClient`.
209
212
 
210
213
  ## Supported operations
211
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
+ ## Links — `sfora url` · `sfora open`
332
+
333
+ Every workspace thing has a page. The server says where; the CLI prints it.
334
+
335
+ ```bash
336
+ sfora url /projects/acme/library/documents/kickoff.md
337
+ # https://www.sfora.ai/org/acme/notes/k17e8c0...
338
+
339
+ sfora open /projects/acme/board/02-todo/0042-fix-login.md # …and opens it
340
+ sfora url /projects/acme/board --json # { "path": …, "url": … }
341
+ ```
342
+
343
+ The CLI holds **no route table and no hostname**. It asks for the path you gave
344
+ and reads the link off the answer — a markdown read carries it in the
345
+ `X-Sfora-Url` header, a JSON one in a `url` field — so links stay right when the
346
+ app's routes move, and a dev deployment hands out dev links.
347
+
348
+ `ls`, `cat` and the write verbs print the same link as one dim line **on
349
+ stderr**, so `sfora cat x.md > x.md` and `sfora ls | wc -l` stay byte-clean.
350
+ Paths with no page of their own (uploaded files, repository trees, your inbox)
351
+ print nothing, and `sfora url` on one says so.
352
+
212
353
  | op | behaviour |
213
354
  |----|-----------|
214
355
  | `ls`, `readdir` | directory listings via `/v1/fs/projects/…` (cached ~5s) |
package/dist/SforaFs.js CHANGED
@@ -79,6 +79,15 @@ function classify(path) {
79
79
  if (dir === "links.md" && seg.length === 3) {
80
80
  return { kind: "linksFile", slug };
81
81
  }
82
+ if (dir === "plan.md" && seg.length === 3) {
83
+ return { kind: "planFile", slug };
84
+ }
85
+ if (dir === "map.md" && seg.length === 3) {
86
+ return { kind: "mapFile", slug };
87
+ }
88
+ if (dir === "asks.md" && seg.length === 3) {
89
+ return { kind: "asksFile", slug };
90
+ }
82
91
  if (dir === "posts" || dir === "drafts") {
83
92
  if (seg.length === 3)
84
93
  return { kind: "postsDir", slug, dir };
@@ -92,6 +101,38 @@ function classify(path) {
92
101
  if (seg.length === 4)
93
102
  return { kind: "docFile", slug, filename: seg[3] };
94
103
  }
104
+ if (dir === "artifacts") {
105
+ if (seg.length === 3)
106
+ return { kind: "filesDir", slug };
107
+ if (seg.length === 4)
108
+ return { kind: "artifactFile", slug, filename: seg[3] };
109
+ }
110
+ if (dir === "library") {
111
+ if (seg.length === 3)
112
+ return { kind: "libraryDir", slug };
113
+ if (seg[3] === "documents") {
114
+ if (seg.length === 4)
115
+ return { kind: "docsDir", slug };
116
+ if (seg.length === 5)
117
+ return { kind: "docFile", slug, filename: seg[4] };
118
+ }
119
+ if (seg[3] === "files") {
120
+ if (seg.length === 4)
121
+ return { kind: "filesDir", slug };
122
+ if (seg.length === 5)
123
+ return { kind: "artifactFile", slug, filename: seg[4] };
124
+ }
125
+ if (seg[3] === "repositories") {
126
+ if (seg.length === 4)
127
+ return { kind: "repositoriesDir", slug };
128
+ return {
129
+ kind: "repositoryPath",
130
+ slug,
131
+ repository: seg[4],
132
+ path: seg.slice(5).join("/"),
133
+ };
134
+ }
135
+ }
95
136
  if (dir === "pulls") {
96
137
  if (seg.length === 3)
97
138
  return { kind: "pullsDir", slug };
@@ -212,9 +253,45 @@ export class SforaFs {
212
253
  return this.#cached(key, async () => {
213
254
  const notes = await this.#client.listNotes(slug);
214
255
  this.#entrySnapshots.set(`/projects/${slug}/docs`, notes.map((n) => n.filename));
256
+ this.#entrySnapshots.set(`/projects/${slug}/library/documents`, notes.map((n) => n.filename));
215
257
  return notes;
216
258
  });
217
259
  }
260
+ #invalidateNotes(slug) {
261
+ this.#cache.delete(`docs:${slug}`);
262
+ }
263
+ #listArtifacts(slug) {
264
+ return this.#cached(`artifacts:${slug}`, async () => {
265
+ const artifacts = await this.#client.listArtifacts(slug);
266
+ const names = artifacts.map((artifact) => artifact.name);
267
+ this.#entrySnapshots.set(`/projects/${slug}/artifacts`, names);
268
+ this.#entrySnapshots.set(`/projects/${slug}/library/files`, names);
269
+ return artifacts;
270
+ });
271
+ }
272
+ #listRepositories(slug) {
273
+ return this.#cached(`repositories:${slug}`, async () => {
274
+ const repositories = await this.#client.listRepositories(slug);
275
+ this.#entrySnapshots.set(`/projects/${slug}/library/repositories`, repositories.map((repository) => repository.dirname));
276
+ return repositories;
277
+ });
278
+ }
279
+ #repositoryTree(slug, repository) {
280
+ return this.#cached(`repository-tree:${slug}/${repository}`, async () => {
281
+ const tree = await this.#client.getRepositoryTree(slug, repository);
282
+ this.#entrySnapshots.set(`/projects/${slug}/library/repositories/${repository}`, tree.entries.map((entry) => entry.path));
283
+ return tree;
284
+ });
285
+ }
286
+ async #repositoryEntry(slug, repository, path) {
287
+ if (!path) {
288
+ const repositories = await this.#listRepositories(slug);
289
+ return repositories.some((candidate) => candidate.dirname === repository)
290
+ ? { kind: "directory" }
291
+ : undefined;
292
+ }
293
+ return (await this.#repositoryTree(slug, repository)).entries.find((entry) => entry.path === path);
294
+ }
218
295
  // ─── pull requests cache (read-only) ─────────────────────────────
219
296
  #listPulls(slug) {
220
297
  const key = `pulls:${slug}`;
@@ -336,12 +413,14 @@ export class SforaFs {
336
413
  const columns = meta.columns
337
414
  .slice()
338
415
  .sort((a, b) => a.position - b.position);
339
- const out = [
340
- `# ${project.name} roadmap`,
341
- "",
342
- `> https://www.sfora.ai/roadmap/${meta.publicSlug}`,
343
- "",
344
- ];
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}`, "");
345
424
  for (const col of columns) {
346
425
  out.push(`## ${col.name}`, "");
347
426
  const cards = await this.#listCards(slug, col.dirname);
@@ -381,6 +460,31 @@ export class SforaFs {
381
460
  catch (e) {
382
461
  throw fromApi(e, "open", normalize(path));
383
462
  }
463
+ case "artifactFile":
464
+ try {
465
+ return await this.#client.readArtifact(loc.slug, loc.filename);
466
+ }
467
+ catch (e) {
468
+ throw fromApi(e, "open", normalize(path));
469
+ }
470
+ case "repositoryPath": {
471
+ if (!loc.path)
472
+ throw eisdir("read", normalize(path));
473
+ const entry = await this.#repositoryEntry(loc.slug, loc.repository, loc.path);
474
+ if (!entry)
475
+ throw enoent("open", normalize(path));
476
+ if (entry.kind === "directory")
477
+ throw eisdir("read", normalize(path));
478
+ if (entry.kind === "submodule") {
479
+ return `Submodule ${loc.path}\nRepository: ${loc.repository}\n`;
480
+ }
481
+ try {
482
+ return await this.#client.readRepositoryFile(loc.slug, loc.repository, loc.path);
483
+ }
484
+ catch (e) {
485
+ throw fromApi(e, "open", normalize(path));
486
+ }
487
+ }
384
488
  case "pullFile":
385
489
  try {
386
490
  return await this.#client.readPull(loc.slug, loc.number);
@@ -416,6 +520,27 @@ export class SforaFs {
416
520
  catch (e) {
417
521
  throw fromApi(e, "open", normalize(path));
418
522
  }
523
+ case "planFile":
524
+ try {
525
+ return await this.#client.readPlan(loc.slug);
526
+ }
527
+ catch (e) {
528
+ throw fromApi(e, "open", normalize(path));
529
+ }
530
+ case "mapFile":
531
+ try {
532
+ return await this.#client.readMap(loc.slug);
533
+ }
534
+ catch (e) {
535
+ throw fromApi(e, "open", normalize(path));
536
+ }
537
+ case "asksFile":
538
+ try {
539
+ return await this.#client.readAsks(loc.slug);
540
+ }
541
+ catch (e) {
542
+ throw fromApi(e, "open", normalize(path));
543
+ }
419
544
  case "root":
420
545
  case "projectsDir":
421
546
  case "inboxDir":
@@ -423,6 +548,9 @@ export class SforaFs {
423
548
  case "projectDir":
424
549
  case "postsDir":
425
550
  case "docsDir":
551
+ case "libraryDir":
552
+ case "filesDir":
553
+ case "repositoriesDir":
426
554
  case "pullsDir":
427
555
  case "boardDir":
428
556
  case "boardColumnDir":
@@ -449,6 +577,10 @@ export class SforaFs {
449
577
  if (loc.kind === "inboxFile" || loc.kind === "meFile") {
450
578
  throw eacces("open", norm);
451
579
  }
580
+ // asks.md is a read-only projection — claims go through the asks API.
581
+ if (loc.kind === "asksFile") {
582
+ throw eacces("open", norm);
583
+ }
452
584
  if (loc.kind === "linksFile") {
453
585
  try {
454
586
  await this.#client.setProjectLinks(loc.slug, toText(content));
@@ -458,6 +590,17 @@ export class SforaFs {
458
590
  }
459
591
  return;
460
592
  }
593
+ // plan.md: the PUT edits `## the goal` only; the server ignores (and
594
+ // names) every generated section, so a read-modify-write is always safe.
595
+ if (loc.kind === "planFile") {
596
+ try {
597
+ await this.#client.writePlan(loc.slug, toText(content));
598
+ }
599
+ catch (e) {
600
+ throw fromApi(e, "open", norm);
601
+ }
602
+ return;
603
+ }
461
604
  if (loc.kind === "root" ||
462
605
  loc.kind === "projectsDir" ||
463
606
  loc.kind === "inboxDir" ||
@@ -465,12 +608,21 @@ export class SforaFs {
465
608
  loc.kind === "projectDir" ||
466
609
  loc.kind === "postsDir" ||
467
610
  loc.kind === "docsDir" ||
611
+ loc.kind === "libraryDir" ||
612
+ loc.kind === "filesDir" ||
613
+ loc.kind === "repositoriesDir" ||
468
614
  loc.kind === "pullsDir" ||
469
615
  loc.kind === "boardDir" ||
470
616
  loc.kind === "boardColumnDir" ||
471
617
  loc.kind === "publicDir") {
472
618
  throw eisdir("open", norm);
473
619
  }
620
+ // map.md is derived from the board and refuses writes server-side (405);
621
+ // fail locally with the same message so an agent learns the write doors
622
+ // without a round trip.
623
+ if (loc.kind === "mapFile") {
624
+ throw eacces("open", norm);
625
+ }
474
626
  if (loc.kind === "postFile") {
475
627
  try {
476
628
  await this.#client.writePost(loc.slug, loc.dir, loc.filename, toText(content));
@@ -481,6 +633,16 @@ export class SforaFs {
481
633
  this.#invalidate(loc.slug, loc.dir);
482
634
  return;
483
635
  }
636
+ if (loc.kind === "docFile") {
637
+ try {
638
+ await this.#client.writeNote(loc.slug, loc.filename, toText(content));
639
+ }
640
+ catch (e) {
641
+ throw fromApi(e, "open", norm);
642
+ }
643
+ this.#invalidateNotes(loc.slug);
644
+ return;
645
+ }
484
646
  if (loc.kind === "boardCardFile") {
485
647
  try {
486
648
  const res = await this.#client.writeCard(loc.slug, loc.column, loc.filename, toText(content));
@@ -520,8 +682,14 @@ export class SforaFs {
520
682
  case "projectDir":
521
683
  case "postsDir":
522
684
  case "docsDir":
685
+ case "libraryDir":
686
+ case "filesDir":
687
+ case "repositoriesDir":
523
688
  case "pullsDir":
524
689
  case "boardDir":
690
+ case "planFile":
691
+ case "mapFile":
692
+ case "asksFile":
525
693
  return await this.#projectExists(loc.slug);
526
694
  case "publicDir":
527
695
  case "roadmapFile":
@@ -536,6 +704,10 @@ export class SforaFs {
536
704
  undefined;
537
705
  case "docFile":
538
706
  return (await this.#matchNote(loc.slug, loc.filename)) !== undefined;
707
+ case "artifactFile":
708
+ return (await this.#listArtifacts(loc.slug)).some((artifact) => artifact.name === loc.filename);
709
+ case "repositoryPath":
710
+ return (await this.#repositoryEntry(loc.slug, loc.repository, loc.path)) !== undefined;
539
711
  case "pullFile":
540
712
  return (await this.#matchPull(loc.slug, loc.number)) !== undefined;
541
713
  case "boardCardFile":
@@ -583,12 +755,18 @@ export class SforaFs {
583
755
  case "meFile":
584
756
  return this.#fileStat(new Date());
585
757
  case "linksFile":
758
+ case "planFile":
759
+ case "mapFile":
760
+ case "asksFile":
586
761
  if (!(await this.#projectExists(loc.slug)))
587
762
  throw enoent("stat", norm);
588
763
  return this.#fileStat(new Date());
589
764
  case "projectDir":
590
765
  case "postsDir":
591
766
  case "docsDir":
767
+ case "libraryDir":
768
+ case "filesDir":
769
+ case "repositoriesDir":
592
770
  case "pullsDir":
593
771
  case "boardDir":
594
772
  if (!(await this.#projectExists(loc.slug)))
@@ -625,6 +803,20 @@ export class SforaFs {
625
803
  throw enoent("stat", norm);
626
804
  return this.#fileStat(new Date(note.lastEditedAt || Date.now()));
627
805
  }
806
+ case "artifactFile": {
807
+ const artifact = (await this.#listArtifacts(loc.slug)).find((candidate) => candidate.name === loc.filename);
808
+ if (!artifact)
809
+ throw enoent("stat", norm);
810
+ return this.#fileStat(new Date(artifact.uploadedAt || Date.now()), artifact.size);
811
+ }
812
+ case "repositoryPath": {
813
+ const entry = await this.#repositoryEntry(loc.slug, loc.repository, loc.path);
814
+ if (!entry)
815
+ throw enoent("stat", norm);
816
+ return entry.kind === "directory"
817
+ ? this.#dirStat()
818
+ : this.#fileStat(new Date(), "size" in entry ? entry.size ?? 0 : 0);
819
+ }
628
820
  case "pullFile": {
629
821
  const pull = await this.#matchPull(loc.slug, loc.number);
630
822
  if (!pull)
@@ -683,10 +875,13 @@ export class SforaFs {
683
875
  throw enoent("scandir", norm);
684
876
  }
685
877
  const entries = [
878
+ dirent("asks.md", false),
686
879
  dirent("board", true),
687
- dirent("docs", true),
688
880
  dirent("drafts", true),
881
+ dirent("library", true),
689
882
  dirent("links.md", false),
883
+ dirent("plan.md", false),
884
+ dirent("map.md", false),
690
885
  dirent("posts", true),
691
886
  dirent("pulls", true),
692
887
  ];
@@ -696,6 +891,14 @@ export class SforaFs {
696
891
  }
697
892
  return entries;
698
893
  }
894
+ case "libraryDir":
895
+ if (!(await this.#projectExists(loc.slug)))
896
+ throw enoent("scandir", norm);
897
+ return [
898
+ dirent("documents", true),
899
+ dirent("files", true),
900
+ dirent("repositories", true),
901
+ ];
699
902
  case "postsDir": {
700
903
  const entries = await this.#listEntries(loc.slug, loc.dir);
701
904
  return entries.map((e) => dirent(e.filename, false));
@@ -707,6 +910,35 @@ export class SforaFs {
707
910
  const notes = await this.#listNotes(loc.slug);
708
911
  return notes.map((n) => dirent(n.filename, false));
709
912
  }
913
+ case "filesDir": {
914
+ if (!(await this.#projectExists(loc.slug)))
915
+ throw enoent("scandir", norm);
916
+ return (await this.#listArtifacts(loc.slug)).map((artifact) => dirent(artifact.name, false));
917
+ }
918
+ case "repositoriesDir": {
919
+ if (!(await this.#projectExists(loc.slug)))
920
+ throw enoent("scandir", norm);
921
+ return (await this.#listRepositories(loc.slug)).map((repository) => dirent(repository.dirname, true));
922
+ }
923
+ case "repositoryPath": {
924
+ const current = await this.#repositoryEntry(loc.slug, loc.repository, loc.path);
925
+ if (!current)
926
+ throw enoent("scandir", norm);
927
+ if (current.kind !== "directory")
928
+ throw enotdir("scandir", norm);
929
+ const prefix = loc.path ? `${loc.path}/` : "";
930
+ const children = new Map();
931
+ for (const entry of (await this.#repositoryTree(loc.slug, loc.repository)).entries) {
932
+ if (!entry.path.startsWith(prefix) || entry.path === loc.path)
933
+ continue;
934
+ const rest = entry.path.slice(prefix.length);
935
+ const [name, ...tail] = rest.split("/");
936
+ if (!name)
937
+ continue;
938
+ children.set(name, tail.length > 0 || entry.kind === "directory");
939
+ }
940
+ return [...children].map(([name, isDir]) => dirent(name, isDir));
941
+ }
710
942
  case "pullsDir": {
711
943
  if (!(await this.#projectExists(loc.slug))) {
712
944
  throw enoent("scandir", norm);
@@ -743,9 +975,12 @@ export class SforaFs {
743
975
  case "meFile":
744
976
  case "postFile":
745
977
  case "docFile":
978
+ case "artifactFile":
746
979
  case "pullFile":
747
980
  case "boardCardFile":
748
981
  case "roadmapFile":
982
+ case "planFile":
983
+ case "asksFile":
749
984
  throw enotdir("scandir", norm);
750
985
  default:
751
986
  throw enoent("scandir", norm);
@@ -772,6 +1007,9 @@ export class SforaFs {
772
1007
  case "projectDir":
773
1008
  case "postsDir":
774
1009
  case "docsDir":
1010
+ case "libraryDir":
1011
+ case "filesDir":
1012
+ case "repositoriesDir":
775
1013
  case "pullsDir":
776
1014
  case "boardDir":
777
1015
  case "publicDir":
@@ -858,12 +1096,32 @@ export class SforaFs {
858
1096
  }
859
1097
  case "inboxFile":
860
1098
  case "meFile":
861
- case "docFile":
1099
+ case "artifactFile":
1100
+ case "repositoryPath":
862
1101
  case "pullFile":
863
1102
  case "roadmapFile":
864
- // Read-only surfaces (notes aren't deletable through the fs yet; the
865
- // roadmap is a projection; pulls are owned by GitHub).
1103
+ case "planFile":
1104
+ case "asksFile":
1105
+ // Uploaded files and repositories are read-only projections; the
1106
+ // roadmap and pulls are owned by their source systems; the plan and
1107
+ // asks are generated views that can't be unlinked.
866
1108
  throw eacces("unlink", norm);
1109
+ case "docFile": {
1110
+ const note = await this.#matchNote(loc.slug, loc.filename);
1111
+ if (!note) {
1112
+ if (options?.force)
1113
+ return;
1114
+ throw enoent("unlink", norm);
1115
+ }
1116
+ try {
1117
+ await this.#client.deleteNote(loc.slug, loc.filename);
1118
+ }
1119
+ catch (e) {
1120
+ throw fromApi(e, "unlink", norm);
1121
+ }
1122
+ this.#invalidateNotes(loc.slug);
1123
+ return;
1124
+ }
867
1125
  case "root":
868
1126
  case "projectsDir":
869
1127
  case "inboxDir":
@@ -871,6 +1129,9 @@ export class SforaFs {
871
1129
  case "projectDir":
872
1130
  case "postsDir":
873
1131
  case "docsDir":
1132
+ case "libraryDir":
1133
+ case "filesDir":
1134
+ case "repositoriesDir":
874
1135
  case "pullsDir":
875
1136
  case "boardDir":
876
1137
  case "publicDir":
@@ -982,9 +1243,16 @@ export class SforaFs {
982
1243
  ]);
983
1244
  for (const slug of this.#projectSlugs) {
984
1245
  paths.add(`/projects/${slug}`);
1246
+ paths.add(`/projects/${slug}/plan.md`);
1247
+ paths.add(`/projects/${slug}/asks.md`);
985
1248
  paths.add(`/projects/${slug}/posts`);
986
1249
  paths.add(`/projects/${slug}/drafts`);
987
1250
  paths.add(`/projects/${slug}/docs`);
1251
+ paths.add(`/projects/${slug}/artifacts`);
1252
+ paths.add(`/projects/${slug}/library`);
1253
+ paths.add(`/projects/${slug}/library/documents`);
1254
+ paths.add(`/projects/${slug}/library/files`);
1255
+ paths.add(`/projects/${slug}/library/repositories`);
988
1256
  paths.add(`/projects/${slug}/pulls`);
989
1257
  paths.add(`/projects/${slug}/board`);
990
1258
  // `public/` is only real for boards known to be shared.