sfora-cli 0.15.0 → 0.17.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.
Files changed (94) hide show
  1. package/README.md +80 -1
  2. package/dist/agent-webhook.d.ts +20 -0
  3. package/dist/agent-webhook.js +42 -0
  4. package/dist/api-client.d.ts +97 -0
  5. package/dist/api-client.js +68 -0
  6. package/dist/ask.d.ts +51 -0
  7. package/dist/ask.js +70 -0
  8. package/dist/attachments-node.d.ts +7 -0
  9. package/dist/attachments-node.js +15 -0
  10. package/dist/attachments.d.ts +112 -0
  11. package/dist/attachments.js +254 -0
  12. package/dist/block-commands.d.ts +10 -0
  13. package/dist/block-commands.js +28 -0
  14. package/dist/chat.d.ts +15 -0
  15. package/dist/chat.js +7 -0
  16. package/dist/cli-args.d.ts +7 -0
  17. package/dist/cli-args.js +28 -1
  18. package/dist/cli.d.ts +12 -1
  19. package/dist/cli.js +186 -19
  20. package/dist/format/linkUrls.d.ts +2 -0
  21. package/dist/format/linkUrls.js +48 -0
  22. package/dist/format/postMarkdown.d.ts +12 -1
  23. package/dist/format/postMarkdown.js +9 -2
  24. package/dist/index.d.ts +23 -1
  25. package/dist/index.js +17 -1
  26. package/dist/local-core/files.d.ts +1 -1
  27. package/dist/local-core/files.js +2 -2
  28. package/dist/local-core/index.d.ts +12 -0
  29. package/dist/local-core/index.js +11 -0
  30. package/dist/local-core/skill-adapters.d.ts +21 -0
  31. package/dist/local-core/skill-adapters.js +19 -0
  32. package/dist/local-core/skill-discovery.d.ts +22 -0
  33. package/dist/local-core/skill-discovery.js +79 -0
  34. package/dist/local-core/skill-domain.d.ts +74 -0
  35. package/dist/local-core/skill-domain.js +1 -0
  36. package/dist/local-core/skill-executor.d.ts +23 -0
  37. package/dist/local-core/skill-executor.js +51 -0
  38. package/dist/local-core/skill-index.d.ts +54 -0
  39. package/dist/local-core/skill-index.js +115 -0
  40. package/dist/local-core/skill-local-executor.d.ts +18 -0
  41. package/dist/local-core/skill-local-executor.js +249 -0
  42. package/dist/local-core/skill-operations.d.ts +61 -0
  43. package/dist/local-core/skill-operations.js +268 -0
  44. package/dist/local-core/skill-review.d.ts +46 -0
  45. package/dist/local-core/skill-review.js +132 -0
  46. package/dist/local-core/skill-service.d.ts +96 -0
  47. package/dist/local-core/skill-service.js +157 -0
  48. package/dist/local-core/skill-store.d.ts +34 -0
  49. package/dist/local-core/skill-store.js +187 -0
  50. package/dist/local-core/skill-sync.d.ts +132 -0
  51. package/dist/local-core/skill-sync.js +111 -0
  52. package/dist/local-core/skills.d.ts +35 -0
  53. package/dist/local-core/skills.js +142 -37
  54. package/dist/mcp-description.d.ts +11 -0
  55. package/dist/mcp-description.js +29 -0
  56. package/dist/mcp-server.d.ts +5 -1
  57. package/dist/mcp-server.js +28 -18
  58. package/dist/shell-commands.d.ts +7 -1
  59. package/dist/shell-commands.js +49 -3
  60. package/dist/skills-client.d.ts +13 -2
  61. package/dist/skills-client.js +57 -5
  62. package/dist/skills-command.d.ts +1 -1
  63. package/dist/skills-command.js +171 -4
  64. package/dist/skills-packet/sfora-asks/SKILL.md +49 -0
  65. package/dist/skills-packet/sfora-asks/references/asks.md +41 -0
  66. package/dist/skills-packet/sfora-board/SKILL.md +53 -0
  67. package/dist/skills-packet/sfora-board/references/board.md +60 -0
  68. package/dist/skills-packet/sfora-board/references/plan.md +25 -0
  69. package/dist/skills-packet/sfora-chat/SKILL.md +55 -0
  70. package/dist/skills-packet/sfora-chat/references/rooms.md +45 -0
  71. package/dist/skills-packet/sfora-chat/references/waiting.md +37 -0
  72. package/dist/skills-packet/sfora-live-edit/SKILL.md +62 -0
  73. package/dist/skills-packet/sfora-live-edit/references/collisions.md +54 -0
  74. package/dist/skills-packet/sfora-live-edit/references/http.md +63 -0
  75. package/dist/skills-packet/sfora-live-edit/references/live-editing.md +49 -0
  76. package/dist/skills-packet/sfora-setup/SKILL.md +37 -0
  77. package/dist/skills-packet/sfora-setup/references/sign-in.md +43 -0
  78. package/dist/skills-packet/sfora-skills/SKILL.md +54 -0
  79. package/dist/skills-packet/sfora-skills/references/skills.md +78 -0
  80. package/dist/skills-packet/sfora-troubleshoot/SKILL.md +39 -0
  81. package/dist/skills-packet/sfora-troubleshoot/references/sharp-edges.md +80 -0
  82. package/dist/skills-packet/sfora-write/SKILL.md +53 -0
  83. package/dist/skills-packet/sfora-write/references/attachments.md +15 -0
  84. package/dist/skills-packet/sfora-write/references/blocks.md +36 -0
  85. package/dist/skills-packet/sfora-write/references/posts-and-docs.md +56 -0
  86. package/dist/skills-packet.d.ts +63 -0
  87. package/dist/skills-packet.js +166 -0
  88. package/dist/typing.d.ts +23 -0
  89. package/dist/typing.js +62 -0
  90. package/dist/version.d.ts +1 -1
  91. package/dist/version.js +1 -1
  92. package/dist/watch.d.ts +78 -1
  93. package/dist/watch.js +109 -0
  94. package/package.json +4 -4
package/dist/cli.js CHANGED
@@ -8,22 +8,28 @@
8
8
  */
9
9
  import { CLI_VERSION } from "./version.js";
10
10
  import { runSkillsCommand, SKILLS_HELP } from "./skills-command.js";
11
+ import { agentWebhookCommand } from "./agent-webhook.js";
11
12
  import * as readline from "node:readline";
12
13
  import { spawn } from "node:child_process";
13
14
  import { realpath, readFile as readLocalFile } from "node:fs/promises";
15
+ import { realpathSync } from "node:fs";
16
+ import { fileURLToPath } from "node:url";
14
17
  import { basename } from "node:path";
15
18
  import { taskUploadFilename } from "./format/taskUploadFilename.js";
16
19
  import { createSforaShell, createLocalShell, SforaApiError, } from "./index.js";
17
- import { askWaitLoop, reshapeCandidatesError, validateAskOptions, } from "./ask.js";
20
+ import { askActionCommand, askWaitLoop, parseAskAction, reshapeCandidatesError, validateAskOptions, } from "./ask.js";
18
21
  import { openerCommand } from "./opener.js";
19
- import { blocksCommand, presenceNotice, putCommand, } from "./block-commands.js";
22
+ import { blocksCommand, backlinksCommand, presenceNotice, putCommand, } from "./block-commands.js";
20
23
  import { colors, ndjson, presenceRecords, renderPresence, renderWriteEffect, urlLine, PRESENCE_NOTE, } from "./render.js";
21
- import { parseWatchTarget, watchLoop } from "./watch.js";
24
+ import { editingPresence, myBlockIn, parseWatchTarget, watchLoop } from "./watch.js";
22
25
  import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, } from "./local/workspace.js";
23
26
  import { runMcpServer } from "./mcp-server.js";
24
27
  import { readConfig, updateConfig, resolveSettings, upsertProfile, effectiveProfiles, profileKey, DEFAULT_URL, } from "./config.js";
25
28
  import { parseArgs } from "./cli-args.js";
26
- import { awaitReplyLoop, capitalizeName, chatMessageJson, chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
29
+ import { awaitReplyLoop, capitalizeName, chatMessageJson, chatMode, chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
30
+ import { typingCommand } from "./typing.js";
31
+ import { attachmentsCommand } from "./attachments.js";
32
+ import { nodeAttachmentIo } from "./attachments-node.js";
27
33
  const HELP = `sfora — the CLI for your sfora workspace
28
34
 
29
35
  Get started (no account needed):
@@ -57,6 +63,9 @@ Browse & read:
57
63
  sfora me Show who you're signed in as
58
64
  sfora ls [path] List a path (default /projects)
59
65
  sfora cat <path> Print a file's markdown
66
+ sfora attachments <post> List a post's attachments (screenshots, files)
67
+ sfora attachments <post> --out <dir>
68
+ Download them into <dir>; prints each path
60
69
  sfora url <path> Print the web URL for a path
61
70
  sfora open <path> Open that URL in your browser
62
71
  sfora desktop <file.md> Open local Markdown in Sfora for macOS
@@ -69,7 +78,9 @@ Chat:
69
78
  sfora rooms List rooms (● joined · ○ open to join)
70
79
  sfora join <room> Join an open room
71
80
  sfora chat <room> [-n <count>] Read the room, then type to talk —
72
- new messages stream in live
81
+ new messages stream in live. Without
82
+ a terminal (agents, pipes) it prints
83
+ the history and exits (--json: NDJSON)
73
84
  sfora chat <room> -m "text" Send one message and exit (for scripts
74
85
  and agents)
75
86
  sfora chat <room> --follow Tail the room without a prompt — for
@@ -78,6 +89,10 @@ Chat:
78
89
  Send, then wait for the next message
79
90
  back and print it — --timeout <secs>
80
91
  stops waiting (exit code 2)
92
+ sfora typing <room> [--for <secs>] Agents: show the room you're working on
93
+ a reply (30s by default, 5–120; run
94
+ again to extend). It ends when you send
95
+ there, or with --stop
81
96
 
82
97
  Chat shows others what's on the line: the CLI reports itself in presence,
83
98
  and a coding agent is named automatically (Claude Code, Codex, Cursor and
@@ -97,13 +112,23 @@ Ask a human:
97
112
  the choice (give secs to stop waiting)
98
113
  Add --project <slug> or --room <room> to say where it belongs; --json for
99
114
  scripts (--wait prints a second JSON line when the answer lands).
115
+ sfora ask claim <ask-id> Take an open ask before working on it —
116
+ fails if someone else holds it
117
+ sfora ask resolve <ask-id> [-m "<resolution>"]
118
+ Report the ask done (the claimant, or
119
+ an admin). Ids: sfora cat
120
+ /projects/<slug>/asks.md
100
121
 
101
122
  Write, and watch others write:
102
123
  sfora blocks <path> List a document's addressable blocks
103
124
  sfora put <path> <file.md> Write a file (add --block <id> for one block)
104
125
  sfora put <path> --block <id> - …or pipe the block's markdown on stdin
105
126
  sfora watch <path-or-project> Stream write pings (add --json for NDJSON)
127
+ sfora watch <doc> --block <id> …and show as editing that block: prints
128
+ who is here, again when that changes,
129
+ and leaves on exit (ids: sfora blocks)
106
130
  sfora where [name] Who's in which document right now
131
+ sfora backlinks <path> What links to a doc, post or task
107
132
 
108
133
  Every write prints what it did — "changed" (with how many block ids
109
134
  survived) or "no change". Watching and writing make you visible in the
@@ -111,6 +136,13 @@ Write, and watch others write:
111
136
  asking never puts you in a document.
112
137
 
113
138
  Agents & config:
139
+ sfora agent webhook <name> --url <https://…> --events <a,b>
140
+ Set an agent's webhook with your key (its
141
+ owner, or a workspace owner/admin). Events:
142
+ message.created, mention, post.created,
143
+ post.commented. --clear removes it. Here
144
+ --url is the webhook's address; set the
145
+ API's with SFORA_URL
114
146
  sfora --mcp [--org <slug>] Run as an MCP server (for agents)
115
147
  sfora mcp-config Print an MCP config snippet to paste
116
148
  sfora contexts List saved deployment+org keys (● = active)
@@ -129,10 +161,12 @@ const SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— yo
129
161
  Standard tools work against your workspace:
130
162
  ls cat grep find head tail wc sed awk echo cd pwd
131
163
 
132
- ${colors.dim}And three of sfora's own${colors.reset}
164
+ ${colors.dim}And five of sfora's own${colors.reset}
133
165
  blocks <path> the blocks a --block write can aim at
134
166
  put <path> [file|-] write a file (--block <id> writes one block)
135
167
  url <path> where it lives on the web
168
+ backlinks <path> what links to it, with the token to paste
169
+ attachments <post> the files attached to a post
136
170
 
137
171
  ${colors.dim}Where things live${colors.reset}
138
172
  /projects your projects
@@ -366,6 +400,8 @@ const VERBS = new Set([
366
400
  "open",
367
401
  "blocks",
368
402
  "put",
403
+ "backlinks",
404
+ "attachments",
369
405
  "watch",
370
406
  "where",
371
407
  "post",
@@ -382,7 +418,9 @@ const VERBS = new Set([
382
418
  "rooms",
383
419
  "join",
384
420
  "chat",
421
+ "typing",
385
422
  "ask",
423
+ "agent",
386
424
  ]);
387
425
  async function runVerb(args, fs, client) {
388
426
  const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
@@ -471,6 +509,21 @@ async function runVerb(args, fs, client) {
471
509
  throw new Error("usage: sfora blocks <path> [--json]");
472
510
  return writeCommandOutput(await blocksCommand(client, args.rest[0], { json: args.json }));
473
511
  }
512
+ if (args.command === "backlinks") {
513
+ if (!args.rest[0])
514
+ throw new Error("usage: sfora backlinks <path> [--json]");
515
+ return writeCommandOutput(await backlinksCommand(client, args.rest[0], { json: args.json }));
516
+ }
517
+ if (args.command === "attachments") {
518
+ if (!args.rest[0]) {
519
+ throw new Error("usage: sfora attachments <post> [--out <dir>] [--json]");
520
+ }
521
+ return writeCommandOutput(await attachmentsCommand(client, args.rest[0], {
522
+ out: args.out,
523
+ json: args.json,
524
+ io: nodeAttachmentIo,
525
+ }));
526
+ }
474
527
  if (args.command === "put") {
475
528
  const path = args.rest[0];
476
529
  if (!path) {
@@ -489,7 +542,7 @@ async function runVerb(args, fs, client) {
489
542
  }
490
543
  if (args.command === "watch") {
491
544
  if (!args.rest[0]) {
492
- throw new Error("usage: sfora watch <path-or-project> [--json] [--self] [--wait <secs>]");
545
+ throw new Error("usage: sfora watch <path-or-project> [--block <id>] [--json] [--self] [--wait <secs>]");
493
546
  }
494
547
  return runWatch(args, client, args.rest[0]);
495
548
  }
@@ -546,9 +599,55 @@ async function runVerb(args, fs, client) {
546
599
  }
547
600
  return runChat(args, client);
548
601
  }
602
+ if (args.command === "typing") {
603
+ const out = await typingCommand(client, args.rest[0], {
604
+ // `--for` is parsed once for `ask --for <member>`; here it is seconds.
605
+ seconds: args.target,
606
+ stop: args.stop,
607
+ json: args.json,
608
+ });
609
+ if (out.stdout)
610
+ process.stdout.write(out.stdout);
611
+ if (out.stderr)
612
+ process.stderr.write(out.stderr);
613
+ if (out.exitCode !== 0)
614
+ process.exitCode = out.exitCode;
615
+ return;
616
+ }
549
617
  if (args.command === "ask") {
618
+ // `ask claim <id>` / `ask resolve <id>` only when it cannot be a question
619
+ // (see parseAskAction); everything else creates an ask, as it always has.
620
+ const action = parseAskAction(args.rest, args.option);
621
+ if (action) {
622
+ const out = await askActionCommand(client, action.action, action.askId, {
623
+ resolution: args.message,
624
+ json: args.json,
625
+ });
626
+ if (out.stdout)
627
+ process.stdout.write(out.stdout);
628
+ if (out.stderr)
629
+ process.stderr.write(out.stderr);
630
+ if (out.exitCode !== 0)
631
+ process.exitCode = out.exitCode;
632
+ return;
633
+ }
550
634
  return runAsk(args, client);
551
635
  }
636
+ if (args.command === "agent") {
637
+ const out = await agentWebhookCommand(client, args.rest, {
638
+ webhookUrl: args.webhookUrl,
639
+ events: args.events,
640
+ clear: args.clear,
641
+ json: args.json,
642
+ });
643
+ if (out.stdout)
644
+ process.stdout.write(out.stdout);
645
+ if (out.stderr)
646
+ process.stderr.write(out.stderr);
647
+ if (out.exitCode !== 0)
648
+ process.exitCode = out.exitCode;
649
+ return;
650
+ }
552
651
  if (args.command === "me" || args.command === "whoami") {
553
652
  const text = (await client.readMe()).trim();
554
653
  if (args.json) {
@@ -712,7 +811,7 @@ async function runWatch(args, client, target) {
712
811
  // Presence, if this path has a roster. Asked rather than inferred: a `422`
713
812
  // is the server saying "no roster here", and posts and cards get one.
714
813
  let canBeat = parsed.kind === "path";
715
- const beat = async ({ leave }) => {
814
+ let beat = async ({ leave }) => {
716
815
  if (!canBeat || parsed.kind !== "path")
717
816
  return;
718
817
  const roster = await client.declarePresence(parsed.path, {
@@ -722,6 +821,25 @@ async function runWatch(args, client, target) {
722
821
  if (roster === null)
723
822
  canBeat = false;
724
823
  };
824
+ // `--block <id>`: the editor of one block, not a viewer of the document.
825
+ // Declared once up front so a path with no roster, or an id that names no
826
+ // block, is a clear refusal instead of a watch that never shows up.
827
+ if (args.block !== undefined) {
828
+ if (parsed.kind !== "path") {
829
+ throw new Error(`--block needs a document path, not a project — sfora watch /projects/${parsed.slug}/docs/<file>.md --block <id>`);
830
+ }
831
+ const path = parsed.path;
832
+ const editing = editingPresence({
833
+ declare: (o) => client.declarePresence(path, o),
834
+ // A read, not a declare: where the server holds MY claim now, so the
835
+ // watch follows a block whose id an edit changed.
836
+ whereAmI: async () => myBlockIn(await client.listPresence("self"), { docId, path }),
837
+ write: (text) => process.stdout.write(text),
838
+ warn: (text) => process.stderr.write(`${colors.dim}${text}${colors.reset}\n`),
839
+ }, { path, block: args.block, json: args.json });
840
+ await editing.start();
841
+ beat = editing.beat;
842
+ }
725
843
  // Start at NOW, not at the retained backlog: `watch` is a live channel, and
726
844
  // opening one should not replay a day of history nobody was waiting for.
727
845
  const since = Date.now();
@@ -754,7 +872,7 @@ async function runWatch(args, client, target) {
754
872
  process.on("SIGINT", onSignal);
755
873
  process.on("SIGTERM", onSignal);
756
874
  if (!args.json) {
757
- process.stderr.write(`${colors.dim}watching ${parsed.kind === "project" ? `project ${parsed.slug}` : parsed.path}${args.self ? "" : " (not your own writes)"} — ^C to stop${colors.reset}\n`);
875
+ process.stderr.write(`${colors.dim}watching ${parsed.kind === "project" ? `project ${parsed.slug}` : parsed.path}${args.block !== undefined ? `, editing block ${args.block}` : ""}${args.self ? "" : " (not your own writes)"} — ^C to stop${colors.reset}\n`);
758
876
  }
759
877
  try {
760
878
  await watchLoop({
@@ -868,6 +986,20 @@ async function runChat(args, client) {
868
986
  const seen = new Set(seeded.map((m) => m._id));
869
987
  const pendingSendBodies = new Set();
870
988
  const messages = seeded.slice(-limit);
989
+ // No terminal on stdin (an agent, a pipe): read the room and exit. Opening
990
+ // the prompt there would wait on stdin for EOF and send what it read.
991
+ if (chatMode({ message: args.message, follow: args.follow, stdinIsTty: isTty }) === "read") {
992
+ if (args.json) {
993
+ for (const msg of messages)
994
+ console.log(chatMessageJson(msg));
995
+ return;
996
+ }
997
+ if (messages.length === 0)
998
+ console.log(`${colors.dim}(no messages yet)${colors.reset}`);
999
+ for (const msg of messages)
1000
+ console.log(renderChatMessage(msg, Date.now()));
1001
+ return;
1002
+ }
871
1003
  if (messages.length === 0) {
872
1004
  console.log(`${colors.dim}(no messages yet)${colors.reset}`);
873
1005
  }
@@ -1476,12 +1608,17 @@ ${colors.dim}Try${colors.reset}
1476
1608
  Everything is git-versioned with your repo. ${colors.dim}Connect a team later with${colors.reset} ${colors.cyan}sfora login${colors.reset}.
1477
1609
  Type ${colors.cyan}exit${colors.reset} to quit.
1478
1610
  `;
1479
- async function main() {
1480
- if (["--version", "-v"].includes(process.argv[2] ?? "")) {
1611
+ /**
1612
+ * The whole CLI for one argv (what follows `sfora`). Exported so a harness can
1613
+ * drive the real command table in-process; the bin below calls it with
1614
+ * `process.argv.slice(2)`.
1615
+ */
1616
+ export async function runCli(argv) {
1617
+ if (["--version", "-v"].includes(argv[0] ?? "")) {
1481
1618
  console.log(`sfora-cli ${CLI_VERSION}`);
1482
1619
  return;
1483
1620
  }
1484
- const args = parseArgs(process.argv.slice(2));
1621
+ const args = parseArgs(argv);
1485
1622
  if (args.help) {
1486
1623
  process.stdout.write(HELP + "\n" + SKILLS_HELP);
1487
1624
  return;
@@ -1537,7 +1674,8 @@ async function main() {
1537
1674
  }
1538
1675
  // Local mode — a .sfora/ workspace in (an ancestor of) cwd wins, unless the
1539
1676
  // user explicitly targets the cloud (--cloud / --url / --org / --key / --bot).
1540
- const cloudIntent = args.cloud || !!args.url || !!args.org || !!args.key || !!args.bot;
1677
+ // `agent …` is cloud-only: agents and their webhooks live in a workspace.
1678
+ const cloudIntent = args.cloud || !!args.url || !!args.org || !!args.key || !!args.bot || args.command === "agent";
1541
1679
  const localRoot = cloudIntent
1542
1680
  ? undefined
1543
1681
  : await findWorkspace(process.cwd());
@@ -1674,7 +1812,7 @@ client) {
1674
1812
  "sed", "awk", "sort", "uniq", "mkdir", "rm", "mv", "cp", "touch", "help",
1675
1813
  "exit",
1676
1814
  // sfora's own, registered on the cloud shell (see `sforaShellCommands`).
1677
- "blocks", "put", "url",
1815
+ "blocks", "put", "url", "backlinks", "attachments",
1678
1816
  ];
1679
1817
  async function completePath(token) {
1680
1818
  const slash = token.lastIndexOf("/");
@@ -1754,8 +1892,37 @@ client) {
1754
1892
  if (isTty)
1755
1893
  console.log("");
1756
1894
  }
1757
- main().catch((e) => {
1758
- const message = e instanceof Error ? e.message : String(e);
1759
- process.stderr.write(`${colors.red}fatal:${colors.reset} ${message}\n`);
1760
- process.exit(1);
1761
- });
1895
+ /**
1896
+ * True when this module is the process entry point. Compared through realpath:
1897
+ * npm's bin shim and `npx` reach dist/cli.js through symlinks, and those must
1898
+ * still run; an `import` of this module (a test, a harness) must not.
1899
+ */
1900
+ export function isEntryPoint(entry = process.argv[1], self = import.meta.url) {
1901
+ if (!entry)
1902
+ return false;
1903
+ let me;
1904
+ try {
1905
+ me = realpathSync(fileURLToPath(self));
1906
+ }
1907
+ catch {
1908
+ return false;
1909
+ }
1910
+ // `node dist/cli` (no extension) names the same file as `node dist/cli.js`.
1911
+ for (const candidate of [entry, `${entry}.js`]) {
1912
+ try {
1913
+ if (realpathSync(candidate) === me)
1914
+ return true;
1915
+ }
1916
+ catch {
1917
+ // not this spelling
1918
+ }
1919
+ }
1920
+ return false;
1921
+ }
1922
+ if (isEntryPoint()) {
1923
+ runCli(process.argv.slice(2)).catch((e) => {
1924
+ const message = e instanceof Error ? e.message : String(e);
1925
+ process.stderr.write(`${colors.red}fatal:${colors.reset} ${message}\n`);
1926
+ process.exit(1);
1927
+ });
1928
+ }
@@ -0,0 +1,2 @@
1
+ export declare function looksLikeEntityUrl(url: string): boolean;
2
+ export declare function scanBodyLinkUrls(body: string): string[];
@@ -0,0 +1,48 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ // Markdown links that can name a workspace entity — card #682 (fault B6).
4
+ //
5
+ // ONE scanner for both sides: the graph (`convex/references.ts`, through the
6
+ // `convex/lib/linkUrls.ts` shim) indexes exactly the URLs the reader asks
7
+ // `references.resolveTokens` about, so a path link cannot be live in one and
8
+ // dead in the other.
9
+ import { codeSpanMask, fencedRegions, isEscaped, lineText, } from "./lint/lintSource.js";
10
+ // Markdown links (`[text](url)`, not images) whose URL could name a workspace
11
+ // entity: an fs path (`/projects/<slug>/docs/<file>.md`, `./other.md`) or an
12
+ // app URL (`/org/<slug>/notes/<id>`, …). Same code geometry as the token
13
+ // scanner, so a link quoted in code is prose, not a link. Card #682 (B6):
14
+ // these used to be dead hrefs and no edge.
15
+ const MD_LINK_RE = /(!?)\[(?:[^\]\\]|\\.)*\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/g;
16
+ const ENTITY_URL_RE = /^(?:https?:\/\/[^/]+)?\/org\/[^/]+\/(?:notes|posts|projects\/[^/]+\/(?:board\?detail=card:|pulls\/))/;
17
+ export function looksLikeEntityUrl(url) {
18
+ const bare = url.split("#")[0];
19
+ if (ENTITY_URL_RE.test(bare))
20
+ return true;
21
+ if (/^[a-z][a-z0-9+.-]*:/i.test(bare))
22
+ return false; // http:, mailto:, …
23
+ return /\.md$/i.test(bare.split("?")[0]);
24
+ }
25
+ export function scanBodyLinkUrls(body) {
26
+ const lines = body.split("\n");
27
+ const fenced = new Set();
28
+ for (const region of fencedRegions(lines)) {
29
+ for (let i = region.open; i <= region.close; i++)
30
+ fenced.add(i);
31
+ }
32
+ const out = [];
33
+ for (let i = 0; i < lines.length; i++) {
34
+ if (fenced.has(i))
35
+ continue;
36
+ const line = lineText(lines, i);
37
+ const mask = codeSpanMask(line);
38
+ MD_LINK_RE.lastIndex = 0;
39
+ let m;
40
+ while ((m = MD_LINK_RE.exec(line)) !== null) {
41
+ if (m[1] === "!" || mask[m.index] || isEscaped(line, m.index))
42
+ continue;
43
+ if (looksLikeEntityUrl(m[2]))
44
+ out.push(m[2]);
45
+ }
46
+ }
47
+ return out;
48
+ }
@@ -17,6 +17,17 @@ export interface MarkdownAuthor {
17
17
  name: string;
18
18
  type: "human" | "agent";
19
19
  }
20
+ /**
21
+ * A file attached to the post (card #822). Listed in the frontmatter so an
22
+ * agent reading the post knows it is there; the bytes are fetched from
23
+ * `<post path>/attachments/<id>`, never inlined.
24
+ */
25
+ export interface MarkdownAttachment {
26
+ id: string;
27
+ name: string;
28
+ type: string;
29
+ size: number;
30
+ }
20
31
  export interface MarkdownProject {
21
32
  _id: string;
22
33
  name: string;
@@ -28,5 +39,5 @@ export interface ParsedMarkdownPost {
28
39
  frontmatter: Record<string, string | string[]>;
29
40
  }
30
41
  export declare function mdFilename(post: MarkdownPostInput): string;
31
- export declare function postToMarkdown(post: MarkdownPostInput, author: MarkdownAuthor | null, project: MarkdownProject | null, commentsCount: number): string;
42
+ export declare function postToMarkdown(post: MarkdownPostInput, author: MarkdownAuthor | null, project: MarkdownProject | null, commentsCount: number, attachments?: MarkdownAttachment[]): string;
32
43
  export declare function parseMarkdownPost(md: string): ParsedMarkdownPost;
@@ -29,7 +29,7 @@ export function mdFilename(post) {
29
29
  return `${datePart(post)}-${slugify(post.title)}.md`;
30
30
  }
31
31
  // ─── Serialize + parse ─────────────────────────────────────────────
32
- export function postToMarkdown(post, author, project, commentsCount) {
32
+ export function postToMarkdown(post, author, project, commentsCount, attachments = []) {
33
33
  const { text, names } = renderMentions(post.body);
34
34
  const fm = serializeFrontmatter([
35
35
  ["id", post._id],
@@ -47,7 +47,14 @@ export function postToMarkdown(post, author, project, commentsCount) {
47
47
  ["comments", String(commentsCount)],
48
48
  ["mentions", names],
49
49
  ]);
50
- return buildDocument(fm, post.title, text);
50
+ // One line of JSON — valid YAML flow, and a JSON.parse away for an agent.
51
+ // The tiny frontmatter YAML holds only flat string arrays, so objects ride
52
+ // as JSON rather than stretching it. Omitted when there are none, so a post
53
+ // without files reads byte-for-byte as it always has.
54
+ const attachmentLine = attachments.length
55
+ ? `\nattachments: ${JSON.stringify(attachments.map(({ id, name, type, size }) => ({ id, name, type, size })))}`
56
+ : "";
57
+ return buildDocument(fm + attachmentLine, post.title, text);
51
58
  }
52
59
  // Parse a markdown file back to post fields (shared document parser).
53
60
  export function parseMarkdownPost(md) {
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * workspace. The CLI also exposes first-class verbs (post/task/doc) and an MCP
8
8
  * server so agents operate sfora natively.
9
9
  */
10
- import { Bash, ReadWriteFs } from "just-bash";
10
+ import { Bash, ReadWriteFs, type CommandName } from "just-bash";
11
11
  import { SforaApiClient } from "./api-client.js";
12
12
  import { SforaFs } from "./SforaFs.js";
13
13
  import { type PresenceNotice } from "./block-commands.js";
@@ -31,13 +31,32 @@ export interface CreateSforaShellOptions {
31
31
  * that made them — the CLI passes `detectClient(process.env, --client)`.
32
32
  */
33
33
  clientLabel?: string;
34
+ /** "mcp" from an MCP server (card #815, D11): sent as `X-Sfora-Transport`. */
35
+ transport?: "mcp";
34
36
  /**
35
37
  * The run's "you are visible" latch. Pass one when something OUTSIDE this
36
38
  * shell can print the note too — the CLI does, after any line that wrote —
37
39
  * so the two share a single "once". Omitted, the shell owns its own.
38
40
  */
39
41
  presence?: PresenceNotice;
42
+ /**
43
+ * Restrict the shell to these built-in commands. Omitted, every built-in is
44
+ * available — the CLI and local shells run on the user's own machine with
45
+ * the user's own key. The hosted /mcp route passes
46
+ * {@link hostedShellCommands} (card #628).
47
+ */
48
+ commands?: CommandName[];
40
49
  }
50
+ /**
51
+ * Built-ins the HOSTED shell does not offer (card #628). `yq` is the only path
52
+ * to the TOML parser just-bash 3.0.1 inlines (smol-toml <= 1.6.1,
53
+ * GHSA-7w5x-hrqm-74c2: a malformed document hangs the parser). The fixed
54
+ * just-bash (3.4.x) cannot be adopted yet: its redirections write every target
55
+ * empty first, which the fs API rejects, so the parser is removed instead.
56
+ */
57
+ export declare const HOSTED_SHELL_EXCLUDED: readonly string[];
58
+ /** Every built-in this just-bash registers, minus {@link HOSTED_SHELL_EXCLUDED}. */
59
+ export declare function hostedShellCommands(): CommandName[];
41
60
  export interface SforaShell {
42
61
  bash: Bash;
43
62
  fs: SforaFs;
@@ -66,6 +85,7 @@ export interface LocalShell {
66
85
  */
67
86
  export declare function createLocalShell(root: string, cwd?: string): LocalShell;
68
87
  export { SforaFs } from "./SforaFs.js";
88
+ export { cloudToolDescription } from "./mcp-description.js";
69
89
  export { LocalWorkspace, initWorkspace, findWorkspace, WORKSPACE_DIR, type TaskEntry, type TaskWriteResult, } from "./local/workspace.js";
70
90
  export * from "./format/index.js";
71
91
  export { SforaApiClient, SforaApiError, blockConflictFrom, writeEffectFrom, type SforaApiConfig, type Project, type Entry, type PostKind, type WriteResult, type WriteOptions, type WriteEffect, type RebindCounts, type ResponseInfo, type BlockView, type BlocksView, type BlockConflict, type BlockSummary, type AgentEvent, type AgentEventsPage, type DocPresence, type DocPresenceMember, } from "./api-client.js";
@@ -73,6 +93,8 @@ export { WEB_URL_HEADER, fsRequestPath, webUrlFromResponse, } from "./web-url.js
73
93
  export { openerCommand, type OpenerCommand } from "./opener.js";
74
94
  export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, type CommandOutput, type PresenceNotice, } from "./block-commands.js";
75
95
  export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
96
+ export { ATTACHMENT_MAX_BYTES, ATTACHMENT_TOOLS, attachmentToolResult, attachmentsCommand, isAttachmentTool, safeAttachmentName, type AttachmentIo, type McpContent, type McpToolResult, } from "./attachments.js";
97
+ export type { AttachmentRow } from "./api-client.js";
76
98
  export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, type WatchDeps, type WatchOptions, type WatchTarget, } from "./watch.js";
77
99
  export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, type DocPing, } from "./render.js";
78
100
  export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, type ChatTailDeps, type ChatTailOptions, type AwaitReplyDeps, type AwaitReplyOptions, type AwaitReplyResult, } from "./chat.js";
package/dist/index.js CHANGED
@@ -7,17 +7,30 @@
7
7
  * workspace. The CLI also exposes first-class verbs (post/task/doc) and an MCP
8
8
  * server so agents operate sfora natively.
9
9
  */
10
- import { Bash, ReadWriteFs } from "just-bash";
10
+ import { Bash, ReadWriteFs, getCommandNames } from "just-bash";
11
11
  import { SforaApiClient } from "./api-client.js";
12
12
  import { SforaFs } from "./SforaFs.js";
13
13
  import { sforaShellCommands } from "./shell-commands.js";
14
14
  import { presenceNotice } from "./block-commands.js";
15
+ /**
16
+ * Built-ins the HOSTED shell does not offer (card #628). `yq` is the only path
17
+ * to the TOML parser just-bash 3.0.1 inlines (smol-toml <= 1.6.1,
18
+ * GHSA-7w5x-hrqm-74c2: a malformed document hangs the parser). The fixed
19
+ * just-bash (3.4.x) cannot be adopted yet: its redirections write every target
20
+ * empty first, which the fs API rejects, so the parser is removed instead.
21
+ */
22
+ export const HOSTED_SHELL_EXCLUDED = ["yq"];
23
+ /** Every built-in this just-bash registers, minus {@link HOSTED_SHELL_EXCLUDED}. */
24
+ export function hostedShellCommands() {
25
+ return getCommandNames().filter((name) => !HOSTED_SHELL_EXCLUDED.includes(name));
26
+ }
15
27
  export function createSforaShell(options) {
16
28
  const client = new SforaApiClient({
17
29
  baseUrl: options.baseUrl,
18
30
  apiKey: options.apiKey,
19
31
  actAs: options.actAs,
20
32
  clientLabel: options.clientLabel,
33
+ transport: options.transport,
21
34
  });
22
35
  const presence = options.presence ?? presenceNotice();
23
36
  const fs = new SforaFs(client);
@@ -34,6 +47,7 @@ export function createSforaShell(options) {
34
47
  // rather than special-cased in the REPL so they compose like every other
35
48
  // command: `blocks x.md | grep heading`, `sed … | put x.md --block <id>`.
36
49
  customCommands: sforaShellCommands(client, presence),
50
+ ...(options.commands ? { commands: options.commands } : {}),
37
51
  });
38
52
  return { bash, fs, client, presence };
39
53
  }
@@ -49,6 +63,7 @@ export function createLocalShell(root, cwd = "/") {
49
63
  return { bash, fs };
50
64
  }
51
65
  export { SforaFs } from "./SforaFs.js";
66
+ export { cloudToolDescription } from "./mcp-description.js";
52
67
  export { LocalWorkspace, initWorkspace, findWorkspace, WORKSPACE_DIR, } from "./local/workspace.js";
53
68
  export * from "./format/index.js";
54
69
  export { SforaApiClient, SforaApiError, blockConflictFrom, writeEffectFrom, } from "./api-client.js";
@@ -56,6 +71,7 @@ export { WEB_URL_HEADER, fsRequestPath, webUrlFromResponse, } from "./web-url.js
56
71
  export { openerCommand } from "./opener.js";
57
72
  export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, } from "./block-commands.js";
58
73
  export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
74
+ export { ATTACHMENT_MAX_BYTES, ATTACHMENT_TOOLS, attachmentToolResult, attachmentsCommand, isAttachmentTool, safeAttachmentName, } from "./attachments.js";
59
75
  export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, } from "./watch.js";
60
76
  export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, } from "./render.js";
61
77
  export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, } from "./chat.js";
@@ -17,7 +17,7 @@ export interface LocalMarkdownSnapshot {
17
17
  export declare function readLocalMarkdown(path: string): Promise<LocalMarkdownSnapshot>;
18
18
  export declare function saveLocalMarkdown(path: string, content: string, expectedRevision: string): Promise<LocalMarkdownSnapshot>;
19
19
  /** Save As creates a new path only. Replacing an existing file requires its current revision. */
20
- export declare function atomicCreate(path: string, content: string | Uint8Array): Promise<string>;
20
+ export declare function atomicCreate(path: string, content: string | Uint8Array, mode?: number): Promise<string>;
21
21
  export declare function saveNewLocalMarkdown(path: string, content: string): Promise<LocalMarkdownSnapshot>;
22
22
  /** Watch the parent so atomic replacement by another editor remains observable. */
23
23
  export declare function watchLocalMarkdown(path: string, onChange: () => void): FSWatcher;
@@ -82,11 +82,11 @@ export async function saveLocalMarkdown(path, content, expectedRevision) {
82
82
  });
83
83
  }
84
84
  /** Save As creates a new path only. Replacing an existing file requires its current revision. */
85
- export async function atomicCreate(path, content) {
85
+ export async function atomicCreate(path, content, mode = 0o600) {
86
86
  const destination = join(await realpath(dirname(resolve(path))), basename(path));
87
87
  const temporary = `${destination}.${randomUUID()}.tmp`;
88
88
  try {
89
- await atomicWrite(temporary, content);
89
+ await atomicWrite(temporary, content, mode);
90
90
  // Hard link publishes atomically and fails EEXIST instead of overwriting a raced save.
91
91
  await link(temporary, destination);
92
92
  }
@@ -2,3 +2,15 @@
2
2
  * Renderers must obtain native dialog grants; never expose these as arbitrary IPC paths. */
3
3
  export * from "./files.js";
4
4
  export * from "./skills.js";
5
+ export * from "./skill-domain.js";
6
+ export * from "./skill-discovery.js";
7
+ export * from "./skill-store.js";
8
+ export * from "./skill-service.js";
9
+ export * from "./skill-sync.js";
10
+ export * from "./skill-operations.js";
11
+ export { SKILL_AGENT_ADAPTERS, projectSkillSources } from "./skill-adapters.js";
12
+ export type { SkillAgentAdapter } from "./skill-adapters.js";
13
+ export * from "./skill-local-executor.js";
14
+ export * from "./skill-executor.js";
15
+ export { SkillIndex } from './skill-index.js';
16
+ export { SkillReviewService, type SkillReview } from './skill-review.js';
@@ -2,3 +2,14 @@
2
2
  * Renderers must obtain native dialog grants; never expose these as arbitrary IPC paths. */
3
3
  export * from "./files.js";
4
4
  export * from "./skills.js";
5
+ export * from "./skill-domain.js";
6
+ export * from "./skill-discovery.js";
7
+ export * from "./skill-store.js";
8
+ export * from "./skill-service.js";
9
+ export * from "./skill-sync.js";
10
+ export * from "./skill-operations.js";
11
+ export { SKILL_AGENT_ADAPTERS, projectSkillSources } from "./skill-adapters.js";
12
+ export * from "./skill-local-executor.js";
13
+ export * from "./skill-executor.js";
14
+ export { SkillIndex } from './skill-index.js';
15
+ export { SkillReviewService } from './skill-review.js';