sfora-cli 0.14.0 → 0.15.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.
@@ -1,3 +1,4 @@
1
+ import { atomicWrite, withLocalLock } from "../local-core/files.js";
1
2
  /**
2
3
  * LocalWorkspace — the OSS local mode. A `.sfora/` directory in your repo is
3
4
  * the workspace: tasks, posts, and docs are plain markdown files on disk, in
@@ -224,32 +225,37 @@ export class LocalWorkspace {
224
225
  * scanning the board. The markdown is written verbatim — files are storage.
225
226
  */
226
227
  async writeTask(markdown, opts = {}) {
227
- const parsed = parseMarkdownCard(markdown);
228
- if (!parsed.title.trim()) {
229
- throw new Error("Title is required — add an H1 (`# Title`) or a frontmatter `title:`");
230
- }
231
- const fmColumn = typeof parsed.frontmatter.column === "string"
232
- ? parsed.frontmatter.column
233
- : undefined;
234
- const fmStatus = typeof parsed.frontmatter.status === "string"
235
- ? parsed.frontmatter.status
236
- : undefined;
237
- const column = await this.#resolveColumn(opts.column ?? fmColumn, fmStatus);
238
- // Existing card (by explicit frontmatter number) → replace in place,
239
- // wherever it currently lives (the new write may also move it).
240
- const fmNumber = typeof parsed.frontmatter.number === "string"
241
- ? Number.parseInt(parsed.frontmatter.number, 10)
242
- : undefined;
243
- const existing = fmNumber !== undefined && Number.isFinite(fmNumber)
244
- ? await this.#findByNumber(fmNumber)
245
- : undefined;
246
- if (existing) {
247
- await rm(join(this.root, "board", existing.column, existing.filename));
248
- }
249
- const number = existing?.number ?? (await this.#nextNumber());
250
- const filename = cardFilename({ number, title: parsed.title });
251
- await writeFile(join(this.root, "board", column, filename), markdown, "utf8");
252
- return { filename, column, number };
228
+ return withLocalLock(join(this.root, "board"), async () => {
229
+ const parsed = parseMarkdownCard(markdown);
230
+ if (!parsed.title.trim()) {
231
+ throw new Error("Title is required — add an H1 (`# Title`) or a frontmatter `title:`");
232
+ }
233
+ const fmColumn = typeof parsed.frontmatter.column === "string"
234
+ ? parsed.frontmatter.column
235
+ : undefined;
236
+ const fmStatus = typeof parsed.frontmatter.status === "string"
237
+ ? parsed.frontmatter.status
238
+ : undefined;
239
+ const column = await this.#resolveColumn(opts.column ?? fmColumn, fmStatus);
240
+ // Existing card (by explicit frontmatter number) → replace in place,
241
+ // wherever it currently lives (the new write may also move it).
242
+ const fmNumber = typeof parsed.frontmatter.number === "string"
243
+ ? Number.parseInt(parsed.frontmatter.number, 10)
244
+ : undefined;
245
+ const existing = fmNumber !== undefined && Number.isFinite(fmNumber)
246
+ ? await this.#findByNumber(fmNumber)
247
+ : undefined;
248
+ const number = existing?.number ?? (await this.#nextNumber());
249
+ const filename = cardFilename({ number, title: parsed.title });
250
+ const destination = join(this.root, "board", column, filename);
251
+ await atomicWrite(destination, markdown, 0o644);
252
+ if (existing) {
253
+ const previous = join(this.root, "board", existing.column, existing.filename);
254
+ if (previous !== destination)
255
+ await rm(previous);
256
+ }
257
+ return { filename, column, number };
258
+ });
253
259
  }
254
260
  // ─── Posts & docs ────────────────────────────────────────────────
255
261
  async writePost(markdown, opts = {}) {
@@ -261,7 +267,7 @@ export class LocalWorkspace {
261
267
  const filename = `${date}-${slugify(parsed.title)}.md`;
262
268
  const dir = opts.draft ? "drafts" : "posts";
263
269
  await mkdir(join(this.root, dir), { recursive: true });
264
- await writeFile(join(this.root, dir, filename), markdown, "utf8");
270
+ await withLocalLock(join(this.root, dir, filename), () => atomicWrite(join(this.root, dir, filename), markdown, 0o644));
265
271
  return { filename };
266
272
  }
267
273
  async writeDoc(markdown) {
@@ -271,7 +277,7 @@ export class LocalWorkspace {
271
277
  }
272
278
  const filename = noteFilename({ title: parsed.title });
273
279
  await mkdir(join(this.root, "docs"), { recursive: true });
274
- await writeFile(join(this.root, "docs", filename), markdown, "utf8");
280
+ await withLocalLock(join(this.root, "docs", filename), () => atomicWrite(join(this.root, "docs", filename), markdown, 0o644));
275
281
  return { filename };
276
282
  }
277
283
  async listPosts(kind = "posts") {
@@ -0,0 +1,23 @@
1
+ import { type FSWatcher } from "node:fs";
2
+ export declare const sha256: (bytes: Uint8Array | string) => string;
3
+ export declare class LocalConflictError extends Error {
4
+ readonly code = "LOCAL_CONFLICT";
5
+ constructor(message?: string);
6
+ }
7
+ /** Exclusive cross-process lock. A crashed process leaves an explicit recoverable lock;
8
+ * never steal a live/slow operation's lock based on a timeout. */
9
+ export declare function withLocalLock<T>(path: string, operation: () => Promise<T>): Promise<T>;
10
+ /** Write a complete staged file on the same filesystem before replacing the destination. */
11
+ export declare function atomicWrite(path: string, content: string | Uint8Array, mode?: number): Promise<void>;
12
+ export interface LocalMarkdownSnapshot {
13
+ path: string;
14
+ content: string;
15
+ revision: string;
16
+ }
17
+ export declare function readLocalMarkdown(path: string): Promise<LocalMarkdownSnapshot>;
18
+ export declare function saveLocalMarkdown(path: string, content: string, expectedRevision: string): Promise<LocalMarkdownSnapshot>;
19
+ /** Save As creates a new path only. Replacing an existing file requires its current revision. */
20
+ export declare function atomicCreate(path: string, content: string | Uint8Array): Promise<string>;
21
+ export declare function saveNewLocalMarkdown(path: string, content: string): Promise<LocalMarkdownSnapshot>;
22
+ /** Watch the parent so atomic replacement by another editor remains observable. */
23
+ export declare function watchLocalMarkdown(path: string, onChange: () => void): FSWatcher;
@@ -0,0 +1,110 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { watch } from "node:fs";
3
+ import { mkdir, open, readFile, realpath, rename, rm, stat, link } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
7
+ export class LocalConflictError extends Error {
8
+ code = "LOCAL_CONFLICT";
9
+ constructor(message = "The file changed on disk. Reload or save a separate copy.") { super(message); this.name = "LocalConflictError"; }
10
+ }
11
+ /** Exclusive cross-process lock. A crashed process leaves an explicit recoverable lock;
12
+ * never steal a live/slow operation's lock based on a timeout. */
13
+ export async function withLocalLock(path, operation) {
14
+ const lock = `${path}.sfora-lock`;
15
+ const deadline = Date.now() + 5000;
16
+ for (;;) {
17
+ try {
18
+ await mkdir(lock, { mode: 0o700 });
19
+ break;
20
+ }
21
+ catch (error) {
22
+ if (error.code !== "EEXIST")
23
+ throw error;
24
+ if (Date.now() >= deadline)
25
+ throw new Error(`Local operation locked: ${lock}. If no Sfora process is using it, remove this lock directory and retry.`);
26
+ await delay(25);
27
+ }
28
+ }
29
+ try {
30
+ return await operation();
31
+ }
32
+ finally {
33
+ await rm(lock, { recursive: true, force: true });
34
+ }
35
+ }
36
+ /** Write a complete staged file on the same filesystem before replacing the destination. */
37
+ export async function atomicWrite(path, content, mode = 0o600) {
38
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
39
+ const handle = await open(temporary, "wx", mode);
40
+ try {
41
+ await handle.writeFile(content);
42
+ await handle.sync();
43
+ }
44
+ catch (error) {
45
+ await rm(temporary, { force: true });
46
+ throw error;
47
+ }
48
+ finally {
49
+ await handle.close();
50
+ }
51
+ try {
52
+ await rename(temporary, path);
53
+ }
54
+ finally {
55
+ await rm(temporary, { force: true });
56
+ }
57
+ }
58
+ const MAX_MARKDOWN_BYTES = 20 * 1024 * 1024;
59
+ export async function readLocalMarkdown(path) {
60
+ const canonical = await realpath(path);
61
+ const info = await stat(canonical);
62
+ if (!info.isFile() || info.size > MAX_MARKDOWN_BYTES)
63
+ throw new Error("Select a regular Markdown file smaller than 20 MiB.");
64
+ const bytes = await readFile(canonical);
65
+ // Buffer decoding preserves BOM. Reject invalid UTF-8 instead of silently corrupting it.
66
+ const content = bytes.toString("utf8");
67
+ if (!Buffer.from(content, "utf8").equals(bytes))
68
+ throw new Error("This file is not valid UTF-8.");
69
+ return { path: canonical, content, revision: sha256(bytes) };
70
+ }
71
+ export async function saveLocalMarkdown(path, content, expectedRevision) {
72
+ const canonical = await realpath(path);
73
+ if (Buffer.byteLength(content) > MAX_MARKDOWN_BYTES)
74
+ throw new Error("Markdown exceeds 20 MiB.");
75
+ return withLocalLock(canonical, async () => {
76
+ const previous = await readLocalMarkdown(canonical);
77
+ if (previous.revision !== expectedRevision)
78
+ throw new LocalConflictError();
79
+ const mode = (await stat(canonical)).mode & 0o777;
80
+ await atomicWrite(canonical, content, mode);
81
+ return { path: canonical, content, revision: sha256(content) };
82
+ });
83
+ }
84
+ /** Save As creates a new path only. Replacing an existing file requires its current revision. */
85
+ export async function atomicCreate(path, content) {
86
+ const destination = join(await realpath(dirname(resolve(path))), basename(path));
87
+ const temporary = `${destination}.${randomUUID()}.tmp`;
88
+ try {
89
+ await atomicWrite(temporary, content);
90
+ // Hard link publishes atomically and fails EEXIST instead of overwriting a raced save.
91
+ await link(temporary, destination);
92
+ }
93
+ finally {
94
+ await rm(temporary, { force: true });
95
+ }
96
+ return destination;
97
+ }
98
+ export async function saveNewLocalMarkdown(path, content) {
99
+ if (Buffer.byteLength(content) > MAX_MARKDOWN_BYTES)
100
+ throw new Error("Markdown exceeds 20 MiB.");
101
+ const destination = await atomicCreate(path, content);
102
+ return { path: destination, content, revision: sha256(content) };
103
+ }
104
+ /** Watch the parent so atomic replacement by another editor remains observable. */
105
+ export function watchLocalMarkdown(path, onChange) {
106
+ return watch(dirname(path), (_event, filename) => {
107
+ if (filename === null || filename.toString() === basename(path))
108
+ onChange();
109
+ });
110
+ }
@@ -0,0 +1,4 @@
1
+ /** Trusted Node-only operations shared by the CLI and Electron main process.
2
+ * Renderers must obtain native dialog grants; never expose these as arbitrary IPC paths. */
3
+ export * from "./files.js";
4
+ export * from "./skills.js";
@@ -0,0 +1,4 @@
1
+ /** Trusted Node-only operations shared by the CLI and Electron main process.
2
+ * Renderers must obtain native dialog grants; never expose these as arbitrary IPC paths. */
3
+ export * from "./files.js";
4
+ export * from "./skills.js";
@@ -0,0 +1,45 @@
1
+ export interface SkillFile {
2
+ path: string;
3
+ contentBase64: string;
4
+ sha256: string;
5
+ size: number;
6
+ executable: boolean;
7
+ }
8
+ export interface SkillBundle {
9
+ schemaVersion: 1;
10
+ name: string;
11
+ files: SkillFile[];
12
+ hash: string;
13
+ }
14
+ export interface SkillInstallation {
15
+ schemaVersion: 1;
16
+ path: string;
17
+ name: string;
18
+ hash: string;
19
+ installedAt: number;
20
+ source?: string;
21
+ sourceVersion?: number;
22
+ }
23
+ export interface SkillDifference {
24
+ path: string;
25
+ kind: "added" | "removed" | "modified";
26
+ }
27
+ export declare const SKILL_LIMITS: {
28
+ files: number;
29
+ fileBytes: number;
30
+ totalBytes: number;
31
+ };
32
+ export declare function validateSkillName(name: string): void;
33
+ export declare function validateSkillPath(path: string): void;
34
+ export declare function skillBundleHash(files: SkillFile[]): string;
35
+ export declare function validateSkillBundle(bundle: SkillBundle): void;
36
+ export declare function readSkillBundle(directory: string, name?: string): Promise<SkillBundle>;
37
+ export declare function scanLocalSkills(roots?: string[]): Promise<Array<{
38
+ path: string;
39
+ name: string;
40
+ hash?: string;
41
+ error?: string;
42
+ }>>;
43
+ export declare function diffSkillBundles(local: SkillBundle, incoming: SkillBundle): SkillDifference[];
44
+ export declare function installSkillBundle(bundle: SkillBundle, targetRoot: string, source?: string, sourceVersion?: number): Promise<SkillInstallation>;
45
+ export declare function uninstallSkill(directory: string): Promise<void>;
@@ -0,0 +1,239 @@
1
+ import { constants } from "node:fs";
2
+ import { chmod, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import { atomicWrite, LocalConflictError, sha256, withLocalLock } from "./files.js";
7
+ export const SKILL_LIMITS = { files: 1000, fileBytes: 5 * 1024 * 1024, totalBytes: 20 * 1024 * 1024 };
8
+ export function validateSkillName(name) {
9
+ if (typeof name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 64)
10
+ throw new Error("Skill name must be 1–64 lowercase letters, digits and single hyphens.");
11
+ }
12
+ export function validateSkillPath(path) {
13
+ if (typeof path !== "string" || !path || path.length > 512 || path.includes("\\") || path.includes(":") || /[\x00-\x1f]/.test(path) || path.split("/").some(p => !p || p === "." || p === ".." || p.endsWith(".") || p.endsWith(" ")))
14
+ throw new Error(`Unsafe skill path: ${path}`);
15
+ }
16
+ export function skillBundleHash(files) {
17
+ return sha256(JSON.stringify([...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(f => [f.path, f.sha256, f.size, f.executable])));
18
+ }
19
+ export function validateSkillBundle(bundle) {
20
+ if (!bundle || bundle.schemaVersion !== 1)
21
+ throw new Error("Unsupported skill bundle schema.");
22
+ validateSkillName(bundle.name);
23
+ if (!Array.isArray(bundle.files) || !bundle.files.length || bundle.files.length > SKILL_LIMITS.files)
24
+ throw new Error("Invalid skill file count.");
25
+ const paths = new Set();
26
+ let total = 0;
27
+ for (const file of bundle.files) {
28
+ if (!file || typeof file.contentBase64 !== "string" || file.contentBase64.length > Math.ceil(SKILL_LIMITS.fileBytes / 3) * 4)
29
+ throw new Error("Invalid skill file encoding or size.");
30
+ validateSkillPath(file.path);
31
+ const key = file.path.normalize("NFC").toLowerCase();
32
+ if (paths.has(key))
33
+ throw new Error(`Duplicate skill path: ${file.path}`);
34
+ paths.add(key);
35
+ const bytes = Buffer.from(file.contentBase64, "base64");
36
+ if (bytes.toString("base64") !== file.contentBase64 || bytes.length !== file.size || sha256(bytes) !== file.sha256 || typeof file.executable !== "boolean")
37
+ throw new Error(`Invalid skill file integrity: ${file.path}`);
38
+ total += bytes.length;
39
+ if (bytes.length > SKILL_LIMITS.fileBytes || total > SKILL_LIMITS.totalBytes)
40
+ throw new Error("Skill exceeds size limits.");
41
+ }
42
+ for (const path of paths) {
43
+ const parts = path.split("/");
44
+ while (parts.length > 1) {
45
+ parts.pop();
46
+ if (paths.has(parts.join("/")))
47
+ throw new Error("Skill file/directory collision.");
48
+ }
49
+ }
50
+ if (!bundle.files.some(f => f.path === "SKILL.md"))
51
+ throw new Error("A skill must contain SKILL.md.");
52
+ if (skillBundleHash(bundle.files) !== bundle.hash)
53
+ throw new Error("Invalid skill bundle hash.");
54
+ }
55
+ export async function readSkillBundle(directory, name = basename(resolve(directory))) {
56
+ validateSkillName(name);
57
+ if ((await lstat(directory)).isSymbolicLink())
58
+ throw new Error("Skill root cannot be a symbolic link.");
59
+ const root = await realpath(directory);
60
+ const files = [];
61
+ let total = 0;
62
+ let directories = 0;
63
+ async function walk(relative) {
64
+ if (++directories > 1000 || relative.split("/").length > 32)
65
+ throw new Error("Skill directory nesting exceeds limits.");
66
+ const folder = join(root, relative);
67
+ if ((await realpath(folder)) !== folder)
68
+ throw new Error("Symbolic links are not supported.");
69
+ for (const entry of await readdir(folder, { withFileTypes: true })) {
70
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
71
+ validateSkillPath(path);
72
+ if (entry.isSymbolicLink())
73
+ throw new Error(`Symbolic links are not supported: ${path}`);
74
+ if (entry.isDirectory()) {
75
+ await walk(path);
76
+ continue;
77
+ }
78
+ if (!entry.isFile())
79
+ throw new Error(`Non-regular skill file: ${path}`);
80
+ const absolute = join(root, path);
81
+ const info = await lstat(absolute);
82
+ if (info.size > SKILL_LIMITS.fileBytes || total + info.size > SKILL_LIMITS.totalBytes || files.length >= SKILL_LIMITS.files)
83
+ throw new Error("Skill exceeds size limits.");
84
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
85
+ let bytes;
86
+ try {
87
+ bytes = await handle.readFile();
88
+ }
89
+ finally {
90
+ await handle.close();
91
+ }
92
+ total += bytes.length;
93
+ files.push({ path, contentBase64: bytes.toString("base64"), size: bytes.length, sha256: sha256(bytes), executable: !!(info.mode & 0o111) });
94
+ }
95
+ }
96
+ await walk("");
97
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
98
+ const bundle = { schemaVersion: 1, name, files, hash: skillBundleHash(files) };
99
+ validateSkillBundle(bundle);
100
+ return bundle;
101
+ }
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;
134
+ }
135
+ export function diffSkillBundles(local, incoming) {
136
+ validateSkillBundle(local);
137
+ validateSkillBundle(incoming);
138
+ const old = new Map(local.files.map(f => [f.path, f]));
139
+ const next = new Map(incoming.files.map(f => [f.path, f]));
140
+ return [...new Set([...old.keys(), ...next.keys()])].sort().flatMap(path => {
141
+ const a = old.get(path), b = next.get(path);
142
+ if (!a)
143
+ return [{ path, kind: "added" }];
144
+ if (!b)
145
+ return [{ path, kind: "removed" }];
146
+ return a.sha256 !== b.sha256 || a.executable !== b.executable ? [{ path, kind: "modified" }] : [];
147
+ });
148
+ }
149
+ function recordPath(destination) { return join(dirname(destination), `.${basename(destination)}.sfora-install.json`); }
150
+ async function readInstallation(destination) {
151
+ try {
152
+ const record = JSON.parse(await readFile(recordPath(destination), "utf8"));
153
+ if (record.schemaVersion !== 1 || record.path !== destination)
154
+ throw new Error("Unsupported or mismatched installation record.");
155
+ return record;
156
+ }
157
+ catch (e) {
158
+ if (e.code === "ENOENT")
159
+ return undefined;
160
+ throw e;
161
+ }
162
+ }
163
+ export async function installSkillBundle(bundle, targetRoot, source, sourceVersion) {
164
+ validateSkillBundle(bundle);
165
+ if (sourceVersion !== undefined && (!Number.isSafeInteger(sourceVersion) || sourceVersion < 1))
166
+ throw new Error("Source version must be a positive integer.");
167
+ await mkdir(targetRoot, { recursive: true });
168
+ const root = await realpath(targetRoot);
169
+ const destination = join(root, bundle.name);
170
+ return withLocalLock(destination, async () => {
171
+ const previous = await readInstallation(destination);
172
+ let exists = false;
173
+ try {
174
+ await lstat(destination);
175
+ exists = true;
176
+ }
177
+ catch (e) {
178
+ if (e.code !== "ENOENT")
179
+ throw e;
180
+ }
181
+ if (exists) {
182
+ if (!previous)
183
+ throw new LocalConflictError("Destination is not owned by Sfora. Choose another target.");
184
+ if ((await readSkillBundle(destination)).hash !== previous.hash)
185
+ throw new LocalConflictError("Installed skill has local changes. Push or copy them before updating.");
186
+ }
187
+ const staging = join(root, `.${bundle.name}.${randomUUID()}.stage`);
188
+ const backup = `${staging}.previous`;
189
+ await mkdir(staging, { mode: 0o700 });
190
+ try {
191
+ for (const file of bundle.files) {
192
+ const path = join(staging, file.path);
193
+ await mkdir(dirname(path), { recursive: true });
194
+ await writeFile(path, Buffer.from(file.contentBase64, "base64"), { flag: "wx", mode: file.executable ? 0o755 : 0o644 });
195
+ await chmod(path, file.executable ? 0o755 : 0o644);
196
+ }
197
+ if (exists)
198
+ await rename(destination, backup);
199
+ try {
200
+ await rename(staging, destination);
201
+ const record = { schemaVersion: 1, path: destination, name: bundle.name, hash: bundle.hash, installedAt: Date.now(), source, sourceVersion };
202
+ await atomicWrite(recordPath(destination), `${JSON.stringify(record, null, 2)}\n`);
203
+ // Record publication completes the transaction. Failed obsolete-backup cleanup
204
+ // must never roll the destination back under a newly committed record.
205
+ await rm(backup, { recursive: true, force: true }).catch(() => { });
206
+ return record;
207
+ }
208
+ catch (error) {
209
+ await rm(destination, { recursive: true, force: true });
210
+ if (exists)
211
+ await rename(backup, destination);
212
+ throw error;
213
+ }
214
+ }
215
+ finally {
216
+ await rm(staging, { recursive: true, force: true });
217
+ }
218
+ });
219
+ }
220
+ export async function uninstallSkill(directory) {
221
+ const destination = join(await realpath(dirname(resolve(directory))), basename(directory));
222
+ await withLocalLock(destination, async () => {
223
+ const record = await readInstallation(destination);
224
+ if (!record)
225
+ throw new Error("This skill installation is not owned by Sfora.");
226
+ if ((await readSkillBundle(destination)).hash !== record.hash)
227
+ throw new LocalConflictError("Installed skill has local changes; uninstall refused.");
228
+ const trash = `${destination}.${randomUUID()}.removed`;
229
+ await rename(destination, trash);
230
+ try {
231
+ await rm(recordPath(destination));
232
+ }
233
+ catch (error) {
234
+ await rename(trash, destination);
235
+ throw error;
236
+ }
237
+ await rm(trash, { recursive: true });
238
+ });
239
+ }
@@ -0,0 +1,21 @@
1
+ import { type SkillBundle } from "./local-core/skills.js";
2
+ import type { ResolvedSettings } from "./config.js";
3
+ export declare class SkillsClient {
4
+ private readonly settings;
5
+ constructor(settings: ResolvedSettings);
6
+ request<T>(route: string, init?: RequestInit): Promise<T>;
7
+ list(project: string): Promise<unknown>;
8
+ detail(project: string, name: string): Promise<{
9
+ skill: {
10
+ version: number;
11
+ draftRevision: number;
12
+ };
13
+ [key: string]: unknown;
14
+ }>;
15
+ download(project: string, name: string, version?: number): Promise<SkillBundle>;
16
+ saveDraft(project: string, bundle: SkillBundle, expectedVersion: number, expectedRevision?: number): Promise<{
17
+ draftRevision: number;
18
+ [key: string]: unknown;
19
+ }>;
20
+ publish(project: string, name: string, expectedVersion: number, expectedRevision?: number): Promise<unknown>;
21
+ }
@@ -0,0 +1,36 @@
1
+ import { validateSkillBundle } from "./local-core/skills.js";
2
+ export class SkillsClient {
3
+ settings;
4
+ constructor(settings) {
5
+ this.settings = settings;
6
+ }
7
+ async request(route, init) {
8
+ if (!this.settings.apiKey)
9
+ throw new Error("Run sfora login to connect to cloud Skills.");
10
+ const response = await fetch(new URL(route, this.settings.url), {
11
+ ...init, headers: { Authorization: `Bearer ${this.settings.apiKey}`, "Content-Type": "application/json", "X-Sfora-Client": "cli", ...init?.headers },
12
+ });
13
+ if (!response.ok)
14
+ throw new Error(`Skills API ${response.status}: ${await response.text()}`);
15
+ return await response.json();
16
+ }
17
+ list(project) { return this.request(`/v1/skills?${new URLSearchParams({ project })}`); }
18
+ detail(project, name) {
19
+ return this.request(`/v1/skills?${new URLSearchParams({ project, name })}`);
20
+ }
21
+ async download(project, name, version) {
22
+ const query = new URLSearchParams({ project, name, download: "1" });
23
+ if (version !== undefined)
24
+ query.set("version", String(version));
25
+ const bundle = await this.request(`/v1/skills?${query}`);
26
+ validateSkillBundle(bundle);
27
+ return bundle;
28
+ }
29
+ saveDraft(project, bundle, expectedVersion, expectedRevision) {
30
+ validateSkillBundle(bundle);
31
+ return this.request("/v1/skills", { method: "POST", body: JSON.stringify({ project, name: bundle.name, bundle, expectedVersion, expectedRevision }) });
32
+ }
33
+ publish(project, name, expectedVersion, expectedRevision) {
34
+ return this.request("/v1/skills/publish", { method: "POST", body: JSON.stringify({ project, name, expectedVersion, expectedRevision }) });
35
+ }
36
+ }
@@ -0,0 +1,4 @@
1
+ import type { CliArgs } from "./cli-args.js";
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";
4
+ export declare function runSkillsCommand(args: CliArgs, settings: ResolvedSettings): Promise<void>;
@@ -0,0 +1,75 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { diffSkillBundles, installSkillBundle, readSkillBundle, scanLocalSkills, uninstallSkill, validateSkillBundle } from "./local-core/index.js";
3
+ import { SkillsClient } from "./skills-client.js";
4
+ export const SKILLS_HELP = `Skills:
5
+ sfora skills scan [root ...] --json Discover complete local skill bundles
6
+ sfora skills list --project <slug> List cloud project skills
7
+ sfora skills push <folder> --project <slug> --expected-version <N> [--expected-revision <N>]
8
+ Upload draft and publish (0 for a new skill)
9
+ sfora skills pull <name> <bundle.json> --project <slug> [--version <N>]
10
+ sfora skills install <name> --project <slug> --skills-target <directory> [--version <N>]
11
+ sfora skills install-file <bundle.json> --skills-target <directory>
12
+ sfora skills diff <folder> <name> --project <slug> [--version <N>]
13
+ sfora skills uninstall <installed-folder> Remove only an unchanged Sfora-owned install
14
+ `;
15
+ export async function runSkillsCommand(args, settings) {
16
+ const [verb, first, second] = args.rest;
17
+ for (const [label, value] of [["version", args.skillVersion], ["expected-version", args.expectedVersion], ["expected-revision", args.expectedRevision]]) {
18
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < (label === "version" ? 1 : 0)))
19
+ throw new Error(`--${label} must be a ${label === "version" ? "positive" : "non-negative"} integer.`);
20
+ }
21
+ const print = (value) => console.log(JSON.stringify(value, null, args.json ? undefined : 2));
22
+ const need = (value, label) => { if (!value)
23
+ 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));
26
+ return;
27
+ }
28
+ if (verb === "uninstall") {
29
+ await uninstallSkill(need(first, "Installed folder"));
30
+ print({ uninstalled: first });
31
+ return;
32
+ }
33
+ if (verb === "install-file") {
34
+ const bundle = JSON.parse(await readFile(need(first, "Bundle JSON"), "utf8"));
35
+ validateSkillBundle(bundle);
36
+ print(await installSkillBundle(bundle, need(args.skillsTarget, "--skills-target"), first));
37
+ return;
38
+ }
39
+ const project = need(args.project, "--project");
40
+ const client = new SkillsClient(settings);
41
+ if (verb === "list") {
42
+ print(await client.list(project));
43
+ return;
44
+ }
45
+ if (verb === "push") {
46
+ if (args.expectedVersion === undefined)
47
+ throw new Error("--expected-version is required (0 for a new skill). Read the current version with skills list first.");
48
+ const bundle = await readSkillBundle(need(first, "Skill folder"));
49
+ const draft = await client.saveDraft(project, bundle, args.expectedVersion, args.expectedRevision);
50
+ print(await client.publish(project, bundle.name, args.expectedVersion, draft.draftRevision));
51
+ return;
52
+ }
53
+ if (verb === "pull") {
54
+ const bundle = await client.download(project, need(first, "Skill name"), args.skillVersion);
55
+ // Export refuses to overwrite unrelated files; explicit repeated exports can choose a new path.
56
+ const { atomicCreate } = await import("./local-core/files.js");
57
+ await atomicCreate(need(second, "Output bundle JSON"), `${JSON.stringify(bundle, null, 2)}\n`);
58
+ print({ path: second, hash: bundle.hash });
59
+ return;
60
+ }
61
+ if (verb === "install") {
62
+ const name = need(first, "Skill name");
63
+ const version = args.skillVersion ?? (await client.detail(project, name)).skill.version;
64
+ if (!Number.isSafeInteger(version) || version < 1)
65
+ throw new Error("This skill has no published version to install.");
66
+ const bundle = await client.download(project, name, version);
67
+ print(await installSkillBundle(bundle, need(args.skillsTarget, "--skills-target"), `${settings.url}/${project}/${name}`, version));
68
+ return;
69
+ }
70
+ if (verb === "diff") {
71
+ print(diffSkillBundles(await readSkillBundle(need(first, "Local folder")), await client.download(project, need(second, "Skill name"), args.skillVersion)));
72
+ return;
73
+ }
74
+ throw new Error(SKILLS_HELP);
75
+ }