sfora-cli 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/dist/SforaFs.js +68 -0
- package/dist/api-client.d.ts +43 -0
- package/dist/api-client.js +41 -53
- package/dist/block-commands.d.ts +1 -0
- package/dist/block-commands.js +1 -0
- package/dist/chat.d.ts +55 -0
- package/dist/chat.js +75 -0
- package/dist/cli-args.d.ts +7 -0
- package/dist/cli-args.js +17 -1
- package/dist/cli.js +264 -42
- package/dist/config.d.ts +2 -0
- package/dist/config.js +15 -6
- package/dist/format/cardMarkdown.d.ts +1 -0
- package/dist/format/cardMarkdown.js +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +3 -1
- package/dist/local/workspace.d.ts +0 -16
- package/dist/local/workspace.js +34 -28
- package/dist/local-core/files.d.ts +23 -0
- package/dist/local-core/files.js +110 -0
- package/dist/local-core/index.d.ts +4 -0
- package/dist/local-core/index.js +4 -0
- package/dist/local-core/skills.d.ts +45 -0
- package/dist/local-core/skills.js +239 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +1 -0
- package/dist/skills-client.d.ts +21 -0
- package/dist/skills-client.js +36 -0
- package/dist/skills-command.d.ts +4 -0
- package/dist/skills-command.js +75 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +20 -2
- package/dist/config.test.d.ts +0 -1
- package/dist/config.test.js +0 -94
package/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,
|
|
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 {
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
@@ -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
|
/**
|