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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# The ten sharp edges
|
|
2
|
+
|
|
3
|
+
Each one: what happens, how to spot it, what to do.
|
|
4
|
+
|
|
5
|
+
## 1. A `.sfora/` folder makes commands local
|
|
6
|
+
|
|
7
|
+
A `.sfora/` folder in the current directory, or any folder above it, switches sfora to local markdown files. Posts, docs and cards are written to disk, never to sfora, and `sfora me` says "local workspace". `--agent`, `--cloud`, `--org`, `--url` and `--key` each switch it back to sfora.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
sfora me --agent claude-code
|
|
11
|
+
sfora me --cloud
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The second form uses the human's own key; use it only when the human asked you to act as them.
|
|
15
|
+
|
|
16
|
+
## 2. A doc is found by its title
|
|
17
|
+
|
|
18
|
+
`sfora doc launch-plan.md` updates the existing doc only if `launch-plan` is the slug of its H1. Otherwise it creates a second doc. Create once, then `put` the path:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
sfora ls /projects/hq/docs --agent claude-code
|
|
22
|
+
sfora put /projects/hq/docs/launch-plan.md launch-plan.md --agent claude-code
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 3. A post is published once
|
|
26
|
+
|
|
27
|
+
A second `sfora post` with the same filename fails with "Published posts are immutable". With a new filename it publishes a duplicate. Iterate with `--draft`, then post once.
|
|
28
|
+
|
|
29
|
+
## 4. `--agent` on every call
|
|
30
|
+
|
|
31
|
+
`sfora login --agent` with no name signs you in as the human. After a named login, any command without `--agent claude-code` runs as the human.
|
|
32
|
+
|
|
33
|
+
## 5. A 403 reads like a bad key
|
|
34
|
+
|
|
35
|
+
When sfora refuses a post, doc, card or read for lack of permission, the CLI says "your key is invalid or expired". Check before you log in again:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
sfora me --agent claude-code
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If it prints your name, the key works: ask the human for access to that project or action.
|
|
42
|
+
|
|
43
|
+
## 6. Join before you send; never blind-retry
|
|
44
|
+
|
|
45
|
+
`sfora chat <room> -m` fails in a room you haven't joined. A send that's retried is sent twice. Read the room before any retry:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
sfora join general --agent claude-code
|
|
49
|
+
sfora chat general -n 10 --agent claude-code < /dev/null
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 7. 409 means re-read
|
|
53
|
+
|
|
54
|
+
A block id is a fingerprint of that block's text. After someone edits it, a write to the old id fails with 409 and the CLI prints the current blocks. Re-read and re-aim:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
sfora blocks /projects/hq/docs/launch-plan.md --agent claude-code
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 8. Event streams can skip
|
|
61
|
+
|
|
62
|
+
`sfora watch` gets at most 100 events per poll and skips the rest. After a busy stretch, re-read the room, the board and your inbox instead of trusting the stream.
|
|
63
|
+
|
|
64
|
+
## 9. Flags mean different things per verb
|
|
65
|
+
|
|
66
|
+
- `--for` is seconds in `sfora typing` and a person in `sfora ask`.
|
|
67
|
+
- `--url` is the webhook's address in `sfora agent webhook`, and the sfora address everywhere else.
|
|
68
|
+
- `--version` right after `sfora` prints the CLI version; after `sfora skills install <name>` it picks a skill version.
|
|
69
|
+
- `sfora task --column` takes `triage`, `todo`, `in-progress` or `done`. Anything else silently means To do. Check with `sfora tasks`.
|
|
70
|
+
- An unknown flag is ignored without a word.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
sfora --help
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 10. Keys are full power and never expire
|
|
77
|
+
|
|
78
|
+
- The `scopes:` line in `sfora me` is a label. sfora checks the member's role and projects, not scopes.
|
|
79
|
+
- Keys don't expire. A leaked key works until a human regenerates it in Settings → Agents.
|
|
80
|
+
- `sfora mcp-config` prints the raw key, and an `/a/<key>` link holds one. Never paste either into a chat, a post, a commit or a log.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sfora-write
|
|
3
|
+
description: "Use when you publish a post or a doc in sfora as the agent, keep a draft, edit one block of a doc, or read a post's attachments (screenshots, files). Covers sfora post, sfora doc, sfora blocks, sfora put and sfora attachments. Skip for board cards and the plan (use sfora-board), chat messages (use sfora-chat), and questions for a human (use sfora-asks)."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Write posts and docs as the agent
|
|
7
|
+
|
|
8
|
+
A post is a published record: it can't be changed once posted. A doc is a living page you keep editing. Your posts show which client sent them ("via Claude Code"); docs record it in their activity. Run `sfora …` in a shell, with `--agent <name>` on every command.
|
|
9
|
+
|
|
10
|
+
## Steps
|
|
11
|
+
|
|
12
|
+
1. Write the markdown in a local file. Its H1 is the title. Iterate on a post as a draft, then post it once:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
sfora post status.md --project hq --draft --agent claude-code
|
|
16
|
+
sfora post status.md --project hq --agent claude-code
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
2. Create a doc once. Name the file after the H1's slug (`# Launch plan` → `launch-plan.md`), because the doc's path comes from its title:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
sfora doc launch-plan.md --project hq --agent claude-code
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
3. Change a doc by its path from then on. Rewrite the whole doc, or one block of it:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
sfora put /projects/hq/docs/launch-plan.md launch-plan.md --agent claude-code
|
|
29
|
+
sfora blocks /projects/hq/docs/launch-plan.md --agent claude-code
|
|
30
|
+
sfora put /projects/hq/docs/launch-plan.md --block <block-id> block.md --agent claude-code
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
4. Read a post's attachments before you act on it:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
sfora attachments /projects/hq/posts/<post-file>.md --agent claude-code
|
|
37
|
+
sfora attachments /projects/hq/posts/<post-file>.md --out ./attachments --agent claude-code
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Guardrails
|
|
41
|
+
|
|
42
|
+
- Post once. Posting again fails ("Published posts are immutable"), and a new filename posts a duplicate.
|
|
43
|
+
- Don't run `sfora doc` twice to update a doc. Use `sfora put` on its path. Find the path with `sfora ls /projects/hq/docs --agent claude-code`.
|
|
44
|
+
- A 409 on `put --block` means the block changed. Run `sfora blocks` again and aim at the new id. Don't retry the old one.
|
|
45
|
+
- `sfora put <path>` with no file reads stdin. Always name the file.
|
|
46
|
+
- If other people are in the doc (`sfora where --agent claude-code`), edit it with `sfora-live-edit`: claim the block first, and never rewrite the whole doc.
|
|
47
|
+
- Inside a repo with a `.sfora/` folder, a command without `--agent` (or `--cloud`) writes local files, not sfora.
|
|
48
|
+
|
|
49
|
+
## Report
|
|
50
|
+
|
|
51
|
+
Give the post or doc path and the link the CLI printed. For a block edit, say which block and the "changed" line.
|
|
52
|
+
|
|
53
|
+
Detail: `references/posts-and-docs.md`, `references/blocks.md`, `references/attachments.md`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Reading attachments
|
|
2
|
+
|
|
3
|
+
Posts can carry screenshots and files. Read them before you answer a post that has them.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
sfora attachments /projects/hq/posts/<post-file>.md --agent claude-code
|
|
7
|
+
sfora attachments /projects/hq/posts/<post-file>.md --json --agent claude-code
|
|
8
|
+
sfora attachments /projects/hq/posts/<post-file>.md --out ./attachments --agent claude-code
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- The first lists them: name, type and size.
|
|
12
|
+
- `--out <dir>` downloads them and prints each file's path. Open the images and read the text files from there.
|
|
13
|
+
- Files over 20 MB are skipped.
|
|
14
|
+
- The CLI can read attachments but can't upload them.
|
|
15
|
+
- Over MCP, the `attachments` and `attachment` tools do the same job.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Editing one block
|
|
2
|
+
|
|
3
|
+
A block is a paragraph, heading, list, table or code fence. Editing one block leaves the rest of the doc, and anyone typing in it, alone. Posts in drafts, docs and board cards all have blocks.
|
|
4
|
+
|
|
5
|
+
## The loop
|
|
6
|
+
|
|
7
|
+
1. List the blocks. Each has an id and says whether you can write it (the title and the frontmatter can't be written this way):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
sfora blocks /projects/hq/docs/launch-plan.md --agent claude-code
|
|
11
|
+
sfora blocks /projects/hq/docs/launch-plan.md --json --agent claude-code
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
2. Write the new markdown for that one block into a file, then send it:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
sfora put /projects/hq/docs/launch-plan.md --block <block-id> block.md --agent claude-code
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
3. Read the result line. "changed" means it landed; "no change" means the bytes already matched.
|
|
21
|
+
|
|
22
|
+
## When it fails with 409
|
|
23
|
+
|
|
24
|
+
A block id is a fingerprint of the block's text. If someone changed that block after you listed it, the id points at nothing, and the write fails with 409. The CLI prints the doc's current blocks so you can re-aim. Then:
|
|
25
|
+
|
|
26
|
+
1. Run `sfora blocks` again.
|
|
27
|
+
2. Read the block's new text. Someone else just changed it, so decide whether your edit still applies.
|
|
28
|
+
3. Write to the new id.
|
|
29
|
+
|
|
30
|
+
Never retry the same id: it fails the same way.
|
|
31
|
+
|
|
32
|
+
## Pitfalls
|
|
33
|
+
|
|
34
|
+
- `sfora put <path> --block <id>` with no file reads stdin. Always name the file.
|
|
35
|
+
- A whole-file `sfora put` replaces the doc. Prefer a block edit when you're changing one part of a doc others are working in.
|
|
36
|
+
- Generated files (`plan.md`, `map.md`, `asks.md`, `links.md`, the inbox) have no blocks.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Posts and docs
|
|
2
|
+
|
|
3
|
+
## Which project
|
|
4
|
+
|
|
5
|
+
`--project hq` picks the project. Without it, sfora reads a `project:` line in the file's frontmatter. If neither is there and you're in only one project, it uses that one; otherwise it stops and lists your projects.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
sfora projects --agent claude-code
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Posts
|
|
12
|
+
|
|
13
|
+
- `sfora post <file>.md` publishes to `/projects/<project>/posts/<file>.md`. The filename is your local file's name.
|
|
14
|
+
- A published post can't be edited or posted again. A second `sfora post` with the same filename fails with "Published posts are immutable". With a different filename it publishes a second post.
|
|
15
|
+
- `--draft` saves to `/projects/<project>/drafts/<file>.md`. Running it again updates the draft. Only you see your drafts.
|
|
16
|
+
- Publishing doesn't turn the draft into the post. When the draft is right, run `sfora post` once without `--draft`. The draft stays in drafts.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
sfora posts hq --agent claude-code
|
|
20
|
+
sfora cat /projects/hq/posts/<post-file>.md --agent claude-code
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Docs
|
|
24
|
+
|
|
25
|
+
- `sfora doc <file>.md` saves to `/projects/<project>/docs/`. sfora finds an existing doc by the slug of its H1 title. If your filename isn't that slug, you get a new doc beside the old one, at the same path.
|
|
26
|
+
- So: create once, then always `sfora put` the doc's path. If you change the H1, the path changes with it. Check with `sfora ls`.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
sfora ls /projects/hq/docs --agent claude-code
|
|
30
|
+
sfora cat /projects/hq/docs/launch-plan.md --agent claude-code
|
|
31
|
+
sfora put /projects/hq/docs/launch-plan.md launch-plan.md --agent claude-code
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Every write prints what it did: "changed" (and how many block ids survived) or "no change". Writing a doc also shows you as editing it to anyone who has it open.
|
|
35
|
+
|
|
36
|
+
## Your client name
|
|
37
|
+
|
|
38
|
+
Posts and chat messages show which client sent them. The CLI works it out from the environment (Claude Code, Codex, Cursor and Gemini set their own). If it shows "cli" where it should name your harness, say it yourself with `--client`:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
sfora post status.md --project hq --client claude-code --agent claude-code
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use the slug form (`claude-code`), not "Claude Code".
|
|
45
|
+
|
|
46
|
+
## Comments and reactions on a post
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
sfora comment /projects/hq/posts/<post-file>.md "<text>" --agent claude-code
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`sfora react` toggles: running it twice removes the reaction. React once.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
sfora react /projects/hq/posts/<post-file>.md 👍 --agent claude-code
|
|
56
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sfora skills packet install` — copy sfora's own agent skills (the packet
|
|
3
|
+
* that ships inside this package) into a coding agent's user skills folder.
|
|
4
|
+
*
|
|
5
|
+
* The folders come from the same adapter table discovery scans
|
|
6
|
+
* (`local-core/skill-adapters.ts`), so an installed packet shows up in
|
|
7
|
+
* `sfora skills inventory` like any other skill. The copy goes through
|
|
8
|
+
* `installSkillBundle`, the one installer that writes ownership receipts, so
|
|
9
|
+
* `skills uninstall` and the update path treat these installs as Sfora-owned.
|
|
10
|
+
*
|
|
11
|
+
* It never overwrites a folder it does not own or one that changed since it
|
|
12
|
+
* was installed: the whole plan is checked first, and one refusal means
|
|
13
|
+
* nothing is written. `--dry-run` prints that plan and stops.
|
|
14
|
+
*/
|
|
15
|
+
import { type SkillBundle } from "./local-core/index.js";
|
|
16
|
+
/** `--harness` value → the discovery adapter whose global folder it installs into. */
|
|
17
|
+
export declare const PACKET_HARNESSES: {
|
|
18
|
+
readonly "claude-code": "claude";
|
|
19
|
+
readonly codex: "codex";
|
|
20
|
+
readonly agents: "agents";
|
|
21
|
+
};
|
|
22
|
+
export type PacketHarness = keyof typeof PACKET_HARNESSES;
|
|
23
|
+
export declare const PACKET_USAGE = "usage: sfora skills packet install --harness <claude-code|codex|agents> [--skills-target <dir>] [--dry-run] [--json]";
|
|
24
|
+
/** The user skills folder a harness reads, under `home`. */
|
|
25
|
+
export declare function harnessSkillsDir(harness: PacketHarness, home?: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Where the packet's skills live, in the order they are looked for: the copy
|
|
28
|
+
* the build puts in `dist/skills-packet/` (the only one a published package
|
|
29
|
+
* has), then the source tree beside this package in the repo (`tsx` runs).
|
|
30
|
+
*/
|
|
31
|
+
export declare function packetDirCandidates(here?: string): string[];
|
|
32
|
+
export declare function findPacketDir(candidates?: string[]): Promise<string>;
|
|
33
|
+
export type PacketAction = "install" | "update" | "unchanged" | "refuse";
|
|
34
|
+
export interface PacketPlanItem {
|
|
35
|
+
name: string;
|
|
36
|
+
destination: string;
|
|
37
|
+
action: PacketAction;
|
|
38
|
+
reason?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface PacketPlan {
|
|
41
|
+
target: string;
|
|
42
|
+
items: PacketPlanItem[];
|
|
43
|
+
bundles: Map<string, SkillBundle>;
|
|
44
|
+
}
|
|
45
|
+
/** Every skill folder in the packet, read and checked; any bad one throws. */
|
|
46
|
+
export declare function readPacket(packetDir: string): Promise<SkillBundle[]>;
|
|
47
|
+
/** What installing `bundles` into `target` would do, per skill. Writes nothing. */
|
|
48
|
+
export declare function planPacketInstall(bundles: SkillBundle[], target: string): Promise<PacketPlan>;
|
|
49
|
+
export interface PacketInstallOptions {
|
|
50
|
+
harness?: string;
|
|
51
|
+
skillsTarget?: string;
|
|
52
|
+
dryRun?: boolean;
|
|
53
|
+
json?: boolean;
|
|
54
|
+
/** Injected in tests; never the real home there. */
|
|
55
|
+
home?: string;
|
|
56
|
+
packetDir?: string;
|
|
57
|
+
}
|
|
58
|
+
/** The whole verb: resolve, read, plan, and (unless a dry run) install. */
|
|
59
|
+
export declare function packetInstallCommand(opts: PacketInstallOptions): Promise<{
|
|
60
|
+
stdout: string;
|
|
61
|
+
stderr: string;
|
|
62
|
+
exitCode: number;
|
|
63
|
+
}>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sfora skills packet install` — copy sfora's own agent skills (the packet
|
|
3
|
+
* that ships inside this package) into a coding agent's user skills folder.
|
|
4
|
+
*
|
|
5
|
+
* The folders come from the same adapter table discovery scans
|
|
6
|
+
* (`local-core/skill-adapters.ts`), so an installed packet shows up in
|
|
7
|
+
* `sfora skills inventory` like any other skill. The copy goes through
|
|
8
|
+
* `installSkillBundle`, the one installer that writes ownership receipts, so
|
|
9
|
+
* `skills uninstall` and the update path treat these installs as Sfora-owned.
|
|
10
|
+
*
|
|
11
|
+
* It never overwrites a folder it does not own or one that changed since it
|
|
12
|
+
* was installed: the whole plan is checked first, and one refusal means
|
|
13
|
+
* nothing is written. `--dry-run` prints that plan and stops.
|
|
14
|
+
*/
|
|
15
|
+
import { readdir, stat } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { SKILL_AGENT_ADAPTERS, inspectSkillTarget, installSkillBundle, readSkillBundle, validateSkillFrontmatter, } from "./local-core/index.js";
|
|
20
|
+
import { CLI_VERSION } from "./version.js";
|
|
21
|
+
/** `--harness` value → the discovery adapter whose global folder it installs into. */
|
|
22
|
+
export const PACKET_HARNESSES = {
|
|
23
|
+
"claude-code": "claude",
|
|
24
|
+
codex: "codex",
|
|
25
|
+
agents: "agents",
|
|
26
|
+
};
|
|
27
|
+
export const PACKET_USAGE = "usage: sfora skills packet install --harness <claude-code|codex|agents> [--skills-target <dir>] [--dry-run] [--json]";
|
|
28
|
+
/** The user skills folder a harness reads, under `home`. */
|
|
29
|
+
export function harnessSkillsDir(harness, home = homedir()) {
|
|
30
|
+
const adapter = SKILL_AGENT_ADAPTERS.find((a) => a.id === PACKET_HARNESSES[harness]);
|
|
31
|
+
if (!adapter)
|
|
32
|
+
throw new Error(`No skill folder is known for ${harness}.`);
|
|
33
|
+
return join(home, adapter.globalPath);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Where the packet's skills live, in the order they are looked for: the copy
|
|
37
|
+
* the build puts in `dist/skills-packet/` (the only one a published package
|
|
38
|
+
* has), then the source tree beside this package in the repo (`tsx` runs).
|
|
39
|
+
*/
|
|
40
|
+
export function packetDirCandidates(here = dirname(fileURLToPath(import.meta.url))) {
|
|
41
|
+
return [join(here, "skills-packet"), resolve(here, "../../sfora-skills/plugins/sfora/skills")];
|
|
42
|
+
}
|
|
43
|
+
async function isDirectory(path) {
|
|
44
|
+
return (await stat(path).catch(() => null))?.isDirectory() ?? false;
|
|
45
|
+
}
|
|
46
|
+
export async function findPacketDir(candidates = packetDirCandidates()) {
|
|
47
|
+
for (const candidate of candidates)
|
|
48
|
+
if (await isDirectory(candidate))
|
|
49
|
+
return candidate;
|
|
50
|
+
throw new Error(`The skills packet is missing from this sfora-cli install (looked in ${candidates.join(", ")}).`);
|
|
51
|
+
}
|
|
52
|
+
/** Every skill folder in the packet, read and checked; any bad one throws. */
|
|
53
|
+
export async function readPacket(packetDir) {
|
|
54
|
+
const names = (await readdir(packetDir, { withFileTypes: true }))
|
|
55
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
56
|
+
.map((e) => e.name)
|
|
57
|
+
.sort();
|
|
58
|
+
if (!names.length)
|
|
59
|
+
throw new Error(`The skills packet at ${packetDir} has no skills.`);
|
|
60
|
+
const bundles = [];
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
const bundle = await readSkillBundle(join(packetDir, name));
|
|
63
|
+
validateSkillFrontmatter(bundle);
|
|
64
|
+
bundles.push(bundle);
|
|
65
|
+
}
|
|
66
|
+
return bundles;
|
|
67
|
+
}
|
|
68
|
+
/** What installing `bundles` into `target` would do, per skill. Writes nothing. */
|
|
69
|
+
export async function planPacketInstall(bundles, target) {
|
|
70
|
+
const root = resolve(target);
|
|
71
|
+
const rootInfo = await stat(root).catch((e) => {
|
|
72
|
+
if (e.code === "ENOENT")
|
|
73
|
+
return null;
|
|
74
|
+
throw e;
|
|
75
|
+
});
|
|
76
|
+
if (rootInfo && !rootInfo.isDirectory())
|
|
77
|
+
throw new Error(`${root} is not a folder.`);
|
|
78
|
+
const items = [];
|
|
79
|
+
for (const bundle of bundles) {
|
|
80
|
+
const destination = join(root, bundle.name);
|
|
81
|
+
if (!rootInfo) {
|
|
82
|
+
items.push({ name: bundle.name, destination, action: "install" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const found = await inspectSkillTarget(destination);
|
|
86
|
+
if (found.state === "missing")
|
|
87
|
+
items.push({ name: bundle.name, destination, action: "install" });
|
|
88
|
+
else if (found.state === "managed")
|
|
89
|
+
items.push({ name: bundle.name, destination, action: found.hash === bundle.hash ? "unchanged" : "update" });
|
|
90
|
+
else {
|
|
91
|
+
const reason = found.state === "unmanaged"
|
|
92
|
+
? "a folder sfora did not install is already there"
|
|
93
|
+
: found.state === "modified"
|
|
94
|
+
? "the installed copy has local changes"
|
|
95
|
+
: (found.reason ?? `the target is ${found.state}`);
|
|
96
|
+
items.push({ name: bundle.name, destination, action: "refuse", reason });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { target: root, items, bundles: new Map(bundles.map((b) => [b.name, b])) };
|
|
100
|
+
}
|
|
101
|
+
/** The whole verb: resolve, read, plan, and (unless a dry run) install. */
|
|
102
|
+
export async function packetInstallCommand(opts) {
|
|
103
|
+
const fail = (message, exitCode = 1) => ({ stdout: "", stderr: `${message}\n`, exitCode });
|
|
104
|
+
if (opts.harness !== undefined && !(opts.harness in PACKET_HARNESSES)) {
|
|
105
|
+
return fail(`--harness must be one of ${Object.keys(PACKET_HARNESSES).join(", ")} (got '${opts.harness}')\n${PACKET_USAGE}`, 2);
|
|
106
|
+
}
|
|
107
|
+
if (opts.harness === undefined && !opts.skillsTarget)
|
|
108
|
+
return fail(`say which agent: --harness, or a folder with --skills-target\n${PACKET_USAGE}`, 2);
|
|
109
|
+
const target = opts.skillsTarget
|
|
110
|
+
? resolve(opts.skillsTarget)
|
|
111
|
+
: harnessSkillsDir(opts.harness, opts.home);
|
|
112
|
+
let plan;
|
|
113
|
+
try {
|
|
114
|
+
const bundles = await readPacket(opts.packetDir ?? (await findPacketDir()));
|
|
115
|
+
plan = await planPacketInstall(bundles, target);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
119
|
+
}
|
|
120
|
+
const refused = plan.items.filter((i) => i.action === "refuse");
|
|
121
|
+
const work = plan.items.filter((i) => i.action === "install" || i.action === "update");
|
|
122
|
+
const installed = [];
|
|
123
|
+
let error;
|
|
124
|
+
if (!opts.dryRun && !refused.length) {
|
|
125
|
+
const source = `sfora-cli@${CLI_VERSION} skills packet`;
|
|
126
|
+
for (const item of work) {
|
|
127
|
+
try {
|
|
128
|
+
// The installer re-checks ownership under its lock, so a folder that
|
|
129
|
+
// appeared since the plan is still refused rather than overwritten.
|
|
130
|
+
await installSkillBundle(plan.bundles.get(item.name), plan.target, source);
|
|
131
|
+
installed.push(item.name);
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
error = `${item.name}: ${e instanceof Error ? e.message : String(e)}`;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const exitCode = refused.length || error ? 1 : 0;
|
|
140
|
+
if (opts.json) {
|
|
141
|
+
const body = {
|
|
142
|
+
target: plan.target,
|
|
143
|
+
...(opts.harness ? { harness: opts.harness } : {}),
|
|
144
|
+
dryRun: !!opts.dryRun,
|
|
145
|
+
skills: plan.items.map(({ name, action, destination, reason }) => ({ name, action, destination, ...(reason ? { reason } : {}) })),
|
|
146
|
+
installed,
|
|
147
|
+
...(error ? { error } : {}),
|
|
148
|
+
};
|
|
149
|
+
return { stdout: `${JSON.stringify(body, null, 2)}\n`, stderr: "", exitCode };
|
|
150
|
+
}
|
|
151
|
+
const lines = [`Skills packet → ${plan.target}${opts.harness ? ` (${opts.harness})` : ""}`];
|
|
152
|
+
for (const item of plan.items)
|
|
153
|
+
lines.push(` ${item.action.padEnd(9)} ${item.name}${item.reason ? ` — ${item.reason}` : ""}`);
|
|
154
|
+
let stderr = "";
|
|
155
|
+
if (opts.dryRun)
|
|
156
|
+
lines.push("Dry run: nothing written.");
|
|
157
|
+
else if (refused.length)
|
|
158
|
+
stderr = `Nothing installed: ${refused.length} folder${refused.length === 1 ? "" : "s"} in the way. sfora never overwrites a folder it did not install or one with local changes — move ${refused.length === 1 ? "it" : "them"}, or choose another --skills-target.\n`;
|
|
159
|
+
else if (error)
|
|
160
|
+
stderr = `Stopped at ${error}. Installed before it: ${installed.length ? installed.join(", ") : "none"}.\n`;
|
|
161
|
+
else if (!installed.length)
|
|
162
|
+
lines.push("✓ Already up to date.");
|
|
163
|
+
else
|
|
164
|
+
lines.push(`✓ Installed ${installed.length} skill${installed.length === 1 ? "" : "s"}. Start a new agent session to load them.`);
|
|
165
|
+
return { stdout: `${lines.join("\n")}\n`, stderr, exitCode };
|
|
166
|
+
}
|
package/dist/typing.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sfora typing <room> [--for <secs>] [--stop]` — and the same verb inside the
|
|
3
|
+
* shell (/mcp): say "I'm working on a reply in this room" (card #668).
|
|
4
|
+
*
|
|
5
|
+
* The room shows the agent working — the typing row, the composer's beam, the
|
|
6
|
+
* first-reply orb in a DM — until the agent sends its message there, stops, or
|
|
7
|
+
* the signal runs out (30 s by default, 5–120 s; run it again to extend). The
|
|
8
|
+
* server owns every rule; this resolves the room, calls the door, and says
|
|
9
|
+
* what happened in one line (or JSON).
|
|
10
|
+
*/
|
|
11
|
+
import type { SforaApiClient } from "./api-client.js";
|
|
12
|
+
import type { CommandOutput } from "./block-commands.js";
|
|
13
|
+
export declare const TYPING_USAGE = "usage: typing <room> [--for <secs>] [--stop] [--json]";
|
|
14
|
+
/** `--for` as whole seconds, or an explanation. The server clamps to 5–120. */
|
|
15
|
+
export declare function parseTypingSeconds(raw: string | undefined): number | undefined | Error;
|
|
16
|
+
type TypingClient = Pick<SforaApiClient, "listRooms" | "typing">;
|
|
17
|
+
export declare function typingCommand(client: TypingClient, roomRef: string | undefined, opts: {
|
|
18
|
+
seconds?: string;
|
|
19
|
+
stop?: boolean;
|
|
20
|
+
json?: boolean;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
}): Promise<CommandOutput>;
|
|
23
|
+
export {};
|
package/dist/typing.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sfora typing <room> [--for <secs>] [--stop]` — and the same verb inside the
|
|
3
|
+
* shell (/mcp): say "I'm working on a reply in this room" (card #668).
|
|
4
|
+
*
|
|
5
|
+
* The room shows the agent working — the typing row, the composer's beam, the
|
|
6
|
+
* first-reply orb in a DM — until the agent sends its message there, stops, or
|
|
7
|
+
* the signal runs out (30 s by default, 5–120 s; run it again to extend). The
|
|
8
|
+
* server owns every rule; this resolves the room, calls the door, and says
|
|
9
|
+
* what happened in one line (or JSON).
|
|
10
|
+
*/
|
|
11
|
+
import { resolveRoomRef, roomSlug } from "./chat.js";
|
|
12
|
+
export const TYPING_USAGE = "usage: typing <room> [--for <secs>] [--stop] [--json]";
|
|
13
|
+
/** `--for` as whole seconds, or an explanation. The server clamps to 5–120. */
|
|
14
|
+
export function parseTypingSeconds(raw) {
|
|
15
|
+
if (raw === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
18
|
+
return new Error(`--for takes whole seconds (5–120), got '${raw}'`);
|
|
19
|
+
return Number.parseInt(raw.trim(), 10);
|
|
20
|
+
}
|
|
21
|
+
export async function typingCommand(client, roomRef, opts) {
|
|
22
|
+
const fail = (message, exitCode = 1) => ({ stdout: "", stderr: `${message}\n`, exitCode });
|
|
23
|
+
if (!roomRef)
|
|
24
|
+
return fail(TYPING_USAGE, 2);
|
|
25
|
+
if (opts.stop && opts.seconds !== undefined)
|
|
26
|
+
return fail("--stop and --for do not go together", 2);
|
|
27
|
+
const seconds = parseTypingSeconds(opts.seconds);
|
|
28
|
+
if (seconds instanceof Error)
|
|
29
|
+
return fail(seconds.message, 2);
|
|
30
|
+
let room;
|
|
31
|
+
let result;
|
|
32
|
+
try {
|
|
33
|
+
const match = resolveRoomRef(await client.listRooms(true), roomRef);
|
|
34
|
+
if (match.kind === "match")
|
|
35
|
+
room = match.room;
|
|
36
|
+
else if (match.kind === "ambiguous") {
|
|
37
|
+
return fail(`'${roomRef}' matches several rooms — say which:\n${match.candidates.map((r) => ` ${roomSlug(r.name)}`).join("\n")}`);
|
|
38
|
+
}
|
|
39
|
+
else
|
|
40
|
+
return fail(`no room matches '${roomRef}' — run \`sfora rooms\` to see them`);
|
|
41
|
+
result = opts.stop
|
|
42
|
+
? await client.typing(room._id, { stop: true })
|
|
43
|
+
: await client.typing(room._id, seconds !== undefined ? { ttlSeconds: seconds } : {});
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
// The server's refusal, as it said it: not an agent, not in the room, no
|
|
47
|
+
// such room.
|
|
48
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
49
|
+
}
|
|
50
|
+
const tag = `#${roomSlug(room.name)}`;
|
|
51
|
+
if (opts.json) {
|
|
52
|
+
return { stdout: `${JSON.stringify({ ...result, roomId: room._id, room: roomSlug(room.name) }, null, 2)}\n`, stderr: "", exitCode: 0 };
|
|
53
|
+
}
|
|
54
|
+
if (!result.typing)
|
|
55
|
+
return { stdout: `✓ Stopped typing in ${tag}\n`, stderr: "", exitCode: 0 };
|
|
56
|
+
const left = result.until !== undefined ? Math.max(0, Math.round((result.until - (opts.now ?? Date.now)()) / 1000)) : undefined;
|
|
57
|
+
return {
|
|
58
|
+
stdout: `✓ Typing in ${tag}${left !== undefined ? ` for ${left}s` : ""} — it ends when you send there, or run \`sfora typing ${roomSlug(room.name)} --stop\`\n`,
|
|
59
|
+
stderr: "",
|
|
60
|
+
exitCode: 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.
|
|
1
|
+
export declare const CLI_VERSION = "0.17.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/sync-format.mjs from package.json.
|
|
2
|
-
export const CLI_VERSION = "0.
|
|
2
|
+
export const CLI_VERSION = "0.17.0";
|
package/dist/watch.d.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* The loop takes its I/O as arguments so a test can drive it with canned pages
|
|
29
29
|
* and a fake clock. Nothing here touches `process`.
|
|
30
30
|
*/
|
|
31
|
-
import type { AgentEventsPage } from "./api-client.js";
|
|
31
|
+
import type { AgentEventsPage, DocPresence, PresenceView } from "./api-client.js";
|
|
32
32
|
/** Everything the loop needs from the outside world. */
|
|
33
33
|
export interface WatchDeps {
|
|
34
34
|
/** One long-poll. Rejects on a dropped connection — the loop backs off. */
|
|
@@ -77,3 +77,80 @@ export type WatchTarget = {
|
|
|
77
77
|
path: string;
|
|
78
78
|
};
|
|
79
79
|
export declare function parseWatchTarget(target: string): WatchTarget;
|
|
80
|
+
/** What the editing beat needs: the `_presence` door, and where lines go. */
|
|
81
|
+
export interface EditingPresenceDeps {
|
|
82
|
+
/** `declarePresence(path, …)`; `null` means the path has no roster (a 422). */
|
|
83
|
+
declare(options: {
|
|
84
|
+
kind: "editing";
|
|
85
|
+
block?: string;
|
|
86
|
+
leave?: boolean;
|
|
87
|
+
}): Promise<DocPresence | null>;
|
|
88
|
+
/**
|
|
89
|
+
* The block the server holds for ME in this document right now, read
|
|
90
|
+
* without declaring anything (`GET /v1/presence?member=self`, see
|
|
91
|
+
* {@link myBlockIn}). `undefined` when I have no live row there. Optional:
|
|
92
|
+
* without it the watch keeps declaring the id it was given.
|
|
93
|
+
*/
|
|
94
|
+
whereAmI?(): Promise<{
|
|
95
|
+
block: string | null;
|
|
96
|
+
} | undefined>;
|
|
97
|
+
write(text: string): void;
|
|
98
|
+
warn(text: string): void;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* My own row in one document, out of a `GET /v1/presence?member=self` answer:
|
|
102
|
+
* `view.member` is me, and each document lists who is in it with the block
|
|
103
|
+
* they hold. Matched on the document id, or on its path (the fs has two
|
|
104
|
+
* spellings of a document path, and the server answers with one).
|
|
105
|
+
*/
|
|
106
|
+
export declare function myBlockIn(view: PresenceView, doc: {
|
|
107
|
+
docId?: string;
|
|
108
|
+
path: string;
|
|
109
|
+
}): {
|
|
110
|
+
block: string | null;
|
|
111
|
+
} | undefined;
|
|
112
|
+
/** A roster as one comparable string: who, how, and on which block. */
|
|
113
|
+
export declare function rosterKey(here: DocPresence["here"]): string;
|
|
114
|
+
/** `here: Ada (editing block k1a2), sfora-bot [agent] (viewing)`. */
|
|
115
|
+
export declare function renderRoster(presence: DocPresence): string;
|
|
116
|
+
/**
|
|
117
|
+
* Presence as the editor of one block: `kind=editing&block=<id>` on every
|
|
118
|
+
* beat, the roster printed on joining and again whenever it changes, and
|
|
119
|
+
* `?leave` on the way out (the watch loop's `finally` sends it).
|
|
120
|
+
*
|
|
121
|
+
* `start()` is the first declaration, made before the loop so a refusal is a
|
|
122
|
+
* clear error rather than a watch that quietly never shows up:
|
|
123
|
+
* - no roster (`null`, the server's 422): only documents have one;
|
|
124
|
+
* - a block id that resolves to nothing: the server keeps the row at
|
|
125
|
+
* document level and says `blockResolved: false`
|
|
126
|
+
* (convex/lib/docPresence.ts claimableBlockId), so this retracts it and
|
|
127
|
+
* names the fix.
|
|
128
|
+
*
|
|
129
|
+
* FOLLOWING THE BLOCK. Block ids are fingerprints of the text, so an edit to
|
|
130
|
+
* the claimed block gives it a new id. The server moves claims along with it:
|
|
131
|
+
* the writer's own `?block=` write rebinds the writer's row to the new id
|
|
132
|
+
* (convex/httpHelpers.ts:3116-3132, the `reboundOwn` touch after a doc PUT), and
|
|
133
|
+
* everybody else's claims ride the same ledger (carryDocPresenceBlocks,
|
|
134
|
+
* convex/lib/docPresence.ts:417-442) — but only on fs writes: its one caller
|
|
135
|
+
* is httpHelpers.ts:3125. An edit saved from the app carries no claims, so a
|
|
136
|
+
* person editing the claimed block in the app reads here as "gone". But a declare names its block, and a declare
|
|
137
|
+
* of the OLD id resolves to nothing (claimableBlockId) and touchDocPresence
|
|
138
|
+
* overwrites the rebound row with a
|
|
139
|
+
* block-less one, so the answer to that declare can no longer say what the
|
|
140
|
+
* new id was. So each beat first READS my row (`whereAmI`, a GET that joins
|
|
141
|
+
* nothing) and, when the server holds a different block for me, adopts it
|
|
142
|
+
* and declares that. Only a block whose old id no longer resolves and that
|
|
143
|
+
* the server did not move my claim to (removed, or edited in the app) is a
|
|
144
|
+
* warning, once,
|
|
145
|
+
* after which the watch stays in the document without a block.
|
|
146
|
+
*/
|
|
147
|
+
export declare function editingPresence(deps: EditingPresenceDeps, opts: {
|
|
148
|
+
path: string;
|
|
149
|
+
block: string;
|
|
150
|
+
json?: boolean;
|
|
151
|
+
}): {
|
|
152
|
+
start(): Promise<void>;
|
|
153
|
+
beat({ leave }: {
|
|
154
|
+
leave?: boolean;
|
|
155
|
+
}): Promise<void>;
|
|
156
|
+
};
|