sfora-cli 0.14.0 → 0.16.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 +128 -0
- package/dist/SforaFs.js +68 -0
- package/dist/api-client.d.ts +23 -0
- package/dist/api-client.js +29 -3
- package/dist/block-commands.d.ts +1 -0
- package/dist/block-commands.js +1 -0
- package/dist/cli-args.d.ts +4 -0
- package/dist/cli-args.js +8 -0
- package/dist/cli.js +39 -33
- 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 +1 -0
- package/dist/index.js +1 -0
- 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 +16 -0
- package/dist/local-core/index.js +15 -0
- package/dist/local-core/skill-adapters.d.ts +21 -0
- package/dist/local-core/skill-adapters.js +19 -0
- package/dist/local-core/skill-discovery.d.ts +22 -0
- package/dist/local-core/skill-discovery.js +79 -0
- package/dist/local-core/skill-domain.d.ts +74 -0
- package/dist/local-core/skill-executor.d.ts +23 -0
- package/dist/local-core/skill-executor.js +51 -0
- package/dist/local-core/skill-index.d.ts +54 -0
- package/dist/local-core/skill-index.js +115 -0
- package/dist/local-core/skill-local-executor.d.ts +18 -0
- package/dist/local-core/skill-local-executor.js +249 -0
- package/dist/local-core/skill-operations.d.ts +61 -0
- package/dist/local-core/skill-operations.js +268 -0
- package/dist/local-core/skill-review.d.ts +46 -0
- package/dist/local-core/skill-review.js +132 -0
- package/dist/local-core/skill-service.d.ts +96 -0
- package/dist/local-core/skill-service.js +157 -0
- package/dist/local-core/skill-store.d.ts +34 -0
- package/dist/local-core/skill-store.js +187 -0
- package/dist/local-core/skill-sync.d.ts +132 -0
- package/dist/local-core/skill-sync.js +111 -0
- package/dist/local-core/skills.d.ts +55 -0
- package/dist/local-core/skills.js +251 -0
- package/dist/skills-client.d.ts +32 -0
- package/dist/skills-client.js +88 -0
- package/dist/skills-command.d.ts +4 -0
- package/dist/skills-command.js +223 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +21 -3
- package/dist/config.test.js +0 -94
- /package/dist/{config.test.d.ts → local-core/skill-domain.js} +0 -0
package/README.md
CHANGED
|
@@ -399,3 +399,131 @@ 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 22.13+; 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.
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
### Persistent local skill inventory
|
|
472
|
+
|
|
473
|
+
Requires Node 22.13 or later. Desktop and CLI share a private SQLite catalogue at `~/.sfora/skills/catalog.sqlite`. Skill names and content hashes are not identity; separate local copies remain separate until explicitly linked.
|
|
474
|
+
|
|
475
|
+
```sh
|
|
476
|
+
sfora skills roots add ~/.my-skills "My skills"
|
|
477
|
+
sfora skills roots add-project ~/Developer/my-project "My project"
|
|
478
|
+
sfora skills roots --json
|
|
479
|
+
sfora skills inventory --json
|
|
480
|
+
sfora skills roots remove <root-id>
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Registered roots and local identities survive restart. Removing a root only removes tracking. Missing or inaccessible skills retain last-known metadata and an unavailable state. `skills scan --json` retains its array result; `skills inventory --json` includes scan warnings. SQLite handles concurrent clients and transaction rollback. Do not edit or delete the catalogue to rebuild observations. Explicit cloud bindings and read-only transfer previews share this catalogue. Transfer execution and recovery remain separate mission increments.
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
### Explicit cloud bindings and transfer previews
|
|
487
|
+
|
|
488
|
+
```sh
|
|
489
|
+
sfora skills bind <location-id> <cloud-name> --project my-project
|
|
490
|
+
sfora skills bindings --json
|
|
491
|
+
sfora skills status <binding-id> --json
|
|
492
|
+
sfora skills plan <binding-id> push --json
|
|
493
|
+
sfora skills plan <binding-id> pull --json
|
|
494
|
+
sfora skills relocate <location-id> /new/skill-folder
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Take the location ID from `skills inventory`. Linking verifies the authenticated member, deployment, organization, project and cloud skill ID. Names are lookup hints. A same-name replacement cannot silently take over a binding. Relocation preserves identity after an explicitly chosen folder move; register the destination discovery root first.
|
|
498
|
+
|
|
499
|
+
Explicit adoption of equal, verified published and local content establishes a baseline. Differing content starts with an unknown baseline, or preserves an existing confirmed baseline. Status compares fresh local bytes and a pinned published cloud revision against that baseline. It distinguishes synchronized, local changed, remote changed, conflict, missing and unavailable content. An unavailable cloud request exits with an error and never reports synchronization.
|
|
500
|
+
|
|
501
|
+
Plans contain exact IDs, local and remote hashes, published version, draft revision and per-file byte/mode metadata. They perform no writes. Unknown baselines, conflicts, existing cloud drafts and unverified local ownership block automatic replacement. CLI previews verify ownership against both the installation receipt and the current filesystem object. A replaced link, file or locally edited copy is never treated as an unchanged managed directory. Reviewed pushes can be executed through the journal below. Recovery for local pull/install mutations and migration of the older transfer commands remain tracked by #470 and #472.
|
|
502
|
+
|
|
503
|
+
The first write upgrades catalogue schema 1 to 2 transactionally after making a private SQLite-consistent `.v1-<id>.sqlite` backup beside the catalogue. Read-only inspection can read schema 1 without upgrading it. Older clients fail closed after an upgrade; update the desktop app and CLI together. Keep the backup until the updated clients have been verified.
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
### Guarded pushes and recovery
|
|
507
|
+
|
|
508
|
+
```sh
|
|
509
|
+
sfora skills plan <binding-id> push --json > push-plan.json
|
|
510
|
+
sfora skills queue push-plan.json
|
|
511
|
+
sfora skills apply <operation-id>
|
|
512
|
+
sfora skills operations --json
|
|
513
|
+
sfora skills recover <operation-id>
|
|
514
|
+
sfora skills cancel <unstarted-operation-id>
|
|
515
|
+
sfora skills plan-install <cloud-name> --project my-project --skills-target /existing/parent
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
`apply push-plan.json` queues and starts a push in one command. A reviewed plan is rechecked against current local content, the explicit binding, published version and draft revision. The server must advertise identity-checked writes; the client refuses to apply against an older server. Existing cloud drafts and changed review tokens stop the operation.
|
|
519
|
+
|
|
520
|
+
The private `~/.sfora/skills/operations.sqlite` journal commits intent before requests and reserves the qualified cloud target across upload/publication. A live worker cannot be recovered by another process. Recover an interrupted worker's operation before starting another operation on its target. Lost responses produce a durable `needs-attention` outcome. Recovery checks whether the exact intended next draft or publication landed before retrying, and a separate confirmation receipt covers interruption during the local baseline commit. Completed operations are not republished. Cancelling is supported only before work starts; it does not pretend to undo remote effects.
|
|
521
|
+
|
|
522
|
+
`plan-install` produces a read-only preview for a new, empty destination and blocks occupied destinations. Pull and installation previews can be queued and applied through the same journal. Local updates preserve the previous directory in an operation-specific backup; recovery verifies ownership and staged content before continuing, and preserves external edits. Backups and staged snapshots remain available for inspection. An interruption between creating a destination and recording its owner requires inspection rather than guessing ownership. The older `push`, `pull` and `install` commands retain their existing behavior; use `plan`/`apply` for binding-aware update safety. Use `skills apply-batch ids.json` or `skills recover-batch ids.json` with a JSON array of operation IDs. Outcomes are retained per item and completed work is not replayed. Ctrl-C stops after the current item and cancels unstarted items. In the macOS app, Skills → Transfers reviews the same device-wide queue before running or recovering selected plans.
|
|
523
|
+
|
|
524
|
+
The desktop keeps a cached local skill inventory and watches registered/agent folders. Known file changes re-read only affected skills; new paths and periodic reconciliation repair missed events. Refresh requests a full reconciliation. Removed or inaccessible folders keep their stable identity and last known metadata. Watchers are hints, not authority for transfer planning, which always rechecks current files.
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
In the macOS app, **On this Mac → Push to Sfora / Update local copy** opens a project chooser and a **Review changes** dialog. Published cloud skills offer **Install on this Mac**, followed by the native destination picker and the same review. No CLI plan file is needed. Opening a review creates no binding or queued operation; confirmation rechecks current content and records the durable transfer. Matching unlinked copies can be explicitly linked. Different unlinked copies remain blocked rather than receiving an invented baseline.
|
|
528
|
+
|
|
529
|
+
A first publication continues into the existing project draft editor. After successful publication, the original local copy is linked only if its files still match the published version. A failed link does not turn a successful publication into a failed result. Transfers remains the history/recovery view, including work queued from the CLI.
|
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
|
@@ -389,6 +389,8 @@ export interface BlockConflict {
|
|
|
389
389
|
export declare function blockConflictFrom(error: unknown): BlockConflict | null;
|
|
390
390
|
/** Options every markdown write door takes. */
|
|
391
391
|
export interface WriteOptions {
|
|
392
|
+
/** Whole-document revision from readPathSnapshot; rejects concurrent changes. */
|
|
393
|
+
expectedRevision?: number;
|
|
392
394
|
/**
|
|
393
395
|
* Write ONE block instead of the whole file — the id from `?view=blocks`.
|
|
394
396
|
*
|
|
@@ -456,6 +458,27 @@ export declare class SforaApiClient {
|
|
|
456
458
|
* A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
|
|
457
459
|
* server's recovery payload; read it with {@link blockConflictFrom}.
|
|
458
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
|
+
}>;
|
|
459
482
|
writePath(fsPath: string, markdown: string, options?: WriteOptions): Promise<WriteResult & Partial<CardWriteResult>>;
|
|
460
483
|
/** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
|
|
461
484
|
deletePost(projectSlug: string, kind: PostKind, filename: string): Promise<void>;
|
package/dist/api-client.js
CHANGED
|
@@ -81,9 +81,15 @@ 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;
|
|
@@ -311,6 +317,26 @@ export class SforaApiClient {
|
|
|
311
317
|
* A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
|
|
312
318
|
* server's recovery payload; read it with {@link blockConflictFrom}.
|
|
313
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
|
+
}
|
|
314
340
|
async writePath(fsPath, markdown, options) {
|
|
315
341
|
const res = await this.#request("PUT", `${fsRequestPath(fsPath)}${blockQuery(options)}`, markdown);
|
|
316
342
|
return this.#jsonFrom(res);
|
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/cli-args.d.ts
CHANGED
package/dist/cli-args.js
CHANGED
|
@@ -13,6 +13,14 @@ export function parseArgs(argv) {
|
|
|
13
13
|
args.mcp = true;
|
|
14
14
|
else if (a === "--help" || a === "-h")
|
|
15
15
|
args.help = true;
|
|
16
|
+
else if (a === "--skills-target")
|
|
17
|
+
args.skillsTarget = argv[++i];
|
|
18
|
+
else if (a === "--version")
|
|
19
|
+
args.skillVersion = Number(argv[++i]);
|
|
20
|
+
else if (a === "--expected-version")
|
|
21
|
+
args.expectedVersion = Number(argv[++i]);
|
|
22
|
+
else if (a === "--expected-revision")
|
|
23
|
+
args.expectedRevision = Number(argv[++i]);
|
|
16
24
|
else if (a === "--org")
|
|
17
25
|
args.org = argv[++i];
|
|
18
26
|
else if (a.startsWith("--org="))
|
package/dist/cli.js
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* SFORA_API_KEY=sk_… SFORA_URL=http://localhost:2222 sfora --org test
|
|
7
7
|
* sfora --mcp # MCP stdio server for Claude/Cursor
|
|
8
8
|
*/
|
|
9
|
+
import { CLI_VERSION } from "./version.js";
|
|
10
|
+
import { runSkillsCommand, SKILLS_HELP } from "./skills-command.js";
|
|
9
11
|
import * as readline from "node:readline";
|
|
10
12
|
import { spawn } from "node:child_process";
|
|
11
|
-
import { readFile as readLocalFile } from "node:fs/promises";
|
|
13
|
+
import { realpath, readFile as readLocalFile } from "node:fs/promises";
|
|
12
14
|
import { basename } from "node:path";
|
|
13
15
|
import { taskUploadFilename } from "./format/taskUploadFilename.js";
|
|
14
16
|
import { createSforaShell, createLocalShell, SforaApiError, } from "./index.js";
|
|
@@ -19,7 +21,7 @@ import { colors, ndjson, presenceRecords, renderPresence, renderWriteEffect, url
|
|
|
19
21
|
import { parseWatchTarget, watchLoop } from "./watch.js";
|
|
20
22
|
import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, } from "./local/workspace.js";
|
|
21
23
|
import { runMcpServer } from "./mcp-server.js";
|
|
22
|
-
import { readConfig,
|
|
24
|
+
import { readConfig, updateConfig, resolveSettings, upsertProfile, effectiveProfiles, profileKey, DEFAULT_URL, } from "./config.js";
|
|
23
25
|
import { parseArgs } from "./cli-args.js";
|
|
24
26
|
import { awaitReplyLoop, capitalizeName, chatMessageJson, chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
|
|
25
27
|
const HELP = `sfora — the CLI for your sfora workspace
|
|
@@ -57,6 +59,7 @@ Browse & read:
|
|
|
57
59
|
sfora cat <path> Print a file's markdown
|
|
58
60
|
sfora url <path> Print the web URL for a path
|
|
59
61
|
sfora open <path> Open that URL in your browser
|
|
62
|
+
sfora desktop <file.md> Open local Markdown in Sfora for macOS
|
|
60
63
|
sfora Open the interactive shell
|
|
61
64
|
|
|
62
65
|
Add --json to any list command (projects/posts/tasks/ls/me) for
|
|
@@ -177,12 +180,9 @@ async function runInit(args) {
|
|
|
177
180
|
process.exitCode = 1;
|
|
178
181
|
return;
|
|
179
182
|
}
|
|
180
|
-
const {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
apiKey: key,
|
|
184
|
-
});
|
|
185
|
-
const path = await writeConfig(next);
|
|
183
|
+
const identity = { url, org: org || cfg.org, apiKey: key };
|
|
184
|
+
const profile = profileKey(identity.url, identity.org);
|
|
185
|
+
const path = await updateConfig(current => upsertProfile(current, identity).cfg);
|
|
186
186
|
console.log(`${colors.green}✓${colors.reset} Saved ${path} ${colors.dim}(${profile})${colors.reset}`);
|
|
187
187
|
}
|
|
188
188
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -251,30 +251,14 @@ async function runLogin(args, baseUrl) {
|
|
|
251
251
|
continue;
|
|
252
252
|
}
|
|
253
253
|
if (data.status === "approved" && data.apiKey) {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
...cfg.bots,
|
|
263
|
-
[args.bot]: { apiKey: data.apiKey, org: data.orgSlug ?? undefined },
|
|
264
|
-
},
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
// Add/update this deployment+org as its own profile instead of
|
|
269
|
-
// overwriting one shared key — so logging in here never invalidates the
|
|
270
|
-
// key you hold for another deployment.
|
|
271
|
-
next = upsertProfile(cfg, {
|
|
272
|
-
url: base,
|
|
273
|
-
org: data.orgSlug ?? undefined,
|
|
274
|
-
apiKey: data.apiKey,
|
|
275
|
-
}).cfg;
|
|
276
|
-
}
|
|
277
|
-
const path = await writeConfig(next);
|
|
254
|
+
const approvedKey = data.apiKey;
|
|
255
|
+
const botName = args.bot;
|
|
256
|
+
const path = await updateConfig(current => botName ? {
|
|
257
|
+
...current, url: base,
|
|
258
|
+
bots: { ...current.bots, [botName]: { apiKey: approvedKey, org: data.orgSlug ?? undefined } },
|
|
259
|
+
} : upsertProfile(current, {
|
|
260
|
+
url: base, org: data.orgSlug ?? undefined, apiKey: approvedKey,
|
|
261
|
+
}).cfg);
|
|
278
262
|
console.log(`\n${colors.green}✓${colors.reset} Logged in as ${data.name ?? "you"}${data.orgSlug ? ` ${colors.dim}(${data.orgSlug})${colors.reset}` : ""}${args.bot ? ` ${colors.dim}[bot: ${args.bot}]${colors.reset}` : ""} — saved ${path}`);
|
|
279
263
|
return;
|
|
280
264
|
}
|
|
@@ -498,6 +482,7 @@ async function runVerb(args, fs, client) {
|
|
|
498
482
|
: await readStdin();
|
|
499
483
|
return writeCommandOutput(await putCommand(client, path, body, {
|
|
500
484
|
blockId: args.block,
|
|
485
|
+
expectedRevision: args.expectedRevision,
|
|
501
486
|
json: args.json,
|
|
502
487
|
presence: runPresence,
|
|
503
488
|
}));
|
|
@@ -1492,9 +1477,26 @@ Everything is git-versioned with your repo. ${colors.dim}Connect a team later wi
|
|
|
1492
1477
|
Type ${colors.cyan}exit${colors.reset} to quit.
|
|
1493
1478
|
`;
|
|
1494
1479
|
async function main() {
|
|
1480
|
+
if (["--version", "-v"].includes(process.argv[2] ?? "")) {
|
|
1481
|
+
console.log(`sfora-cli ${CLI_VERSION}`);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1495
1484
|
const args = parseArgs(process.argv.slice(2));
|
|
1496
1485
|
if (args.help) {
|
|
1497
|
-
process.stdout.write(HELP);
|
|
1486
|
+
process.stdout.write(HELP + "\n" + SKILLS_HELP);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (args.command === "desktop") {
|
|
1490
|
+
if (process.platform !== "darwin")
|
|
1491
|
+
throw new Error("Desktop opening is currently supported on macOS.");
|
|
1492
|
+
if (!args.rest.length)
|
|
1493
|
+
throw new Error("usage: sfora desktop <file.md> [file.md ...]");
|
|
1494
|
+
const paths = await Promise.all(args.rest.map(path => realpath(path)));
|
|
1495
|
+
await new Promise((resolve, reject) => {
|
|
1496
|
+
const child = spawn("/usr/bin/open", ["-a", "Sfora", "--", ...paths], { stdio: "inherit" });
|
|
1497
|
+
child.once("error", reject);
|
|
1498
|
+
child.once("exit", code => code === 0 ? resolve() : reject(new Error("Could not open Sfora. Install Sfora.app in Applications first.")));
|
|
1499
|
+
});
|
|
1498
1500
|
return;
|
|
1499
1501
|
}
|
|
1500
1502
|
if (args.command === "init") {
|
|
@@ -1510,6 +1512,10 @@ async function main() {
|
|
|
1510
1512
|
}
|
|
1511
1513
|
const cfg = await readConfig();
|
|
1512
1514
|
const settings = resolveSettings({ url: args.url, apiKey: args.key, org: args.org, bot: args.bot }, cfg);
|
|
1515
|
+
if (args.command === "skills") {
|
|
1516
|
+
await runSkillsCommand(args, settings);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1513
1519
|
const { url: baseUrl, apiKey } = settings;
|
|
1514
1520
|
// Once per invocation: what kind of client is on the line. The api client
|
|
1515
1521
|
// sends it on every request (`X-Sfora-Client`), so every creation door
|
package/dist/config.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface SforaConfig {
|
|
|
17
17
|
export declare const CONFIG_PATH: string;
|
|
18
18
|
export declare const DEFAULT_URL = "https://www.sfora.ai";
|
|
19
19
|
export declare function readConfig(): Promise<SforaConfig>;
|
|
20
|
+
export declare function updateConfig(update: (current: SforaConfig) => SforaConfig): Promise<string>;
|
|
21
|
+
/** Merge profile maps under a process lock for older callers holding a snapshot. */
|
|
20
22
|
export declare function writeConfig(cfg: SforaConfig): Promise<string>;
|
|
21
23
|
export interface ResolvedSettings {
|
|
22
24
|
url: string;
|
package/dist/config.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import { readFile,
|
|
14
|
+
import { readFile, mkdir } from "node:fs/promises";
|
|
15
|
+
import { atomicWrite, withLocalLock } from "./local-core/files.js";
|
|
15
16
|
const CONFIG_DIR = join(homedir(), ".sfora");
|
|
16
17
|
export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
17
18
|
// Production sfora. Local development of sfora itself overrides via --url or
|
|
@@ -27,12 +28,20 @@ export async function readConfig() {
|
|
|
27
28
|
return {};
|
|
28
29
|
}
|
|
29
30
|
}
|
|
31
|
+
export async function updateConfig(update) {
|
|
32
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
33
|
+
return withLocalLock(CONFIG_PATH, async () => {
|
|
34
|
+
const next = update(await readConfig());
|
|
35
|
+
await atomicWrite(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, 0o600);
|
|
36
|
+
return CONFIG_PATH;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Merge profile maps under a process lock for older callers holding a snapshot. */
|
|
30
40
|
export async function writeConfig(cfg) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return CONFIG_PATH;
|
|
41
|
+
return updateConfig(current => ({ ...current, ...cfg,
|
|
42
|
+
profiles: { ...current.profiles, ...cfg.profiles },
|
|
43
|
+
bots: { ...current.bots, ...cfg.bots },
|
|
44
|
+
}));
|
|
36
45
|
}
|
|
37
46
|
// First non-empty value (treats "" / undefined as unset).
|
|
38
47
|
function pick(...vals) {
|
|
@@ -63,6 +63,7 @@ export function cardToMarkdown(card, board, column, commentsCount) {
|
|
|
63
63
|
const fm = serializeFrontmatter([
|
|
64
64
|
["id", card._id],
|
|
65
65
|
["number", String(card.number)],
|
|
66
|
+
["parentCardId", card.parentCardId],
|
|
66
67
|
["board", board?.slug ?? board?.name ?? ""],
|
|
67
68
|
["boardId", board?._id ?? ""],
|
|
68
69
|
["column", column?.name ?? ""],
|
package/dist/index.d.ts
CHANGED
|
@@ -77,3 +77,4 @@ export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, type WatchDeps, type Watch
|
|
|
77
77
|
export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, type DocPing, } from "./render.js";
|
|
78
78
|
export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, type ChatTailDeps, type ChatTailOptions, type AwaitReplyDeps, type AwaitReplyOptions, type AwaitReplyResult, } from "./chat.js";
|
|
79
79
|
export { validateAskOptions, reshapeCandidatesError, askWaitLoop, ASK_OPTIONS_MIN, ASK_OPTIONS_MAX, ASK_OPTION_MAX_LENGTH, type AskWaitDeps, type AskWaitOptions, type AskWaitResult, } from "./ask.js";
|
|
80
|
+
export { SkillsClient } from "./skills-client.js";
|
package/dist/index.js
CHANGED
|
@@ -60,3 +60,4 @@ export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, } from "./watch.js";
|
|
|
60
60
|
export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, } from "./render.js";
|
|
61
61
|
export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, } from "./chat.js";
|
|
62
62
|
export { validateAskOptions, reshapeCandidatesError, askWaitLoop, ASK_OPTIONS_MIN, ASK_OPTIONS_MAX, ASK_OPTION_MAX_LENGTH, } from "./ask.js";
|
|
63
|
+
export { SkillsClient } from "./skills-client.js";
|
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LocalWorkspace — the OSS local mode. A `.sfora/` directory in your repo is
|
|
3
|
-
* the workspace: tasks, posts, and docs are plain markdown files on disk, in
|
|
4
|
-
* exactly the same format the cloud serves over /v1/fs (shared `../format`
|
|
5
|
-
* core), so `cp` is a migration.
|
|
6
|
-
*
|
|
7
|
-
* .sfora/
|
|
8
|
-
* board/01-todo/0001-fix-login.md tasks — NNNN-<slug>.md per column dir
|
|
9
|
-
* posts/2026-07-02-standup.md posts — YYYY-MM-DD-<slug>.md
|
|
10
|
-
* docs/architecture.md docs — <slug>.md
|
|
11
|
-
*
|
|
12
|
-
* This module owns the *semantics* (scaffolding, card numbering, canonical
|
|
13
|
-
* filenames, listings). The interactive shell needs no virtualization locally —
|
|
14
|
-
* just-bash's ReadWriteFs jails a real directory, and real `mv` between column
|
|
15
|
-
* dirs IS a card move.
|
|
16
|
-
*/
|
|
17
1
|
/** Directory name that marks a local sfora workspace. */
|
|
18
2
|
export declare const WORKSPACE_DIR = ".sfora";
|
|
19
3
|
/**
|