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
@@ -0,0 +1,132 @@
1
+ import type { SkillCloudBinding, SkillRevision } from './skill-domain.js';
2
+ import type { SkillStore } from './skill-store.js';
3
+ import { type SkillBundle, type inspectSkillTarget } from './skills.js';
4
+ export interface SkillCloudScope {
5
+ deployment: string;
6
+ accountId: string;
7
+ organizationId: string;
8
+ }
9
+ export interface PublishedSkillSnapshot {
10
+ remote: SkillCloudBinding['remote'];
11
+ accountId: string;
12
+ locator: SkillCloudBinding['locator'];
13
+ revision: SkillRevision;
14
+ draftRevision: number;
15
+ hasDraft: boolean;
16
+ draftHash?: string;
17
+ supportsIdentityGuards?: boolean;
18
+ bundle: SkillBundle;
19
+ }
20
+ export type SkillTransferPlan = ReturnType<typeof planSkillTransfer>;
21
+ /** A new installation needs an explicitly selected empty destination, not a guessed name match. */
22
+ export declare function planSkillInstallation(remote: PublishedSkillSnapshot, destination: string, target: Awaited<ReturnType<typeof inspectSkillTarget>>): {
23
+ schemaVersion: 1;
24
+ kind: "skill-install-preview";
25
+ source: {
26
+ accountId: string;
27
+ deployment: string;
28
+ organizationId: string;
29
+ skillId: string;
30
+ projectId: string;
31
+ };
32
+ locator: {
33
+ project: string;
34
+ name: string;
35
+ };
36
+ destination: string;
37
+ expected: {
38
+ remote: {
39
+ hash: string;
40
+ contentPolicy: "bundle-v1";
41
+ cloudVersion?: number;
42
+ };
43
+ targetState: "file" | "link" | "unavailable" | "managed" | "unmanaged" | "modified" | "missing";
44
+ localHash: string | null;
45
+ };
46
+ ownership: "unknown" | "managed" | "unmanaged";
47
+ outcome: string;
48
+ blockers: string[];
49
+ files: {
50
+ path: string;
51
+ kind: "added";
52
+ after: {
53
+ hash: string;
54
+ size: number;
55
+ executable: boolean;
56
+ };
57
+ }[];
58
+ };
59
+ export type SkillContentState = {
60
+ state: 'available';
61
+ bundle: SkillBundle;
62
+ } | {
63
+ state: 'missing';
64
+ reason: string;
65
+ } | {
66
+ state: 'unavailable';
67
+ reason: string;
68
+ };
69
+ export type SkillSyncStatus = 'local-only' | 'synced' | 'local-changed' | 'remote-changed' | 'conflict' | 'missing' | 'unknown' | 'unavailable';
70
+ export declare function assertSkillBindingScope(binding: SkillCloudBinding, scope: SkillCloudScope): void;
71
+ export declare function validatePublishedSkillSnapshot(snapshot: PublishedSkillSnapshot): void;
72
+ export declare function assertSkillBindingSnapshot(binding: SkillCloudBinding, snapshot: PublishedSkillSnapshot): void;
73
+ /** Adoption is explicit. Matching names, install receipts and stale observations cannot establish a baseline. */
74
+ export declare class SkillBindingService {
75
+ readonly store: SkillStore;
76
+ constructor(store: SkillStore);
77
+ list(): Promise<SkillCloudBinding[]>;
78
+ adopt(localSkillId: string, snapshot: PublishedSkillSnapshot, verifiedLocal: SkillBundle): Promise<SkillCloudBinding>;
79
+ }
80
+ /** Pure three-way comparison. Unavailable input is never interpreted as deletion or synchronization. */
81
+ export declare function compareSkillStates(local: SkillContentState, remote: SkillContentState | undefined, baseline?: SkillRevision): SkillSyncStatus;
82
+ /** Review artifact only. A future executor must revalidate every token and record a durable operation. */
83
+ export declare function planSkillTransfer(input: {
84
+ direction: 'push' | 'pull';
85
+ binding: SkillCloudBinding;
86
+ location: {
87
+ id: string;
88
+ path: string;
89
+ skillId: string;
90
+ };
91
+ local: SkillContentState;
92
+ remote: PublishedSkillSnapshot;
93
+ ownership: 'managed' | 'unmanaged' | 'unknown';
94
+ }): {
95
+ schemaVersion: 1;
96
+ kind: "skill-transfer-preview";
97
+ direction: "push" | "pull";
98
+ status: SkillSyncStatus;
99
+ outcome: string;
100
+ blockers: string[];
101
+ binding: SkillCloudBinding;
102
+ location: {
103
+ id: string;
104
+ path: string;
105
+ skillId: string;
106
+ };
107
+ ownership: "unknown" | "managed" | "unmanaged";
108
+ expected: {
109
+ localHash: string | null;
110
+ remote: {
111
+ hash: string;
112
+ contentPolicy: "bundle-v1";
113
+ cloudVersion?: number;
114
+ };
115
+ draftRevision: number;
116
+ };
117
+ files: {
118
+ before: {
119
+ hash: string;
120
+ size: number;
121
+ executable: boolean;
122
+ } | null;
123
+ after: {
124
+ hash: string;
125
+ size: number;
126
+ executable: boolean;
127
+ } | null;
128
+ path: string;
129
+ kind: "added" | "removed" | "modified";
130
+ }[];
131
+ };
132
+ export type SkillInstallPlan = ReturnType<typeof planSkillInstallation>;
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { isAbsolute } from 'node:path';
3
+ import { diffSkillBundles, validateSkillBundle } from './skills.js';
4
+ /** A new installation needs an explicitly selected empty destination, not a guessed name match. */
5
+ export function planSkillInstallation(remote, destination, target) {
6
+ validatePublishedSkillSnapshot(remote);
7
+ if (!isAbsolute(destination))
8
+ throw new Error('Installation destination must be absolute.');
9
+ const blockers = target.state === 'missing' ? [] : ['Destination is occupied or unavailable. Choose a new folder, or explicitly bind and review an update.'];
10
+ return { schemaVersion: 1, kind: 'skill-install-preview', source: { ...remote.remote, accountId: remote.accountId }, locator: { ...remote.locator }, destination,
11
+ expected: { remote: { ...remote.revision }, targetState: target.state, localHash: target.hash ?? null }, ownership: target.ownership,
12
+ outcome: blockers.length ? 'blocked' : 'reviewable', blockers,
13
+ files: remote.bundle.files.map(f => ({ path: f.path, kind: 'added', after: { hash: f.sha256, size: f.size, executable: f.executable } })) };
14
+ }
15
+ export function assertSkillBindingScope(binding, scope) {
16
+ if (binding.accountId !== scope.accountId || binding.remote.deployment !== scope.deployment || binding.remote.organizationId !== scope.organizationId) {
17
+ throw new Error('Skill binding belongs to a different account, organization or deployment. Explicitly link the intended destination.');
18
+ }
19
+ }
20
+ export function validatePublishedSkillSnapshot(snapshot) {
21
+ validateSkillBundle(snapshot.bundle);
22
+ if (snapshot.revision.contentPolicy !== 'bundle-v1' || snapshot.revision.hash !== snapshot.bundle.hash
23
+ || !Number.isSafeInteger(snapshot.revision.cloudVersion) || Number(snapshot.revision.cloudVersion) < 1
24
+ || !Number.isSafeInteger(snapshot.draftRevision) || snapshot.draftRevision < 0 || typeof snapshot.hasDraft !== 'boolean') {
25
+ throw new Error('Published skill revision does not match its verified bundle.');
26
+ }
27
+ }
28
+ export function assertSkillBindingSnapshot(binding, snapshot) {
29
+ validatePublishedSkillSnapshot(snapshot);
30
+ assertSkillBindingScope(binding, { ...snapshot.remote, accountId: snapshot.accountId });
31
+ if (binding.remote.skillId !== snapshot.remote.skillId || binding.remote.projectId !== snapshot.remote.projectId) {
32
+ throw new Error('Cloud lookup resolved to a different skill identity. No binding was changed.');
33
+ }
34
+ }
35
+ /** Adoption is explicit. Matching names, install receipts and stale observations cannot establish a baseline. */
36
+ export class SkillBindingService {
37
+ store;
38
+ constructor(store) {
39
+ this.store = store;
40
+ }
41
+ async list() { return (await this.store.read()).bindings; }
42
+ async adopt(localSkillId, snapshot, verifiedLocal) {
43
+ validatePublishedSkillSnapshot(snapshot);
44
+ validateSkillBundle(verifiedLocal);
45
+ return this.store.transaction(c => {
46
+ if (!c.identities.some(i => i.id === localSkillId))
47
+ throw new Error('Local skill identity not found. Refresh the inventory first.');
48
+ const existing = c.bindings.find(b => b.localSkillId === localSkillId && b.accountId === snapshot.accountId
49
+ && b.remote.deployment === snapshot.remote.deployment && b.remote.organizationId === snapshot.remote.organizationId && b.remote.projectId === snapshot.remote.projectId);
50
+ if (existing)
51
+ assertSkillBindingSnapshot(existing, snapshot);
52
+ const binding = existing ?? { id: randomUUID(), localSkillId, remote: { ...snapshot.remote }, accountId: snapshot.accountId, locator: { ...snapshot.locator } };
53
+ binding.locator = { ...snapshot.locator };
54
+ // Differing bytes retain the last confirmed baseline, or remain unknown.
55
+ if (verifiedLocal.hash === snapshot.revision.hash)
56
+ binding.baseline = { ...snapshot.revision };
57
+ if (!existing)
58
+ c.bindings.push(binding);
59
+ return structuredClone(binding);
60
+ });
61
+ }
62
+ }
63
+ /** Pure three-way comparison. Unavailable input is never interpreted as deletion or synchronization. */
64
+ export function compareSkillStates(local, remote, baseline) {
65
+ if (local.state === 'available')
66
+ validateSkillBundle(local.bundle);
67
+ if (remote?.state === 'available')
68
+ validateSkillBundle(remote.bundle);
69
+ if (local.state === 'unavailable' || remote?.state === 'unavailable')
70
+ return 'unavailable';
71
+ if (local.state === 'missing' || remote?.state === 'missing')
72
+ return 'missing';
73
+ if (!remote)
74
+ return 'local-only';
75
+ if (!baseline || baseline.contentPolicy !== 'bundle-v1' || !/^[a-f0-9]{64}$/.test(baseline.hash))
76
+ return 'unknown';
77
+ if (local.bundle.hash === remote.bundle.hash)
78
+ return 'synced';
79
+ if (local.bundle.hash === baseline.hash)
80
+ return 'remote-changed';
81
+ if (remote.bundle.hash === baseline.hash)
82
+ return 'local-changed';
83
+ return 'conflict';
84
+ }
85
+ /** Review artifact only. A future executor must revalidate every token and record a durable operation. */
86
+ export function planSkillTransfer(input) {
87
+ const { binding, local, remote, direction, location, ownership } = input;
88
+ assertSkillBindingSnapshot(binding, remote);
89
+ if (location.skillId !== binding.localSkillId)
90
+ throw new Error('Location does not belong to the bound local skill.');
91
+ const status = compareSkillStates(local, { state: 'available', bundle: remote.bundle }, binding.baseline);
92
+ const blockers = [];
93
+ if (!['synced', direction === 'push' ? 'local-changed' : 'remote-changed'].includes(status))
94
+ blockers.push(`Cannot ${direction} automatically from ${status}; review the divergence first.`);
95
+ if (status !== 'synced' && direction === 'pull' && ownership !== 'managed')
96
+ blockers.push('Replacing local files requires verified Sfora ownership or an explicitly chosen new destination.');
97
+ if (status !== 'synced' && direction === 'push' && remote.hasDraft)
98
+ blockers.push('The cloud contains an unpublished draft; review it before replacing it.');
99
+ const before = direction === 'push' ? remote.bundle : local.state === 'available' ? local.bundle : undefined;
100
+ const after = direction === 'pull' ? remote.bundle : local.state === 'available' ? local.bundle : undefined;
101
+ const files = before && after ? diffSkillBundles(before, after).map(change => {
102
+ const metadata = (bundle) => { const f = bundle.files.find(f => f.path === change.path); return f ? { hash: f.sha256, size: f.size, executable: f.executable } : null; };
103
+ return { ...change, before: metadata(before), after: metadata(after) };
104
+ }) : [];
105
+ return {
106
+ schemaVersion: 1, kind: 'skill-transfer-preview', direction, status,
107
+ outcome: blockers.length ? 'blocked' : status === 'synced' ? 'no-op' : 'reviewable', blockers,
108
+ binding: structuredClone(binding), location: { ...location }, ownership,
109
+ expected: { localHash: local.state === 'available' ? local.bundle.hash : null, remote: { ...remote.revision }, draftRevision: remote.draftRevision }, files,
110
+ };
111
+ }
@@ -33,7 +33,33 @@ export declare function validateSkillName(name: string): void;
33
33
  export declare function validateSkillPath(path: string): void;
34
34
  export declare function skillBundleHash(files: SkillFile[]): string;
35
35
  export declare function validateSkillBundle(bundle: SkillBundle): void;
36
+ /** The longest `description` a SKILL.md may carry (the Agent Skills format's limit). */
37
+ export declare const SKILL_DESCRIPTION_MAX = 1024;
38
+ /**
39
+ * The top-level scalar keys of a SKILL.md frontmatter block, or `null` when the
40
+ * file does not open with one. A deliberately small YAML reader: plain, quoted
41
+ * and block (`|`, `>`) scalars, plus indented continuation lines. Nested maps
42
+ * (`metadata:`) are skipped. It never throws; a value it cannot read is absent.
43
+ */
44
+ export declare function readSkillFrontmatter(markdown: string): Record<string, string> | null;
45
+ /**
46
+ * What is wrong with a bundle's SKILL.md frontmatter, as sentences; empty when
47
+ * nothing is. `name` must be present and equal the folder name, `description`
48
+ * present, non-empty and at most {@link SKILL_DESCRIPTION_MAX} characters.
49
+ * Kept apart from {@link validateSkillBundle} on purpose: that one gates every
50
+ * read (scan, inventory, uninstall, cloud download), and many existing skills
51
+ * have no frontmatter at all. This one gates what Sfora itself publishes or
52
+ * ships: `skills push` and `skills packet install`.
53
+ */
54
+ export declare function skillFrontmatterProblems(bundle: SkillBundle): string[];
55
+ /**
56
+ * Throws every {@link skillFrontmatterProblems} finding, followed by the exact
57
+ * block to write, with the folder name filled in: a skill that pushed fine
58
+ * before this check existed is fixed with one edit.
59
+ */
60
+ export declare function validateSkillFrontmatter(bundle: SkillBundle): void;
36
61
  export declare function readSkillBundle(directory: string, name?: string): Promise<SkillBundle>;
62
+ /** Legacy array API; the shared discovery engine owns all traversal. */
37
63
  export declare function scanLocalSkills(roots?: string[]): Promise<Array<{
38
64
  path: string;
39
65
  name: string;
@@ -41,5 +67,14 @@ export declare function scanLocalSkills(roots?: string[]): Promise<Array<{
41
67
  error?: string;
42
68
  }>>;
43
69
  export declare function diffSkillBundles(local: SkillBundle, incoming: SkillBundle): SkillDifference[];
70
+ export declare function readInstallation(destination: string): Promise<SkillInstallation | undefined>;
71
+ /** A receipt is historical intent. Classify today's filesystem object before trusting it. */
72
+ export declare function inspectSkillTarget(directory: string): Promise<{
73
+ state: 'missing' | 'link' | 'file' | 'managed' | 'modified' | 'unmanaged' | 'unavailable';
74
+ ownership: 'managed' | 'unmanaged' | 'unknown';
75
+ hash?: string;
76
+ receipt?: SkillInstallation;
77
+ reason?: string;
78
+ }>;
44
79
  export declare function installSkillBundle(bundle: SkillBundle, targetRoot: string, source?: string, sourceVersion?: number): Promise<SkillInstallation>;
45
80
  export declare function uninstallSkill(directory: string): Promise<void>;
@@ -1,7 +1,6 @@
1
1
  import { constants } from "node:fs";
2
- import { chmod, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
2
+ import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
- import { homedir } from "node:os";
5
4
  import { randomUUID } from "node:crypto";
6
5
  import { atomicWrite, LocalConflictError, sha256, withLocalLock } from "./files.js";
7
6
  export const SKILL_LIMITS = { files: 1000, fileBytes: 5 * 1024 * 1024, totalBytes: 20 * 1024 * 1024 };
@@ -52,6 +51,99 @@ export function validateSkillBundle(bundle) {
52
51
  if (skillBundleHash(bundle.files) !== bundle.hash)
53
52
  throw new Error("Invalid skill bundle hash.");
54
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
+ }
55
147
  export async function readSkillBundle(directory, name = basename(resolve(directory))) {
56
148
  validateSkillName(name);
57
149
  if ((await lstat(directory)).isSymbolicLink())
@@ -99,38 +191,13 @@ export async function readSkillBundle(directory, name = basename(resolve(directo
99
191
  validateSkillBundle(bundle);
100
192
  return bundle;
101
193
  }
102
- export async function scanLocalSkills(roots = [join(homedir(), ".agents/skills"), join(homedir(), ".codex/skills"), join(homedir(), ".claude/skills")]) {
103
- const results = [];
104
- for (const root of roots) {
105
- let entries;
106
- try {
107
- entries = await readdir(root, { withFileTypes: true });
108
- }
109
- catch (e) {
110
- if (e.code === "ENOENT")
111
- continue;
112
- throw e;
113
- }
114
- for (const entry of entries) {
115
- if (!entry.isDirectory() || entry.name.startsWith("."))
116
- continue;
117
- const path = join(root, entry.name);
118
- try {
119
- await lstat(join(path, "SKILL.md"));
120
- }
121
- catch {
122
- continue;
123
- }
124
- try {
125
- const bundle = await readSkillBundle(path);
126
- results.push({ path, name: bundle.name, hash: bundle.hash });
127
- }
128
- catch (e) {
129
- results.push({ path, name: entry.name, error: e instanceof Error ? e.message : String(e) });
130
- }
131
- }
132
- }
133
- return results;
194
+ /** Legacy array API; the shared discovery engine owns all traversal. */
195
+ export async function scanLocalSkills(roots) {
196
+ const { discoverSkills } = await import("./skill-discovery.js");
197
+ const result = await discoverSkills(roots?.map(path => ({ path, label: "Chosen folder", depth: 4 })));
198
+ if (result.warnings.length)
199
+ throw new Error(result.warnings.join("\n"));
200
+ return result.skills;
134
201
  }
135
202
  export function diffSkillBundles(local, incoming) {
136
203
  validateSkillBundle(local);
@@ -147,10 +214,24 @@ export function diffSkillBundles(local, incoming) {
147
214
  });
148
215
  }
149
216
  function recordPath(destination) { return join(dirname(destination), `.${basename(destination)}.sfora-install.json`); }
150
- async function readInstallation(destination) {
217
+ export async function readInstallation(destination) {
151
218
  try {
152
- const record = JSON.parse(await readFile(recordPath(destination), "utf8"));
153
- if (record.schemaVersion !== 1 || record.path !== destination)
219
+ const handle = await open(recordPath(destination), constants.O_RDONLY | constants.O_NOFOLLOW);
220
+ let raw;
221
+ try {
222
+ const info = await handle.stat();
223
+ if (!info.isFile() || info.size > 65536)
224
+ throw new Error('Invalid installation record file.');
225
+ raw = await handle.readFile('utf8');
226
+ }
227
+ finally {
228
+ await handle.close();
229
+ }
230
+ const record = JSON.parse(raw);
231
+ if (record.schemaVersion !== 1 || record.path !== destination || record.name !== basename(destination)
232
+ || !/^[a-f0-9]{64}$/.test(record.hash) || !Number.isSafeInteger(record.installedAt) || record.installedAt < 0
233
+ || (record.source !== undefined && (typeof record.source !== 'string' || record.source.length > 8192))
234
+ || (record.sourceVersion !== undefined && (!Number.isSafeInteger(record.sourceVersion) || record.sourceVersion < 1)))
154
235
  throw new Error("Unsupported or mismatched installation record.");
155
236
  return record;
156
237
  }
@@ -160,6 +241,30 @@ async function readInstallation(destination) {
160
241
  throw e;
161
242
  }
162
243
  }
244
+ /** A receipt is historical intent. Classify today's filesystem object before trusting it. */
245
+ export async function inspectSkillTarget(directory) {
246
+ try {
247
+ const destination = join(await realpath(dirname(resolve(directory))), basename(directory));
248
+ const info = await lstat(destination).catch(e => { if (e.code === 'ENOENT')
249
+ return null; throw e; });
250
+ if (!info)
251
+ return { state: 'missing', ownership: 'unknown' };
252
+ if (info.isSymbolicLink())
253
+ return { state: 'link', ownership: 'unmanaged', reason: 'A link cannot be replaced using a copy installation receipt.' };
254
+ if (!info.isDirectory())
255
+ return { state: 'file', ownership: 'unmanaged', reason: 'The target is no longer a skill directory.' };
256
+ const bundle = await readSkillBundle(destination);
257
+ const receipt = await readInstallation(destination);
258
+ if (!receipt)
259
+ return { state: 'unmanaged', ownership: 'unmanaged', hash: bundle.hash };
260
+ if (receipt.hash !== bundle.hash)
261
+ return { state: 'modified', ownership: 'unmanaged', hash: bundle.hash, receipt, reason: 'The managed copy has local changes.' };
262
+ return { state: 'managed', ownership: 'managed', hash: bundle.hash, receipt };
263
+ }
264
+ catch (error) {
265
+ return { state: 'unavailable', ownership: 'unknown', reason: error.message };
266
+ }
267
+ }
163
268
  export async function installSkillBundle(bundle, targetRoot, source, sourceVersion) {
164
269
  validateSkillBundle(bundle);
165
270
  if (sourceVersion !== undefined && (!Number.isSafeInteger(sourceVersion) || sourceVersion < 1))
@@ -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: [