sfora-cli 0.13.1 → 0.15.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 +67 -0
- package/dist/SforaFs.js +68 -0
- package/dist/api-client.d.ts +43 -0
- package/dist/api-client.js +41 -53
- package/dist/block-commands.d.ts +1 -0
- package/dist/block-commands.js +1 -0
- package/dist/chat.d.ts +55 -0
- package/dist/chat.js +75 -0
- package/dist/cli-args.d.ts +7 -0
- package/dist/cli-args.js +17 -1
- package/dist/cli.js +264 -42
- package/dist/config.d.ts +2 -0
- package/dist/config.js +15 -6
- package/dist/format/cardMarkdown.d.ts +1 -0
- package/dist/format/cardMarkdown.js +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +3 -1
- package/dist/local/workspace.d.ts +0 -16
- package/dist/local/workspace.js +34 -28
- package/dist/local-core/files.d.ts +23 -0
- package/dist/local-core/files.js +110 -0
- package/dist/local-core/index.d.ts +4 -0
- package/dist/local-core/index.js +4 -0
- package/dist/local-core/skills.d.ts +45 -0
- package/dist/local-core/skills.js +239 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +1 -0
- package/dist/skills-client.d.ts +21 -0
- package/dist/skills-client.js +36 -0
- package/dist/skills-command.d.ts +4 -0
- package/dist/skills-command.js +75 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +20 -2
- package/dist/config.test.d.ts +0 -1
- package/dist/config.test.js +0 -94
package/README.md
CHANGED
|
@@ -399,3 +399,70 @@ print nothing, and `sfora url` on one says so.
|
|
|
399
399
|
Reads outside `projects/<slug>/(posts|drafts)`, `inbox/mentions.md`, and
|
|
400
400
|
`me/api-key` return `ENOENT`; writes outside the post/draft dirs return `EACCES`.
|
|
401
401
|
```
|
|
402
|
+
|
|
403
|
+
## macOS and shared local operations
|
|
404
|
+
|
|
405
|
+
The desktop app and npm CLI use the same Node-only `sfora-cli/local-core`
|
|
406
|
+
operations. `sfora desktop path/to/file.md` opens a local file in the installed
|
|
407
|
+
Sfora macOS app. `sfora open` continues to open Sfora web URLs. npm installation
|
|
408
|
+
still requires Node 20+; the desktop distribution bundles its own CLI runtime.
|
|
409
|
+
Both read the existing `~/.sfora/config.json` profiles. Config writes are atomic
|
|
410
|
+
and process-locked; no account is required for local files or local skill scans.
|
|
411
|
+
|
|
412
|
+
Local Markdown snapshots contain the canonical `path`, unmodified UTF-8
|
|
413
|
+
`content`, and SHA-256 `revision`. Saving requires the loaded revision; Save As
|
|
414
|
+
refuses existing destinations. BOM and line endings are preserved. A stale
|
|
415
|
+
revision reports a conflict instead of overwriting an external edit. Locks
|
|
416
|
+
coordinate Sfora processes; other editors do not participate in those locks.
|
|
417
|
+
A crashed process may leave a `.sfora-lock` directory: the error identifies it
|
|
418
|
+
for removal after confirming no process is using it.
|
|
419
|
+
|
|
420
|
+
## Project skills
|
|
421
|
+
|
|
422
|
+
Skills are complete recursive bundles: `SKILL.md`, scripts, references, and
|
|
423
|
+
binary assets retain exact bytes and executable flags. Import/install never
|
|
424
|
+
executes skill scripts. Symbolic links, traversal, duplicate case-insensitive
|
|
425
|
+
paths, invalid hashes and oversized bundles are rejected (1,000 files,
|
|
426
|
+
5 MiB/file, 20 MiB total).
|
|
427
|
+
|
|
428
|
+
```sh
|
|
429
|
+
sfora skills scan ~/.agents/skills ~/.codex/skills --json
|
|
430
|
+
sfora skills list --project my-project
|
|
431
|
+
sfora skills push ./my-skill --project my-project --expected-version 0
|
|
432
|
+
sfora skills pull my-skill ./my-skill.bundle.json --project my-project
|
|
433
|
+
sfora skills install my-skill --project my-project --skills-target ~/.agents/skills
|
|
434
|
+
sfora skills diff ~/.agents/skills/my-skill my-skill --project my-project
|
|
435
|
+
sfora skills uninstall ~/.agents/skills/my-skill
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Push uses an explicit base version (`0` creates a skill); updating an existing
|
|
439
|
+
skill also requires its `--expected-revision` draft token from `skills list`.
|
|
440
|
+
Saving the draft and publishing are separately checked operations, so a
|
|
441
|
+
concurrent change cannot silently publish somebody else's draft. Use
|
|
442
|
+
`--version N` to pull/install a particular immutable release. Offline bundle
|
|
443
|
+
installation is `skills install-file bundle.json --skills-target <directory>`.
|
|
444
|
+
|
|
445
|
+
Install targets are explicit. A sibling `.name.sfora-install.json` records
|
|
446
|
+
ownership and last installed hash. Existing unowned folders are never replaced;
|
|
447
|
+
updates and uninstalls refuse locally modified installations. Save or push
|
|
448
|
+
those modifications before retrying. `skills pull` writes a new bundle JSON
|
|
449
|
+
file and refuses to overwrite an existing file.
|
|
450
|
+
|
|
451
|
+
Cloud document callers can use `SforaApiClient.readPathSnapshot()` and pass its
|
|
452
|
+
revision to `writePath(..., { expectedRevision })`. CLI writes accept
|
|
453
|
+
`put <path> <file.md> --expected-revision N`. A null read revision means the
|
|
454
|
+
server did not advertise concurrency support; clients must not infer a token.
|
|
455
|
+
|
|
456
|
+
Published Skills are also mounted read-only in the ordinary filesystem view:
|
|
457
|
+
|
|
458
|
+
```sh
|
|
459
|
+
sfora ls /projects/my-project/skills
|
|
460
|
+
sfora ls /projects/my-project/skills/my-skill/scripts
|
|
461
|
+
sfora cat /projects/my-project/skills/my-skill/SKILL.md
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Nested directories, binary reads and executable metadata are preserved. Draft
|
|
465
|
+
changes appear here only after publication; use `skills push` or the project
|
|
466
|
+
workbench to change a bundle. Cloud installations record both the immutable
|
|
467
|
+
bundle hash and numeric `sourceVersion`; resolving “latest” pins that version
|
|
468
|
+
before downloading so concurrent publications cannot mix provenance and bytes.
|
package/dist/SforaFs.js
CHANGED
|
@@ -76,6 +76,8 @@ function classify(path) {
|
|
|
76
76
|
if (seg.length === 2)
|
|
77
77
|
return { kind: "projectDir", slug };
|
|
78
78
|
const dir = seg[2];
|
|
79
|
+
if (dir === "skills")
|
|
80
|
+
return { kind: "skillPath", slug, name: seg[3], path: seg.slice(4).join("/") };
|
|
79
81
|
if (dir === "links.md" && seg.length === 3) {
|
|
80
82
|
return { kind: "linksFile", slug };
|
|
81
83
|
}
|
|
@@ -560,7 +562,20 @@ export class SforaFs {
|
|
|
560
562
|
throw enoent("open", normalize(path));
|
|
561
563
|
}
|
|
562
564
|
}
|
|
565
|
+
async #skillManifest(slug, name) {
|
|
566
|
+
const manifest = await this.#client.readSkillManifest(slug, name);
|
|
567
|
+
const paths = new Set();
|
|
568
|
+
for (const file of manifest.files) {
|
|
569
|
+
const parts = file.path.split("/");
|
|
570
|
+
for (let count = 1; count <= parts.length; count++)
|
|
571
|
+
paths.add(parts.slice(0, count).join("/"));
|
|
572
|
+
}
|
|
573
|
+
this.#entrySnapshots.set(`/projects/${slug}/skills/${name}`, [...paths]);
|
|
574
|
+
return manifest;
|
|
575
|
+
}
|
|
563
576
|
async readFile(path, options) {
|
|
577
|
+
if (classify(path).kind === "skillPath")
|
|
578
|
+
return Buffer.from(await this.readFileBuffer(path)).toString(toNodeEncoding(readEncoding(options)));
|
|
564
579
|
const text = await this.#readText(path);
|
|
565
580
|
const enc = readEncoding(options);
|
|
566
581
|
if (enc === "utf8" || enc === "utf-8")
|
|
@@ -568,12 +583,25 @@ export class SforaFs {
|
|
|
568
583
|
return Buffer.from(text, "utf8").toString(toNodeEncoding(enc));
|
|
569
584
|
}
|
|
570
585
|
async readFileBuffer(path) {
|
|
586
|
+
const loc = classify(path);
|
|
587
|
+
if (loc.kind === "skillPath") {
|
|
588
|
+
if ((await this.stat(path)).isDirectory)
|
|
589
|
+
throw eisdir("read", normalize(path));
|
|
590
|
+
try {
|
|
591
|
+
return await this.#client.readSkillFile(loc.slug, loc.name, loc.path);
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
throw fromApi(error, "open", normalize(path));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
571
597
|
const text = await this.#readText(path);
|
|
572
598
|
return new TextEncoder().encode(text);
|
|
573
599
|
}
|
|
574
600
|
async writeFile(path, content, _options) {
|
|
575
601
|
const loc = classify(path);
|
|
576
602
|
const norm = normalize(path);
|
|
603
|
+
if (loc.kind === "skillPath")
|
|
604
|
+
throw eacces("open", norm);
|
|
577
605
|
if (loc.kind === "inboxFile" || loc.kind === "meFile") {
|
|
578
606
|
throw eacces("open", norm);
|
|
579
607
|
}
|
|
@@ -746,6 +774,21 @@ export class SforaFs {
|
|
|
746
774
|
const norm = normalize(path);
|
|
747
775
|
try {
|
|
748
776
|
switch (loc.kind) {
|
|
777
|
+
case "skillPath": {
|
|
778
|
+
if (!loc.name) {
|
|
779
|
+
await this.#client.listSkillDirectories(loc.slug);
|
|
780
|
+
return this.#dirStat();
|
|
781
|
+
}
|
|
782
|
+
const manifest = await this.#skillManifest(loc.slug, loc.name);
|
|
783
|
+
if (!loc.path)
|
|
784
|
+
return this.#dirStat();
|
|
785
|
+
const file = manifest.files.find(file => file.path === loc.path);
|
|
786
|
+
if (file)
|
|
787
|
+
return { ...this.#fileStat(new Date(0), file.size), mode: file.executable ? 0o755 : FILE_MODE };
|
|
788
|
+
if (manifest.files.some(file => file.path.startsWith(`${loc.path}/`)))
|
|
789
|
+
return this.#dirStat();
|
|
790
|
+
throw enoent("stat", norm);
|
|
791
|
+
}
|
|
749
792
|
case "root":
|
|
750
793
|
case "projectsDir":
|
|
751
794
|
case "inboxDir":
|
|
@@ -853,6 +896,27 @@ export class SforaFs {
|
|
|
853
896
|
});
|
|
854
897
|
try {
|
|
855
898
|
switch (loc.kind) {
|
|
899
|
+
case "skillPath": {
|
|
900
|
+
if (!loc.name) {
|
|
901
|
+
const directories = (await this.#client.listSkillDirectories(loc.slug)).directories;
|
|
902
|
+
this.#entrySnapshots.set(`/projects/${loc.slug}/skills`, directories.map(skill => skill.name));
|
|
903
|
+
return directories.map(skill => dirent(skill.name, true));
|
|
904
|
+
}
|
|
905
|
+
const manifest = await this.#skillManifest(loc.slug, loc.name);
|
|
906
|
+
if (manifest.files.some(file => file.path === loc.path))
|
|
907
|
+
throw enotdir("scandir", norm);
|
|
908
|
+
const prefix = loc.path ? `${loc.path}/` : "";
|
|
909
|
+
const children = new Map();
|
|
910
|
+
for (const file of manifest.files) {
|
|
911
|
+
if (!file.path.startsWith(prefix))
|
|
912
|
+
continue;
|
|
913
|
+
const parts = file.path.slice(prefix.length).split("/");
|
|
914
|
+
children.set(parts[0], parts.length > 1);
|
|
915
|
+
}
|
|
916
|
+
if (loc.path && !children.size)
|
|
917
|
+
throw enoent("scandir", norm);
|
|
918
|
+
return [...children].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, directory]) => dirent(name, directory));
|
|
919
|
+
}
|
|
856
920
|
case "root":
|
|
857
921
|
return [
|
|
858
922
|
dirent("inbox", true),
|
|
@@ -884,6 +948,7 @@ export class SforaFs {
|
|
|
884
948
|
dirent("map.md", false),
|
|
885
949
|
dirent("posts", true),
|
|
886
950
|
dirent("pulls", true),
|
|
951
|
+
dirent("skills", true),
|
|
887
952
|
];
|
|
888
953
|
// `public/` only appears once the board is shared publicly.
|
|
889
954
|
if ((await this.#publicSlug(loc.slug)) != null) {
|
|
@@ -998,6 +1063,7 @@ export class SforaFs {
|
|
|
998
1063
|
const loc = classify(path);
|
|
999
1064
|
const norm = normalize(path);
|
|
1000
1065
|
switch (loc.kind) {
|
|
1066
|
+
case "skillPath": throw eacces("mkdir", norm);
|
|
1001
1067
|
// Known structural directories already exist — mkdir is a no-op.
|
|
1002
1068
|
case "root":
|
|
1003
1069
|
case "projectsDir":
|
|
@@ -1044,6 +1110,7 @@ export class SforaFs {
|
|
|
1044
1110
|
const loc = classify(path);
|
|
1045
1111
|
const norm = normalize(path);
|
|
1046
1112
|
switch (loc.kind) {
|
|
1113
|
+
case "skillPath": throw eacces("unlink", norm);
|
|
1047
1114
|
case "postFile": {
|
|
1048
1115
|
const exists = (await this.#matchEntry(loc.slug, loc.dir, loc.filename)) !==
|
|
1049
1116
|
undefined;
|
|
@@ -1249,6 +1316,7 @@ export class SforaFs {
|
|
|
1249
1316
|
paths.add(`/projects/${slug}/drafts`);
|
|
1250
1317
|
paths.add(`/projects/${slug}/docs`);
|
|
1251
1318
|
paths.add(`/projects/${slug}/artifacts`);
|
|
1319
|
+
paths.add(`/projects/${slug}/skills`);
|
|
1252
1320
|
paths.add(`/projects/${slug}/library`);
|
|
1253
1321
|
paths.add(`/projects/${slug}/library/documents`);
|
|
1254
1322
|
paths.add(`/projects/${slug}/library/files`);
|
package/dist/api-client.d.ts
CHANGED
|
@@ -260,6 +260,12 @@ export interface ChatMessage {
|
|
|
260
260
|
_creationTime: number;
|
|
261
261
|
/** Null when the author's member row is gone. */
|
|
262
262
|
author: ChatAuthor | null;
|
|
263
|
+
/**
|
|
264
|
+
* The client slug the message was sent through ("claude-code"), when the
|
|
265
|
+
* sending door stamped one. Absent on app sends and on servers that do not
|
|
266
|
+
* surface it yet.
|
|
267
|
+
*/
|
|
268
|
+
sentVia?: string;
|
|
263
269
|
}
|
|
264
270
|
/** A page of messages, NEWEST FIRST — the server paginates backwards. */
|
|
265
271
|
export interface MessagesPage {
|
|
@@ -297,6 +303,14 @@ export interface SforaApiConfig {
|
|
|
297
303
|
* key per agent.
|
|
298
304
|
*/
|
|
299
305
|
actAs?: string;
|
|
306
|
+
/**
|
|
307
|
+
* What kind of client is on the line — a short slug ("claude-code", "cli").
|
|
308
|
+
* Sent as `X-Sfora-Client` on every request so creation doors can stamp the
|
|
309
|
+
* things they make ("sent via Claude Code" on a message or post). Sent
|
|
310
|
+
* always, including the plain "cli" — a terminal is honest attribution too.
|
|
311
|
+
* The server sanitizes it and ignores it where it means nothing.
|
|
312
|
+
*/
|
|
313
|
+
clientLabel?: string;
|
|
300
314
|
}
|
|
301
315
|
/** Non-2xx response from the fs API. Carries the HTTP status + machine code so SforaFs can map it to an errno. */
|
|
302
316
|
export declare class SforaApiError extends Error {
|
|
@@ -375,6 +389,8 @@ export interface BlockConflict {
|
|
|
375
389
|
export declare function blockConflictFrom(error: unknown): BlockConflict | null;
|
|
376
390
|
/** Options every markdown write door takes. */
|
|
377
391
|
export interface WriteOptions {
|
|
392
|
+
/** Whole-document revision from readPathSnapshot; rejects concurrent changes. */
|
|
393
|
+
expectedRevision?: number;
|
|
378
394
|
/**
|
|
379
395
|
* Write ONE block instead of the whole file — the id from `?view=blocks`.
|
|
380
396
|
*
|
|
@@ -442,6 +458,27 @@ export declare class SforaApiClient {
|
|
|
442
458
|
* A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
|
|
443
459
|
* server's recovery payload; read it with {@link blockConflictFrom}.
|
|
444
460
|
*/
|
|
461
|
+
listSkillDirectories(project: string): Promise<{
|
|
462
|
+
directories: Array<{
|
|
463
|
+
name: string;
|
|
464
|
+
version: number;
|
|
465
|
+
}>;
|
|
466
|
+
}>;
|
|
467
|
+
readSkillManifest(project: string, name: string): Promise<{
|
|
468
|
+
files: Array<{
|
|
469
|
+
path: string;
|
|
470
|
+
sha256: string;
|
|
471
|
+
size: number;
|
|
472
|
+
executable: boolean;
|
|
473
|
+
}>;
|
|
474
|
+
version: number;
|
|
475
|
+
hash: string;
|
|
476
|
+
}>;
|
|
477
|
+
readSkillFile(project: string, name: string, path: string): Promise<Uint8Array>;
|
|
478
|
+
readPathSnapshot(fsPath: string): Promise<{
|
|
479
|
+
content: string;
|
|
480
|
+
revision: number | null;
|
|
481
|
+
}>;
|
|
445
482
|
writePath(fsPath: string, markdown: string, options?: WriteOptions): Promise<WriteResult & Partial<CardWriteResult>>;
|
|
446
483
|
/** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
|
|
447
484
|
deletePost(projectSlug: string, kind: PostKind, filename: string): Promise<void>;
|
|
@@ -550,6 +587,12 @@ export declare class SforaApiClient {
|
|
|
550
587
|
doc?: string;
|
|
551
588
|
project?: string;
|
|
552
589
|
includeSelf?: boolean;
|
|
590
|
+
/**
|
|
591
|
+
* Deliver message events for the caller's OWN messages too — how a tail
|
|
592
|
+
* hears the same member speaking from the app. Distinct from
|
|
593
|
+
* `includeSelf`, which is about document writes.
|
|
594
|
+
*/
|
|
595
|
+
includeOwn?: boolean;
|
|
553
596
|
signal?: AbortSignal;
|
|
554
597
|
}): Promise<AgentEventsPage>;
|
|
555
598
|
/**
|
package/dist/api-client.js
CHANGED
|
@@ -81,14 +81,21 @@ function routeBase(kind) {
|
|
|
81
81
|
}
|
|
82
82
|
/** `?block=<id>` when one was asked for, and nothing at all when it wasn't. */
|
|
83
83
|
function blockQuery(options) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
const query = new URLSearchParams();
|
|
85
|
+
if (options?.blockId)
|
|
86
|
+
query.set("block", options.blockId);
|
|
87
|
+
if (options?.expectedRevision !== undefined) {
|
|
88
|
+
if (!Number.isSafeInteger(options.expectedRevision) || options.expectedRevision < 0)
|
|
89
|
+
throw new Error("Expected revision must be a non-negative integer.");
|
|
90
|
+
query.set("expectedRevision", String(options.expectedRevision));
|
|
91
|
+
}
|
|
92
|
+
return query.size ? `?${query}` : "";
|
|
87
93
|
}
|
|
88
94
|
export class SforaApiClient {
|
|
89
95
|
#baseUrl;
|
|
90
96
|
#apiKey;
|
|
91
97
|
#actAs;
|
|
98
|
+
#clientLabel;
|
|
92
99
|
/**
|
|
93
100
|
* What the LAST response said about itself, beyond the data it carried.
|
|
94
101
|
*
|
|
@@ -108,6 +115,7 @@ export class SforaApiClient {
|
|
|
108
115
|
this.#baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
109
116
|
this.#apiKey = config.apiKey;
|
|
110
117
|
this.#actAs = config.actAs;
|
|
118
|
+
this.#clientLabel = config.clientLabel;
|
|
111
119
|
}
|
|
112
120
|
/** What the last response said about itself, consumed. */
|
|
113
121
|
takeResponseInfo() {
|
|
@@ -148,6 +156,8 @@ export class SforaApiClient {
|
|
|
148
156
|
headers.Authorization = `Bearer ${this.#apiKey}`;
|
|
149
157
|
if (this.#actAs)
|
|
150
158
|
headers["X-Sfora-Act-As"] = this.#actAs;
|
|
159
|
+
if (this.#clientLabel)
|
|
160
|
+
headers["X-Sfora-Client"] = this.#clientLabel;
|
|
151
161
|
if (body !== undefined)
|
|
152
162
|
headers["Content-Type"] = contentType;
|
|
153
163
|
let res;
|
|
@@ -307,6 +317,26 @@ export class SforaApiClient {
|
|
|
307
317
|
* A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
|
|
308
318
|
* server's recovery payload; read it with {@link blockConflictFrom}.
|
|
309
319
|
*/
|
|
320
|
+
async listSkillDirectories(project) {
|
|
321
|
+
return this.#json(`/v1/fs/projects/${encodeURIComponent(project)}/skills`);
|
|
322
|
+
}
|
|
323
|
+
async readSkillManifest(project, name) {
|
|
324
|
+
return this.#json(`/v1/fs/projects/${encodeURIComponent(project)}/skills/${encodeURIComponent(name)}`);
|
|
325
|
+
}
|
|
326
|
+
async readSkillFile(project, name, path) {
|
|
327
|
+
const response = await this.#request("GET", `/v1/fs/projects/${encodeURIComponent(project)}/skills/${encodeURIComponent(name)}/${path.split("/").map(encodeURIComponent).join("/")}`);
|
|
328
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
329
|
+
}
|
|
330
|
+
async readPathSnapshot(fsPath) {
|
|
331
|
+
const response = await this.#request("GET", fsRequestPath(fsPath));
|
|
332
|
+
const advertised = response.headers.get("x-sfora-revision");
|
|
333
|
+
const etag = response.headers.get("etag")?.replace(/^W\//, "").replaceAll('"', "");
|
|
334
|
+
const raw = advertised !== null && /^\d+$/.test(advertised)
|
|
335
|
+
? advertised : etag?.match(/^(\d+)(?:-(?:gzip|br))?$/)?.[1];
|
|
336
|
+
const numeric = raw === undefined ? NaN : Number(raw);
|
|
337
|
+
const revision = Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : null;
|
|
338
|
+
return { content: await response.text(), revision };
|
|
339
|
+
}
|
|
310
340
|
async writePath(fsPath, markdown, options) {
|
|
311
341
|
const res = await this.#request("PUT", `${fsRequestPath(fsPath)}${blockQuery(options)}`, markdown);
|
|
312
342
|
return this.#jsonFrom(res);
|
|
@@ -445,16 +475,7 @@ export class SforaApiClient {
|
|
|
445
475
|
const slug = encodeURIComponent(projectSlug);
|
|
446
476
|
// The server slugifies `name` to derive the directory; we send it as a
|
|
447
477
|
// single-field JSON body to keep the spec consistent with future fields.
|
|
448
|
-
const res = await
|
|
449
|
-
method: "POST",
|
|
450
|
-
headers: {
|
|
451
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
452
|
-
"Content-Type": "application/json",
|
|
453
|
-
},
|
|
454
|
-
body: JSON.stringify({ name }),
|
|
455
|
-
});
|
|
456
|
-
if (!res.ok)
|
|
457
|
-
throw await this.#toError(res);
|
|
478
|
+
const res = await this.#request("POST", `/v1/fs/projects/${slug}/board`, JSON.stringify({ name }), undefined, "application/json");
|
|
458
479
|
return await this.#jsonFrom(res);
|
|
459
480
|
}
|
|
460
481
|
/**
|
|
@@ -464,16 +485,7 @@ export class SforaApiClient {
|
|
|
464
485
|
async renameColumn(projectSlug, columnDir, newName) {
|
|
465
486
|
const slug = encodeURIComponent(projectSlug);
|
|
466
487
|
const col = encodeURIComponent(columnDir);
|
|
467
|
-
const res = await
|
|
468
|
-
method: "POST",
|
|
469
|
-
headers: {
|
|
470
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
471
|
-
"Content-Type": "application/json",
|
|
472
|
-
},
|
|
473
|
-
body: JSON.stringify({ name: newName }),
|
|
474
|
-
});
|
|
475
|
-
if (!res.ok)
|
|
476
|
-
throw await this.#toError(res);
|
|
488
|
+
const res = await this.#request("POST", `/v1/fs/projects/${slug}/board/${col}/_rename`, JSON.stringify({ name: newName }), undefined, "application/json");
|
|
477
489
|
return await this.#jsonFrom(res);
|
|
478
490
|
}
|
|
479
491
|
/** `DELETE …/board/:column` — column must be empty. */
|
|
@@ -490,31 +502,13 @@ export class SforaApiClient {
|
|
|
490
502
|
const slug = encodeURIComponent(projectSlug);
|
|
491
503
|
const from = encodeURIComponent(fromCol);
|
|
492
504
|
const file = encodeURIComponent(filename);
|
|
493
|
-
const res = await
|
|
494
|
-
method: "POST",
|
|
495
|
-
headers: {
|
|
496
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
497
|
-
"Content-Type": "application/json",
|
|
498
|
-
},
|
|
499
|
-
body: JSON.stringify({ toColumn: toCol }),
|
|
500
|
-
});
|
|
501
|
-
if (!res.ok)
|
|
502
|
-
throw await this.#toError(res);
|
|
505
|
+
const res = await this.#request("POST", `/v1/fs/projects/${slug}/board/${from}/${file}/_move`, JSON.stringify({ toColumn: toCol }), undefined, "application/json");
|
|
503
506
|
return await this.#jsonFrom(res);
|
|
504
507
|
}
|
|
505
508
|
// ─── Post actions (comment / react) ─────────────────────────────
|
|
506
509
|
/** `POST /v1/posts/:postId/comments` — add a comment to a post. */
|
|
507
510
|
async createComment(postId, body) {
|
|
508
|
-
const res = await
|
|
509
|
-
method: "POST",
|
|
510
|
-
headers: {
|
|
511
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
512
|
-
"Content-Type": "application/json",
|
|
513
|
-
},
|
|
514
|
-
body: JSON.stringify({ body }),
|
|
515
|
-
});
|
|
516
|
-
if (!res.ok)
|
|
517
|
-
throw await this.#toError(res);
|
|
511
|
+
const res = await this.#request("POST", `/v1/posts/${encodeURIComponent(postId)}/comments`, JSON.stringify({ body }), undefined, "application/json");
|
|
518
512
|
const data = await this.#jsonFrom(res);
|
|
519
513
|
return { id: data.comment?._id ?? "" };
|
|
520
514
|
}
|
|
@@ -523,16 +517,7 @@ export class SforaApiClient {
|
|
|
523
517
|
* Returns whether the reaction is now on (`reacted: true`) or off.
|
|
524
518
|
*/
|
|
525
519
|
async reactToPost(postId, emoji) {
|
|
526
|
-
const res = await
|
|
527
|
-
method: "POST",
|
|
528
|
-
headers: {
|
|
529
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
530
|
-
"Content-Type": "application/json",
|
|
531
|
-
},
|
|
532
|
-
body: JSON.stringify({ emoji }),
|
|
533
|
-
});
|
|
534
|
-
if (!res.ok)
|
|
535
|
-
throw await this.#toError(res);
|
|
520
|
+
const res = await this.#request("POST", `/v1/posts/${encodeURIComponent(postId)}/reactions`, JSON.stringify({ emoji }), undefined, "application/json");
|
|
536
521
|
const data = await this.#jsonFrom(res);
|
|
537
522
|
return data.reaction ?? { content: emoji, reacted: true };
|
|
538
523
|
}
|
|
@@ -600,6 +585,9 @@ export class SforaApiClient {
|
|
|
600
585
|
// own writes, matching the message branch's "don't wake on your own".
|
|
601
586
|
if (params.includeSelf)
|
|
602
587
|
query.set("self", "include");
|
|
588
|
+
// `?includeOwn=1` — exactly "1", the server's spelling.
|
|
589
|
+
if (params.includeOwn)
|
|
590
|
+
query.set("includeOwn", "1");
|
|
603
591
|
return this.#json(`/v1/events?${query.toString()}`, params.signal);
|
|
604
592
|
}
|
|
605
593
|
/**
|
package/dist/block-commands.d.ts
CHANGED
|
@@ -71,6 +71,7 @@ export declare function blocksCommand(client: SforaApiClient, fsPath: string, op
|
|
|
71
71
|
*/
|
|
72
72
|
export declare function putCommand(client: SforaApiClient, fsPath: string, body: string, options?: {
|
|
73
73
|
blockId?: string;
|
|
74
|
+
expectedRevision?: number;
|
|
74
75
|
json?: boolean;
|
|
75
76
|
/**
|
|
76
77
|
* The run's presence latch. Omitted means "this call is the run" — a
|
package/dist/block-commands.js
CHANGED
|
@@ -106,6 +106,7 @@ export async function putCommand(client, fsPath, body, options = {}) {
|
|
|
106
106
|
try {
|
|
107
107
|
const result = await client.writePath(fsPath, body, {
|
|
108
108
|
blockId: options.blockId,
|
|
109
|
+
...(options.expectedRevision !== undefined ? { expectedRevision: options.expectedRevision } : {}),
|
|
109
110
|
});
|
|
110
111
|
const info = client.takeResponseInfo();
|
|
111
112
|
if (options.json)
|
package/dist/chat.d.ts
CHANGED
|
@@ -82,6 +82,14 @@ export declare function renderChatBody(markdown: string): string;
|
|
|
82
82
|
* context, not content.
|
|
83
83
|
*/
|
|
84
84
|
export declare function renderChatMessage(msg: ChatMessage, now: number): string;
|
|
85
|
+
/**
|
|
86
|
+
* One message as an NDJSON line — the machine half of `chat --follow --json`
|
|
87
|
+
* and `--await-reply --json`. A small, stable shape rather than the wire row:
|
|
88
|
+
* `{ author, body, at, sentVia? }`, `at` in ISO 8601. `sentVia` appears only
|
|
89
|
+
* when the sending door stamped one; the author's name is passed through raw —
|
|
90
|
+
* capitalization is a display rule, and this line is for a program.
|
|
91
|
+
*/
|
|
92
|
+
export declare function chatMessageJson(msg: ChatMessage): string;
|
|
85
93
|
/** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
|
|
86
94
|
export declare function renderRoomList(rooms: Room[]): string;
|
|
87
95
|
/** Everything the tail loop needs from the outside world. */
|
|
@@ -114,3 +122,50 @@ export interface ChatTailOptions {
|
|
|
114
122
|
* `seen` set is the single ledger, shared with the prompt's own sends.
|
|
115
123
|
*/
|
|
116
124
|
export declare function chatTailLoop(deps: ChatTailDeps, options: ChatTailOptions): Promise<number>;
|
|
125
|
+
/** Everything the reply wait needs from the outside world. */
|
|
126
|
+
export interface AwaitReplyDeps {
|
|
127
|
+
/**
|
|
128
|
+
* One `/v1/events` long-poll — the caller's poll passes `includeOwn`, so
|
|
129
|
+
* the same member answering from the app rings the doorbell too.
|
|
130
|
+
*/
|
|
131
|
+
poll(since: number): Promise<AgentEventsPage>;
|
|
132
|
+
/** The room's recent page, any order — refetched when the doorbell rings. */
|
|
133
|
+
fetchRecent(): Promise<ChatMessage[]>;
|
|
134
|
+
/** A warning, on stderr — never mixed into stdout. */
|
|
135
|
+
warn(text: string): void;
|
|
136
|
+
sleep(ms: number): Promise<void>;
|
|
137
|
+
/** The clock, injectable for tests. */
|
|
138
|
+
now?(): number;
|
|
139
|
+
}
|
|
140
|
+
export interface AwaitReplyOptions {
|
|
141
|
+
roomId: string;
|
|
142
|
+
/**
|
|
143
|
+
* Message ids that are NOT the reply: the history before the send, and the
|
|
144
|
+
* send itself. The reply is picked by id, not author — the same human
|
|
145
|
+
* answering from the app IS the reply.
|
|
146
|
+
*/
|
|
147
|
+
seen: Set<string>;
|
|
148
|
+
/** Events cursor to start from — BEFORE the send, on server time. */
|
|
149
|
+
since: number;
|
|
150
|
+
/** Absolute deadline (ms epoch); absent means wait forever. */
|
|
151
|
+
deadlineMs?: number;
|
|
152
|
+
stopped?: () => boolean;
|
|
153
|
+
backoffMs?: number;
|
|
154
|
+
}
|
|
155
|
+
export type AwaitReplyResult = {
|
|
156
|
+
outcome: "reply";
|
|
157
|
+
message: ChatMessage;
|
|
158
|
+
} | {
|
|
159
|
+
outcome: "timeout";
|
|
160
|
+
} | {
|
|
161
|
+
outcome: "stopped";
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Poll until the room speaks back, the deadline passes, or the caller stops.
|
|
165
|
+
*
|
|
166
|
+
* The same rules the tail holds — forward-only cursor, doubling backoff to
|
|
167
|
+
* {@link MAX_BACKOFF_MS}, the doorbell-then-refetch shape — but instead of
|
|
168
|
+
* printing forever, the FIRST unseen message ends the wait. Oldest wins when
|
|
169
|
+
* several land at once: the reply is the next thing said, not the latest.
|
|
170
|
+
*/
|
|
171
|
+
export declare function awaitReplyLoop(deps: AwaitReplyDeps, options: AwaitReplyOptions): Promise<AwaitReplyResult>;
|
package/dist/chat.js
CHANGED
|
@@ -199,6 +199,21 @@ export function renderChatMessage(msg, now) {
|
|
|
199
199
|
const header = `${colors.bold}${author}${colors.reset} ${dim(relativeTime(msg._creationTime, now))}`;
|
|
200
200
|
return `${header}\n${renderChatBody(msg.body)}`;
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* One message as an NDJSON line — the machine half of `chat --follow --json`
|
|
204
|
+
* and `--await-reply --json`. A small, stable shape rather than the wire row:
|
|
205
|
+
* `{ author, body, at, sentVia? }`, `at` in ISO 8601. `sentVia` appears only
|
|
206
|
+
* when the sending door stamped one; the author's name is passed through raw —
|
|
207
|
+
* capitalization is a display rule, and this line is for a program.
|
|
208
|
+
*/
|
|
209
|
+
export function chatMessageJson(msg) {
|
|
210
|
+
return JSON.stringify({
|
|
211
|
+
author: msg.author?.name ?? null,
|
|
212
|
+
body: msg.body,
|
|
213
|
+
at: new Date(msg._creationTime).toISOString(),
|
|
214
|
+
...(msg.sentVia ? { sentVia: msg.sentVia } : {}),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
202
217
|
/** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
|
|
203
218
|
export function renderRoomList(rooms) {
|
|
204
219
|
if (rooms.length === 0)
|
|
@@ -266,3 +281,63 @@ export async function chatTailLoop(deps, options) {
|
|
|
266
281
|
}
|
|
267
282
|
return cursor;
|
|
268
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Poll until the room speaks back, the deadline passes, or the caller stops.
|
|
286
|
+
*
|
|
287
|
+
* The same rules the tail holds — forward-only cursor, doubling backoff to
|
|
288
|
+
* {@link MAX_BACKOFF_MS}, the doorbell-then-refetch shape — but instead of
|
|
289
|
+
* printing forever, the FIRST unseen message ends the wait. Oldest wins when
|
|
290
|
+
* several land at once: the reply is the next thing said, not the latest.
|
|
291
|
+
*/
|
|
292
|
+
export async function awaitReplyLoop(deps, options) {
|
|
293
|
+
const stopped = options.stopped ?? (() => false);
|
|
294
|
+
const now = deps.now ?? Date.now;
|
|
295
|
+
const firstBackoff = options.backoffMs ?? 1_000;
|
|
296
|
+
let cursor = options.since;
|
|
297
|
+
let backoff = firstBackoff;
|
|
298
|
+
while (!stopped()) {
|
|
299
|
+
if (options.deadlineMs !== undefined && now() >= options.deadlineMs) {
|
|
300
|
+
return { outcome: "timeout" };
|
|
301
|
+
}
|
|
302
|
+
let page;
|
|
303
|
+
try {
|
|
304
|
+
page = await deps.poll(cursor);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (stopped())
|
|
308
|
+
break;
|
|
309
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
310
|
+
deps.warn(`reconnecting in ${Math.round(backoff / 1000)}s — ${message}`);
|
|
311
|
+
await deps.sleep(backoff);
|
|
312
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
backoff = firstBackoff;
|
|
316
|
+
const rang = page.events.some((e) => e.type === "message" && e.roomId === options.roomId);
|
|
317
|
+
if (rang) {
|
|
318
|
+
let recent;
|
|
319
|
+
try {
|
|
320
|
+
recent = await deps.fetchRecent();
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
324
|
+
deps.warn(`could not fetch new messages — ${message}`);
|
|
325
|
+
// The doorbell rang and the refetch failed: do NOT advance the
|
|
326
|
+
// cursor, or the reply's event is consumed and the wait can hang
|
|
327
|
+
// forever if nothing else is ever said (reviewer MAJOR). Back off
|
|
328
|
+
// briefly and let the next poll re-deliver the same event.
|
|
329
|
+
await deps.sleep(backoff);
|
|
330
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const fresh = recent
|
|
334
|
+
.filter((m) => !options.seen.has(m._id))
|
|
335
|
+
.sort((a, b) => a._creationTime - b._creationTime);
|
|
336
|
+
if (fresh.length > 0)
|
|
337
|
+
return { outcome: "reply", message: fresh[0] };
|
|
338
|
+
}
|
|
339
|
+
// Never backwards: an empty page holds the cursor where it was.
|
|
340
|
+
cursor = Math.max(cursor, page.cursor);
|
|
341
|
+
}
|
|
342
|
+
return { outcome: "stopped" };
|
|
343
|
+
}
|
package/dist/cli-args.d.ts
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* process — argv in, a parsed shape out.
|
|
7
7
|
*/
|
|
8
8
|
export interface CliArgs {
|
|
9
|
+
skillsTarget?: string;
|
|
10
|
+
skillVersion?: number;
|
|
11
|
+
expectedVersion?: number;
|
|
12
|
+
expectedRevision?: number;
|
|
9
13
|
command?: string;
|
|
10
14
|
rest: string[];
|
|
11
15
|
org?: string;
|
|
@@ -29,6 +33,9 @@ export interface CliArgs {
|
|
|
29
33
|
waitFlag: boolean;
|
|
30
34
|
message?: string;
|
|
31
35
|
limit?: number;
|
|
36
|
+
follow: boolean;
|
|
37
|
+
awaitReply: boolean;
|
|
38
|
+
timeout?: number;
|
|
32
39
|
client?: string;
|
|
33
40
|
option: string[];
|
|
34
41
|
target?: string;
|