sfora-cli 0.16.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 (66) hide show
  1. package/README.md +18 -0
  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/skills.d.ts +25 -0
  27. package/dist/local-core/skills.js +93 -0
  28. package/dist/mcp-description.d.ts +11 -0
  29. package/dist/mcp-description.js +29 -0
  30. package/dist/mcp-server.d.ts +5 -1
  31. package/dist/mcp-server.js +28 -18
  32. package/dist/shell-commands.d.ts +7 -1
  33. package/dist/shell-commands.js +49 -3
  34. package/dist/skills-command.d.ts +1 -1
  35. package/dist/skills-command.js +20 -1
  36. package/dist/skills-packet/sfora-asks/SKILL.md +49 -0
  37. package/dist/skills-packet/sfora-asks/references/asks.md +41 -0
  38. package/dist/skills-packet/sfora-board/SKILL.md +53 -0
  39. package/dist/skills-packet/sfora-board/references/board.md +60 -0
  40. package/dist/skills-packet/sfora-board/references/plan.md +25 -0
  41. package/dist/skills-packet/sfora-chat/SKILL.md +55 -0
  42. package/dist/skills-packet/sfora-chat/references/rooms.md +45 -0
  43. package/dist/skills-packet/sfora-chat/references/waiting.md +37 -0
  44. package/dist/skills-packet/sfora-live-edit/SKILL.md +62 -0
  45. package/dist/skills-packet/sfora-live-edit/references/collisions.md +54 -0
  46. package/dist/skills-packet/sfora-live-edit/references/http.md +63 -0
  47. package/dist/skills-packet/sfora-live-edit/references/live-editing.md +49 -0
  48. package/dist/skills-packet/sfora-setup/SKILL.md +37 -0
  49. package/dist/skills-packet/sfora-setup/references/sign-in.md +43 -0
  50. package/dist/skills-packet/sfora-skills/SKILL.md +54 -0
  51. package/dist/skills-packet/sfora-skills/references/skills.md +78 -0
  52. package/dist/skills-packet/sfora-troubleshoot/SKILL.md +39 -0
  53. package/dist/skills-packet/sfora-troubleshoot/references/sharp-edges.md +80 -0
  54. package/dist/skills-packet/sfora-write/SKILL.md +53 -0
  55. package/dist/skills-packet/sfora-write/references/attachments.md +15 -0
  56. package/dist/skills-packet/sfora-write/references/blocks.md +36 -0
  57. package/dist/skills-packet/sfora-write/references/posts-and-docs.md +56 -0
  58. package/dist/skills-packet.d.ts +63 -0
  59. package/dist/skills-packet.js +166 -0
  60. package/dist/typing.d.ts +23 -0
  61. package/dist/typing.js +62 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/watch.d.ts +78 -1
  65. package/dist/watch.js +109 -0
  66. package/package.json +3 -3
package/README.md CHANGED
@@ -400,6 +400,24 @@ Reads outside `projects/<slug>/(posts|drafts)`, `inbox/mentions.md`, and
400
400
  `me/api-key` return `ENOENT`; writes outside the post/draft dirs return `EACCES`.
401
401
  ```
402
402
 
403
+ ## Attachments — `sfora attachments`
404
+
405
+ A post's screenshots and files are listed in its frontmatter by `cat`, as one JSON
406
+ line: `attachments: [{"id","name","type","size"}]`. To get the files:
407
+
408
+ ```bash
409
+ sfora attachments /projects/acme/posts/2026-10-06-errors.md # list them
410
+ sfora attachments /projects/acme/posts/2026-10-06-errors.md --out ./shots # write them
411
+ ```
412
+
413
+ `--out` writes each file under its own name (only the last path segment is
414
+ kept, and clashes become `shot-2.png`). It checks each file's type and size
415
+ against the list and prints one local path per line, so an agent can open the
416
+ images. A file over 20 MB, or one that doesn't match the list, is skipped with a
417
+ line on stderr, and the command exits 1. Inside the shell, `attachments <post>`
418
+ lists only. Over MCP, the `attachments` and `attachment` tools return images as
419
+ image content.
420
+
403
421
  ## macOS and shared local operations
404
422
 
405
423
  The desktop app and npm CLI use the same Node-only `sfora-cli/local-core`
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `sfora agent webhook <name> --url <address> --events <a,b> | --clear`
3
+ * (card #811, the CLI follow-up). Sets an agent's webhook with YOUR key, under
4
+ * the same rule as Settings → Agents: the agent's owner or a workspace
5
+ * owner/admin. An agent's key is refused by the server (an agent never sets a
6
+ * webhook). Pure: a client in, the text and the exit code out.
7
+ */
8
+ import type { SforaApiClient } from "./api-client.js";
9
+ import type { CommandOutput } from "./block-commands.js";
10
+ /** Every event the webhook fan-out emits; the server's list (convex/lib/webhookEvents.ts). */
11
+ export declare const WEBHOOK_EVENTS: readonly ["message.created", "mention", "post.created", "post.commented"];
12
+ export declare const AGENT_WEBHOOK_USAGE = "usage: sfora agent webhook <name> [--url <https://\u2026>] [--events <a,b>] | --clear [--json]";
13
+ type WebhookClient = Pick<SforaApiClient, "setAgentWebhook">;
14
+ export declare function agentWebhookCommand(client: WebhookClient, rest: string[], opts: {
15
+ webhookUrl?: string;
16
+ events?: string;
17
+ clear?: boolean;
18
+ json?: boolean;
19
+ }): Promise<CommandOutput>;
20
+ export {};
@@ -0,0 +1,42 @@
1
+ /** Every event the webhook fan-out emits; the server's list (convex/lib/webhookEvents.ts). */
2
+ export const WEBHOOK_EVENTS = ["message.created", "mention", "post.created", "post.commented"];
3
+ export const AGENT_WEBHOOK_USAGE = "usage: sfora agent webhook <name> [--url <https://…>] [--events <a,b>] | --clear [--json]";
4
+ export async function agentWebhookCommand(client, rest, opts) {
5
+ const fail = (message, exitCode = 1) => ({ stdout: "", stderr: `${message}\n`, exitCode });
6
+ const [sub, name] = rest;
7
+ if (sub !== "webhook" || !name)
8
+ return fail(AGENT_WEBHOOK_USAGE, 2);
9
+ if (opts.clear && (opts.webhookUrl !== undefined || opts.events !== undefined)) {
10
+ return fail("--clear removes the webhook; give it alone", 2);
11
+ }
12
+ if (!opts.clear && opts.webhookUrl === undefined && opts.events === undefined) {
13
+ return fail(`nothing to change — give --url, --events or --clear\n${AGENT_WEBHOOK_USAGE}`, 2);
14
+ }
15
+ let events;
16
+ if (opts.events !== undefined) {
17
+ events = opts.events.split(",").map((e) => e.trim()).filter(Boolean);
18
+ const unknown = events.filter((e) => !WEBHOOK_EVENTS.includes(e));
19
+ if (unknown.length)
20
+ return fail(`unknown event ${unknown.join(", ")} — use one of: ${WEBHOOK_EVENTS.join(", ")}`, 2);
21
+ }
22
+ let result;
23
+ try {
24
+ result = await client.setAgentWebhook(name, opts.clear ? { clear: true } : { ...(opts.webhookUrl !== undefined ? { url: opts.webhookUrl } : {}), ...(events ? { events } : {}) });
25
+ }
26
+ catch (e) {
27
+ // The server's refusal as it said it: not a person, not yours, no such agent.
28
+ return fail(e instanceof Error ? e.message : String(e));
29
+ }
30
+ if (opts.json)
31
+ return { stdout: `${JSON.stringify(result, null, 2)}\n`, stderr: "", exitCode: 0 };
32
+ const evts = result.events.length ? result.events.join(", ") : "none";
33
+ return {
34
+ stdout: result.webhookUrl
35
+ ? `✓ ${result.agent}'s webhook: ${result.webhookUrl} · events: ${evts}\n`
36
+ : opts.clear
37
+ ? `✓ ${result.agent}'s webhook removed\n`
38
+ : `✓ ${result.agent} has no webhook URL yet · events: ${evts} — add one with --url\n`,
39
+ stderr: "",
40
+ exitCode: 0,
41
+ };
42
+ }
@@ -311,6 +311,13 @@ export interface SforaApiConfig {
311
311
  * The server sanitizes it and ignores it where it means nothing.
312
312
  */
313
313
  clientLabel?: string;
314
+ /**
315
+ * How the client is on the line (card #815, D11). The CLI's MCP server and
316
+ * the hosted /mcp route pass "mcp", sent as `X-Sfora-Transport: mcp` beside
317
+ * the client label, so usage can tell an MCP tool call from a terminal
318
+ * command. Omitted, no header is sent — the CLI's own requests.
319
+ */
320
+ transport?: "mcp";
314
321
  }
315
322
  /** Non-2xx response from the fs API. Carries the HTTP status + machine code so SforaFs can map it to an errno. */
316
323
  export declare class SforaApiError extends Error {
@@ -341,6 +348,32 @@ export interface BlockView {
341
348
  lang?: string;
342
349
  }
343
350
  /** `GET …?view=blocks` — the addressable view of a document. */
351
+ /** The `_backlinks` door's answer — see `readBacklinks`. */
352
+ export interface BacklinksView {
353
+ target: {
354
+ kind: string;
355
+ id: string;
356
+ title: string;
357
+ };
358
+ truncated: boolean;
359
+ backlinks: Array<{
360
+ kind: string;
361
+ title: string;
362
+ token: string | null;
363
+ url: string | null;
364
+ anchors: string[];
365
+ }>;
366
+ }
367
+ /** One file attached to a post — `GET <post>/attachments` (card #822). */
368
+ export interface AttachmentRow {
369
+ id: string;
370
+ name: string;
371
+ /** The media type the uploader's browser reported. */
372
+ type: string;
373
+ size: number;
374
+ width?: number;
375
+ height?: number;
376
+ }
344
377
  export interface BlocksView {
345
378
  /** The fs path these blocks are OF — what to GET for the document itself. */
346
379
  canonical: string;
@@ -451,6 +484,26 @@ export declare class SforaApiClient {
451
484
  * `/v1/fs` URL: the CLI does not build routes.
452
485
  */
453
486
  readBlocks(fsPath: string): Promise<BlocksView>;
487
+ /**
488
+ * `GET <fs path>/_backlinks` — what links to a doc, post or task, as the
489
+ * agent may see it (card #682): each source with the `[[…]]` token to paste
490
+ * and the anchors it links to.
491
+ */
492
+ readBacklinks(fsPath: string): Promise<BacklinksView>;
493
+ /**
494
+ * `GET <post path>/attachments` — the files attached to a post (card #822).
495
+ * The path is the post's fs path, as for every other door here.
496
+ */
497
+ listAttachments(fsPath: string): Promise<AttachmentRow[]>;
498
+ /**
499
+ * `GET <post path>/attachments/<id>` — one file's bytes, streamed through the
500
+ * API with this key (no storage URL is ever handed out). `type` is the
501
+ * served Content-Type, so a caller can hold it against the listing.
502
+ */
503
+ downloadAttachment(fsPath: string, id: string): Promise<{
504
+ bytes: Uint8Array;
505
+ type: string;
506
+ }>;
454
507
  /**
455
508
  * `PUT <fs path>` — the generic write door, so the CLI can put to any path
456
509
  * (and to a single block of it) without a per-entity method.
@@ -613,6 +666,19 @@ export declare class SforaApiClient {
613
666
  joined: boolean;
614
667
  already: boolean;
615
668
  }>;
669
+ /**
670
+ * `PUT /v1/agents/:name/webhook` (card #811) — set or clear an agent's
671
+ * webhook with a PERSON's key, under the same rule as Settings.
672
+ */
673
+ setAgentWebhook(name: string, body: {
674
+ url?: string;
675
+ events?: string[];
676
+ clear?: boolean;
677
+ }): Promise<{
678
+ agent: string;
679
+ webhookUrl: string | null;
680
+ events: string[];
681
+ }>;
616
682
  /** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
617
683
  listRoomMessages(roomId: string, limit?: number): Promise<MessagesPage>;
618
684
  /** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
@@ -621,6 +687,21 @@ export declare class SforaApiClient {
621
687
  client?: string): Promise<{
622
688
  messageId: string;
623
689
  }>;
690
+ /**
691
+ * `POST /api/rooms/:id/typing` with `{ ttlSeconds? }` — say this agent is
692
+ * working on a reply in the room (card #668). The server clamps the expiry
693
+ * to 5–120 s (default 30); send again to extend. `{ stop: true }` is the
694
+ * `DELETE` that ends it; sending a message in the room also ends it.
695
+ * Agents only, in a room they are a member of.
696
+ */
697
+ typing(roomId: string, opts?: {
698
+ ttlSeconds?: number;
699
+ } | {
700
+ stop: true;
701
+ }): Promise<{
702
+ typing: boolean;
703
+ until?: number;
704
+ }>;
624
705
  /**
625
706
  * `POST /api/presence` with `{ roomId?, client? }` — one presence beat.
626
707
  *
@@ -652,6 +733,22 @@ export declare class SforaApiClient {
652
733
  }): Promise<{
653
734
  askId: string;
654
735
  }>;
736
+ /**
737
+ * `POST /api/asks/:askId/claim` — take an open ask atomically. A 409 means
738
+ * someone else holds it (their name is in the message) or it is no longer
739
+ * open; the server owns that race, this only carries the answer.
740
+ */
741
+ claimAsk(askId: string): Promise<{
742
+ ok: boolean;
743
+ askId: string;
744
+ state: string;
745
+ }>;
746
+ /** `POST /api/asks/:askId/resolve` with `{ resolution? }` — report it done. */
747
+ resolveAsk(askId: string, resolution?: string): Promise<{
748
+ ok: boolean;
749
+ askId: string;
750
+ state: string;
751
+ }>;
655
752
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
656
753
  readInbox(): Promise<string>;
657
754
  /** `GET /v1/fs/me/api-key` — text identity (no key material). */
@@ -96,6 +96,7 @@ export class SforaApiClient {
96
96
  #apiKey;
97
97
  #actAs;
98
98
  #clientLabel;
99
+ #transport;
99
100
  /**
100
101
  * What the LAST response said about itself, beyond the data it carried.
101
102
  *
@@ -116,6 +117,7 @@ export class SforaApiClient {
116
117
  this.#apiKey = config.apiKey;
117
118
  this.#actAs = config.actAs;
118
119
  this.#clientLabel = config.clientLabel;
120
+ this.#transport = config.transport;
119
121
  }
120
122
  /** What the last response said about itself, consumed. */
121
123
  takeResponseInfo() {
@@ -158,6 +160,8 @@ export class SforaApiClient {
158
160
  headers["X-Sfora-Act-As"] = this.#actAs;
159
161
  if (this.#clientLabel)
160
162
  headers["X-Sfora-Client"] = this.#clientLabel;
163
+ if (this.#transport)
164
+ headers["X-Sfora-Transport"] = this.#transport;
161
165
  if (body !== undefined)
162
166
  headers["Content-Type"] = contentType;
163
167
  let res;
@@ -310,6 +314,34 @@ export class SforaApiClient {
310
314
  async readBlocks(fsPath) {
311
315
  return this.#json(`${fsRequestPath(fsPath)}?view=blocks`);
312
316
  }
317
+ /**
318
+ * `GET <fs path>/_backlinks` — what links to a doc, post or task, as the
319
+ * agent may see it (card #682): each source with the `[[…]]` token to paste
320
+ * and the anchors it links to.
321
+ */
322
+ async readBacklinks(fsPath) {
323
+ return this.#json(`${fsRequestPath(fsPath.replace(/\/+$/, ""))}/_backlinks`);
324
+ }
325
+ /**
326
+ * `GET <post path>/attachments` — the files attached to a post (card #822).
327
+ * The path is the post's fs path, as for every other door here.
328
+ */
329
+ async listAttachments(fsPath) {
330
+ const data = await this.#json(`${fsRequestPath(fsPath.replace(/\/+$/, ""))}/attachments`);
331
+ return data.attachments;
332
+ }
333
+ /**
334
+ * `GET <post path>/attachments/<id>` — one file's bytes, streamed through the
335
+ * API with this key (no storage URL is ever handed out). `type` is the
336
+ * served Content-Type, so a caller can hold it against the listing.
337
+ */
338
+ async downloadAttachment(fsPath, id) {
339
+ const res = await this.#request("GET", `${fsRequestPath(fsPath.replace(/\/+$/, ""))}/attachments/${encodeURIComponent(id)}`);
340
+ return {
341
+ bytes: new Uint8Array(await res.arrayBuffer()),
342
+ type: res.headers.get("content-type") ?? "",
343
+ };
344
+ }
313
345
  /**
314
346
  * `PUT <fs path>` — the generic write door, so the CLI can put to any path
315
347
  * (and to a single block of it) without a per-entity method.
@@ -614,6 +646,14 @@ export class SforaApiClient {
614
646
  const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/join`);
615
647
  return this.#jsonFrom(res);
616
648
  }
649
+ /**
650
+ * `PUT /v1/agents/:name/webhook` (card #811) — set or clear an agent's
651
+ * webhook with a PERSON's key, under the same rule as Settings.
652
+ */
653
+ async setAgentWebhook(name, body) {
654
+ const res = await this.#request("PUT", `/v1/agents/${encodeURIComponent(name)}/webhook`, JSON.stringify(body), undefined, "application/json");
655
+ return this.#jsonFrom(res);
656
+ }
617
657
  /** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
618
658
  async listRoomMessages(roomId, limit = 30) {
619
659
  return this.#json(`/api/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}`);
@@ -627,6 +667,20 @@ export class SforaApiClient {
627
667
  const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/messages`, JSON.stringify({ body, client }), undefined, "application/json");
628
668
  return this.#jsonFrom(res);
629
669
  }
670
+ /**
671
+ * `POST /api/rooms/:id/typing` with `{ ttlSeconds? }` — say this agent is
672
+ * working on a reply in the room (card #668). The server clamps the expiry
673
+ * to 5–120 s (default 30); send again to extend. `{ stop: true }` is the
674
+ * `DELETE` that ends it; sending a message in the room also ends it.
675
+ * Agents only, in a room they are a member of.
676
+ */
677
+ async typing(roomId, opts = {}) {
678
+ const path = `/api/rooms/${encodeURIComponent(roomId)}/typing`;
679
+ const res = "stop" in opts
680
+ ? await this.#request("DELETE", path)
681
+ : await this.#request("POST", path, JSON.stringify(opts.ttlSeconds !== undefined ? { ttlSeconds: opts.ttlSeconds } : {}), undefined, "application/json");
682
+ return this.#jsonFrom(res);
683
+ }
630
684
  /**
631
685
  * `POST /api/presence` with `{ roomId?, client? }` — one presence beat.
632
686
  *
@@ -658,6 +712,20 @@ export class SforaApiClient {
658
712
  const res = await this.#request("POST", "/api/asks", JSON.stringify(body), undefined, "application/json");
659
713
  return this.#jsonFrom(res);
660
714
  }
715
+ /**
716
+ * `POST /api/asks/:askId/claim` — take an open ask atomically. A 409 means
717
+ * someone else holds it (their name is in the message) or it is no longer
718
+ * open; the server owns that race, this only carries the answer.
719
+ */
720
+ async claimAsk(askId) {
721
+ const res = await this.#request("POST", `/api/asks/${encodeURIComponent(askId)}/claim`, "{}", undefined, "application/json");
722
+ return this.#jsonFrom(res);
723
+ }
724
+ /** `POST /api/asks/:askId/resolve` with `{ resolution? }` — report it done. */
725
+ async resolveAsk(askId, resolution) {
726
+ const res = await this.#request("POST", `/api/asks/${encodeURIComponent(askId)}/resolve`, JSON.stringify(resolution !== undefined ? { resolution } : {}), undefined, "application/json");
727
+ return this.#jsonFrom(res);
728
+ }
661
729
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
662
730
  async readInbox() {
663
731
  return this.#text("/v1/fs/inbox/mentions.md");
package/dist/ask.d.ts CHANGED
@@ -73,3 +73,54 @@ export type AskWaitResult = {
73
73
  * and other asks' traffic pass through silently.
74
74
  */
75
75
  export declare function askWaitLoop(deps: AskWaitDeps, options: AskWaitOptions): Promise<AskWaitResult>;
76
+ export declare const ASK_ACTIONS: readonly ["claim", "resolve"];
77
+ export type AskAction = (typeof ASK_ACTIONS)[number];
78
+ export declare const ASK_ACTION_USAGE = "usage: sfora ask claim <ask-id> | sfora ask resolve <ask-id> [-m \"<resolution>\"] [--json]";
79
+ /**
80
+ * `sfora ask claim <id>` / `sfora ask resolve <id>`, or `null` for a question.
81
+ *
82
+ * The same verb creates asks, so the subcommand is taken only when it cannot
83
+ * be a question: the first word is exactly `claim` or `resolve`, exactly one
84
+ * more word follows (the id), and no `--option` was given (a claim never has
85
+ * options, a question to pick from always does). `sfora ask "claim the
86
+ * domain?"` arrives as ONE word and stays a question; so does
87
+ * `sfora ask claim the domain`.
88
+ */
89
+ export declare function parseAskAction(rest: string[], options?: string[]): {
90
+ action: AskAction;
91
+ askId: string;
92
+ } | null;
93
+ /**
94
+ * The words of a refusal. The `/api/asks` doors answer `{ "error": "<words>" }`
95
+ * (convex/http.ts errorResponse), which the client keeps as the raw body; this
96
+ * reads the words back out so the terminal says "Already claimed by Ada", not
97
+ * a JSON blob.
98
+ */
99
+ export declare function refusalText(e: unknown): string;
100
+ type AskActionClient = {
101
+ claimAsk(askId: string): Promise<{
102
+ askId?: string;
103
+ state?: string;
104
+ }>;
105
+ resolveAsk(askId: string, resolution?: string): Promise<{
106
+ askId?: string;
107
+ state?: string;
108
+ }>;
109
+ };
110
+ /**
111
+ * Claim or resolve one ask and say what happened in one line (or JSON).
112
+ *
113
+ * The server owns every rule: a 409 says someone else holds the ask (with
114
+ * their name), or that it is a question only a human answers, or that it is
115
+ * no longer open; a 403 says only the claimant may resolve it. Those come back
116
+ * as they were said, on stderr, exit 1.
117
+ */
118
+ export declare function askActionCommand(client: AskActionClient, action: AskAction, askId: string, opts?: {
119
+ resolution?: string;
120
+ json?: boolean;
121
+ }): Promise<{
122
+ stdout: string;
123
+ stderr: string;
124
+ exitCode: number;
125
+ }>;
126
+ export {};
package/dist/ask.js CHANGED
@@ -117,3 +117,73 @@ export async function askWaitLoop(deps, options) {
117
117
  }
118
118
  return { outcome: "stopped" };
119
119
  }
120
+ // ─── Claim and resolve (the coordination half) ───────────────────────
121
+ export const ASK_ACTIONS = ["claim", "resolve"];
122
+ export const ASK_ACTION_USAGE = 'usage: sfora ask claim <ask-id> | sfora ask resolve <ask-id> [-m "<resolution>"] [--json]';
123
+ /**
124
+ * `sfora ask claim <id>` / `sfora ask resolve <id>`, or `null` for a question.
125
+ *
126
+ * The same verb creates asks, so the subcommand is taken only when it cannot
127
+ * be a question: the first word is exactly `claim` or `resolve`, exactly one
128
+ * more word follows (the id), and no `--option` was given (a claim never has
129
+ * options, a question to pick from always does). `sfora ask "claim the
130
+ * domain?"` arrives as ONE word and stays a question; so does
131
+ * `sfora ask claim the domain`.
132
+ */
133
+ export function parseAskAction(rest, options = []) {
134
+ if (rest.length !== 2 || options.length > 0)
135
+ return null;
136
+ const [word, askId] = rest;
137
+ if (!ASK_ACTIONS.includes(word))
138
+ return null;
139
+ if (!askId.trim())
140
+ return null;
141
+ return { action: word, askId: askId.trim() };
142
+ }
143
+ /**
144
+ * The words of a refusal. The `/api/asks` doors answer `{ "error": "<words>" }`
145
+ * (convex/http.ts errorResponse), which the client keeps as the raw body; this
146
+ * reads the words back out so the terminal says "Already claimed by Ada", not
147
+ * a JSON blob.
148
+ */
149
+ export function refusalText(e) {
150
+ const data = e?.data;
151
+ if (data && typeof data.message !== "string" && typeof data.error === "string")
152
+ return data.error;
153
+ return e instanceof Error ? e.message : String(e);
154
+ }
155
+ /**
156
+ * Claim or resolve one ask and say what happened in one line (or JSON).
157
+ *
158
+ * The server owns every rule: a 409 says someone else holds the ask (with
159
+ * their name), or that it is a question only a human answers, or that it is
160
+ * no longer open; a 403 says only the claimant may resolve it. Those come back
161
+ * as they were said, on stderr, exit 1.
162
+ */
163
+ export async function askActionCommand(client, action, askId, opts = {}) {
164
+ if (action === "claim" && opts.resolution !== undefined) {
165
+ return { stdout: "", stderr: `-m is for resolve; claim takes only the id\n${ASK_ACTION_USAGE}\n`, exitCode: 2 };
166
+ }
167
+ let result;
168
+ try {
169
+ result =
170
+ action === "claim"
171
+ ? await client.claimAsk(askId)
172
+ : await client.resolveAsk(askId, opts.resolution);
173
+ }
174
+ catch (e) {
175
+ return { stdout: "", stderr: `could not ${action} ask ${askId}: ${refusalText(e)}\n`, exitCode: 1 };
176
+ }
177
+ const state = result.state ?? (action === "claim" ? "claimed" : "resolved");
178
+ if (opts.json) {
179
+ return {
180
+ stdout: `${JSON.stringify({ askId, state, ...(action === "resolve" && opts.resolution !== undefined ? { resolution: opts.resolution } : {}) })}\n`,
181
+ stderr: "",
182
+ exitCode: 0,
183
+ };
184
+ }
185
+ const line = action === "claim"
186
+ ? `✓ Claimed ask ${askId} — it's yours; run \`sfora ask resolve ${askId} -m "…"\` when done`
187
+ : `✓ Resolved ask ${askId}${opts.resolution ? ` · ${opts.resolution}` : ""}`;
188
+ return { stdout: `${line}\n`, stderr: "", exitCode: 0 };
189
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The disk `sfora attachments --out` writes to — the Node half of
3
+ * attachments.ts, kept apart so that module stays importable where there is
4
+ * no local filesystem (the hosted MCP route).
5
+ */
6
+ import type { AttachmentIo } from "./attachments.js";
7
+ export declare const nodeAttachmentIo: AttachmentIo;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The disk `sfora attachments --out` writes to — the Node half of
3
+ * attachments.ts, kept apart so that module stays importable where there is
4
+ * no local filesystem (the hosted MCP route).
5
+ */
6
+ import { mkdir, writeFile } from "node:fs/promises";
7
+ import { join, resolve } from "node:path";
8
+ export const nodeAttachmentIo = {
9
+ mkdir: async (dir) => {
10
+ await mkdir(dir, { recursive: true });
11
+ },
12
+ writeFile: (path, bytes) => writeFile(path, bytes),
13
+ // Absolute, so the printed path opens from anywhere.
14
+ join: (dir, name) => join(resolve(dir), name),
15
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `attachments <post>` — the files a person attached to a post (card #822).
3
+ *
4
+ * Three doors, one module: the `sfora attachments` verb (list, or write the
5
+ * files with `--out`), the shell's `attachments` command (list only — the
6
+ * shell's fs is the workspace, not the disk), and the MCP tools' list and
7
+ * fetch, where an image comes back as image content a vision client can see.
8
+ *
9
+ * Every byte is fetched through the API with the caller's key; the server
10
+ * applies the post's own read rule, so nothing here decides access. What this
11
+ * module does decide is what lands on disk: a name that cannot leave the
12
+ * `--out` directory, and a file whose type and size match what was listed.
13
+ *
14
+ * No `node:` imports, so the hosted MCP route can import it; the CLI hands in
15
+ * the disk through {@link AttachmentIo} (see attachments-node.ts).
16
+ */
17
+ import { type SforaApiClient } from "./api-client.js";
18
+ import type { CommandOutput } from "./block-commands.js";
19
+ /**
20
+ * The server's limit (Convex's HTTP response cap, checked against the stored
21
+ * size). Mirrored here so a listed file over it is skipped without a request.
22
+ */
23
+ export declare const ATTACHMENT_MAX_BYTES: number;
24
+ /** The two disk operations `--out` needs, injected so this module stays portable. */
25
+ export interface AttachmentIo {
26
+ mkdir(dir: string): Promise<void>;
27
+ writeFile(path: string, bytes: Uint8Array): Promise<void>;
28
+ join(dir: string, name: string): string;
29
+ }
30
+ /**
31
+ * A file name that is safe to write inside one directory: the last path
32
+ * segment only (either separator), no control characters, no leading dots,
33
+ * and unique among the names already `taken` (`shot.png`, `shot-2.png`, …).
34
+ * An empty result falls back to `attachment-<id>`.
35
+ */
36
+ export declare function safeAttachmentName(name: string, id: string, taken: Set<string>): string;
37
+ /**
38
+ * `sfora attachments <post> [--out <dir>] [--json]`.
39
+ *
40
+ * Without `--out` it lists. With it, it writes each file into the directory
41
+ * and prints one local path per line (so an agent can open the images); a
42
+ * file that is too large, or does not match its listing, is skipped with a
43
+ * line on stderr and the command exits 1 after writing the rest.
44
+ */
45
+ export declare function attachmentsCommand(client: SforaApiClient, fsPath: string, options?: {
46
+ out?: string;
47
+ json?: boolean;
48
+ io?: AttachmentIo;
49
+ }): Promise<CommandOutput>;
50
+ export type McpContent = {
51
+ type: "text";
52
+ text: string;
53
+ } | {
54
+ type: "image";
55
+ data: string;
56
+ mimeType: string;
57
+ };
58
+ export type McpToolResult = {
59
+ content: McpContent[];
60
+ isError: boolean;
61
+ };
62
+ /** The two tools both MCP servers list, so their schemas cannot drift. */
63
+ export declare const ATTACHMENT_TOOLS: readonly [{
64
+ readonly name: "attachments";
65
+ readonly description: "List the files attached to a post (screenshots, documents). Pass the post's path, e.g. /projects/web/posts/2026-10-06-errors.md. Returns JSON rows { id, name, type, size }; fetch one with the `attachment` tool.";
66
+ readonly inputSchema: {
67
+ readonly type: "object";
68
+ readonly properties: {
69
+ readonly path: {
70
+ readonly type: "string";
71
+ readonly description: "The post's absolute path under /projects/<slug>/posts/ (or drafts/).";
72
+ };
73
+ };
74
+ readonly required: readonly ["path"];
75
+ };
76
+ }, {
77
+ readonly name: "attachment";
78
+ readonly description: "Fetch one attachment of a post by id (from the `attachments` tool). Images come back as image content you can look at; text files as text; other files as a description only.";
79
+ readonly inputSchema: {
80
+ readonly type: "object";
81
+ readonly properties: {
82
+ readonly path: {
83
+ readonly type: "string";
84
+ readonly description: "The post's absolute path.";
85
+ };
86
+ readonly id: {
87
+ readonly type: "string";
88
+ readonly description: "The attachment id from the `attachments` list.";
89
+ };
90
+ };
91
+ readonly required: readonly ["path", "id"];
92
+ };
93
+ }];
94
+ export declare const isAttachmentTool: (name: unknown) => name is "attachments" | "attachment";
95
+ /**
96
+ * The largest image the MCP fetch returns as image content: 3 MB. Base64
97
+ * makes it ~4 MB of JSON, which fits the hosted route's 4.5 MB response
98
+ * limit (a Vercel function), and it is under the 5 MB a Claude image may
99
+ * be. Larger images are metadata plus a pointer to `attachments --out`.
100
+ */
101
+ export declare const MCP_IMAGE_MAX_BYTES: number;
102
+ /** How much of a text file the MCP fetch inlines: 256 KB, decoded as UTF-8; the rest is cut with a note. */
103
+ export declare const MCP_TEXT_MAX_BYTES: number;
104
+ /**
105
+ * Answer an MCP call to `attachments` (no id) or `attachment` (with one).
106
+ * Errors are results with `isError`, never throws: the servers return them
107
+ * as tool output the agent can read and act on.
108
+ */
109
+ export declare function attachmentToolResult(client: SforaApiClient, args: {
110
+ path?: unknown;
111
+ id?: unknown;
112
+ }): Promise<McpToolResult>;