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.
- package/README.md +18 -0
- package/dist/agent-webhook.d.ts +20 -0
- package/dist/agent-webhook.js +42 -0
- package/dist/api-client.d.ts +97 -0
- package/dist/api-client.js +68 -0
- package/dist/ask.d.ts +51 -0
- package/dist/ask.js +70 -0
- package/dist/attachments-node.d.ts +7 -0
- package/dist/attachments-node.js +15 -0
- package/dist/attachments.d.ts +112 -0
- package/dist/attachments.js +254 -0
- package/dist/block-commands.d.ts +10 -0
- package/dist/block-commands.js +28 -0
- package/dist/chat.d.ts +15 -0
- package/dist/chat.js +7 -0
- package/dist/cli-args.d.ts +7 -0
- package/dist/cli-args.js +28 -1
- package/dist/cli.d.ts +12 -1
- package/dist/cli.js +186 -19
- package/dist/format/linkUrls.d.ts +2 -0
- package/dist/format/linkUrls.js +48 -0
- package/dist/format/postMarkdown.d.ts +12 -1
- package/dist/format/postMarkdown.js +9 -2
- package/dist/index.d.ts +23 -1
- package/dist/index.js +17 -1
- package/dist/local-core/skills.d.ts +25 -0
- package/dist/local-core/skills.js +93 -0
- package/dist/mcp-description.d.ts +11 -0
- package/dist/mcp-description.js +29 -0
- package/dist/mcp-server.d.ts +5 -1
- package/dist/mcp-server.js +28 -18
- package/dist/shell-commands.d.ts +7 -1
- package/dist/shell-commands.js +49 -3
- package/dist/skills-command.d.ts +1 -1
- package/dist/skills-command.js +20 -1
- package/dist/skills-packet/sfora-asks/SKILL.md +49 -0
- package/dist/skills-packet/sfora-asks/references/asks.md +41 -0
- package/dist/skills-packet/sfora-board/SKILL.md +53 -0
- package/dist/skills-packet/sfora-board/references/board.md +60 -0
- package/dist/skills-packet/sfora-board/references/plan.md +25 -0
- package/dist/skills-packet/sfora-chat/SKILL.md +55 -0
- package/dist/skills-packet/sfora-chat/references/rooms.md +45 -0
- package/dist/skills-packet/sfora-chat/references/waiting.md +37 -0
- package/dist/skills-packet/sfora-live-edit/SKILL.md +62 -0
- package/dist/skills-packet/sfora-live-edit/references/collisions.md +54 -0
- package/dist/skills-packet/sfora-live-edit/references/http.md +63 -0
- package/dist/skills-packet/sfora-live-edit/references/live-editing.md +49 -0
- package/dist/skills-packet/sfora-setup/SKILL.md +37 -0
- package/dist/skills-packet/sfora-setup/references/sign-in.md +43 -0
- package/dist/skills-packet/sfora-skills/SKILL.md +54 -0
- package/dist/skills-packet/sfora-skills/references/skills.md +78 -0
- package/dist/skills-packet/sfora-troubleshoot/SKILL.md +39 -0
- package/dist/skills-packet/sfora-troubleshoot/references/sharp-edges.md +80 -0
- package/dist/skills-packet/sfora-write/SKILL.md +53 -0
- package/dist/skills-packet/sfora-write/references/attachments.md +15 -0
- package/dist/skills-packet/sfora-write/references/blocks.md +36 -0
- package/dist/skills-packet/sfora-write/references/posts-and-docs.md +56 -0
- package/dist/skills-packet.d.ts +63 -0
- package/dist/skills-packet.js +166 -0
- package/dist/typing.d.ts +23 -0
- package/dist/typing.js +62 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/watch.d.ts +78 -1
- package/dist/watch.js +109 -0
- 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
|
+
}
|
package/dist/api-client.d.ts
CHANGED
|
@@ -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). */
|
package/dist/api-client.js
CHANGED
|
@@ -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>;
|