sfora-cli 0.6.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 +38 -10
- package/dist/SforaFs.d.ts +3 -0
- package/dist/SforaFs.js +231 -7
- package/dist/api-client.d.ts +70 -0
- package/dist/api-client.js +76 -3
- package/dist/cli.js +451 -27
- package/dist/config.d.ts +18 -0
- package/dist/config.js +97 -5
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +94 -0
- package/dist/format/cardMarkdown.d.ts +36 -0
- package/dist/format/cardMarkdown.js +83 -0
- package/dist/format/index.d.ts +13 -0
- package/dist/format/index.js +13 -0
- package/dist/format/markdown/dates.d.ts +3 -0
- package/dist/format/markdown/dates.js +26 -0
- package/dist/format/markdown/document.d.ts +7 -0
- package/dist/format/markdown/document.js +45 -0
- package/dist/format/markdown/index.d.ts +5 -0
- package/dist/format/markdown/index.js +9 -0
- package/dist/format/markdown/mentions.d.ts +8 -0
- package/dist/format/markdown/mentions.js +36 -0
- package/dist/format/markdown/slug.d.ts +2 -0
- package/dist/format/markdown/slug.js +19 -0
- package/dist/format/markdown/yaml.d.ts +4 -0
- package/dist/format/markdown/yaml.js +72 -0
- package/dist/format/noteMarkdown.d.ts +28 -0
- package/dist/format/noteMarkdown.js +66 -0
- package/dist/format/postMarkdown.d.ts +32 -0
- package/dist/format/postMarkdown.js +53 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.js +15 -1
- package/dist/local/workspace.d.ts +68 -0
- package/dist/local/workspace.js +250 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +19 -8
- package/package.json +2 -2
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.
|
|
@@ -48,12 +74,14 @@ so a saved config means no env vars on every run.
|
|
|
48
74
|
pnpm -F sfora build && node packages/sfora/dist/cli.js --help
|
|
49
75
|
```
|
|
50
76
|
|
|
51
|
-
### The
|
|
77
|
+
### The shell engine
|
|
52
78
|
|
|
53
|
-
|
|
79
|
+
The sfora shell is a real POSIX-style shell mounted on your workspace. It's
|
|
80
|
+
powered by an embedded interpreter (the `just-bash` package, `^3.0.1`) — a
|
|
81
|
+
normal npm dependency, no extra steps needed.
|
|
54
82
|
|
|
55
|
-
If you're working against an **unpublished**
|
|
56
|
-
`context/just-bash`), point the dependency at it and build it first:
|
|
83
|
+
If you're working against an **unpublished** build of the engine (e.g. the copy
|
|
84
|
+
in `context/just-bash`), point the dependency at it and build it first:
|
|
57
85
|
|
|
58
86
|
```jsonc
|
|
59
87
|
// packages/sfora/package.json
|
|
@@ -65,11 +93,11 @@ cd context/just-bash && pnpm install && pnpm build # produces dist/bundle/*
|
|
|
65
93
|
cd ../../ && pnpm install
|
|
66
94
|
```
|
|
67
95
|
|
|
68
|
-
> Note: `createSforaShell` constructs the `
|
|
69
|
-
>
|
|
70
|
-
>
|
|
71
|
-
>
|
|
72
|
-
>
|
|
96
|
+
> Note: `createSforaShell` constructs the shell with `defenseInDepth: false`.
|
|
97
|
+
> The engine's in-process hardening (which blocks globals like `WeakRef`)
|
|
98
|
+
> defaults on and would break `fetch` (undici uses `WeakRef` internally). sfora
|
|
99
|
+
> runs the user's/agent's own commands against their own workspace over HTTPS,
|
|
100
|
+
> so that sandbox isn't needed here.
|
|
73
101
|
|
|
74
102
|
## Auth
|
|
75
103
|
|
|
@@ -187,7 +215,7 @@ backend) are the public exports, alongside the lower-level `SforaApiClient`.
|
|
|
187
215
|
| `cat`, read | lazy `GET` of the markdown body (fetched only when read) |
|
|
188
216
|
| `echo >`, write | `PUT` markdown → create/update a post or draft |
|
|
189
217
|
| `rm`, unlink | `DELETE` → soft-delete (author or org admin/owner) |
|
|
190
|
-
| `cd`, `pwd`, `grep`, `find`, pipes, … | all the
|
|
218
|
+
| `cd`, `pwd`, `grep`, `find`, pipes, … | all the standard shell builtins/commands |
|
|
191
219
|
| `mkdir` | no-op on known dirs; denied elsewhere (can't create projects) |
|
|
192
220
|
| `chmod`, `utimes` | accepted no-ops (permissions/mtime are server-owned) |
|
|
193
221
|
| symlinks, `cp`, `mv` | denied (`EPERM`) — no backend operation |
|
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
|
-
|
|
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
|
|
187
|
-
this.#entrySnapshots.set(`/projects/${slug}/board`, columns.map((c) => c.dirname));
|
|
188
|
-
|
|
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
|
-
|
|
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)
|
package/dist/api-client.d.ts
CHANGED
|
@@ -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. */
|
|
@@ -120,6 +178,18 @@ export declare class SforaApiClient {
|
|
|
120
178
|
* card across columns without changing its body. Used by `mv` in the shell.
|
|
121
179
|
*/
|
|
122
180
|
moveCard(projectSlug: string, fromCol: string, filename: string, toCol: string): Promise<CardWriteResult>;
|
|
181
|
+
/** `POST /v1/posts/:postId/comments` — add a comment to a post. */
|
|
182
|
+
createComment(postId: string, body: string): Promise<{
|
|
183
|
+
id: string;
|
|
184
|
+
}>;
|
|
185
|
+
/**
|
|
186
|
+
* `POST /v1/posts/:postId/reactions` — toggle an emoji reaction on a post.
|
|
187
|
+
* Returns whether the reaction is now on (`reacted: true`) or off.
|
|
188
|
+
*/
|
|
189
|
+
reactToPost(postId: string, emoji: string): Promise<{
|
|
190
|
+
content: string;
|
|
191
|
+
reacted: boolean;
|
|
192
|
+
}>;
|
|
123
193
|
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
124
194
|
readInbox(): Promise<string>;
|
|
125
195
|
/** `GET /v1/fs/me/api-key` — text identity (no key material). */
|