sfora-cli 0.14.0 → 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 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`);
@@ -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>;
@@ -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
- return options?.blockId
85
- ? `?block=${encodeURIComponent(options.blockId)}`
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);
@@ -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
@@ -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)
@@ -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;
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, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
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 { cfg: next, key: profile } = upsertProfile(cfg, {
181
- url,
182
- org: org || cfg.org,
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 cfg = await readConfig();
255
- let next;
256
- if (args.bot) {
257
- // Save under the bot's slot; keep the user's personal key intact.
258
- next = {
259
- ...cfg,
260
- url: base,
261
- bots: {
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, writeFile, mkdir, chmod } from "node:fs/promises";
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
- await mkdir(CONFIG_DIR, { recursive: true });
32
- await writeFile(CONFIG_PATH, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
33
- // The file holds an API key — keep it owner-readable only.
34
- await chmod(CONFIG_PATH, 0o600).catch(() => { });
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) {
@@ -16,6 +16,7 @@ export interface MarkdownCardInput {
16
16
  resolution?: string;
17
17
  scope?: "out";
18
18
  blockedBy?: number[];
19
+ parentCardId?: string;
19
20
  }
20
21
  export interface MarkdownBoardRef {
21
22
  _id: string;
@@ -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
  /**