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/dist/cli-args.js CHANGED
@@ -6,13 +6,21 @@
6
6
  * process — argv in, a parsed shape out.
7
7
  */
8
8
  export function parseArgs(argv) {
9
- const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false, waitFlag: false, option: [] };
9
+ const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false, waitFlag: false, follow: false, awaitReply: false, option: [] };
10
10
  for (let i = 0; i < argv.length; i++) {
11
11
  const a = argv[i];
12
12
  if (a === "--mcp")
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="))
@@ -89,6 +97,14 @@ export function parseArgs(argv) {
89
97
  args.limit = Number.parseInt(argv[++i] ?? "", 10);
90
98
  else if (a.startsWith("--limit="))
91
99
  args.limit = Number.parseInt(a.slice("--limit=".length), 10);
100
+ else if (a === "--follow")
101
+ args.follow = true;
102
+ else if (a === "--await-reply")
103
+ args.awaitReply = true;
104
+ else if (a === "--timeout")
105
+ args.timeout = Number.parseInt(argv[++i] ?? "", 10);
106
+ else if (a.startsWith("--timeout="))
107
+ args.timeout = Number.parseInt(a.slice("--timeout=".length), 10);
92
108
  else if (a === "--client")
93
109
  args.client = argv[++i];
94
110
  else if (a.startsWith("--client="))
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,9 +21,9 @@ 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
- import { capitalizeName, chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
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
26
28
 
27
29
  Get started (no account needed):
@@ -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
@@ -69,10 +72,18 @@ Chat:
69
72
  new messages stream in live
70
73
  sfora chat <room> -m "text" Send one message and exit (for scripts
71
74
  and agents)
75
+ sfora chat <room> --follow Tail the room without a prompt — for
76
+ logs and pipes (--json for NDJSON)
77
+ sfora chat <room> -m "text" --await-reply
78
+ Send, then wait for the next message
79
+ back and print it — --timeout <secs>
80
+ stops waiting (exit code 2)
72
81
 
73
82
  Chat shows others what's on the line: the CLI reports itself in presence,
74
83
  and a coding agent is named automatically (Claude Code, Codex, Cursor and
75
84
  Gemini set their own environment). Add --client <name> to say it yourself.
85
+ Everything the CLI creates — messages, posts, tasks, docs — carries the
86
+ same name, so the feed says what sent it.
76
87
 
77
88
  Ask a human:
78
89
  sfora ask "<question>" --option "A" --option "B"
@@ -169,15 +180,22 @@ async function runInit(args) {
169
180
  process.exitCode = 1;
170
181
  return;
171
182
  }
172
- const { cfg: next, key: profile } = upsertProfile(cfg, {
173
- url,
174
- org: org || cfg.org,
175
- apiKey: key,
176
- });
177
- 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);
178
186
  console.log(`${colors.green}✓${colors.reset} Saved ${path} ${colors.dim}(${profile})${colors.reset}`);
179
187
  }
180
188
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
189
+ /** A backoff sleep that a stop check can cut short: sliced into 250ms
190
+ * intervals so ^C during a 30s reconnect backoff exits promptly instead of
191
+ * waiting the sleep out (reviewer catch — --follow/--await-reply are
192
+ * unattended surfaces where a hung shutdown means a hung supervisor). */
193
+ const stoppableSleep = (stopped) => async (ms) => {
194
+ const until = Date.now() + ms;
195
+ while (Date.now() < until && !stopped()) {
196
+ await sleep(Math.min(250, until - Date.now()));
197
+ }
198
+ };
181
199
  function openBrowser(url) {
182
200
  const { command, args } = openerCommand(process.platform, url);
183
201
  try {
@@ -233,30 +251,14 @@ async function runLogin(args, baseUrl) {
233
251
  continue;
234
252
  }
235
253
  if (data.status === "approved" && data.apiKey) {
236
- const cfg = await readConfig();
237
- let next;
238
- if (args.bot) {
239
- // Save under the bot's slot; keep the user's personal key intact.
240
- next = {
241
- ...cfg,
242
- url: base,
243
- bots: {
244
- ...cfg.bots,
245
- [args.bot]: { apiKey: data.apiKey, org: data.orgSlug ?? undefined },
246
- },
247
- };
248
- }
249
- else {
250
- // Add/update this deployment+org as its own profile instead of
251
- // overwriting one shared key — so logging in here never invalidates the
252
- // key you hold for another deployment.
253
- next = upsertProfile(cfg, {
254
- url: base,
255
- org: data.orgSlug ?? undefined,
256
- apiKey: data.apiKey,
257
- }).cfg;
258
- }
259
- 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);
260
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}`);
261
263
  return;
262
264
  }
@@ -480,6 +482,7 @@ async function runVerb(args, fs, client) {
480
482
  : await readStdin();
481
483
  return writeCommandOutput(await putCommand(client, path, body, {
482
484
  blockId: args.block,
485
+ expectedRevision: args.expectedRevision,
483
486
  json: args.json,
484
487
  presence: runPresence,
485
488
  }));
@@ -539,7 +542,7 @@ async function runVerb(args, fs, client) {
539
542
  }
540
543
  if (args.command === "chat") {
541
544
  if (!args.rest[0]) {
542
- throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"]');
545
+ throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"] [--follow] [--await-reply]');
543
546
  }
544
547
  return runChat(args, client);
545
548
  }
@@ -767,7 +770,7 @@ async function runWatch(args, client, target) {
767
770
  write: (text) => process.stdout.write(text),
768
771
  // Warnings on stderr so `--json` stdout stays parseable NDJSON.
769
772
  warn: (text) => process.stderr.write(`${colors.dim}${text}${colors.reset}\n`),
770
- sleep,
773
+ sleep: stoppableSleep(() => stop),
771
774
  }, { json: args.json, since, stopped: () => stop });
772
775
  }
773
776
  finally {
@@ -829,13 +832,16 @@ async function runChat(args, client) {
829
832
  // is driving — so the app can show the terminal next to the online dot.
830
833
  const clientLabel = detectClient(process.env, args.client);
831
834
  const beat = () => client.heartbeat({ roomId: room._id, client: clientLabel }).catch(() => { });
832
- // One-shot send.
835
+ // One-shot send — with --await-reply, the wait for what comes back.
833
836
  if (args.message !== undefined) {
834
837
  const text = args.message.trim();
835
838
  if (!text)
836
839
  throw new Error('usage: sfora chat <room> -m "text"');
837
840
  // One beat alongside the send — a script that speaks was here, briefly.
838
841
  void beat();
842
+ if (args.awaitReply) {
843
+ return runSendAwaitReply(args, client, room, tag, text, clientLabel);
844
+ }
839
845
  const { messageId } = await client.sendRoomMessage(room._id, text, clientLabel);
840
846
  if (args.json) {
841
847
  console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
@@ -844,6 +850,12 @@ async function runChat(args, client) {
844
850
  console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
845
851
  return;
846
852
  }
853
+ if (args.awaitReply) {
854
+ throw new Error('usage: sfora chat <room> -m "text" --await-reply [--timeout <secs>] — the reply needs a message to reply to');
855
+ }
856
+ // The tail without the prompt — for logs, pipes, and agents.
857
+ if (args.follow)
858
+ return runChatFollow(args, client, room, tag, clientLabel);
847
859
  // History, oldest first — the page arrives newest-first.
848
860
  const limit = Number.isFinite(args.limit) && args.limit > 0
849
861
  ? Math.min(args.limit, 100)
@@ -854,6 +866,7 @@ async function runChat(args, client) {
854
866
  const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
855
867
  const seeded = history.page.slice().reverse();
856
868
  const seen = new Set(seeded.map((m) => m._id));
869
+ const pendingSendBodies = new Set();
857
870
  const messages = seeded.slice(-limit);
858
871
  if (messages.length === 0) {
859
872
  console.log(`${colors.dim}(no messages yet)${colors.reset}`);
@@ -899,11 +912,27 @@ async function runChat(args, client) {
899
912
  });
900
913
  const wait = Number.isFinite(args.wait) ? args.wait : undefined;
901
914
  const tail = chatTailLoop({
902
- poll: (since) => client.pollEvents({ since, wait, signal: inFlight.signal }),
915
+ // `includeOwn`: your own app sends ring the doorbell too, so talking in
916
+ // the web while sitting here shows both halves of the conversation. The
917
+ // seen set already keeps the CLI's own sends from printing twice.
918
+ poll: (since) => client.pollEvents({
919
+ since,
920
+ wait,
921
+ includeOwn: true,
922
+ signal: inFlight.signal,
923
+ }),
903
924
  fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
904
- print: (msg) => printAbove(renderChatMessage(msg, Date.now())),
925
+ // With includeOwn on, a send's event can race its own HTTP response:
926
+ // the tail may refetch before `seen.add(messageId)` runs. Bodies in
927
+ // flight are suppressed for that window (the typed line on screen is
928
+ // the echo); the id joins `seen` when the send resolves.
929
+ print: (msg) => {
930
+ if (pendingSendBodies.has(msg.body))
931
+ return;
932
+ printAbove(renderChatMessage(msg, Date.now()));
933
+ },
905
934
  warn: (text) => printAbove(`${colors.dim}${text}${colors.reset}`),
906
- sleep,
935
+ sleep: stoppableSleep(() => stop),
907
936
  }, {
908
937
  roomId: room._id,
909
938
  seen,
@@ -924,6 +953,7 @@ async function runChat(args, client) {
924
953
  }
925
954
  if (line === "/quit" || line === "/exit" || line === "/q")
926
955
  break;
956
+ pendingSendBodies.add(line);
927
957
  try {
928
958
  // The send's own echo is the typed line still on screen; recording the
929
959
  // id keeps the tail's refetch from printing it a second time.
@@ -934,6 +964,9 @@ async function runChat(args, client) {
934
964
  const msg = e instanceof Error ? e.message : String(e);
935
965
  process.stderr.write(`${colors.red}error:${colors.reset} ${msg}\n`);
936
966
  }
967
+ finally {
968
+ pendingSendBodies.delete(line);
969
+ }
937
970
  if (isTty)
938
971
  rl.prompt();
939
972
  }
@@ -948,6 +981,165 @@ async function runChat(args, client) {
948
981
  if (isTty)
949
982
  console.log("");
950
983
  }
984
+ /**
985
+ * `sfora chat <room> --follow` — the tail without the prompt.
986
+ *
987
+ * For logs, pipes, and agents: history first (respecting `-n`), then new
988
+ * messages as they arrive — no prompt, no TTY needed. The poll asks for the
989
+ * caller's OWN messages too (`includeOwn`), so the same person talking from
990
+ * the app appears here; nothing prints twice, the seen set is the ledger.
991
+ * `--json` emits one NDJSON object per message. ^C exits cleanly.
992
+ */
993
+ async function runChatFollow(args, client, room, tag, clientLabel) {
994
+ const emit = (msg) => console.log(args.json ? chatMessageJson(msg) : renderChatMessage(msg, Date.now()));
995
+ // History, oldest first — seeded wide enough that the doorbell's refetch
996
+ // (20) never "discovers" older history, printed only as far as -n asked.
997
+ const limit = Number.isFinite(args.limit) && args.limit > 0
998
+ ? Math.min(args.limit, 100)
999
+ : 30;
1000
+ const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
1001
+ const seeded = history.page.slice().reverse();
1002
+ const seen = new Set(seeded.map((m) => m._id));
1003
+ for (const msg of seeded.slice(-limit))
1004
+ emit(msg);
1005
+ process.stderr.write(`${colors.dim}following ${tag} — ^C to stop${colors.reset}\n`);
1006
+ // Following is being in the room: beat now, then every ~45s, same as the
1007
+ // interactive chat. `unref` so the timer never holds the process open.
1008
+ const beat = () => client.heartbeat({ roomId: room._id, client: clientLabel }).catch(() => { });
1009
+ void beat();
1010
+ const pulse = setInterval(() => void beat(), 45_000);
1011
+ pulse.unref?.();
1012
+ // ^C has to reach the socket — the long-poll hangs for tens of seconds.
1013
+ // Second ^C stands down and lets the default behaviour end things.
1014
+ let stop = false;
1015
+ const inFlight = new AbortController();
1016
+ let signalled = false;
1017
+ const onSignal = (signal) => {
1018
+ stop = true;
1019
+ inFlight.abort();
1020
+ if (signalled) {
1021
+ process.off("SIGINT", onSignal);
1022
+ process.off("SIGTERM", onSignal);
1023
+ process.kill(process.pid, signal);
1024
+ return;
1025
+ }
1026
+ signalled = true;
1027
+ };
1028
+ process.on("SIGINT", onSignal);
1029
+ process.on("SIGTERM", onSignal);
1030
+ const wait = Number.isFinite(args.wait) ? args.wait : undefined;
1031
+ try {
1032
+ await chatTailLoop({
1033
+ poll: (since) => client.pollEvents({
1034
+ since,
1035
+ wait,
1036
+ includeOwn: true,
1037
+ signal: inFlight.signal,
1038
+ }),
1039
+ fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
1040
+ print: emit,
1041
+ warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
1042
+ sleep: stoppableSleep(() => stop),
1043
+ }, {
1044
+ roomId: room._id,
1045
+ seen,
1046
+ // Cursor on SERVER time — see the interactive tail.
1047
+ since: history.page[0]?._creationTime ?? Date.now(),
1048
+ stopped: () => stop,
1049
+ });
1050
+ }
1051
+ finally {
1052
+ clearInterval(pulse);
1053
+ process.off("SIGINT", onSignal);
1054
+ process.off("SIGTERM", onSignal);
1055
+ }
1056
+ // A parting beat, best-effort — awaited so exit doesn't race it.
1057
+ await beat();
1058
+ }
1059
+ /**
1060
+ * `sfora chat <room> -m "…" --await-reply` — send, then hold the line.
1061
+ *
1062
+ * Blocks until the NEXT message that is not the send itself lands in the
1063
+ * room, prints it, and exits 0 — the reply is picked by message id, not
1064
+ * author, so the same human answering from the app counts. `--timeout <secs>`
1065
+ * gives up with exit code 2. Everything already said before the send is
1066
+ * seeded as seen, so an old message can never pose as the answer.
1067
+ */
1068
+ async function runSendAwaitReply(args, client, room, tag, text, clientLabel) {
1069
+ // Seed BEFORE the send: the history is not the reply, and the events cursor
1070
+ // is server time, so a fast local clock opens no blind window.
1071
+ const history = await client.listRoomMessages(room._id, 50);
1072
+ const seen = new Set(history.page.map((m) => m._id));
1073
+ const since = history.page[0]?._creationTime ?? Date.now();
1074
+ // A mistyped --timeout must fail loudly, not silently wait forever — the
1075
+ // exact opposite of what the flag asked for (reviewer catch).
1076
+ if (args.timeout !== undefined && (!Number.isFinite(args.timeout) || args.timeout <= 0)) {
1077
+ throw new Error("--timeout needs a positive number of seconds");
1078
+ }
1079
+ const { messageId } = await client.sendRoomMessage(room._id, text, clientLabel);
1080
+ seen.add(messageId);
1081
+ const deadlineMs = args.timeout !== undefined ? Date.now() + args.timeout * 1000 : undefined;
1082
+ if (args.json) {
1083
+ // NDJSON-shaped: this line says it landed; the reply is the next line.
1084
+ console.log(JSON.stringify({ messageId, roomId: room._id }));
1085
+ }
1086
+ else {
1087
+ console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
1088
+ process.stderr.write(`${colors.dim}waiting for a reply${deadlineMs ? ` (up to ${args.timeout}s)` : ""} — ^C to stop${colors.reset}\n`);
1089
+ }
1090
+ // Same signal shape as `ask --wait`: ^C reaches the socket, second ^C
1091
+ // stands down and lets the default behaviour end things.
1092
+ let stop = false;
1093
+ const inFlight = new AbortController();
1094
+ let signalled = false;
1095
+ const onSignal = (signal) => {
1096
+ stop = true;
1097
+ inFlight.abort();
1098
+ if (signalled) {
1099
+ process.off("SIGINT", onSignal);
1100
+ process.off("SIGTERM", onSignal);
1101
+ process.kill(process.pid, signal);
1102
+ return;
1103
+ }
1104
+ signalled = true;
1105
+ };
1106
+ process.on("SIGINT", onSignal);
1107
+ process.on("SIGTERM", onSignal);
1108
+ try {
1109
+ const result = await awaitReplyLoop({
1110
+ // `includeOwn`: the same member answering from the app must ring the
1111
+ // doorbell — the reply filter is by message id, not author. With a
1112
+ // deadline, each poll's server-side budget is capped to what is left.
1113
+ poll: (cursor) => client.pollEvents({
1114
+ since: cursor,
1115
+ wait: deadlineMs
1116
+ ? Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))
1117
+ : undefined,
1118
+ includeOwn: true,
1119
+ signal: inFlight.signal,
1120
+ }),
1121
+ fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
1122
+ warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
1123
+ sleep: stoppableSleep(() => stop),
1124
+ }, { roomId: room._id, seen, since, deadlineMs, stopped: () => stop });
1125
+ if (result.outcome === "reply") {
1126
+ console.log(args.json
1127
+ ? chatMessageJson(result.message)
1128
+ : renderChatMessage(result.message, Date.now()));
1129
+ }
1130
+ else if (result.outcome === "timeout") {
1131
+ process.stderr.write(`${colors.dim}no reply within ${args.timeout}s${colors.reset}\n`);
1132
+ // 2, not 1: "nobody answered" is its own branch for a script — distinct
1133
+ // from "the send failed", which throws and exits 1.
1134
+ process.exitCode = 2;
1135
+ }
1136
+ // stopped (^C): say nothing — the message is sent and stands.
1137
+ }
1138
+ finally {
1139
+ process.off("SIGINT", onSignal);
1140
+ process.off("SIGTERM", onSignal);
1141
+ }
1142
+ }
951
1143
  /**
952
1144
  * `sfora ask` — how an agent asks a human, wired to the real process.
953
1145
  *
@@ -1063,7 +1255,7 @@ async function runAsk(args, client) {
1063
1255
  signal: inFlight.signal,
1064
1256
  }),
1065
1257
  warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
1066
- sleep,
1258
+ sleep: stoppableSleep(() => stop),
1067
1259
  }, { askId: created.askId, since, deadlineMs, stopped: () => stop });
1068
1260
  if (result.outcome === "answered") {
1069
1261
  const answer = result.chosenOption ?? result.resolution;
@@ -1285,9 +1477,26 @@ Everything is git-versioned with your repo. ${colors.dim}Connect a team later wi
1285
1477
  Type ${colors.cyan}exit${colors.reset} to quit.
1286
1478
  `;
1287
1479
  async function main() {
1480
+ if (["--version", "-v"].includes(process.argv[2] ?? "")) {
1481
+ console.log(`sfora-cli ${CLI_VERSION}`);
1482
+ return;
1483
+ }
1288
1484
  const args = parseArgs(process.argv.slice(2));
1289
1485
  if (args.help) {
1290
- 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
+ });
1291
1500
  return;
1292
1501
  }
1293
1502
  if (args.command === "init") {
@@ -1303,7 +1512,17 @@ async function main() {
1303
1512
  }
1304
1513
  const cfg = await readConfig();
1305
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
+ }
1306
1519
  const { url: baseUrl, apiKey } = settings;
1520
+ // Once per invocation: what kind of client is on the line. The api client
1521
+ // sends it on every request (`X-Sfora-Client`), so every creation door
1522
+ // stamps its work — a post made from Claude Code says so on the feed, the
1523
+ // same way a chat message does. Sent always, "cli" included: a plain
1524
+ // terminal is honest attribution too.
1525
+ const clientLabel = detectClient(process.env, args.client);
1307
1526
  if (args.command === "mcp-config") {
1308
1527
  printMcpConfig(settings);
1309
1528
  return;
@@ -1344,6 +1563,7 @@ async function main() {
1344
1563
  apiKey: apiKey ?? "",
1345
1564
  org: settings.org ?? "",
1346
1565
  localRoot,
1566
+ clientLabel,
1347
1567
  });
1348
1568
  return;
1349
1569
  }
@@ -1372,6 +1592,7 @@ async function main() {
1372
1592
  apiKey,
1373
1593
  org: settings.org ?? "",
1374
1594
  actAs: args.as,
1595
+ clientLabel,
1375
1596
  presence: runPresence,
1376
1597
  });
1377
1598
  try {
@@ -1392,7 +1613,7 @@ async function main() {
1392
1613
  }
1393
1614
  // MCP mode: stdout is the protocol channel — never write logs there.
1394
1615
  if (args.mcp) {
1395
- await runMcpServer({ baseUrl, apiKey, org: settings.org ?? "" });
1616
+ await runMcpServer({ baseUrl, apiKey, org: settings.org ?? "", clientLabel });
1396
1617
  return;
1397
1618
  }
1398
1619
  if (!settings.org) {
@@ -1406,6 +1627,7 @@ async function main() {
1406
1627
  apiKey,
1407
1628
  org,
1408
1629
  cwd: args.cwd,
1630
+ clientLabel,
1409
1631
  presence: runPresence,
1410
1632
  });
1411
1633
  // Pre-flight: confirm auth + connectivity and greet with the resolved identity.
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
@@ -25,6 +25,12 @@ export interface CreateSforaShellOptions {
25
25
  cwd?: string;
26
26
  /** Act/post as an owned agent, using this key (sent as `X-Sfora-Act-As`). */
27
27
  actAs?: string;
28
+ /**
29
+ * What kind of client is on the line ("claude-code", "cli"). Sent as
30
+ * `X-Sfora-Client` on every request so writes are stamped with the terminal
31
+ * that made them — the CLI passes `detectClient(process.env, --client)`.
32
+ */
33
+ clientLabel?: string;
28
34
  /**
29
35
  * The run's "you are visible" latch. Pass one when something OUTSIDE this
30
36
  * shell can print the note too — the CLI does, after any line that wrote —
@@ -69,5 +75,6 @@ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, t
69
75
  export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
70
76
  export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, type WatchDeps, type WatchOptions, type WatchTarget, } from "./watch.js";
71
77
  export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, type DocPing, } from "./render.js";
72
- export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug } from "./chat.js";
78
+ export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, type ChatTailDeps, type ChatTailOptions, type AwaitReplyDeps, type AwaitReplyOptions, type AwaitReplyResult, } from "./chat.js";
73
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
@@ -17,6 +17,7 @@ export function createSforaShell(options) {
17
17
  baseUrl: options.baseUrl,
18
18
  apiKey: options.apiKey,
19
19
  actAs: options.actAs,
20
+ clientLabel: options.clientLabel,
20
21
  });
21
22
  const presence = options.presence ?? presenceNotice();
22
23
  const fs = new SforaFs(client);
@@ -57,5 +58,6 @@ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, }
57
58
  export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
58
59
  export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, } from "./watch.js";
59
60
  export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, } from "./render.js";
60
- export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug } from "./chat.js";
61
+ export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, } from "./chat.js";
61
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
  /**