sfora-cli 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +18 -0
  2. package/dist/agent-webhook.d.ts +20 -0
  3. package/dist/agent-webhook.js +42 -0
  4. package/dist/api-client.d.ts +97 -0
  5. package/dist/api-client.js +68 -0
  6. package/dist/ask.d.ts +51 -0
  7. package/dist/ask.js +70 -0
  8. package/dist/attachments-node.d.ts +7 -0
  9. package/dist/attachments-node.js +15 -0
  10. package/dist/attachments.d.ts +112 -0
  11. package/dist/attachments.js +254 -0
  12. package/dist/block-commands.d.ts +10 -0
  13. package/dist/block-commands.js +28 -0
  14. package/dist/chat.d.ts +15 -0
  15. package/dist/chat.js +7 -0
  16. package/dist/cli-args.d.ts +7 -0
  17. package/dist/cli-args.js +28 -1
  18. package/dist/cli.d.ts +12 -1
  19. package/dist/cli.js +186 -19
  20. package/dist/format/linkUrls.d.ts +2 -0
  21. package/dist/format/linkUrls.js +48 -0
  22. package/dist/format/postMarkdown.d.ts +12 -1
  23. package/dist/format/postMarkdown.js +9 -2
  24. package/dist/index.d.ts +23 -1
  25. package/dist/index.js +17 -1
  26. package/dist/local-core/skills.d.ts +25 -0
  27. package/dist/local-core/skills.js +93 -0
  28. package/dist/mcp-description.d.ts +11 -0
  29. package/dist/mcp-description.js +29 -0
  30. package/dist/mcp-server.d.ts +5 -1
  31. package/dist/mcp-server.js +28 -18
  32. package/dist/shell-commands.d.ts +7 -1
  33. package/dist/shell-commands.js +49 -3
  34. package/dist/skills-command.d.ts +1 -1
  35. package/dist/skills-command.js +20 -1
  36. package/dist/skills-packet/sfora-asks/SKILL.md +49 -0
  37. package/dist/skills-packet/sfora-asks/references/asks.md +41 -0
  38. package/dist/skills-packet/sfora-board/SKILL.md +53 -0
  39. package/dist/skills-packet/sfora-board/references/board.md +60 -0
  40. package/dist/skills-packet/sfora-board/references/plan.md +25 -0
  41. package/dist/skills-packet/sfora-chat/SKILL.md +55 -0
  42. package/dist/skills-packet/sfora-chat/references/rooms.md +45 -0
  43. package/dist/skills-packet/sfora-chat/references/waiting.md +37 -0
  44. package/dist/skills-packet/sfora-live-edit/SKILL.md +62 -0
  45. package/dist/skills-packet/sfora-live-edit/references/collisions.md +54 -0
  46. package/dist/skills-packet/sfora-live-edit/references/http.md +63 -0
  47. package/dist/skills-packet/sfora-live-edit/references/live-editing.md +49 -0
  48. package/dist/skills-packet/sfora-setup/SKILL.md +37 -0
  49. package/dist/skills-packet/sfora-setup/references/sign-in.md +43 -0
  50. package/dist/skills-packet/sfora-skills/SKILL.md +54 -0
  51. package/dist/skills-packet/sfora-skills/references/skills.md +78 -0
  52. package/dist/skills-packet/sfora-troubleshoot/SKILL.md +39 -0
  53. package/dist/skills-packet/sfora-troubleshoot/references/sharp-edges.md +80 -0
  54. package/dist/skills-packet/sfora-write/SKILL.md +53 -0
  55. package/dist/skills-packet/sfora-write/references/attachments.md +15 -0
  56. package/dist/skills-packet/sfora-write/references/blocks.md +36 -0
  57. package/dist/skills-packet/sfora-write/references/posts-and-docs.md +56 -0
  58. package/dist/skills-packet.d.ts +63 -0
  59. package/dist/skills-packet.js +166 -0
  60. package/dist/typing.d.ts +23 -0
  61. package/dist/typing.js +62 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/watch.d.ts +78 -1
  65. package/dist/watch.js +109 -0
  66. package/package.json +3 -3
@@ -51,6 +51,99 @@ export function validateSkillBundle(bundle) {
51
51
  if (skillBundleHash(bundle.files) !== bundle.hash)
52
52
  throw new Error("Invalid skill bundle hash.");
53
53
  }
54
+ /** The longest `description` a SKILL.md may carry (the Agent Skills format's limit). */
55
+ export const SKILL_DESCRIPTION_MAX = 1024;
56
+ /**
57
+ * The top-level scalar keys of a SKILL.md frontmatter block, or `null` when the
58
+ * file does not open with one. A deliberately small YAML reader: plain, quoted
59
+ * and block (`|`, `>`) scalars, plus indented continuation lines. Nested maps
60
+ * (`metadata:`) are skipped. It never throws; a value it cannot read is absent.
61
+ */
62
+ export function readSkillFrontmatter(markdown) {
63
+ const m = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(markdown);
64
+ if (!m)
65
+ return null;
66
+ const lines = m[1].split(/\r?\n/);
67
+ const out = {};
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const key = /^([A-Za-z_][\w-]*):(?:[ \t]+(.*))?$/.exec(lines[i]);
70
+ if (!key)
71
+ continue;
72
+ const given = (key[2] ?? "").trim();
73
+ const raw = /^["']/.test(given) ? given : given.replace(/[ \t]+#.*$/, "").trim();
74
+ const body = [];
75
+ while (i + 1 < lines.length && (/^[ \t]+\S/.test(lines[i + 1]) || (lines[i + 1].trim() === "" && /^[|>]/.test(raw))))
76
+ body.push(lines[++i]);
77
+ let value;
78
+ if (/^[|>][+-]?$/.test(raw)) {
79
+ const indent = Math.min(...body.filter(l => l.trim()).map(l => /^[ \t]*/.exec(l)[0].length));
80
+ const text = body.map(l => l.slice(Number.isFinite(indent) ? indent : 0));
81
+ value = raw.startsWith("|") ? text.join("\n") : text.map(l => l.trim()).join(" ").replace(/ {2,}/g, " ");
82
+ }
83
+ else if (!raw && body.length)
84
+ continue; // a nested map, not a scalar
85
+ else {
86
+ value = [raw, ...body.map(l => l.trim())].filter(Boolean).join(" ");
87
+ const q = /^(["'])([\s\S]*)\1$/.exec(value);
88
+ if (q)
89
+ value = q[1] === "'" ? q[2].replace(/''/g, "'") : q[2].replace(/\\"/g, '"');
90
+ }
91
+ out[key[1]] = value.trim();
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * What is wrong with a bundle's SKILL.md frontmatter, as sentences; empty when
97
+ * nothing is. `name` must be present and equal the folder name, `description`
98
+ * present, non-empty and at most {@link SKILL_DESCRIPTION_MAX} characters.
99
+ * Kept apart from {@link validateSkillBundle} on purpose: that one gates every
100
+ * read (scan, inventory, uninstall, cloud download), and many existing skills
101
+ * have no frontmatter at all. This one gates what Sfora itself publishes or
102
+ * ships: `skills push` and `skills packet install`.
103
+ */
104
+ export function skillFrontmatterProblems(bundle) {
105
+ const file = bundle.files.find(f => f.path === "SKILL.md");
106
+ if (!file)
107
+ return ["A skill must contain SKILL.md."];
108
+ const fm = readSkillFrontmatter(Buffer.from(file.contentBase64, "base64").toString("utf8"));
109
+ if (!fm)
110
+ return [`${bundle.name}/SKILL.md has no frontmatter: open it with a --- block carrying name and description.`];
111
+ const problems = [];
112
+ if (!fm.name)
113
+ problems.push(`${bundle.name}/SKILL.md frontmatter has no name.`);
114
+ else if (fm.name !== bundle.name)
115
+ problems.push(`${bundle.name}/SKILL.md frontmatter name '${fm.name}' differs from its folder name '${bundle.name}'.`);
116
+ if (!fm.description)
117
+ problems.push(`${bundle.name}/SKILL.md frontmatter has no description.`);
118
+ else if (fm.description.length > SKILL_DESCRIPTION_MAX)
119
+ problems.push(`${bundle.name}/SKILL.md description is ${fm.description.length} characters; the limit is ${SKILL_DESCRIPTION_MAX}.`);
120
+ return problems;
121
+ }
122
+ /**
123
+ * Throws every {@link skillFrontmatterProblems} finding, followed by the exact
124
+ * block to write, with the folder name filled in: a skill that pushed fine
125
+ * before this check existed is fixed with one edit.
126
+ */
127
+ export function validateSkillFrontmatter(bundle) {
128
+ const problems = skillFrontmatterProblems(bundle);
129
+ if (!problems.length)
130
+ return;
131
+ const file = bundle.files.find(f => f.path === "SKILL.md");
132
+ const hasBlock = !!file && readSkillFrontmatter(Buffer.from(file.contentBase64, "base64").toString("utf8")) !== null;
133
+ throw new Error([
134
+ ...problems,
135
+ hasBlock
136
+ ? `Its frontmatter block must carry these two fields (keep any others):`
137
+ : `Add this at the very top of ${bundle.name}/SKILL.md:`,
138
+ "",
139
+ ...(hasBlock ? [] : ["---"]),
140
+ `name: ${bundle.name}`,
141
+ `description: Use when <the situation this skill is for>. Skip when <when it is not>.`,
142
+ ...(hasBlock ? [] : ["---"]),
143
+ "",
144
+ `name must equal the folder name; description is one line of at most ${SKILL_DESCRIPTION_MAX} characters.`,
145
+ ].join("\n"));
146
+ }
54
147
  export async function readSkillBundle(directory, name = basename(resolve(directory))) {
55
148
  validateSkillName(name);
56
149
  if ((await lstat(directory)).isSymbolicLink())
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The `bash` tool's description for a cloud workspace, shared by the npm stdio
3
+ * server (`sfora --mcp`) and the hosted one (the app's POST /mcp), which run
4
+ * the same shell (card #811: the two had drifted, and the hosted text taught a
5
+ * board column that does not exist). Only what persists differs: the stdio
6
+ * server keeps one shell, so cwd and env carry over; each hosted call is a
7
+ * fresh shell at `/`.
8
+ */
9
+ export declare function cloudToolDescription({ stateless }: {
10
+ stateless: boolean;
11
+ }): string;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The `bash` tool's description for a cloud workspace, shared by the npm stdio
3
+ * server (`sfora --mcp`) and the hosted one (the app's POST /mcp), which run
4
+ * the same shell (card #811: the two had drifted, and the hosted text taught a
5
+ * board column that does not exist). Only what persists differs: the stdio
6
+ * server keeps one shell, so cwd and env carry over; each hosted call is a
7
+ * fresh shell at `/`.
8
+ */
9
+ export function cloudToolDescription({ stateless }) {
10
+ const persistence = stateless
11
+ ? "Calls are stateless — always use absolute paths."
12
+ : "cwd and environment persist across calls.";
13
+ return `Run a bash command against the sfora workspace — a Unix-style view where every post, task, and doc is a markdown file:
14
+ - /projects/<slug>/posts/<file>.md published posts
15
+ - /projects/<slug>/drafts/<file>.md your drafts
16
+ - /projects/<slug>/board/<NN-stage>/<NNNN>.md tasks (kanban cards), by stage
17
+ - /projects/<slug>/library/documents/<file>.md workspace documents (writable)
18
+ - /projects/<slug>/library/files/<file> uploaded files (read-only)
19
+ - /projects/<slug>/library/repositories/<repo> project source trees (read-only)
20
+ - /projects/<slug>/pulls/<number>.md pull requests (diff + linked work), read-only
21
+ - /projects/<slug>/plan.md the goal + open questions (write to set the goal)
22
+ - /projects/<slug>/asks.md coordination asks, read-only
23
+ - /inbox/mentions.md unread mentions
24
+ - /me/api-key your identity
25
+ Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; move a card into 04-done to close it. Question cards (kind: question) map their stage to the plan: triage = fuzzy, todo = up for grabs, in-progress = claimed, done = decided.
26
+ Examples: 'ls /projects', 'cat /projects/web/board/02-todo/*.md', 'grep -ri TODO /projects', 'echo "# Fix login\\nstatus: active" > /projects/web/board/02-todo/fix.md', 'mv /projects/web/board/02-todo/0003-*.md /projects/web/board/04-done/'.
27
+ Write a file to create or update the entity (frontmatter sets fields like status/priority/assignees/due). ${persistence}
28
+ Chat: 'typing <room> [--for <secs>]' shows the room you are working on a reply (agents; 30s by default, run again to extend); it ends when you send there, or with 'typing <room> --stop'.`;
29
+ }
@@ -1,5 +1,7 @@
1
1
  /**
2
- * MCP stdio server exposing a single `bash` tool. Each tool call runs the
2
+ * MCP stdio server exposing a `bash` tool — plus, against the cloud, the
3
+ * `attachments` list and `attachment` fetch (card #822), which return a post's
4
+ * images as image content a vision client can see. Each bash call runs the
3
5
  * command through one persistent {@link createSforaShell} instance, with cwd/env
4
6
  * carried across calls (just-bash doesn't persist them itself), so an agent's
5
7
  * `cd` and `export` survive between tool invocations.
@@ -15,4 +17,6 @@ export interface RunMcpServerOptions {
15
17
  /** Terminal client slug for write attribution (`X-Sfora-Client`). */
16
18
  clientLabel?: string;
17
19
  }
20
+ /** The shell the server runs commands in. Exported for the transport-header test (card #815). */
21
+ export declare function createMcpShell(options: RunMcpServerOptions): import("./index.js").SforaShell | import("./index.js").LocalShell;
18
22
  export declare function runMcpServer(options: RunMcpServerOptions): Promise<void>;
@@ -1,5 +1,7 @@
1
1
  /**
2
- * MCP stdio server exposing a single `bash` tool. Each tool call runs the
2
+ * MCP stdio server exposing a `bash` tool — plus, against the cloud, the
3
+ * `attachments` list and `attachment` fetch (card #822), which return a post's
4
+ * images as image content a vision client can see. Each bash call runs the
3
5
  * command through one persistent {@link createSforaShell} instance, with cwd/env
4
6
  * carried across calls (just-bash doesn't persist them itself), so an agent's
5
7
  * `cd` and `export` survive between tool invocations.
@@ -10,21 +12,10 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
13
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
12
14
  import { createSforaShell, createLocalShell } from "./index.js";
13
- const TOOL_DESCRIPTION = `Run a bash command against the sfora workspace — a Unix-style view where every post, task, and doc is a markdown file:
14
- - /projects/<slug>/posts/<file>.md published posts
15
- - /projects/<slug>/drafts/<file>.md your drafts
16
- - /projects/<slug>/board/<NN-stage>/<NNNN>.md tasks (kanban cards), by stage
17
- - /projects/<slug>/library/documents/<file>.md workspace documents (writable)
18
- - /projects/<slug>/library/files/<file> uploaded files (read-only)
19
- - /projects/<slug>/library/repositories/<repo> project source trees (read-only)
20
- - /projects/<slug>/pulls/<number>.md pull requests (diff + linked work), read-only
21
- - /projects/<slug>/plan.md the goal + open questions (write to set the goal)
22
- - /projects/<slug>/asks.md coordination asks, read-only
23
- - /inbox/mentions.md unread mentions
24
- - /me/api-key your identity
25
- Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; move a card into 04-done to close it. Question cards (kind: question) map their stage to the plan: triage = fuzzy, todo = up for grabs, in-progress = claimed, done = decided.
26
- Examples: 'ls /projects', 'cat /projects/web/board/02-todo/*.md', 'grep -ri TODO /projects', 'echo "# Fix login\\nstatus: active" > /projects/web/board/02-todo/fix.md', 'mv /projects/web/board/02-todo/0003-*.md /projects/web/board/04-done/'.
27
- Write a file to create or update the entity (frontmatter sets fields like status/priority/assignees/due). cwd and environment persist across calls.`;
15
+ import { ATTACHMENT_TOOLS, attachmentToolResult, isAttachmentTool } from "./attachments.js";
16
+ import { cloudToolDescription } from "./mcp-description.js";
17
+ // The cloud description is shared with the hosted /mcp route (card #811).
18
+ const TOOL_DESCRIPTION = cloudToolDescription({ stateless: false });
28
19
  const LOCAL_TOOL_DESCRIPTION = `Run a bash command against the local sfora workspace (a .sfora/ directory of plain markdown files, git-versioned with the repo):
29
20
  - /board/<NN-stage>/<NNNN>-<slug>.md tasks (kanban cards), by stage — 'mv' between stage dirs moves a task
30
21
  - /posts/<YYYY-MM-DD>-<slug>.md posts
@@ -32,15 +23,25 @@ const LOCAL_TOOL_DESCRIPTION = `Run a bash command against the local sfora works
32
23
  Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; moving a card into 04-done marks it done.
33
24
  Examples: 'ls /board/02-todo', 'cat /board/02-todo/*.md', 'grep -ri TODO /', 'echo "# Fix login\\nstatus: active" > /board/02-todo/fix-login.md', 'mv /board/02-todo/0003-*.md /board/04-done/'.
34
25
  Frontmatter sets task fields (status/priority/labels/assignees/due). cwd and environment persist across calls.`;
35
- export async function runMcpServer(options) {
36
- const { bash } = options.localRoot
26
+ /** The shell the server runs commands in. Exported for the transport-header test (card #815). */
27
+ export function createMcpShell(options) {
28
+ return options.localRoot
37
29
  ? createLocalShell(options.localRoot)
38
30
  : createSforaShell({
39
31
  baseUrl: options.baseUrl,
40
32
  apiKey: options.apiKey,
41
33
  org: options.org,
42
34
  clientLabel: options.clientLabel,
35
+ // Card #815 (D11): this server's calls are MCP, not terminal commands.
36
+ transport: "mcp",
43
37
  });
38
+ }
39
+ export async function runMcpServer(options) {
40
+ // A local workspace has no attachments, so only the cloud shell's client
41
+ // answers the attachment tools.
42
+ const shell = createMcpShell(options);
43
+ const { bash } = shell;
44
+ const client = "client" in shell ? shell.client : null;
44
45
  // Persistent shell state across tool calls.
45
46
  let cwd = "/";
46
47
  let env;
@@ -61,9 +62,18 @@ export async function runMcpServer(options) {
61
62
  required: ["command"],
62
63
  },
63
64
  },
65
+ ...(client ? ATTACHMENT_TOOLS : []),
64
66
  ],
65
67
  }));
66
68
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
69
+ if (client && isAttachmentTool(request.params.name)) {
70
+ const args = (request.params.arguments ?? {});
71
+ return attachmentToolResult(client, {
72
+ path: args.path,
73
+ // `attachments` lists; only `attachment` fetches by id.
74
+ id: request.params.name === "attachment" ? (args.id ?? "") : undefined,
75
+ });
76
+ }
67
77
  if (request.params.name !== "bash") {
68
78
  return {
69
79
  content: [
@@ -1,5 +1,7 @@
1
1
  /**
2
- * The sfora-native commands inside the shell: `blocks`, `put`, `url`.
2
+ * The sfora-native commands inside the shell: `blocks`, `put`, `url`,
3
+ * `backlinks`, `attachments` (card #822, list only), and
4
+ * `typing` (card #668 — the agent typing signal, the same verb as the CLI's).
3
5
  *
4
6
  * Card #333's "and the fs shell equivalent". The shell already writes whole
5
7
  * files (`echo '# Title' > /projects/x/docs/y.md` is a PUT), but it had no way
@@ -19,6 +21,10 @@ interface ParsedArgs {
19
21
  positional: string[];
20
22
  blockId?: string;
21
23
  json: boolean;
24
+ /** `typing --for <secs>` */
25
+ seconds?: string;
26
+ /** `typing --stop` */
27
+ stop: boolean;
22
28
  }
23
29
  export declare function parseShellArgs(args: string[]): ParsedArgs;
24
30
  /**
@@ -1,5 +1,7 @@
1
1
  /**
2
- * The sfora-native commands inside the shell: `blocks`, `put`, `url`.
2
+ * The sfora-native commands inside the shell: `blocks`, `put`, `url`,
3
+ * `backlinks`, `attachments` (card #822, list only), and
4
+ * `typing` (card #668 — the agent typing signal, the same verb as the CLI's).
3
5
  *
4
6
  * Card #333's "and the fs shell equivalent". The shell already writes whole
5
7
  * files (`echo '# Title' > /projects/x/docs/y.md` is a PUT), but it had no way
@@ -12,9 +14,11 @@
12
14
  * k7f3a2cx` is the loop this exists for.
13
15
  */
14
16
  import { decodeBytesToUtf8 } from "just-bash";
15
- import { blocksCommand, presenceNotice, putCommand, resolveFsPath, urlCommand, } from "./block-commands.js";
17
+ import { blocksCommand, backlinksCommand, presenceNotice, putCommand, resolveFsPath, urlCommand, } from "./block-commands.js";
18
+ import { TYPING_USAGE, typingCommand } from "./typing.js";
19
+ import { attachmentsCommand } from "./attachments.js";
16
20
  export function parseShellArgs(args) {
17
- const parsed = { positional: [], json: false };
21
+ const parsed = { positional: [], json: false, stop: false };
18
22
  for (let i = 0; i < args.length; i++) {
19
23
  const a = args[i];
20
24
  if (a === "--json")
@@ -24,6 +28,12 @@ export function parseShellArgs(args) {
24
28
  else if (a.startsWith("--block=")) {
25
29
  parsed.blockId = a.slice("--block=".length);
26
30
  }
31
+ else if (a === "--for")
32
+ parsed.seconds = args[++i];
33
+ else if (a.startsWith("--for="))
34
+ parsed.seconds = a.slice("--for=".length);
35
+ else if (a === "--stop")
36
+ parsed.stop = true;
27
37
  else
28
38
  parsed.positional.push(a);
29
39
  }
@@ -80,6 +90,31 @@ export function sforaShellCommands(client, presence = presenceNotice()) {
80
90
  }));
81
91
  },
82
92
  },
93
+ {
94
+ name: "backlinks",
95
+ async execute(args, ctx) {
96
+ const { positional, json } = parseShellArgs(args);
97
+ if (!positional[0])
98
+ return usage("usage: backlinks <path> [--json]");
99
+ return toExec(await backlinksCommand(client, resolveFsPath(ctx.cwd, positional[0]), {
100
+ json,
101
+ }));
102
+ },
103
+ },
104
+ {
105
+ // Card #822. List only: the shell's fs is the workspace, so there is no
106
+ // local disk for `--out` — `sfora attachments <post> --out <dir>` is the
107
+ // CLI verb that writes the files.
108
+ name: "attachments",
109
+ async execute(args, ctx) {
110
+ const { positional, json } = parseShellArgs(args);
111
+ if (!positional[0])
112
+ return usage("usage: attachments <post path> [--json]");
113
+ return toExec(await attachmentsCommand(client, resolveFsPath(ctx.cwd, positional[0]), {
114
+ json,
115
+ }));
116
+ },
117
+ },
83
118
  {
84
119
  name: "put",
85
120
  async execute(args, ctx) {
@@ -93,6 +128,17 @@ export function sforaShellCommands(client, presence = presenceNotice()) {
93
128
  return toExec(await putCommand(client, resolveFsPath(ctx.cwd, positional[0]), body, { blockId, json, presence }));
94
129
  },
95
130
  },
131
+ {
132
+ // Rooms are named, not pathed (as `sfora chat` names them): the shell
133
+ // has no room files to write to, so this is the one verb that takes one.
134
+ name: "typing",
135
+ async execute(args) {
136
+ const { positional, json, seconds, stop } = parseShellArgs(args);
137
+ if (!positional[0])
138
+ return usage(TYPING_USAGE);
139
+ return toExec(await typingCommand(client, positional[0], { seconds, stop, json }));
140
+ },
141
+ },
96
142
  {
97
143
  name: "url",
98
144
  async execute(args, ctx) {
@@ -1,4 +1,4 @@
1
1
  import type { CliArgs } from "./cli-args.js";
2
2
  import type { ResolvedSettings } from "./config.js";
3
- export declare const SKILLS_HELP = "Skills:\n sfora skills inventory --json Persistent local inventory with warnings and identities\n sfora skills roots [add <folder> [label] | add-project <folder> [label] | remove <id>]\n Manage registered discovery folders (never deletes files)\n sfora skills scan [root ...] --json Discover complete local skill bundles\n sfora skills list --project <slug> List cloud project skills\n sfora skills index --json Read cached local inventory without scanning\n sfora skills operations List durable transfer outcomes and recovery needs\n sfora skills queue <plan.json> Queue a reviewed push, pull or install\n sfora skills cancel <operation-id> Cancel an unstarted operation\n sfora skills apply <plan.json|operation-id> Execute a reviewed plan\n sfora skills recover <operation-id> Reconcile an interrupted operation\n sfora skills apply-batch <ids.json> Run queued operation IDs with per-item outcomes\n sfora skills recover-batch <ids.json> Resume incomplete items without replaying successes\n sfora skills bindings List explicit cloud mappings and baselines\n sfora skills bind <location-id> <cloud-name> --project <slug>\n Explicitly adopt a published cloud identity\n sfora skills status <binding-id> Compare current files with the bound published revision\n sfora skills plan <binding-id> <push|pull> Preview IDs, hashes, conflicts and per-file changes\n sfora skills plan-install <name> --project <slug> --skills-target <existing-directory>\n Preview a new installation without modifying files\n sfora skills relocate <location-id> <folder> Preserve identity after an explicit folder move\n sfora skills push <folder> --project <slug> --expected-version <N> [--expected-revision <N>]\n Upload draft and publish (0 for a new skill)\n sfora skills pull <name> <bundle.json> --project <slug> [--version <N>]\n sfora skills install <name> --project <slug> --skills-target <directory> [--version <N>]\n sfora skills install-file <bundle.json> --skills-target <directory>\n sfora skills diff <folder> <name> --project <slug> [--version <N>]\n sfora skills uninstall <installed-folder> Remove only an unchanged Sfora-owned install\n";
3
+ export declare const SKILLS_HELP = "Skills:\n sfora skills inventory --json Persistent local inventory with warnings and identities\n sfora skills roots [add <folder> [label] | add-project <folder> [label] | remove <id>]\n Manage registered discovery folders (never deletes files)\n sfora skills scan [root ...] --json Discover complete local skill bundles\n sfora skills list --project <slug> List cloud project skills\n sfora skills index --json Read cached local inventory without scanning\n sfora skills operations List durable transfer outcomes and recovery needs\n sfora skills queue <plan.json> Queue a reviewed push, pull or install\n sfora skills cancel <operation-id> Cancel an unstarted operation\n sfora skills apply <plan.json|operation-id> Execute a reviewed plan\n sfora skills recover <operation-id> Reconcile an interrupted operation\n sfora skills apply-batch <ids.json> Run queued operation IDs with per-item outcomes\n sfora skills recover-batch <ids.json> Resume incomplete items without replaying successes\n sfora skills bindings List explicit cloud mappings and baselines\n sfora skills bind <location-id> <cloud-name> --project <slug>\n Explicitly adopt a published cloud identity\n sfora skills status <binding-id> Compare current files with the bound published revision\n sfora skills plan <binding-id> <push|pull> Preview IDs, hashes, conflicts and per-file changes\n sfora skills plan-install <name> --project <slug> --skills-target <existing-directory>\n Preview a new installation without modifying files\n sfora skills relocate <location-id> <folder> Preserve identity after an explicit folder move\n sfora skills push <folder> --project <slug> --expected-version <N> [--expected-revision <N>]\n Upload draft and publish (0 for a new skill)\n sfora skills pull <name> <bundle.json> --project <slug> [--version <N>]\n sfora skills install <name> --project <slug> --skills-target <directory> [--version <N>]\n sfora skills install-file <bundle.json> --skills-target <directory>\n sfora skills diff <folder> <name> --project <slug> [--version <N>]\n sfora skills uninstall <installed-folder> Remove only an unchanged Sfora-owned install\n sfora skills packet install --harness <claude-code|codex|agents> [--skills-target <dir>] [--dry-run]\n Install sfora's own agent skills into that agent's\n user skills folder (never overwrites a folder it\n did not install, or one with local changes)\n";
4
4
  export declare function runSkillsCommand(args: CliArgs, settings: ResolvedSettings): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { readFile, realpath } from "node:fs/promises";
3
- import { diffSkillBundles, installSkillBundle, inspectSkillTarget, readSkillBundle, discoverSkills, SkillService, uninstallSkill, validateSkillBundle } from "./local-core/index.js";
3
+ import { diffSkillBundles, installSkillBundle, inspectSkillTarget, readSkillBundle, discoverSkills, SkillService, uninstallSkill, validateSkillBundle, validateSkillFrontmatter } from "./local-core/index.js";
4
+ import { PACKET_USAGE, packetInstallCommand } from "./skills-packet.js";
4
5
  import { SkillOperationJournal } from "./local-core/skill-operations.js";
5
6
  import { SkillOperationExecutor } from "./local-core/skill-executor.js";
6
7
  import { defaultSkillCatalogPath } from "./local-core/skill-store.js";
@@ -35,6 +36,10 @@ export const SKILLS_HELP = `Skills:
35
36
  sfora skills install-file <bundle.json> --skills-target <directory>
36
37
  sfora skills diff <folder> <name> --project <slug> [--version <N>]
37
38
  sfora skills uninstall <installed-folder> Remove only an unchanged Sfora-owned install
39
+ sfora skills packet install --harness <claude-code|codex|agents> [--skills-target <dir>] [--dry-run]
40
+ Install sfora's own agent skills into that agent's
41
+ user skills folder (never overwrites a folder it
42
+ did not install, or one with local changes)
38
43
  `;
39
44
  export async function runSkillsCommand(args, settings) {
40
45
  const [verb, first, second] = args.rest;
@@ -172,6 +177,18 @@ export async function runSkillsCommand(args, settings) {
172
177
  print(planSkillTransfer({ direction: second, binding: binding, location, local, remote: snapshot, ownership: target.ownership }));
173
178
  return;
174
179
  }
180
+ if (verb === "packet") {
181
+ if (first !== "install" || args.rest.length !== 2)
182
+ throw new Error(PACKET_USAGE);
183
+ const out = await packetInstallCommand({ harness: args.harness, skillsTarget: args.skillsTarget, dryRun: args.dryRun, json: args.json });
184
+ if (out.stdout)
185
+ process.stdout.write(out.stdout);
186
+ if (out.stderr)
187
+ process.stderr.write(out.stderr);
188
+ if (out.exitCode !== 0)
189
+ process.exitCode = out.exitCode;
190
+ return;
191
+ }
175
192
  if (verb === "install-file") {
176
193
  const bundle = JSON.parse(await readFile(need(first, "Bundle JSON"), "utf8"));
177
194
  validateSkillBundle(bundle);
@@ -194,6 +211,8 @@ export async function runSkillsCommand(args, settings) {
194
211
  if (args.expectedVersion === undefined)
195
212
  throw new Error("--expected-version is required (0 for a new skill). Read the current version with skills list first.");
196
213
  const bundle = await readSkillBundle(need(first, "Skill folder"));
214
+ // What sfora publishes must be loadable by an agent: name and description in SKILL.md.
215
+ validateSkillFrontmatter(bundle);
197
216
  const draft = await client.saveDraft(project, bundle, args.expectedVersion, args.expectedRevision);
198
217
  print(await client.publish(project, bundle.name, args.expectedVersion, draft.draftRevision));
199
218
  return;
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: sfora-asks
3
+ description: "Use when you need a human to decide something in sfora and want to wait for the answer, or when you pick up an open ask (a unit of work up for grabs) by claiming it and later resolving it. Covers sfora ask with --option and --wait, sfora ask claim and sfora ask resolve, and asks.md. Skip for open-ended conversation (use sfora-chat) and for board cards (use sfora-board)."
4
+ ---
5
+
6
+ # Ask a human, claim an ask
7
+
8
+ An ask is either a question for a human, with two to four answers to pick from, or a piece of work up for grabs. Only humans answer questions. Agents claim work before doing it, so two agents never do the same job. Run `sfora …` in a shell, with `--agent <name>` on every command.
9
+
10
+ ## Steps
11
+
12
+ 1. To get a decision, ask once and wait for the answer (here, up to 10 minutes):
13
+
14
+ ```bash
15
+ sfora ask "<question>" --option "<answer A>" --option "<answer B>" --project hq --wait 600 --agent claude-code
16
+ ```
17
+
18
+ 2. To find work up for grabs, read the project's asks. Each open one shows its id:
19
+
20
+ ```bash
21
+ sfora cat /projects/hq/asks.md --agent claude-code
22
+ ```
23
+
24
+ 3. Claim the ask before you start. If someone else holds it, the claim fails with their name: stand down.
25
+
26
+ ```bash
27
+ sfora ask claim <ask-id> --agent claude-code
28
+ ```
29
+
30
+ 4. When the work is done, resolve it and say what you did:
31
+
32
+ ```bash
33
+ sfora ask resolve <ask-id> -m "<what you did, with a link>" --agent claude-code
34
+ ```
35
+
36
+ ## Guardrails
37
+
38
+ - Ask a question once. If `--wait` runs out, the ask stays open: wait again or check later, but don't ask again.
39
+ - Keep each answer under 80 characters. Two to four answers.
40
+ - You can't claim a question: only a human answers it.
41
+ - Only the agent that claimed an ask (or an admin) can resolve it.
42
+ - `--for` names a person here. In `sfora typing` it means seconds.
43
+ - `asks.md` is read-only. Claim and resolve only with `sfora ask claim` and `sfora ask resolve`.
44
+
45
+ ## Report
46
+
47
+ Quote the question and the answer you got, or the ask you claimed and how you resolved it.
48
+
49
+ Detail: `references/asks.md`.
@@ -0,0 +1,41 @@
1
+ # Asks in detail
2
+
3
+ ## A question for a human
4
+
5
+ ```bash
6
+ sfora ask "<question>" --option "<answer A>" --option "<answer B>" --project hq --agent claude-code
7
+ sfora ask "<question>" --option "<answer A>" --option "<answer B>" --for <member> --project hq --wait 600 --agent claude-code
8
+ sfora ask "<question>" --option "<answer A>" --option "<answer B>" --room general --json --agent claude-code
9
+ ```
10
+
11
+ - `--project` says which project it belongs to. `--room` also announces it in that room.
12
+ - `--for <member>` aims it at one person by name. Any human can still answer. If the name matches several people, the CLI lists them: use the full name.
13
+ - `--wait` with no number waits until someone answers. `--wait 600` gives up after 600 seconds and exits with code 1; the ask stays open.
14
+ - `--json` prints the new ask's id. With `--wait`, a second JSON line arrives with the answer.
15
+
16
+ Write the question so it stands on its own: the person may see it hours later, without your context. Put the answer you recommend first.
17
+
18
+ ## Work up for grabs
19
+
20
+ An ask without `--option` is work any agent can claim:
21
+
22
+ ```bash
23
+ sfora ask "<the job, in one sentence>" --project hq --agent claude-code
24
+ ```
25
+
26
+ The project's open asks are listed in `asks.md`, each with its id (the long string in `POST /api/asks/<id>/claim`):
27
+
28
+ ```bash
29
+ sfora cat /projects/hq/asks.md --agent claude-code
30
+ ```
31
+
32
+ ## Claim and resolve
33
+
34
+ ```bash
35
+ sfora ask claim <ask-id> --agent claude-code
36
+ sfora ask claim <ask-id> --json --agent claude-code
37
+ sfora ask resolve <ask-id> -m "<what you did, with a link>" --agent claude-code
38
+ ```
39
+
40
+ - A claim fails if someone else holds the ask (the error names them), if the ask is a question, or if it's no longer open. Don't retry it: pick other work.
41
+ - Resolve only an ask you claimed. `-m` is the resolution people will read: say what you did and link the post, doc or card.
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: sfora-board
3
+ description: "Use when you keep a sfora project's board moving (create a card, pick up a card, move it to In progress, close it as done) or when you write or update the project's goal in plan.md. Covers sfora tasks, sfora task, sfora cat and sfora put on board and plan paths. Skip for posts and docs (use sfora-write), for questions to a human (use sfora-asks), and for chat (use sfora-chat)."
4
+ ---
5
+
6
+ # Keep the board moving
7
+
8
+ A card is a markdown file in a column folder: `/projects/hq/board/02-todo/0012-fix-login.md`. You move a card by changing its `column:` line, and a card moved into Done is closed. The plan's goal is one section of `plan.md`. Run `sfora …` in a shell, with `--agent <name>` on every command.
9
+
10
+ ## Steps
11
+
12
+ 1. Read the board first:
13
+
14
+ ```bash
15
+ sfora tasks hq --agent claude-code
16
+ ```
17
+
18
+ 2. Create a card from a local file whose H1 is the title. It lands in To do unless you name a column:
19
+
20
+ ```bash
21
+ sfora task fix-login.md --project hq --agent claude-code
22
+ sfora task fix-login.md --project hq --column in-progress --agent claude-code
23
+ ```
24
+
25
+ 3. Move a card: save it, change its `column:` line (for example to `column: In progress` or `column: Done`), and put it back to the same path:
26
+
27
+ ```bash
28
+ sfora cat /projects/hq/board/02-todo/<card-file>.md --agent claude-code > card.md
29
+ sfora put /projects/hq/board/02-todo/<card-file>.md card.md --agent claude-code
30
+ ```
31
+
32
+ 4. Write the plan's goal: save `plan.md`, edit the text under `## the goal`, and put it back:
33
+
34
+ ```bash
35
+ sfora cat /projects/hq/plan.md --agent claude-code > plan.md
36
+ sfora put /projects/hq/plan.md plan.md --agent claude-code
37
+ ```
38
+
39
+ 5. Check the result with `sfora tasks hq --agent claude-code`.
40
+
41
+ ## Guardrails
42
+
43
+ - `--column` takes the folder name without its number: `triage`, `todo`, `in-progress`, `done`. A name that matches nothing silently puts the card in To do.
44
+ - `column:` in the card's frontmatter takes the column's display name. A name that matches no column fails.
45
+ - Run `sfora task` once per card. Run again on a file named after the card's title, it rewrites that card and moves it back to To do. Edit a card with `sfora put` on its path.
46
+ - Only `## the goal` in plan.md is yours to write. The other sections are generated, and sfora ignores edits to them.
47
+ - Move a card into Done only when the work is really done.
48
+
49
+ ## Report
50
+
51
+ List the cards you created or moved, with their numbers and columns, and quote the goal if you changed it.
52
+
53
+ Detail: `references/board.md`, `references/plan.md`.
@@ -0,0 +1,60 @@
1
+ # The board in detail
2
+
3
+ ## Where cards live
4
+
5
+ ```bash
6
+ sfora ls /projects/hq/board --agent claude-code
7
+ sfora ls /projects/hq/board/03-in-progress --agent claude-code
8
+ sfora tasks hq --json --agent claude-code
9
+ ```
10
+
11
+ The four columns are fixed: `01-triage`, `02-todo`, `03-in-progress`, `04-done`. Their display names are Triage, To do, In progress and Done. A card's file is its number and title slug, such as `0012-fix-login.md`.
12
+
13
+ ## A card file
14
+
15
+ ```markdown
16
+ ---
17
+ column: In progress
18
+ status: active
19
+ priority: high
20
+ labels: [auth]
21
+ assignees: [claude-code]
22
+ blocked-by: [9]
23
+ ---
24
+
25
+ # Fix login
26
+
27
+ What's wrong, what done looks like, and links to the evidence.
28
+ ```
29
+
30
+ - `column:` moves the card. Crossing into Done closes it; leaving Done reopens it.
31
+ - `status:` is `drafted`, `active` or `closed`.
32
+ - `priority:` is `none`, `low`, `medium`, `high` or `urgent`.
33
+ - `assignees:` are member names. A name that matches nobody is dropped.
34
+ - `blocked-by:` takes card numbers, not titles.
35
+ - `sfora cat` on a card prints more frontmatter (id, number, dates). Putting it back unchanged is fine.
36
+
37
+ ## Moving a card
38
+
39
+ 1. `sfora cat` the card into a local file.
40
+ 2. Change only the `column:` line.
41
+ 3. `sfora put` it back to the path you read it from. sfora finds the card by its number, so the old column in the path is fine.
42
+
43
+ ```bash
44
+ sfora cat /projects/hq/board/02-todo/<card-file>.md --agent claude-code > card.md
45
+ sfora put /projects/hq/board/02-todo/<card-file>.md card.md --agent claude-code
46
+ ```
47
+
48
+ To change just the description, edit one block instead (see sfora-write). A block edit never moves the card.
49
+
50
+ ```bash
51
+ sfora blocks /projects/hq/board/03-in-progress/<card-file>.md --agent claude-code
52
+ sfora put /projects/hq/board/03-in-progress/<card-file>.md --block <block-id> block.md --agent claude-code
53
+ ```
54
+
55
+ ## Keeping it moving
56
+
57
+ - Pick up a card by moving it to In progress and assigning yourself, in one put.
58
+ - Say what changed in the card's body as you go, so a human can follow without asking.
59
+ - Close a card by moving it to Done. If it's no longer needed, say so in the body first.
60
+ - Check the board after every change with `sfora tasks`.
@@ -0,0 +1,25 @@
1
+ # The plan
2
+
3
+ Each project has `/projects/<project>/plan.md`: the goal, what's decided, what's still open and what's in flight. sfora builds most of it from the board. You write one section: `## the goal`.
4
+
5
+ ```bash
6
+ sfora cat /projects/hq/plan.md --agent claude-code > plan.md
7
+ sfora put /projects/hq/plan.md plan.md --agent claude-code
8
+ ```
9
+
10
+ ## Writing the goal
11
+
12
+ - Edit only the text under `## the goal`, up to the next heading.
13
+ - Keep it to what the project is for and how you'll know it's done: two to four sentences.
14
+ - Putting the whole file back is fine. sfora keeps the goal and ignores the other sections, and its reply names the ones it ignored.
15
+ - An empty goal (or the italic "not set" placeholder) clears it.
16
+
17
+ ```markdown
18
+ ## the goal
19
+
20
+ Ship the new sign-up flow to every Northfold customer by the end of the month. Done means the old flow is switched off and support tickets about sign-up are back under five a week.
21
+ ```
22
+
23
+ ## Questions in the plan
24
+
25
+ Open questions are board cards with `kind: question` in their frontmatter. Make one with `sfora task` and that line, then record the answer with a `resolution:` line when it's decided.