sfora-cli 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -0
- package/dist/SforaFs.js +68 -0
- package/dist/api-client.d.ts +23 -0
- package/dist/api-client.js +29 -3
- package/dist/block-commands.d.ts +1 -0
- package/dist/block-commands.js +1 -0
- package/dist/cli-args.d.ts +4 -0
- package/dist/cli-args.js +8 -0
- package/dist/cli.js +39 -33
- package/dist/config.d.ts +2 -0
- package/dist/config.js +15 -6
- package/dist/format/cardMarkdown.d.ts +1 -0
- package/dist/format/cardMarkdown.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local/workspace.d.ts +0 -16
- package/dist/local/workspace.js +34 -28
- package/dist/local-core/files.d.ts +23 -0
- package/dist/local-core/files.js +110 -0
- package/dist/local-core/index.d.ts +16 -0
- package/dist/local-core/index.js +15 -0
- package/dist/local-core/skill-adapters.d.ts +21 -0
- package/dist/local-core/skill-adapters.js +19 -0
- package/dist/local-core/skill-discovery.d.ts +22 -0
- package/dist/local-core/skill-discovery.js +79 -0
- package/dist/local-core/skill-domain.d.ts +74 -0
- package/dist/local-core/skill-executor.d.ts +23 -0
- package/dist/local-core/skill-executor.js +51 -0
- package/dist/local-core/skill-index.d.ts +54 -0
- package/dist/local-core/skill-index.js +115 -0
- package/dist/local-core/skill-local-executor.d.ts +18 -0
- package/dist/local-core/skill-local-executor.js +249 -0
- package/dist/local-core/skill-operations.d.ts +61 -0
- package/dist/local-core/skill-operations.js +268 -0
- package/dist/local-core/skill-review.d.ts +46 -0
- package/dist/local-core/skill-review.js +132 -0
- package/dist/local-core/skill-service.d.ts +96 -0
- package/dist/local-core/skill-service.js +157 -0
- package/dist/local-core/skill-store.d.ts +34 -0
- package/dist/local-core/skill-store.js +187 -0
- package/dist/local-core/skill-sync.d.ts +132 -0
- package/dist/local-core/skill-sync.js +111 -0
- package/dist/local-core/skills.d.ts +55 -0
- package/dist/local-core/skills.js +251 -0
- package/dist/skills-client.d.ts +32 -0
- package/dist/skills-client.js +88 -0
- package/dist/skills-command.d.ts +4 -0
- package/dist/skills-command.js +223 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +21 -3
- package/dist/config.test.js +0 -94
- /package/dist/{config.test.d.ts → local-core/skill-domain.js} +0 -0
package/dist/local/workspace.js
CHANGED
|
@@ -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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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
|
|
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, mode?: number): 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, mode = 0o600) {
|
|
86
|
+
const destination = join(await realpath(dirname(resolve(path))), basename(path));
|
|
87
|
+
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
88
|
+
try {
|
|
89
|
+
await atomicWrite(temporary, content, mode);
|
|
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,16 @@
|
|
|
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";
|
|
5
|
+
export * from "./skill-domain.js";
|
|
6
|
+
export * from "./skill-discovery.js";
|
|
7
|
+
export * from "./skill-store.js";
|
|
8
|
+
export * from "./skill-service.js";
|
|
9
|
+
export * from "./skill-sync.js";
|
|
10
|
+
export * from "./skill-operations.js";
|
|
11
|
+
export { SKILL_AGENT_ADAPTERS, projectSkillSources } from "./skill-adapters.js";
|
|
12
|
+
export type { SkillAgentAdapter } from "./skill-adapters.js";
|
|
13
|
+
export * from "./skill-local-executor.js";
|
|
14
|
+
export * from "./skill-executor.js";
|
|
15
|
+
export { SkillIndex } from './skill-index.js';
|
|
16
|
+
export { SkillReviewService, type SkillReview } from './skill-review.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
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";
|
|
5
|
+
export * from "./skill-domain.js";
|
|
6
|
+
export * from "./skill-discovery.js";
|
|
7
|
+
export * from "./skill-store.js";
|
|
8
|
+
export * from "./skill-service.js";
|
|
9
|
+
export * from "./skill-sync.js";
|
|
10
|
+
export * from "./skill-operations.js";
|
|
11
|
+
export { SKILL_AGENT_ADAPTERS, projectSkillSources } from "./skill-adapters.js";
|
|
12
|
+
export * from "./skill-local-executor.js";
|
|
13
|
+
export * from "./skill-executor.js";
|
|
14
|
+
export { SkillIndex } from './skill-index.js';
|
|
15
|
+
export { SkillReviewService } from './skill-review.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SkillSource } from './skill-discovery.js';
|
|
2
|
+
export interface SkillAgentAdapter {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
globalPath: string;
|
|
6
|
+
projectPath: string;
|
|
7
|
+
depth: number;
|
|
8
|
+
/** A folder observation alone never establishes that an agent enables a skill. */
|
|
9
|
+
activation: 'unknown';
|
|
10
|
+
deploymentModes: readonly ['copy'];
|
|
11
|
+
caches: readonly {
|
|
12
|
+
id: string;
|
|
13
|
+
path: string;
|
|
14
|
+
label: string;
|
|
15
|
+
depth: number;
|
|
16
|
+
}[];
|
|
17
|
+
}
|
|
18
|
+
export declare const SKILL_AGENT_ADAPTERS: readonly SkillAgentAdapter[];
|
|
19
|
+
export declare function defaultSkillSources(home?: string): SkillSource[];
|
|
20
|
+
/** Caller explicitly selects/registers the project; never infer access from cwd in desktop. */
|
|
21
|
+
export declare function projectSkillSources(project: string): SkillSource[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
export const SKILL_AGENT_ADAPTERS = [
|
|
4
|
+
{ id: 'agents', label: 'Agents', globalPath: '.agents/skills', projectPath: '.agents/skills', depth: 1, activation: 'unknown', deploymentModes: ['copy'], caches: [] },
|
|
5
|
+
{ id: 'codex', label: 'Codex', globalPath: '.codex/skills', projectPath: '.codex/skills', depth: 2, activation: 'unknown', deploymentModes: ['copy'], caches: [{ id: 'codex-plugin-cache', path: '.codex/plugins/cache', label: 'Codex plugin cache', depth: 6 }] },
|
|
6
|
+
{ id: 'claude', label: 'Claude', globalPath: '.claude/skills', projectPath: '.claude/skills', depth: 1, activation: 'unknown', deploymentModes: ['copy'], caches: [{ id: 'claude-plugin-cache', path: '.claude/plugins/cache', label: 'Claude plugin cache', depth: 6 }] },
|
|
7
|
+
{ id: 'cursor', label: 'Cursor', globalPath: '.cursor/skills', projectPath: '.cursor/skills', depth: 1, activation: 'unknown', deploymentModes: ['copy'], caches: [] },
|
|
8
|
+
];
|
|
9
|
+
export function defaultSkillSources(home = homedir()) {
|
|
10
|
+
return SKILL_AGENT_ADAPTERS.flatMap(adapter => [
|
|
11
|
+
{ id: adapter.id, path: join(home, adapter.globalPath), label: adapter.label, depth: adapter.depth, kind: 'agent' },
|
|
12
|
+
...adapter.caches.map(cache => ({ id: cache.id, path: join(home, cache.path), label: cache.label, depth: cache.depth, kind: 'cache' })),
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
15
|
+
/** Caller explicitly selects/registers the project; never infer access from cwd in desktop. */
|
|
16
|
+
export function projectSkillSources(project) {
|
|
17
|
+
const root = resolve(project);
|
|
18
|
+
return SKILL_AGENT_ADAPTERS.map(adapter => ({ id: `project:${root}:${adapter.id}`, path: join(root, adapter.projectPath), label: `${adapter.label} · project`, depth: adapter.depth, kind: 'agent' }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { defaultSkillSources } from './skill-adapters.js';
|
|
2
|
+
export interface SkillSource {
|
|
3
|
+
path: string;
|
|
4
|
+
label: string;
|
|
5
|
+
depth: number;
|
|
6
|
+
id?: string;
|
|
7
|
+
kind?: "agent" | "cache" | "custom";
|
|
8
|
+
}
|
|
9
|
+
export interface LocalSkill {
|
|
10
|
+
path: string;
|
|
11
|
+
name: string;
|
|
12
|
+
sources: string[];
|
|
13
|
+
aliases: import("./skill-domain.js").SkillAlias[];
|
|
14
|
+
hash?: string;
|
|
15
|
+
fileCount?: number;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Bounded discovery; canonical aliases share a row. Bundles retain strict link/size checks. */
|
|
19
|
+
export declare function discoverSkills(sources?: SkillSource[]): Promise<{
|
|
20
|
+
skills: LocalSkill[];
|
|
21
|
+
warnings: string[];
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readdir, realpath, lstat } from 'node:fs/promises';
|
|
2
|
+
import { defaultSkillSources } from './skill-adapters.js';
|
|
3
|
+
export { defaultSkillSources } from './skill-adapters.js';
|
|
4
|
+
import { join, basename } from 'node:path';
|
|
5
|
+
import { readSkillBundle } from './skills.js';
|
|
6
|
+
class ScanLimitError extends Error {
|
|
7
|
+
}
|
|
8
|
+
/** Bounded discovery; canonical aliases share a row. Bundles retain strict link/size checks. */
|
|
9
|
+
export async function discoverSkills(sources = defaultSkillSources()) {
|
|
10
|
+
const found = new Map();
|
|
11
|
+
const warnings = [];
|
|
12
|
+
let visited = 0;
|
|
13
|
+
for (const source of sources) {
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
async function walk(path, depth) {
|
|
16
|
+
if (++visited > 5000 || found.size >= 1000)
|
|
17
|
+
throw new ScanLimitError('Scan limit reached. Choose a narrower folder to find more skills.');
|
|
18
|
+
const canonical = await realpath(path);
|
|
19
|
+
if (seen.has(canonical)) {
|
|
20
|
+
const existing = found.get(canonical);
|
|
21
|
+
if (existing && !existing.aliases.some(a => a.sourceId === (source.id ?? source.path) && a.path === path))
|
|
22
|
+
existing.aliases.push({ sourceId: source.id ?? source.path, path, label: source.label, kind: source.kind ?? "custom" });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
seen.add(canonical);
|
|
26
|
+
const entries = await readdir(canonical, { withFileTypes: true });
|
|
27
|
+
if (entries.some(entry => entry.name === 'SKILL.md')) {
|
|
28
|
+
const existing = found.get(canonical);
|
|
29
|
+
if (existing) {
|
|
30
|
+
if (!existing.sources.includes(source.label))
|
|
31
|
+
existing.sources.push(source.label);
|
|
32
|
+
existing.aliases.push({ sourceId: source.id ?? source.path, path, label: source.label, kind: source.kind ?? "custom" });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const skill = { path: canonical, name: basename(canonical), sources: [source.label], aliases: [{ sourceId: source.id ?? source.path, path, label: source.label, kind: source.kind ?? "custom" }] };
|
|
36
|
+
found.set(canonical, skill);
|
|
37
|
+
try {
|
|
38
|
+
const bundle = await readSkillBundle(canonical);
|
|
39
|
+
skill.hash = bundle.hash;
|
|
40
|
+
skill.fileCount = bundle.files.length;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
skill.error = error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (depth === 0)
|
|
48
|
+
return;
|
|
49
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
50
|
+
if (['node_modules', '.git'].includes(entry.name))
|
|
51
|
+
continue;
|
|
52
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
53
|
+
continue;
|
|
54
|
+
const child = join(path, entry.name);
|
|
55
|
+
try {
|
|
56
|
+
if ((await lstat(await realpath(child))).isDirectory())
|
|
57
|
+
await walk(child, depth - 1);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof ScanLimitError)
|
|
61
|
+
throw error;
|
|
62
|
+
warnings.push(`${source.label}: ${child}: ${error instanceof Error ? error.message : String(error)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
await walk(source.path, source.depth);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof ScanLimitError) {
|
|
71
|
+
warnings.push(error.message);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (error.code !== 'ENOENT')
|
|
75
|
+
warnings.push(`${source.label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { skills: [...found.values()].sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)), warnings };
|
|
79
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Stable domain records. Names/hashes are labels/content, never identity or permission. */
|
|
2
|
+
export interface SkillIdentity {
|
|
3
|
+
id: string;
|
|
4
|
+
createdAt: number;
|
|
5
|
+
}
|
|
6
|
+
export interface SkillAlias {
|
|
7
|
+
sourceId: string;
|
|
8
|
+
path: string;
|
|
9
|
+
label: string;
|
|
10
|
+
kind: 'agent' | 'cache' | 'custom';
|
|
11
|
+
}
|
|
12
|
+
export interface SkillLocation {
|
|
13
|
+
id: string;
|
|
14
|
+
skillId: string;
|
|
15
|
+
path: string;
|
|
16
|
+
aliases: SkillAlias[];
|
|
17
|
+
createdAt: number;
|
|
18
|
+
}
|
|
19
|
+
export interface SkillObservation {
|
|
20
|
+
locationId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
observedAt: number;
|
|
23
|
+
hash?: string;
|
|
24
|
+
fileCount?: number;
|
|
25
|
+
error?: string;
|
|
26
|
+
availability: 'available' | 'unavailable';
|
|
27
|
+
}
|
|
28
|
+
export interface SkillRevision {
|
|
29
|
+
hash: string;
|
|
30
|
+
contentPolicy: 'bundle-v1';
|
|
31
|
+
cloudVersion?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface CloudSkillIdentity {
|
|
34
|
+
deployment: string;
|
|
35
|
+
organizationId: string;
|
|
36
|
+
skillId: string;
|
|
37
|
+
}
|
|
38
|
+
export interface SkillCloudBinding {
|
|
39
|
+
id: string;
|
|
40
|
+
localSkillId: string;
|
|
41
|
+
remote: CloudSkillIdentity & {
|
|
42
|
+
projectId: string;
|
|
43
|
+
};
|
|
44
|
+
/** Authenticated member ID, never an API key or its fingerprint. */
|
|
45
|
+
accountId: string;
|
|
46
|
+
/** Lookup hints only; every lookup must revalidate the qualified IDs. */
|
|
47
|
+
locator: {
|
|
48
|
+
project: string;
|
|
49
|
+
name: string;
|
|
50
|
+
};
|
|
51
|
+
baseline?: SkillRevision;
|
|
52
|
+
}
|
|
53
|
+
export interface SkillProjectAssignment {
|
|
54
|
+
projectId: string;
|
|
55
|
+
remote: CloudSkillIdentity;
|
|
56
|
+
pinnedVersion: number;
|
|
57
|
+
overrideSkillId?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface SkillAgentTarget {
|
|
60
|
+
skillId: string;
|
|
61
|
+
agentId: string;
|
|
62
|
+
path: string;
|
|
63
|
+
scope: 'global' | 'project';
|
|
64
|
+
mode: 'copy' | 'link';
|
|
65
|
+
ownership: 'managed' | 'unmanaged' | 'unknown';
|
|
66
|
+
}
|
|
67
|
+
export interface SkillOperation {
|
|
68
|
+
id: string;
|
|
69
|
+
kind: 'push' | 'pull' | 'install';
|
|
70
|
+
skillId: string;
|
|
71
|
+
expectedLocalHash?: string;
|
|
72
|
+
expectedRemote?: SkillRevision;
|
|
73
|
+
status: 'planned' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
|
74
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SkillOperationJournal, type SkillPushPorts } from './skill-operations.js';
|
|
2
|
+
import type { SkillStore } from './skill-store.js';
|
|
3
|
+
export declare class SkillOperationExecutor {
|
|
4
|
+
readonly store: SkillStore;
|
|
5
|
+
readonly journal: SkillOperationJournal;
|
|
6
|
+
readonly cloud: SkillPushPorts;
|
|
7
|
+
constructor(store: SkillStore, journal: SkillOperationJournal, cloud: SkillPushPorts);
|
|
8
|
+
apply(id: string, recover?: boolean): Promise<import("./skill-operations.js").SkillTransferOperation>;
|
|
9
|
+
/** Each item owns its durable outcome. Successful work is never replayed. */
|
|
10
|
+
batch(ids: string[], options?: {
|
|
11
|
+
recover?: boolean;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
onResult?: (result: {
|
|
14
|
+
id: string;
|
|
15
|
+
status: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
}) => void;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
id: string;
|
|
20
|
+
status: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}[]>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SkillPushExecutor, operationDirection } from './skill-operations.js';
|
|
2
|
+
import { SkillLocalExecutor } from './skill-local-executor.js';
|
|
3
|
+
export class SkillOperationExecutor {
|
|
4
|
+
store;
|
|
5
|
+
journal;
|
|
6
|
+
cloud;
|
|
7
|
+
constructor(store, journal, cloud) {
|
|
8
|
+
this.store = store;
|
|
9
|
+
this.journal = journal;
|
|
10
|
+
this.cloud = cloud;
|
|
11
|
+
}
|
|
12
|
+
async apply(id, recover = false) {
|
|
13
|
+
const op = (await this.journal.list()).find(o => o.id === id);
|
|
14
|
+
if (!op)
|
|
15
|
+
throw new Error('Operation not found.');
|
|
16
|
+
return operationDirection(op.plan) === 'push'
|
|
17
|
+
? new SkillPushExecutor(this.store, this.journal, this.cloud).apply(id, recover)
|
|
18
|
+
: new SkillLocalExecutor(this.store, this.journal, this.cloud).apply(id, recover);
|
|
19
|
+
}
|
|
20
|
+
/** Each item owns its durable outcome. Successful work is never replayed. */
|
|
21
|
+
async batch(ids, options = {}) {
|
|
22
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 1000 || new Set(ids).size !== ids.length || ids.some(id => typeof id !== 'string'))
|
|
23
|
+
throw new Error('Select 1–1000 distinct operation IDs.');
|
|
24
|
+
const operations = await this.journal.list();
|
|
25
|
+
if (ids.some(id => !operations.some(op => op.id === id)))
|
|
26
|
+
throw new Error('A selected operation no longer exists. Refresh before running.');
|
|
27
|
+
const results = [];
|
|
28
|
+
for (const id of ids) {
|
|
29
|
+
let result;
|
|
30
|
+
if (options.signal?.aborted) {
|
|
31
|
+
const op = (await this.journal.list()).find(o => o.id === id);
|
|
32
|
+
if (op.status === 'planned')
|
|
33
|
+
result = { id, status: (await this.journal.cancel(id)).status };
|
|
34
|
+
else
|
|
35
|
+
result = { id, status: op.status, error: ['succeeded', 'cancelled'].includes(op.status) ? undefined : 'Batch stopped before this item; its previous outcome is retained.' };
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
try {
|
|
39
|
+
result = { id, status: (await this.apply(id, options.recover)).status };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const current = (await this.journal.list()).find(o => o.id === id);
|
|
43
|
+
result = { id, status: current.status, error: error.message };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
results.push(result);
|
|
47
|
+
options.onResult?.(result);
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { SkillService } from './skill-service.js';
|
|
2
|
+
/** Events are hints; periodic reconciliation repairs missed events and root changes. */
|
|
3
|
+
export declare class SkillIndex {
|
|
4
|
+
readonly service: SkillService;
|
|
5
|
+
readonly changed: () => void;
|
|
6
|
+
readonly reconciliationMs: number;
|
|
7
|
+
private watchers;
|
|
8
|
+
private timer?;
|
|
9
|
+
private interval?;
|
|
10
|
+
private pending;
|
|
11
|
+
private full;
|
|
12
|
+
private running;
|
|
13
|
+
private active?;
|
|
14
|
+
private closed;
|
|
15
|
+
private started?;
|
|
16
|
+
private warnings;
|
|
17
|
+
constructor(service: SkillService, changed?: () => void, reconciliationMs?: number);
|
|
18
|
+
start(): Promise<void>;
|
|
19
|
+
inventory(): Promise<{
|
|
20
|
+
warnings: string[];
|
|
21
|
+
skills: {
|
|
22
|
+
skillId: string;
|
|
23
|
+
locationId: string;
|
|
24
|
+
path: string;
|
|
25
|
+
name: string;
|
|
26
|
+
sources: string[];
|
|
27
|
+
aliases: import("./skill-domain.js").SkillAlias[];
|
|
28
|
+
hash: string | undefined;
|
|
29
|
+
fileCount: number | undefined;
|
|
30
|
+
error: string | undefined;
|
|
31
|
+
availability: "available" | "unavailable";
|
|
32
|
+
}[];
|
|
33
|
+
}>;
|
|
34
|
+
invalidate(path?: string): void;
|
|
35
|
+
private configure;
|
|
36
|
+
refresh(): Promise<{
|
|
37
|
+
warnings: string[];
|
|
38
|
+
skills: {
|
|
39
|
+
skillId: string;
|
|
40
|
+
locationId: string;
|
|
41
|
+
path: string;
|
|
42
|
+
name: string;
|
|
43
|
+
sources: string[];
|
|
44
|
+
aliases: import("./skill-domain.js").SkillAlias[];
|
|
45
|
+
hash: string | undefined;
|
|
46
|
+
fileCount: number | undefined;
|
|
47
|
+
error: string | undefined;
|
|
48
|
+
availability: "available" | "unavailable";
|
|
49
|
+
}[];
|
|
50
|
+
}>;
|
|
51
|
+
private flush;
|
|
52
|
+
private perform;
|
|
53
|
+
close(): void;
|
|
54
|
+
}
|