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/README.md CHANGED
@@ -400,12 +400,30 @@ 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`
406
424
  operations. `sfora desktop path/to/file.md` opens a local file in the installed
407
425
  Sfora macOS app. `sfora open` continues to open Sfora web URLs. npm installation
408
- still requires Node 20+; the desktop distribution bundles its own CLI runtime.
426
+ still requires Node 22.13+; the desktop distribution bundles its own CLI runtime.
409
427
  Both read the existing `~/.sfora/config.json` profiles. Config writes are atomic
410
428
  and process-locked; no account is required for local files or local skill scans.
411
429
 
@@ -466,3 +484,64 @@ changes appear here only after publication; use `skills push` or the project
466
484
  workbench to change a bundle. Cloud installations record both the immutable
467
485
  bundle hash and numeric `sourceVersion`; resolving “latest” pins that version
468
486
  before downloading so concurrent publications cannot mix provenance and bytes.
487
+
488
+
489
+ ### Persistent local skill inventory
490
+
491
+ Requires Node 22.13 or later. Desktop and CLI share a private SQLite catalogue at `~/.sfora/skills/catalog.sqlite`. Skill names and content hashes are not identity; separate local copies remain separate until explicitly linked.
492
+
493
+ ```sh
494
+ sfora skills roots add ~/.my-skills "My skills"
495
+ sfora skills roots add-project ~/Developer/my-project "My project"
496
+ sfora skills roots --json
497
+ sfora skills inventory --json
498
+ sfora skills roots remove <root-id>
499
+ ```
500
+
501
+ Registered roots and local identities survive restart. Removing a root only removes tracking. Missing or inaccessible skills retain last-known metadata and an unavailable state. `skills scan --json` retains its array result; `skills inventory --json` includes scan warnings. SQLite handles concurrent clients and transaction rollback. Do not edit or delete the catalogue to rebuild observations. Explicit cloud bindings and read-only transfer previews share this catalogue. Transfer execution and recovery remain separate mission increments.
502
+
503
+
504
+ ### Explicit cloud bindings and transfer previews
505
+
506
+ ```sh
507
+ sfora skills bind <location-id> <cloud-name> --project my-project
508
+ sfora skills bindings --json
509
+ sfora skills status <binding-id> --json
510
+ sfora skills plan <binding-id> push --json
511
+ sfora skills plan <binding-id> pull --json
512
+ sfora skills relocate <location-id> /new/skill-folder
513
+ ```
514
+
515
+ Take the location ID from `skills inventory`. Linking verifies the authenticated member, deployment, organization, project and cloud skill ID. Names are lookup hints. A same-name replacement cannot silently take over a binding. Relocation preserves identity after an explicitly chosen folder move; register the destination discovery root first.
516
+
517
+ Explicit adoption of equal, verified published and local content establishes a baseline. Differing content starts with an unknown baseline, or preserves an existing confirmed baseline. Status compares fresh local bytes and a pinned published cloud revision against that baseline. It distinguishes synchronized, local changed, remote changed, conflict, missing and unavailable content. An unavailable cloud request exits with an error and never reports synchronization.
518
+
519
+ Plans contain exact IDs, local and remote hashes, published version, draft revision and per-file byte/mode metadata. They perform no writes. Unknown baselines, conflicts, existing cloud drafts and unverified local ownership block automatic replacement. CLI previews verify ownership against both the installation receipt and the current filesystem object. A replaced link, file or locally edited copy is never treated as an unchanged managed directory. Reviewed pushes can be executed through the journal below. Recovery for local pull/install mutations and migration of the older transfer commands remain tracked by #470 and #472.
520
+
521
+ The first write upgrades catalogue schema 1 to 2 transactionally after making a private SQLite-consistent `.v1-<id>.sqlite` backup beside the catalogue. Read-only inspection can read schema 1 without upgrading it. Older clients fail closed after an upgrade; update the desktop app and CLI together. Keep the backup until the updated clients have been verified.
522
+
523
+
524
+ ### Guarded pushes and recovery
525
+
526
+ ```sh
527
+ sfora skills plan <binding-id> push --json > push-plan.json
528
+ sfora skills queue push-plan.json
529
+ sfora skills apply <operation-id>
530
+ sfora skills operations --json
531
+ sfora skills recover <operation-id>
532
+ sfora skills cancel <unstarted-operation-id>
533
+ sfora skills plan-install <cloud-name> --project my-project --skills-target /existing/parent
534
+ ```
535
+
536
+ `apply push-plan.json` queues and starts a push in one command. A reviewed plan is rechecked against current local content, the explicit binding, published version and draft revision. The server must advertise identity-checked writes; the client refuses to apply against an older server. Existing cloud drafts and changed review tokens stop the operation.
537
+
538
+ The private `~/.sfora/skills/operations.sqlite` journal commits intent before requests and reserves the qualified cloud target across upload/publication. A live worker cannot be recovered by another process. Recover an interrupted worker's operation before starting another operation on its target. Lost responses produce a durable `needs-attention` outcome. Recovery checks whether the exact intended next draft or publication landed before retrying, and a separate confirmation receipt covers interruption during the local baseline commit. Completed operations are not republished. Cancelling is supported only before work starts; it does not pretend to undo remote effects.
539
+
540
+ `plan-install` produces a read-only preview for a new, empty destination and blocks occupied destinations. Pull and installation previews can be queued and applied through the same journal. Local updates preserve the previous directory in an operation-specific backup; recovery verifies ownership and staged content before continuing, and preserves external edits. Backups and staged snapshots remain available for inspection. An interruption between creating a destination and recording its owner requires inspection rather than guessing ownership. The older `push`, `pull` and `install` commands retain their existing behavior; use `plan`/`apply` for binding-aware update safety. Use `skills apply-batch ids.json` or `skills recover-batch ids.json` with a JSON array of operation IDs. Outcomes are retained per item and completed work is not replayed. Ctrl-C stops after the current item and cancels unstarted items. In the macOS app, Skills → Transfers reviews the same device-wide queue before running or recovering selected plans.
541
+
542
+ The desktop keeps a cached local skill inventory and watches registered/agent folders. Known file changes re-read only affected skills; new paths and periodic reconciliation repair missed events. Refresh requests a full reconciliation. Removed or inaccessible folders keep their stable identity and last known metadata. Watchers are hints, not authority for transfer planning, which always rechecks current files.
543
+
544
+
545
+ In the macOS app, **On this Mac → Push to Sfora / Update local copy** opens a project chooser and a **Review changes** dialog. Published cloud skills offer **Install on this Mac**, followed by the native destination picker and the same review. No CLI plan file is needed. Opening a review creates no binding or queued operation; confirmation rechecks current content and records the durable transfer. Matching unlinked copies can be explicitly linked. Different unlinked copies remain blocked rather than receiving an invented baseline.
546
+
547
+ A first publication continues into the existing project draft editor. After successful publication, the original local copy is linked only if its files still match the published version. A failed link does not turn a successful publication into a failed result. Transfers remains the history/recovery view, including work queued from the CLI.
@@ -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
+ };