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.
- package/README.md +80 -1
- 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/files.d.ts +1 -1
- package/dist/local-core/files.js +2 -2
- package/dist/local-core/index.d.ts +12 -0
- package/dist/local-core/index.js +11 -0
- package/dist/local-core/skill-adapters.d.ts +21 -0
- package/dist/local-core/skill-adapters.js +19 -0
- package/dist/local-core/skill-discovery.d.ts +22 -0
- package/dist/local-core/skill-discovery.js +79 -0
- package/dist/local-core/skill-domain.d.ts +74 -0
- package/dist/local-core/skill-domain.js +1 -0
- package/dist/local-core/skill-executor.d.ts +23 -0
- package/dist/local-core/skill-executor.js +51 -0
- package/dist/local-core/skill-index.d.ts +54 -0
- package/dist/local-core/skill-index.js +115 -0
- package/dist/local-core/skill-local-executor.d.ts +18 -0
- package/dist/local-core/skill-local-executor.js +249 -0
- package/dist/local-core/skill-operations.d.ts +61 -0
- package/dist/local-core/skill-operations.js +268 -0
- package/dist/local-core/skill-review.d.ts +46 -0
- package/dist/local-core/skill-review.js +132 -0
- package/dist/local-core/skill-service.d.ts +96 -0
- package/dist/local-core/skill-service.js +157 -0
- package/dist/local-core/skill-store.d.ts +34 -0
- package/dist/local-core/skill-store.js +187 -0
- package/dist/local-core/skill-sync.d.ts +132 -0
- package/dist/local-core/skill-sync.js +111 -0
- package/dist/local-core/skills.d.ts +35 -0
- package/dist/local-core/skills.js +142 -37
- 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-client.d.ts +13 -2
- package/dist/skills-client.js +57 -5
- package/dist/skills-command.d.ts +1 -1
- package/dist/skills-command.js +171 -4
- 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 +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
|
|
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
|
+
}
|
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
|
+
};
|