sfora-cli 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +80 -1
  2. package/dist/agent-webhook.d.ts +20 -0
  3. package/dist/agent-webhook.js +42 -0
  4. package/dist/api-client.d.ts +97 -0
  5. package/dist/api-client.js +68 -0
  6. package/dist/ask.d.ts +51 -0
  7. package/dist/ask.js +70 -0
  8. package/dist/attachments-node.d.ts +7 -0
  9. package/dist/attachments-node.js +15 -0
  10. package/dist/attachments.d.ts +112 -0
  11. package/dist/attachments.js +254 -0
  12. package/dist/block-commands.d.ts +10 -0
  13. package/dist/block-commands.js +28 -0
  14. package/dist/chat.d.ts +15 -0
  15. package/dist/chat.js +7 -0
  16. package/dist/cli-args.d.ts +7 -0
  17. package/dist/cli-args.js +28 -1
  18. package/dist/cli.d.ts +12 -1
  19. package/dist/cli.js +186 -19
  20. package/dist/format/linkUrls.d.ts +2 -0
  21. package/dist/format/linkUrls.js +48 -0
  22. package/dist/format/postMarkdown.d.ts +12 -1
  23. package/dist/format/postMarkdown.js +9 -2
  24. package/dist/index.d.ts +23 -1
  25. package/dist/index.js +17 -1
  26. package/dist/local-core/files.d.ts +1 -1
  27. package/dist/local-core/files.js +2 -2
  28. package/dist/local-core/index.d.ts +12 -0
  29. package/dist/local-core/index.js +11 -0
  30. package/dist/local-core/skill-adapters.d.ts +21 -0
  31. package/dist/local-core/skill-adapters.js +19 -0
  32. package/dist/local-core/skill-discovery.d.ts +22 -0
  33. package/dist/local-core/skill-discovery.js +79 -0
  34. package/dist/local-core/skill-domain.d.ts +74 -0
  35. package/dist/local-core/skill-domain.js +1 -0
  36. package/dist/local-core/skill-executor.d.ts +23 -0
  37. package/dist/local-core/skill-executor.js +51 -0
  38. package/dist/local-core/skill-index.d.ts +54 -0
  39. package/dist/local-core/skill-index.js +115 -0
  40. package/dist/local-core/skill-local-executor.d.ts +18 -0
  41. package/dist/local-core/skill-local-executor.js +249 -0
  42. package/dist/local-core/skill-operations.d.ts +61 -0
  43. package/dist/local-core/skill-operations.js +268 -0
  44. package/dist/local-core/skill-review.d.ts +46 -0
  45. package/dist/local-core/skill-review.js +132 -0
  46. package/dist/local-core/skill-service.d.ts +96 -0
  47. package/dist/local-core/skill-service.js +157 -0
  48. package/dist/local-core/skill-store.d.ts +34 -0
  49. package/dist/local-core/skill-store.js +187 -0
  50. package/dist/local-core/skill-sync.d.ts +132 -0
  51. package/dist/local-core/skill-sync.js +111 -0
  52. package/dist/local-core/skills.d.ts +35 -0
  53. package/dist/local-core/skills.js +142 -37
  54. package/dist/mcp-description.d.ts +11 -0
  55. package/dist/mcp-description.js +29 -0
  56. package/dist/mcp-server.d.ts +5 -1
  57. package/dist/mcp-server.js +28 -18
  58. package/dist/shell-commands.d.ts +7 -1
  59. package/dist/shell-commands.js +49 -3
  60. package/dist/skills-client.d.ts +13 -2
  61. package/dist/skills-client.js +57 -5
  62. package/dist/skills-command.d.ts +1 -1
  63. package/dist/skills-command.js +171 -4
  64. package/dist/skills-packet/sfora-asks/SKILL.md +49 -0
  65. package/dist/skills-packet/sfora-asks/references/asks.md +41 -0
  66. package/dist/skills-packet/sfora-board/SKILL.md +53 -0
  67. package/dist/skills-packet/sfora-board/references/board.md +60 -0
  68. package/dist/skills-packet/sfora-board/references/plan.md +25 -0
  69. package/dist/skills-packet/sfora-chat/SKILL.md +55 -0
  70. package/dist/skills-packet/sfora-chat/references/rooms.md +45 -0
  71. package/dist/skills-packet/sfora-chat/references/waiting.md +37 -0
  72. package/dist/skills-packet/sfora-live-edit/SKILL.md +62 -0
  73. package/dist/skills-packet/sfora-live-edit/references/collisions.md +54 -0
  74. package/dist/skills-packet/sfora-live-edit/references/http.md +63 -0
  75. package/dist/skills-packet/sfora-live-edit/references/live-editing.md +49 -0
  76. package/dist/skills-packet/sfora-setup/SKILL.md +37 -0
  77. package/dist/skills-packet/sfora-setup/references/sign-in.md +43 -0
  78. package/dist/skills-packet/sfora-skills/SKILL.md +54 -0
  79. package/dist/skills-packet/sfora-skills/references/skills.md +78 -0
  80. package/dist/skills-packet/sfora-troubleshoot/SKILL.md +39 -0
  81. package/dist/skills-packet/sfora-troubleshoot/references/sharp-edges.md +80 -0
  82. package/dist/skills-packet/sfora-write/SKILL.md +53 -0
  83. package/dist/skills-packet/sfora-write/references/attachments.md +15 -0
  84. package/dist/skills-packet/sfora-write/references/blocks.md +36 -0
  85. package/dist/skills-packet/sfora-write/references/posts-and-docs.md +56 -0
  86. package/dist/skills-packet.d.ts +63 -0
  87. package/dist/skills-packet.js +166 -0
  88. package/dist/typing.d.ts +23 -0
  89. package/dist/typing.js +62 -0
  90. package/dist/version.d.ts +1 -1
  91. package/dist/version.js +1 -1
  92. package/dist/watch.d.ts +78 -1
  93. package/dist/watch.js +109 -0
  94. package/package.json +4 -4
@@ -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,5 +1,14 @@
1
1
  import { type SkillBundle } from "./local-core/skills.js";
2
2
  import type { ResolvedSettings } from "./config.js";
3
+ import { type PublishedSkillSnapshot } from "./local-core/skill-sync.js";
4
+ export declare class SkillsApiError extends Error {
5
+ readonly status: number;
6
+ constructor(status: number, body: string);
7
+ }
8
+ export declare class SkillPublicationUnavailableError extends Error {
9
+ readonly code: 'SKILL_NOT_FOUND' | 'SKILL_NOT_PUBLISHED';
10
+ constructor(code: 'SKILL_NOT_FOUND' | 'SKILL_NOT_PUBLISHED', message: string);
11
+ }
3
12
  export declare class SkillsClient {
4
13
  private readonly settings;
5
14
  constructor(settings: ResolvedSettings);
@@ -13,9 +22,11 @@ export declare class SkillsClient {
13
22
  [key: string]: unknown;
14
23
  }>;
15
24
  download(project: string, name: string, version?: number): Promise<SkillBundle>;
16
- saveDraft(project: string, bundle: SkillBundle, expectedVersion: number, expectedRevision?: number): Promise<{
25
+ /** Pin a published revision and verify IDs/hash; lookup labels confer no update authority. */
26
+ snapshot(project: string, name: string): Promise<PublishedSkillSnapshot>;
27
+ saveDraft(project: string, bundle: SkillBundle, expectedVersion: number, expectedRevision?: number, expectedSkillId?: string): Promise<{
17
28
  draftRevision: number;
18
29
  [key: string]: unknown;
19
30
  }>;
20
- publish(project: string, name: string, expectedVersion: number, expectedRevision?: number): Promise<unknown>;
31
+ publish(project: string, name: string, expectedVersion: number, expectedRevision?: number, expectedSkillId?: string): Promise<unknown>;
21
32
  }
@@ -1,4 +1,20 @@
1
1
  import { validateSkillBundle } from "./local-core/skills.js";
2
+ import { SforaApiClient } from "./api-client.js";
3
+ import { validatePublishedSkillSnapshot } from "./local-core/skill-sync.js";
4
+ export class SkillsApiError extends Error {
5
+ status;
6
+ constructor(status, body) {
7
+ super(`Skills API ${status}: ${body}`);
8
+ this.status = status;
9
+ }
10
+ }
11
+ export class SkillPublicationUnavailableError extends Error {
12
+ code;
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ }
17
+ }
2
18
  export class SkillsClient {
3
19
  settings;
4
20
  constructor(settings) {
@@ -11,7 +27,7 @@ export class SkillsClient {
11
27
  ...init, headers: { Authorization: `Bearer ${this.settings.apiKey}`, "Content-Type": "application/json", "X-Sfora-Client": "cli", ...init?.headers },
12
28
  });
13
29
  if (!response.ok)
14
- throw new Error(`Skills API ${response.status}: ${await response.text()}`);
30
+ throw new SkillsApiError(response.status, await response.text());
15
31
  return await response.json();
16
32
  }
17
33
  list(project) { return this.request(`/v1/skills?${new URLSearchParams({ project })}`); }
@@ -26,11 +42,47 @@ export class SkillsClient {
26
42
  validateSkillBundle(bundle);
27
43
  return bundle;
28
44
  }
29
- saveDraft(project, bundle, expectedVersion, expectedRevision) {
45
+ /** Pin a published revision and verify IDs/hash; lookup labels confer no update authority. */
46
+ async snapshot(project, name) {
47
+ if (!this.settings.apiKey)
48
+ throw new Error("Run sfora login to connect to cloud Skills.");
49
+ const me = await new SforaApiClient({ baseUrl: this.settings.url, apiKey: this.settings.apiKey }).readMe();
50
+ const accountId = /^memberId: ([a-zA-Z0-9_-]+)$/m.exec(me)?.[1];
51
+ if (!accountId)
52
+ throw new Error('Cloud identity response is missing a member ID.');
53
+ let detail;
54
+ try {
55
+ detail = await this.detail(project, name);
56
+ }
57
+ catch (error) {
58
+ if (error instanceof SkillsApiError && error.status === 404)
59
+ throw new SkillPublicationUnavailableError('SKILL_NOT_FOUND', 'This project has no published skill with that name.');
60
+ throw error;
61
+ }
62
+ const skill = detail.skill;
63
+ if (![skill._id, skill.orgId, skill.projectId].every(v => typeof v === 'string' && v.length > 0))
64
+ throw new Error('Cloud skill response is missing qualified identity fields.');
65
+ if (!Number.isSafeInteger(skill.version) || skill.version < 1)
66
+ throw new SkillPublicationUnavailableError('SKILL_NOT_PUBLISHED', 'This skill has no published version to compare.');
67
+ const versions = detail.versions;
68
+ const revision = Array.isArray(versions) ? versions.find(v => v.version === skill.version && v.skillId === skill._id) : undefined;
69
+ if (!revision || typeof revision.hash !== 'string')
70
+ throw new Error('Cloud skill response is missing the published revision.');
71
+ const bundle = await this.download(project, name, skill.version);
72
+ const snapshot = {
73
+ remote: { deployment: new URL(this.settings.url).origin, organizationId: skill.orgId, projectId: skill.projectId, skillId: skill._id },
74
+ accountId, locator: { project, name }, revision: { hash: revision.hash, cloudVersion: skill.version, contentPolicy: 'bundle-v1' },
75
+ draftRevision: skill.draftRevision, hasDraft: !!skill.draftStorageId, draftHash: skill.draftHash,
76
+ supportsIdentityGuards: Array.isArray(detail.capabilities) && detail.capabilities.includes('qualified-skill-write-v1'), bundle,
77
+ };
78
+ validatePublishedSkillSnapshot(snapshot);
79
+ return snapshot;
80
+ }
81
+ saveDraft(project, bundle, expectedVersion, expectedRevision, expectedSkillId) {
30
82
  validateSkillBundle(bundle);
31
- return this.request("/v1/skills", { method: "POST", body: JSON.stringify({ project, name: bundle.name, bundle, expectedVersion, expectedRevision }) });
83
+ return this.request("/v1/skills", { method: "POST", body: JSON.stringify({ project, name: bundle.name, bundle, expectedVersion, expectedRevision, expectedSkillId }) });
32
84
  }
33
- publish(project, name, expectedVersion, expectedRevision) {
34
- return this.request("/v1/skills/publish", { method: "POST", body: JSON.stringify({ project, name, expectedVersion, expectedRevision }) });
85
+ publish(project, name, expectedVersion, expectedRevision, expectedSkillId) {
86
+ return this.request("/v1/skills/publish", { method: "POST", body: JSON.stringify({ project, name, expectedVersion, expectedRevision, expectedSkillId }) });
35
87
  }
36
88
  }
@@ -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 scan [root ...] --json Discover complete local skill bundles\n sfora skills list --project <slug> List cloud project skills\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,9 +1,34 @@
1
- import { readFile } from "node:fs/promises";
2
- import { diffSkillBundles, installSkillBundle, readSkillBundle, scanLocalSkills, uninstallSkill, validateSkillBundle } from "./local-core/index.js";
1
+ import { dirname, join } from "node:path";
2
+ import { readFile, realpath } from "node:fs/promises";
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";
5
+ import { SkillOperationJournal } from "./local-core/skill-operations.js";
6
+ import { SkillOperationExecutor } from "./local-core/skill-executor.js";
7
+ import { defaultSkillCatalogPath } from "./local-core/skill-store.js";
3
8
  import { SkillsClient } from "./skills-client.js";
9
+ import { SkillBindingService, assertSkillBindingSnapshot, compareSkillStates, planSkillTransfer, planSkillInstallation } from './local-core/skill-sync.js';
4
10
  export const SKILLS_HELP = `Skills:
11
+ sfora skills inventory --json Persistent local inventory with warnings and identities
12
+ sfora skills roots [add <folder> [label] | add-project <folder> [label] | remove <id>]
13
+ Manage registered discovery folders (never deletes files)
5
14
  sfora skills scan [root ...] --json Discover complete local skill bundles
6
15
  sfora skills list --project <slug> List cloud project skills
16
+ sfora skills index --json Read cached local inventory without scanning
17
+ sfora skills operations List durable transfer outcomes and recovery needs
18
+ sfora skills queue <plan.json> Queue a reviewed push, pull or install
19
+ sfora skills cancel <operation-id> Cancel an unstarted operation
20
+ sfora skills apply <plan.json|operation-id> Execute a reviewed plan
21
+ sfora skills recover <operation-id> Reconcile an interrupted operation
22
+ sfora skills apply-batch <ids.json> Run queued operation IDs with per-item outcomes
23
+ sfora skills recover-batch <ids.json> Resume incomplete items without replaying successes
24
+ sfora skills bindings List explicit cloud mappings and baselines
25
+ sfora skills bind <location-id> <cloud-name> --project <slug>
26
+ Explicitly adopt a published cloud identity
27
+ sfora skills status <binding-id> Compare current files with the bound published revision
28
+ sfora skills plan <binding-id> <push|pull> Preview IDs, hashes, conflicts and per-file changes
29
+ sfora skills plan-install <name> --project <slug> --skills-target <existing-directory>
30
+ Preview a new installation without modifying files
31
+ sfora skills relocate <location-id> <folder> Preserve identity after an explicit folder move
7
32
  sfora skills push <folder> --project <slug> --expected-version <N> [--expected-revision <N>]
8
33
  Upload draft and publish (0 for a new skill)
9
34
  sfora skills pull <name> <bundle.json> --project <slug> [--version <N>]
@@ -11,6 +36,10 @@ export const SKILLS_HELP = `Skills:
11
36
  sfora skills install-file <bundle.json> --skills-target <directory>
12
37
  sfora skills diff <folder> <name> --project <slug> [--version <N>]
13
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)
14
43
  `;
15
44
  export async function runSkillsCommand(args, settings) {
16
45
  const [verb, first, second] = args.rest;
@@ -21,8 +50,35 @@ export async function runSkillsCommand(args, settings) {
21
50
  const print = (value) => console.log(JSON.stringify(value, null, args.json ? undefined : 2));
22
51
  const need = (value, label) => { if (!value)
23
52
  throw new Error(`${label} is required.\n${SKILLS_HELP}`); return value; };
24
- if (verb === "scan") {
25
- print(await scanLocalSkills(args.rest.length > 1 ? args.rest.slice(1) : undefined));
53
+ if (verb === "roots") {
54
+ const service = new SkillService();
55
+ if (!first)
56
+ print(await service.roots());
57
+ else if (first === "add" || first === "add-project")
58
+ print(await service.registerRoot(need(second, "Folder"), args.rest[3], first === "add-project" ? "project" : "custom"));
59
+ else if (first === "remove") {
60
+ await service.removeRoot(need(second, "Root ID"));
61
+ print({ removed: second });
62
+ }
63
+ else
64
+ throw new Error("Use skills roots, skills roots add <folder> [label], or skills roots remove <id>.");
65
+ return;
66
+ }
67
+ if (verb === "index") {
68
+ print(await new SkillService().cachedInventory());
69
+ return;
70
+ }
71
+ if (verb === "inventory" || verb === "scan") {
72
+ const result = verb === "scan" && args.rest.length > 1
73
+ ? await discoverSkills(args.rest.slice(1).map(path => ({ path, label: "Chosen folder", depth: 4 })))
74
+ : await new SkillService().inventory();
75
+ if (verb === "scan") {
76
+ print(result.skills);
77
+ if (result.warnings.length)
78
+ console.error(JSON.stringify({ warnings: result.warnings }));
79
+ }
80
+ else
81
+ print(result);
26
82
  return;
27
83
  }
28
84
  if (verb === "uninstall") {
@@ -30,6 +86,109 @@ export async function runSkillsCommand(args, settings) {
30
86
  print({ uninstalled: first });
31
87
  return;
32
88
  }
89
+ if (['operations', 'queue', 'cancel', 'apply', 'recover', 'apply-batch', 'recover-batch'].includes(verb)) {
90
+ const journal = new SkillOperationJournal(join(dirname(defaultSkillCatalogPath()), 'operations.sqlite'));
91
+ if (verb === 'operations') {
92
+ print(await journal.list());
93
+ return;
94
+ }
95
+ if (verb === 'cancel') {
96
+ print(await journal.cancel(need(first, 'Operation ID')));
97
+ return;
98
+ }
99
+ if (verb === 'queue') {
100
+ print(await journal.create(JSON.parse(await readFile(need(first, 'Plan JSON'), 'utf8'))));
101
+ return;
102
+ }
103
+ const service = new SkillService();
104
+ const client = new SkillsClient(settings);
105
+ const executor = new SkillOperationExecutor(service.store, journal, client);
106
+ if (verb === 'apply-batch' || verb === 'recover-batch') {
107
+ const ids = JSON.parse(await readFile(need(first, 'Operation IDs JSON'), 'utf8'));
108
+ const controller = new AbortController();
109
+ const stop = () => { controller.abort(); console.error('Stopping after the current item; unstarted work will be cancelled.'); };
110
+ process.once('SIGINT', stop);
111
+ try {
112
+ const results = await executor.batch(ids, { recover: verb === 'recover-batch', signal: controller.signal });
113
+ print(results);
114
+ if (results.some(r => r.error))
115
+ process.exitCode = 1;
116
+ }
117
+ finally {
118
+ process.removeListener('SIGINT', stop);
119
+ }
120
+ return;
121
+ }
122
+ const operation = verb === 'apply' && !/^[a-f0-9-]{36}$/.test(first ?? '') ? await journal.create(JSON.parse(await readFile(need(first, 'Plan JSON'), 'utf8'))) : undefined;
123
+ const id = operation?.id ?? need(first, 'Operation ID');
124
+ try {
125
+ print(await executor.apply(id, verb === 'recover'));
126
+ }
127
+ catch (error) {
128
+ throw new Error(`${error.message} Operation ${id} was retained; inspect skills operations, then use skills recover ${id}.`);
129
+ }
130
+ return;
131
+ }
132
+ if (['bindings', 'bind', 'status', 'plan', 'relocate'].includes(verb)) {
133
+ const service = new SkillService();
134
+ const bindings = new SkillBindingService(service.store);
135
+ if (verb === 'bindings') {
136
+ print(await bindings.list());
137
+ return;
138
+ }
139
+ if (verb === 'relocate') {
140
+ print(await service.relocateLocation(need(first, 'Location ID'), need(second, 'Folder')));
141
+ return;
142
+ }
143
+ const catalog = await service.store.read();
144
+ const binding = verb === 'bind' ? undefined : catalog.bindings.find(b => b.id === need(first, 'Binding ID'));
145
+ if (verb !== 'bind' && !binding)
146
+ throw new Error('Skill binding not found.');
147
+ if (binding && args.project && args.project !== binding.locator.project)
148
+ throw new Error('--project differs from the explicit skill binding.');
149
+ const location = catalog.locations.find(l => verb === 'bind' ? l.id === first : l.skillId === binding.localSkillId);
150
+ if (!location)
151
+ throw new Error('Local skill location not found. Register its folder and refresh the inventory first.');
152
+ if (verb === 'plan' && second !== 'push' && second !== 'pull')
153
+ throw new Error('Choose skills plan <binding-id> <push|pull>.');
154
+ const client = new SkillsClient(settings);
155
+ const snapshot = await client.snapshot(binding?.locator.project ?? need(args.project, '--project'), binding?.locator.name ?? need(second, 'Cloud skill name'));
156
+ if (binding)
157
+ assertSkillBindingSnapshot(binding, snapshot);
158
+ let local;
159
+ try {
160
+ local = { state: 'available', bundle: await readSkillBundle(location.path) };
161
+ }
162
+ catch (error) {
163
+ local = { state: error.code === 'ENOENT' ? 'missing' : 'unavailable', reason: error.message };
164
+ }
165
+ if (verb === 'bind') {
166
+ if (local.state !== 'available')
167
+ throw new Error(local.reason);
168
+ print(await bindings.adopt(location.skillId, snapshot, local.bundle));
169
+ return;
170
+ }
171
+ if (verb === 'status') {
172
+ print({ bindingId: binding.id, status: compareSkillStates(local, { state: 'available', bundle: snapshot.bundle }, binding.baseline),
173
+ local: local.state === 'available' ? { state: local.state, hash: local.bundle.hash } : local, remote: snapshot.revision, baseline: binding.baseline ?? null });
174
+ return;
175
+ }
176
+ const target = await inspectSkillTarget(location.path);
177
+ print(planSkillTransfer({ direction: second, binding: binding, location, local, remote: snapshot, ownership: target.ownership }));
178
+ return;
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
+ }
33
192
  if (verb === "install-file") {
34
193
  const bundle = JSON.parse(await readFile(need(first, "Bundle JSON"), "utf8"));
35
194
  validateSkillBundle(bundle);
@@ -38,6 +197,12 @@ export async function runSkillsCommand(args, settings) {
38
197
  }
39
198
  const project = need(args.project, "--project");
40
199
  const client = new SkillsClient(settings);
200
+ if (verb === 'plan-install') {
201
+ const remote = await client.snapshot(project, need(first, 'Skill name'));
202
+ const target = join(await realpath(need(args.skillsTarget, '--skills-target')), remote.bundle.name);
203
+ print(planSkillInstallation(remote, target, await inspectSkillTarget(target)));
204
+ return;
205
+ }
41
206
  if (verb === "list") {
42
207
  print(await client.list(project));
43
208
  return;
@@ -46,6 +211,8 @@ export async function runSkillsCommand(args, settings) {
46
211
  if (args.expectedVersion === undefined)
47
212
  throw new Error("--expected-version is required (0 for a new skill). Read the current version with skills list first.");
48
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);
49
216
  const draft = await client.saveDraft(project, bundle, args.expectedVersion, args.expectedRevision);
50
217
  print(await client.publish(project, bundle.name, args.expectedVersion, draft.draftRevision));
51
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`.