sfora-cli 0.7.0 → 0.8.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.
package/README.md CHANGED
@@ -39,6 +39,32 @@ sfora # interactive shell — explore /projects, cat, grep
39
39
  One CLI for everyone: the default identity is **you**; add `--bot <name>` to any
40
40
  command to run as that bot.
41
41
 
42
+ ## Local mode — no account, no server
43
+
44
+ `sfora init --local` turns the current repo into a workspace: a `.sfora/`
45
+ directory where tasks, posts, and docs are plain markdown files — versioned
46
+ with your code, reviewable in PRs, greppable, offline.
47
+
48
+ ```bash
49
+ sfora init --local # scaffold .sfora/ (board columns, posts/, docs/)
50
+ sfora task plan.md # → .sfora/board/01-todo/0001-<slug>.md
51
+ sfora tasks # board by column (add --json for scripting)
52
+ sfora # shell over the files — and `mv` between column
53
+ # dirs IS a card move
54
+ sfora --mcp # MCP server over the local workspace
55
+ ```
56
+
57
+ ```
58
+ .sfora/
59
+ ├── board/01-todo/0001-fix-login.md # tasks — NNNN-<slug>.md per column
60
+ ├── posts/2026-07-02-standup.md # posts — YYYY-MM-DD-<slug>.md
61
+ └── docs/architecture.md # docs
62
+ ```
63
+
64
+ It's the exact same format the cloud serves, so connecting a team later is just
65
+ `sfora login` — inside a `.sfora/` repo the CLI targets the local files;
66
+ add `--cloud` (or `--org`) to reach your cloud workspace from there.
67
+
42
68
  `sfora init` writes `~/.sfora/config.json` (chmod 600). Resolution precedence is
43
69
  **flags > env (`SFORA_API_KEY` / `SFORA_URL` / `SFORA_ORG`) > config > default**,
44
70
  so a saved config means no env vars on every run.
package/dist/SforaFs.d.ts CHANGED
@@ -5,6 +5,9 @@
5
5
  * /
6
6
  * ├── projects/<slug>/posts/<YYYY-MM-DD-title>.md (lazy → GET …/posts/…)
7
7
  * │ /drafts/<…>.md (lazy → GET …/drafts/…)
8
+ * │ /docs/<slug>.md (lazy → GET …/docs/…)
9
+ * │ /pulls/<number>.md (lazy → GET …/pulls/…, read-only)
10
+ * │ /public/roadmap.md (rendered from …/board)
8
11
  * ├── inbox/mentions.md (lazy → GET …/inbox)
9
12
  * └── me/api-key (lazy → GET …/me/api-key)
10
13
  *
package/dist/SforaFs.js CHANGED
@@ -5,6 +5,9 @@
5
5
  * /
6
6
  * ├── projects/<slug>/posts/<YYYY-MM-DD-title>.md (lazy → GET …/posts/…)
7
7
  * │ /drafts/<…>.md (lazy → GET …/drafts/…)
8
+ * │ /docs/<slug>.md (lazy → GET …/docs/…)
9
+ * │ /pulls/<number>.md (lazy → GET …/pulls/…, read-only)
10
+ * │ /public/roadmap.md (rendered from …/board)
8
11
  * ├── inbox/mentions.md (lazy → GET …/inbox)
9
12
  * └── me/api-key (lazy → GET …/me/api-key)
10
13
  *
@@ -83,6 +86,28 @@ function classify(path) {
83
86
  return { kind: "postFile", slug, dir, filename: seg[3] };
84
87
  }
85
88
  }
89
+ if (dir === "docs") {
90
+ if (seg.length === 3)
91
+ return { kind: "docsDir", slug };
92
+ if (seg.length === 4)
93
+ return { kind: "docFile", slug, filename: seg[3] };
94
+ }
95
+ if (dir === "pulls") {
96
+ if (seg.length === 3)
97
+ return { kind: "pullsDir", slug };
98
+ if (seg.length === 4) {
99
+ const number = Number.parseInt(seg[3], 10);
100
+ if (Number.isFinite(number))
101
+ return { kind: "pullFile", slug, number };
102
+ }
103
+ }
104
+ if (dir === "public") {
105
+ if (seg.length === 3)
106
+ return { kind: "publicDir", slug };
107
+ if (seg.length === 4 && seg[3] === "roadmap.md") {
108
+ return { kind: "roadmapFile", slug };
109
+ }
110
+ }
86
111
  if (dir === "board") {
87
112
  if (seg.length === 3)
88
113
  return { kind: "boardDir", slug };
@@ -142,6 +167,8 @@ export class SforaFs {
142
167
  // Sync snapshots for getAllPaths() (glob support) — updated as lists resolve.
143
168
  #projectSlugs = [];
144
169
  #entrySnapshots = new Map();
170
+ // Slugs whose board is shared publicly — gates the `public/` projection.
171
+ #sharedSlugs = new Set();
145
172
  constructor(client) {
146
173
  this.#client = client;
147
174
  }
@@ -179,15 +206,50 @@ export class SforaFs {
179
206
  this.#cache.delete(`${dir}:${slug}`);
180
207
  this.#cache.delete("projects"); // postCount changes
181
208
  }
209
+ // ─── notes (docs) cache ──────────────────────────────────────────
210
+ #listNotes(slug) {
211
+ const key = `docs:${slug}`;
212
+ return this.#cached(key, async () => {
213
+ const notes = await this.#client.listNotes(slug);
214
+ this.#entrySnapshots.set(`/projects/${slug}/docs`, notes.map((n) => n.filename));
215
+ return notes;
216
+ });
217
+ }
218
+ // ─── pull requests cache (read-only) ─────────────────────────────
219
+ #listPulls(slug) {
220
+ const key = `pulls:${slug}`;
221
+ return this.#cached(key, async () => {
222
+ const pulls = await this.#client.listPulls(slug);
223
+ this.#entrySnapshots.set(`/projects/${slug}/pulls`, pulls.map((p) => `${p.number}.md`));
224
+ return pulls;
225
+ });
226
+ }
227
+ async #matchPull(slug, number) {
228
+ const pulls = await this.#listPulls(slug);
229
+ return pulls.find((p) => p.number === number);
230
+ }
182
231
  // ─── board caches ────────────────────────────────────────────────
183
- #listColumns(slug) {
232
+ /** Cached `GET …/board`: sharing state (`publicSlug`) + columns, one fetch. */
233
+ #boardMeta(slug) {
184
234
  const key = `columns:${slug}`;
185
235
  return this.#cached(key, async () => {
186
- const columns = await this.#client.listColumns(slug);
187
- this.#entrySnapshots.set(`/projects/${slug}/board`, columns.map((c) => c.dirname));
188
- return columns;
236
+ const meta = await this.#client.getBoardMeta(slug);
237
+ this.#entrySnapshots.set(`/projects/${slug}/board`, meta.columns.map((c) => c.dirname));
238
+ // Keep a sync snapshot of which boards are shared, for getAllPaths().
239
+ if (meta.publicSlug)
240
+ this.#sharedSlugs.add(slug);
241
+ else
242
+ this.#sharedSlugs.delete(slug);
243
+ return meta;
189
244
  });
190
245
  }
246
+ async #listColumns(slug) {
247
+ return (await this.#boardMeta(slug)).columns;
248
+ }
249
+ /** The board's public roadmap slug, or null when it isn't shared. */
250
+ async #publicSlug(slug) {
251
+ return (await this.#boardMeta(slug)).publicSlug;
252
+ }
191
253
  #listCards(slug, column) {
192
254
  const key = `cards:${slug}/${column}`;
193
255
  return this.#cached(key, async () => {
@@ -247,6 +309,53 @@ export class SforaFs {
247
309
  return (entries.find((e) => e.filename === filename) ??
248
310
  entries.find((e) => fileSlug(e.filename) === want));
249
311
  }
312
+ async #matchNote(slug, filename) {
313
+ const notes = await this.#listNotes(slug);
314
+ const want = fileSlug(filename);
315
+ return (notes.find((n) => n.filename === filename) ??
316
+ notes.find((n) => fileSlug(n.filename) === want));
317
+ }
318
+ // ─── public roadmap render ───────────────────────────────────────
319
+ /**
320
+ * Render a shared board as a single markdown roadmap: one `##` section per
321
+ * column (position order), each card a `-` list item, with the live public URL
322
+ * under the heading. Built from the same board fetch the `/board/` tree uses.
323
+ *
324
+ * Only shared boards render — an unshared board (`publicSlug == null`) ENOENTs,
325
+ * matching the gated `public/` projection.
326
+ */
327
+ async #renderRoadmap(slug) {
328
+ const roadmapPath = `/projects/${slug}/public/roadmap.md`;
329
+ const projects = await this.#listProjects();
330
+ const project = projects.find((p) => p.slug === slug);
331
+ if (!project)
332
+ throw enoent("open", roadmapPath);
333
+ const meta = await this.#boardMeta(slug);
334
+ if (meta.publicSlug == null)
335
+ throw enoent("open", roadmapPath);
336
+ const columns = meta.columns
337
+ .slice()
338
+ .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
+ ];
345
+ for (const col of columns) {
346
+ out.push(`## ${col.name}`, "");
347
+ const cards = await this.#listCards(slug, col.dirname);
348
+ if (cards.length === 0) {
349
+ out.push("_No cards yet._", "");
350
+ }
351
+ else {
352
+ for (const card of cards)
353
+ out.push(`- ${card.title}`);
354
+ out.push("");
355
+ }
356
+ }
357
+ return `${out.join("\n").trimEnd()}\n`;
358
+ }
250
359
  // ─── reads ───────────────────────────────────────────────────────
251
360
  async #readText(path) {
252
361
  const loc = classify(path);
@@ -265,6 +374,27 @@ export class SforaFs {
265
374
  catch (e) {
266
375
  throw fromApi(e, "open", normalize(path));
267
376
  }
377
+ case "docFile":
378
+ try {
379
+ return await this.#client.readNote(loc.slug, loc.filename);
380
+ }
381
+ catch (e) {
382
+ throw fromApi(e, "open", normalize(path));
383
+ }
384
+ case "pullFile":
385
+ try {
386
+ return await this.#client.readPull(loc.slug, loc.number);
387
+ }
388
+ catch (e) {
389
+ throw fromApi(e, "open", normalize(path));
390
+ }
391
+ case "roadmapFile":
392
+ try {
393
+ return await this.#renderRoadmap(loc.slug);
394
+ }
395
+ catch (e) {
396
+ throw fromApi(e, "open", normalize(path));
397
+ }
268
398
  case "inboxFile":
269
399
  try {
270
400
  return await this.#client.readInbox();
@@ -292,8 +422,11 @@ export class SforaFs {
292
422
  case "meDir":
293
423
  case "projectDir":
294
424
  case "postsDir":
425
+ case "docsDir":
426
+ case "pullsDir":
295
427
  case "boardDir":
296
428
  case "boardColumnDir":
429
+ case "publicDir":
297
430
  throw eisdir("read", normalize(path));
298
431
  default:
299
432
  throw enoent("open", normalize(path));
@@ -331,8 +464,11 @@ export class SforaFs {
331
464
  loc.kind === "meDir" ||
332
465
  loc.kind === "projectDir" ||
333
466
  loc.kind === "postsDir" ||
467
+ loc.kind === "docsDir" ||
468
+ loc.kind === "pullsDir" ||
334
469
  loc.kind === "boardDir" ||
335
- loc.kind === "boardColumnDir") {
470
+ loc.kind === "boardColumnDir" ||
471
+ loc.kind === "publicDir") {
336
472
  throw eisdir("open", norm);
337
473
  }
338
474
  if (loc.kind === "postFile") {
@@ -383,14 +519,25 @@ export class SforaFs {
383
519
  return true;
384
520
  case "projectDir":
385
521
  case "postsDir":
522
+ case "docsDir":
523
+ case "pullsDir":
386
524
  case "boardDir":
387
525
  return await this.#projectExists(loc.slug);
526
+ case "publicDir":
527
+ case "roadmapFile":
528
+ // Only present when the board is shared publicly.
529
+ return ((await this.#projectExists(loc.slug)) &&
530
+ (await this.#publicSlug(loc.slug)) != null);
388
531
  case "boardColumnDir":
389
532
  return ((await this.#projectExists(loc.slug)) &&
390
533
  (await this.#columnExists(loc.slug, loc.column)));
391
534
  case "postFile":
392
535
  return (await this.#matchEntry(loc.slug, loc.dir, loc.filename)) !==
393
536
  undefined;
537
+ case "docFile":
538
+ return (await this.#matchNote(loc.slug, loc.filename)) !== undefined;
539
+ case "pullFile":
540
+ return (await this.#matchPull(loc.slug, loc.number)) !== undefined;
394
541
  case "boardCardFile":
395
542
  return ((await this.#matchCard(loc.slug, loc.column, loc.filename)) !==
396
543
  undefined);
@@ -441,10 +588,24 @@ export class SforaFs {
441
588
  return this.#fileStat(new Date());
442
589
  case "projectDir":
443
590
  case "postsDir":
591
+ case "docsDir":
592
+ case "pullsDir":
444
593
  case "boardDir":
445
594
  if (!(await this.#projectExists(loc.slug)))
446
595
  throw enoent("stat", norm);
447
596
  return this.#dirStat();
597
+ case "publicDir":
598
+ if (!(await this.#projectExists(loc.slug)))
599
+ throw enoent("stat", norm);
600
+ if ((await this.#publicSlug(loc.slug)) == null)
601
+ throw enoent("stat", norm);
602
+ return this.#dirStat();
603
+ case "roadmapFile":
604
+ if (!(await this.#projectExists(loc.slug)))
605
+ throw enoent("stat", norm);
606
+ if ((await this.#publicSlug(loc.slug)) == null)
607
+ throw enoent("stat", norm);
608
+ return this.#fileStat(new Date());
448
609
  case "boardColumnDir":
449
610
  if (!(await this.#projectExists(loc.slug)))
450
611
  throw enoent("stat", norm);
@@ -458,6 +619,18 @@ export class SforaFs {
458
619
  throw enoent("stat", norm);
459
620
  return this.#fileStat(new Date(entry.publishedAt || Date.now()));
460
621
  }
622
+ case "docFile": {
623
+ const note = await this.#matchNote(loc.slug, loc.filename);
624
+ if (!note)
625
+ throw enoent("stat", norm);
626
+ return this.#fileStat(new Date(note.lastEditedAt || Date.now()));
627
+ }
628
+ case "pullFile": {
629
+ const pull = await this.#matchPull(loc.slug, loc.number);
630
+ if (!pull)
631
+ throw enoent("stat", norm);
632
+ return this.#fileStat(new Date(pull.updatedAt || Date.now()));
633
+ }
461
634
  case "boardCardFile": {
462
635
  const card = await this.#matchCard(loc.slug, loc.column, loc.filename);
463
636
  if (!card)
@@ -505,20 +678,50 @@ export class SforaFs {
505
678
  return [dirent("mentions.md", false)];
506
679
  case "meDir":
507
680
  return [dirent("api-key", false)];
508
- case "projectDir":
681
+ case "projectDir": {
509
682
  if (!(await this.#projectExists(loc.slug))) {
510
683
  throw enoent("scandir", norm);
511
684
  }
512
- return [
685
+ const entries = [
513
686
  dirent("board", true),
687
+ dirent("docs", true),
514
688
  dirent("drafts", true),
515
689
  dirent("links.md", false),
516
690
  dirent("posts", true),
691
+ dirent("pulls", true),
517
692
  ];
693
+ // `public/` only appears once the board is shared publicly.
694
+ if ((await this.#publicSlug(loc.slug)) != null) {
695
+ entries.push(dirent("public", true));
696
+ }
697
+ return entries;
698
+ }
518
699
  case "postsDir": {
519
700
  const entries = await this.#listEntries(loc.slug, loc.dir);
520
701
  return entries.map((e) => dirent(e.filename, false));
521
702
  }
703
+ case "docsDir": {
704
+ if (!(await this.#projectExists(loc.slug))) {
705
+ throw enoent("scandir", norm);
706
+ }
707
+ const notes = await this.#listNotes(loc.slug);
708
+ return notes.map((n) => dirent(n.filename, false));
709
+ }
710
+ case "pullsDir": {
711
+ if (!(await this.#projectExists(loc.slug))) {
712
+ throw enoent("scandir", norm);
713
+ }
714
+ const pulls = await this.#listPulls(loc.slug);
715
+ return pulls.map((p) => dirent(`${p.number}.md`, false));
716
+ }
717
+ case "publicDir":
718
+ if (!(await this.#projectExists(loc.slug))) {
719
+ throw enoent("scandir", norm);
720
+ }
721
+ if ((await this.#publicSlug(loc.slug)) == null) {
722
+ throw enoent("scandir", norm);
723
+ }
724
+ return [dirent("roadmap.md", false)];
522
725
  case "boardDir": {
523
726
  if (!(await this.#projectExists(loc.slug))) {
524
727
  throw enoent("scandir", norm);
@@ -539,7 +742,10 @@ export class SforaFs {
539
742
  case "inboxFile":
540
743
  case "meFile":
541
744
  case "postFile":
745
+ case "docFile":
746
+ case "pullFile":
542
747
  case "boardCardFile":
748
+ case "roadmapFile":
543
749
  throw enotdir("scandir", norm);
544
750
  default:
545
751
  throw enoent("scandir", norm);
@@ -565,7 +771,10 @@ export class SforaFs {
565
771
  return;
566
772
  case "projectDir":
567
773
  case "postsDir":
774
+ case "docsDir":
775
+ case "pullsDir":
568
776
  case "boardDir":
777
+ case "publicDir":
569
778
  if (await this.#projectExists(loc.slug))
570
779
  return;
571
780
  throw eacces("mkdir", norm);
@@ -649,6 +858,11 @@ export class SforaFs {
649
858
  }
650
859
  case "inboxFile":
651
860
  case "meFile":
861
+ case "docFile":
862
+ case "pullFile":
863
+ 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).
652
866
  throw eacces("unlink", norm);
653
867
  case "root":
654
868
  case "projectsDir":
@@ -656,7 +870,10 @@ export class SforaFs {
656
870
  case "meDir":
657
871
  case "projectDir":
658
872
  case "postsDir":
873
+ case "docsDir":
874
+ case "pullsDir":
659
875
  case "boardDir":
876
+ case "publicDir":
660
877
  // Top-level structural dirs can't be removed.
661
878
  if (options?.recursive)
662
879
  throw eperm("rm", norm);
@@ -767,7 +984,14 @@ export class SforaFs {
767
984
  paths.add(`/projects/${slug}`);
768
985
  paths.add(`/projects/${slug}/posts`);
769
986
  paths.add(`/projects/${slug}/drafts`);
987
+ paths.add(`/projects/${slug}/docs`);
988
+ paths.add(`/projects/${slug}/pulls`);
770
989
  paths.add(`/projects/${slug}/board`);
990
+ // `public/` is only real for boards known to be shared.
991
+ if (this.#sharedSlugs.has(slug)) {
992
+ paths.add(`/projects/${slug}/public`);
993
+ paths.add(`/projects/${slug}/public/roadmap.md`);
994
+ }
771
995
  }
772
996
  for (const [dirPath, filenames] of this.#entrySnapshots) {
773
997
  for (const name of filenames)
@@ -29,6 +29,33 @@ export interface WriteResult {
29
29
  filename: string;
30
30
  id: string;
31
31
  }
32
+ /**
33
+ * A note (doc) file entry. Mirrors a row of `GET …/docs`. Notes have stable
34
+ * `<slug>.md` filenames (no date prefix) and no draft concept.
35
+ */
36
+ export interface Note {
37
+ filename: string;
38
+ title: string;
39
+ lastEditedAt: number;
40
+ }
41
+ /**
42
+ * A pull request entry. Mirrors a row of `GET …/pulls`. Read-only — the source
43
+ * of truth is GitHub; sfora syncs these so agents can see the code work
44
+ * alongside posts/tasks/docs.
45
+ */
46
+ export interface Pull {
47
+ number: number;
48
+ title: string;
49
+ state: "open" | "closed" | "merged";
50
+ draft: boolean;
51
+ author: string;
52
+ head: string;
53
+ base: string;
54
+ url: string;
55
+ additions?: number;
56
+ deletions?: number;
57
+ updatedAt: number;
58
+ }
32
59
  /** A column directory under `/projects/<slug>/board/`. `dirname` is `NN-<slug>`. */
33
60
  export interface BoardColumn {
34
61
  dirname: string;
@@ -37,6 +64,14 @@ export interface BoardColumn {
37
64
  position: number;
38
65
  cardCount: number;
39
66
  }
67
+ /**
68
+ * Board sharing state + columns. Mirrors `GET …/board`. `publicSlug` is the
69
+ * board's public roadmap slug, or `null` when the board isn't shared publicly.
70
+ */
71
+ export interface BoardMeta {
72
+ publicSlug: string | null;
73
+ columns: BoardColumn[];
74
+ }
40
75
  /** A card file under `/projects/<slug>/board/<col>/`. `filename` is `NNNN-<slug>.md`. */
41
76
  export interface BoardCardEntry {
42
77
  filename: string;
@@ -58,6 +93,13 @@ export interface SforaApiConfig {
58
93
  baseUrl: string;
59
94
  /** Agent API key (`sk_…`). Sent as `Authorization: Bearer <apiKey>`. */
60
95
  apiKey: string;
96
+ /**
97
+ * Act as another agent you own (or any, if you're an org admin), using this
98
+ * key. Sent as `X-Sfora-Act-As: <name-or-id>`. Lets one authorized CLI
99
+ * attribute work to different agents — run several on one device without a
100
+ * key per agent.
101
+ */
102
+ actAs?: string;
61
103
  }
62
104
  /** Non-2xx response from the fs API. Carries the HTTP status + machine code so SforaFs can map it to an errno. */
63
105
  export declare class SforaApiError extends Error {
@@ -90,6 +132,22 @@ export declare class SforaApiClient {
90
132
  writePost(projectSlug: string, kind: PostKind, filename: string, markdown: string): Promise<WriteResult>;
91
133
  /** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
92
134
  deletePost(projectSlug: string, kind: PostKind, filename: string): Promise<void>;
135
+ /** `GET …/docs` — the project's notes, most-recently-edited first. */
136
+ listNotes(projectSlug: string): Promise<Note[]>;
137
+ /** `GET …/docs/:filename.md`. Returns the raw markdown body. */
138
+ readNote(projectSlug: string, filename: string): Promise<string>;
139
+ /** `GET …/pulls` — the project's synced pull requests, open first. */
140
+ listPulls(projectSlug: string): Promise<Pull[]>;
141
+ /**
142
+ * `GET …/pulls/:number` — one PR as markdown: metadata, the cards it resolves,
143
+ * and the unified diff. Returns the raw markdown body.
144
+ */
145
+ readPull(projectSlug: string, number: number): Promise<string>;
146
+ /**
147
+ * `GET …/board` — board sharing state + columns (auto-creates the board on
148
+ * first call). `publicSlug` is null unless the board is shared publicly.
149
+ */
150
+ getBoardMeta(projectSlug: string): Promise<BoardMeta>;
93
151
  /** `GET …/board` — list the project's columns (auto-creates the board on first call). */
94
152
  listColumns(projectSlug: string): Promise<BoardColumn[]>;
95
153
  /** `GET …/board/:column` — list cards in a column. `column` is the `NN-slug` dirname. */
@@ -26,14 +26,18 @@ function routeBase(kind) {
26
26
  export class SforaApiClient {
27
27
  #baseUrl;
28
28
  #apiKey;
29
+ #actAs;
29
30
  constructor(config) {
30
31
  this.#baseUrl = config.baseUrl.replace(/\/+$/, "");
31
32
  this.#apiKey = config.apiKey;
33
+ this.#actAs = config.actAs;
32
34
  }
33
35
  // ─── HTTP plumbing ──────────────────────────────────────────────
34
36
  async #request(method, path, body) {
35
37
  const headers = Object.create(null);
36
38
  headers.Authorization = `Bearer ${this.#apiKey}`;
39
+ if (this.#actAs)
40
+ headers["X-Sfora-Act-As"] = this.#actAs;
37
41
  if (body !== undefined)
38
42
  headers["Content-Type"] = "text/markdown";
39
43
  let res;
@@ -135,12 +139,47 @@ export class SforaApiClient {
135
139
  const file = encodeURIComponent(filename);
136
140
  await this.#request("DELETE", `/v1/fs/projects/${slug}/${base}/${file}`);
137
141
  }
142
+ // ─── Notes (docs) ────────────────────────────────────────────────
143
+ /** `GET …/docs` — the project's notes, most-recently-edited first. */
144
+ async listNotes(projectSlug) {
145
+ const slug = encodeURIComponent(projectSlug);
146
+ const data = await this.#json(`/v1/fs/projects/${slug}/docs`);
147
+ return data.docs ?? [];
148
+ }
149
+ /** `GET …/docs/:filename.md`. Returns the raw markdown body. */
150
+ async readNote(projectSlug, filename) {
151
+ const slug = encodeURIComponent(projectSlug);
152
+ const file = encodeURIComponent(filename);
153
+ return this.#text(`/v1/fs/projects/${slug}/docs/${file}`);
154
+ }
155
+ // ─── Pull requests (read-only) ───────────────────────────────────
156
+ /** `GET …/pulls` — the project's synced pull requests, open first. */
157
+ async listPulls(projectSlug) {
158
+ const slug = encodeURIComponent(projectSlug);
159
+ const data = await this.#json(`/v1/fs/projects/${slug}/pulls`);
160
+ return data.pulls ?? [];
161
+ }
162
+ /**
163
+ * `GET …/pulls/:number` — one PR as markdown: metadata, the cards it resolves,
164
+ * and the unified diff. Returns the raw markdown body.
165
+ */
166
+ async readPull(projectSlug, number) {
167
+ const slug = encodeURIComponent(projectSlug);
168
+ return this.#text(`/v1/fs/projects/${slug}/pulls/${number}`);
169
+ }
138
170
  // ─── Kanban board ────────────────────────────────────────────────
139
- /** `GET …/board` — list the project's columns (auto-creates the board on first call). */
140
- async listColumns(projectSlug) {
171
+ /**
172
+ * `GET …/board` — board sharing state + columns (auto-creates the board on
173
+ * first call). `publicSlug` is null unless the board is shared publicly.
174
+ */
175
+ async getBoardMeta(projectSlug) {
141
176
  const slug = encodeURIComponent(projectSlug);
142
177
  const data = await this.#json(`/v1/fs/projects/${slug}/board`);
143
- return data.columns;
178
+ return { publicSlug: data.publicSlug ?? null, columns: data.columns };
179
+ }
180
+ /** `GET …/board` — list the project's columns (auto-creates the board on first call). */
181
+ async listColumns(projectSlug) {
182
+ return (await this.getBoardMeta(projectSlug)).columns;
144
183
  }
145
184
  /** `GET …/board/:column` — list cards in a column. `column` is the `NN-slug` dirname. */
146
185
  async listCards(projectSlug, columnDir) {