sfora-cli 0.15.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 +62 -1
- package/dist/local-core/files.d.ts +1 -1
- package/dist/local-core/files.js +2 -2
- package/dist/local-core/index.d.ts +12 -0
- package/dist/local-core/index.js +11 -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-domain.js +1 -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 +10 -0
- package/dist/local-core/skills.js +49 -37
- package/dist/skills-client.d.ts +13 -2
- package/dist/skills-client.js +57 -5
- package/dist/skills-command.d.ts +1 -1
- package/dist/skills-command.js +152 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -405,7 +405,7 @@ Reads outside `projects/<slug>/(posts|drafts)`, `inbox/mentions.md`, and
|
|
|
405
405
|
The desktop app and npm CLI use the same Node-only `sfora-cli/local-core`
|
|
406
406
|
operations. `sfora desktop path/to/file.md` opens a local file in the installed
|
|
407
407
|
Sfora macOS app. `sfora open` continues to open Sfora web URLs. npm installation
|
|
408
|
-
still requires Node
|
|
408
|
+
still requires Node 22.13+; the desktop distribution bundles its own CLI runtime.
|
|
409
409
|
Both read the existing `~/.sfora/config.json` profiles. Config writes are atomic
|
|
410
410
|
and process-locked; no account is required for local files or local skill scans.
|
|
411
411
|
|
|
@@ -466,3 +466,64 @@ changes appear here only after publication; use `skills push` or the project
|
|
|
466
466
|
workbench to change a bundle. Cloud installations record both the immutable
|
|
467
467
|
bundle hash and numeric `sourceVersion`; resolving “latest” pins that version
|
|
468
468
|
before downloading so concurrent publications cannot mix provenance and bytes.
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
### Persistent local skill inventory
|
|
472
|
+
|
|
473
|
+
Requires Node 22.13 or later. Desktop and CLI share a private SQLite catalogue at `~/.sfora/skills/catalog.sqlite`. Skill names and content hashes are not identity; separate local copies remain separate until explicitly linked.
|
|
474
|
+
|
|
475
|
+
```sh
|
|
476
|
+
sfora skills roots add ~/.my-skills "My skills"
|
|
477
|
+
sfora skills roots add-project ~/Developer/my-project "My project"
|
|
478
|
+
sfora skills roots --json
|
|
479
|
+
sfora skills inventory --json
|
|
480
|
+
sfora skills roots remove <root-id>
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Registered roots and local identities survive restart. Removing a root only removes tracking. Missing or inaccessible skills retain last-known metadata and an unavailable state. `skills scan --json` retains its array result; `skills inventory --json` includes scan warnings. SQLite handles concurrent clients and transaction rollback. Do not edit or delete the catalogue to rebuild observations. Explicit cloud bindings and read-only transfer previews share this catalogue. Transfer execution and recovery remain separate mission increments.
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
### Explicit cloud bindings and transfer previews
|
|
487
|
+
|
|
488
|
+
```sh
|
|
489
|
+
sfora skills bind <location-id> <cloud-name> --project my-project
|
|
490
|
+
sfora skills bindings --json
|
|
491
|
+
sfora skills status <binding-id> --json
|
|
492
|
+
sfora skills plan <binding-id> push --json
|
|
493
|
+
sfora skills plan <binding-id> pull --json
|
|
494
|
+
sfora skills relocate <location-id> /new/skill-folder
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Take the location ID from `skills inventory`. Linking verifies the authenticated member, deployment, organization, project and cloud skill ID. Names are lookup hints. A same-name replacement cannot silently take over a binding. Relocation preserves identity after an explicitly chosen folder move; register the destination discovery root first.
|
|
498
|
+
|
|
499
|
+
Explicit adoption of equal, verified published and local content establishes a baseline. Differing content starts with an unknown baseline, or preserves an existing confirmed baseline. Status compares fresh local bytes and a pinned published cloud revision against that baseline. It distinguishes synchronized, local changed, remote changed, conflict, missing and unavailable content. An unavailable cloud request exits with an error and never reports synchronization.
|
|
500
|
+
|
|
501
|
+
Plans contain exact IDs, local and remote hashes, published version, draft revision and per-file byte/mode metadata. They perform no writes. Unknown baselines, conflicts, existing cloud drafts and unverified local ownership block automatic replacement. CLI previews verify ownership against both the installation receipt and the current filesystem object. A replaced link, file or locally edited copy is never treated as an unchanged managed directory. Reviewed pushes can be executed through the journal below. Recovery for local pull/install mutations and migration of the older transfer commands remain tracked by #470 and #472.
|
|
502
|
+
|
|
503
|
+
The first write upgrades catalogue schema 1 to 2 transactionally after making a private SQLite-consistent `.v1-<id>.sqlite` backup beside the catalogue. Read-only inspection can read schema 1 without upgrading it. Older clients fail closed after an upgrade; update the desktop app and CLI together. Keep the backup until the updated clients have been verified.
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
### Guarded pushes and recovery
|
|
507
|
+
|
|
508
|
+
```sh
|
|
509
|
+
sfora skills plan <binding-id> push --json > push-plan.json
|
|
510
|
+
sfora skills queue push-plan.json
|
|
511
|
+
sfora skills apply <operation-id>
|
|
512
|
+
sfora skills operations --json
|
|
513
|
+
sfora skills recover <operation-id>
|
|
514
|
+
sfora skills cancel <unstarted-operation-id>
|
|
515
|
+
sfora skills plan-install <cloud-name> --project my-project --skills-target /existing/parent
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
`apply push-plan.json` queues and starts a push in one command. A reviewed plan is rechecked against current local content, the explicit binding, published version and draft revision. The server must advertise identity-checked writes; the client refuses to apply against an older server. Existing cloud drafts and changed review tokens stop the operation.
|
|
519
|
+
|
|
520
|
+
The private `~/.sfora/skills/operations.sqlite` journal commits intent before requests and reserves the qualified cloud target across upload/publication. A live worker cannot be recovered by another process. Recover an interrupted worker's operation before starting another operation on its target. Lost responses produce a durable `needs-attention` outcome. Recovery checks whether the exact intended next draft or publication landed before retrying, and a separate confirmation receipt covers interruption during the local baseline commit. Completed operations are not republished. Cancelling is supported only before work starts; it does not pretend to undo remote effects.
|
|
521
|
+
|
|
522
|
+
`plan-install` produces a read-only preview for a new, empty destination and blocks occupied destinations. Pull and installation previews can be queued and applied through the same journal. Local updates preserve the previous directory in an operation-specific backup; recovery verifies ownership and staged content before continuing, and preserves external edits. Backups and staged snapshots remain available for inspection. An interruption between creating a destination and recording its owner requires inspection rather than guessing ownership. The older `push`, `pull` and `install` commands retain their existing behavior; use `plan`/`apply` for binding-aware update safety. Use `skills apply-batch ids.json` or `skills recover-batch ids.json` with a JSON array of operation IDs. Outcomes are retained per item and completed work is not replayed. Ctrl-C stops after the current item and cancels unstarted items. In the macOS app, Skills → Transfers reviews the same device-wide queue before running or recovering selected plans.
|
|
523
|
+
|
|
524
|
+
The desktop keeps a cached local skill inventory and watches registered/agent folders. Known file changes re-read only affected skills; new paths and periodic reconciliation repair missed events. Refresh requests a full reconciliation. Removed or inaccessible folders keep their stable identity and last known metadata. Watchers are hints, not authority for transfer planning, which always rechecks current files.
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
In the macOS app, **On this Mac → Push to Sfora / Update local copy** opens a project chooser and a **Review changes** dialog. Published cloud skills offer **Install on this Mac**, followed by the native destination picker and the same review. No CLI plan file is needed. Opening a review creates no binding or queued operation; confirmation rechecks current content and records the durable transfer. Matching unlinked copies can be explicitly linked. Different unlinked copies remain blocked rather than receiving an invented baseline.
|
|
528
|
+
|
|
529
|
+
A first publication continues into the existing project draft editor. After successful publication, the original local copy is linked only if its files still match the published version. A failed link does not turn a successful publication into a failed result. Transfers remains the history/recovery view, including work queued from the CLI.
|
|
@@ -17,7 +17,7 @@ export interface LocalMarkdownSnapshot {
|
|
|
17
17
|
export declare function readLocalMarkdown(path: string): Promise<LocalMarkdownSnapshot>;
|
|
18
18
|
export declare function saveLocalMarkdown(path: string, content: string, expectedRevision: string): Promise<LocalMarkdownSnapshot>;
|
|
19
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>;
|
|
20
|
+
export declare function atomicCreate(path: string, content: string | Uint8Array, mode?: number): Promise<string>;
|
|
21
21
|
export declare function saveNewLocalMarkdown(path: string, content: string): Promise<LocalMarkdownSnapshot>;
|
|
22
22
|
/** Watch the parent so atomic replacement by another editor remains observable. */
|
|
23
23
|
export declare function watchLocalMarkdown(path: string, onChange: () => void): FSWatcher;
|
package/dist/local-core/files.js
CHANGED
|
@@ -82,11 +82,11 @@ export async function saveLocalMarkdown(path, content, expectedRevision) {
|
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
/** Save As creates a new path only. Replacing an existing file requires its current revision. */
|
|
85
|
-
export async function atomicCreate(path, content) {
|
|
85
|
+
export async function atomicCreate(path, content, mode = 0o600) {
|
|
86
86
|
const destination = join(await realpath(dirname(resolve(path))), basename(path));
|
|
87
87
|
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
88
88
|
try {
|
|
89
|
-
await atomicWrite(temporary, content);
|
|
89
|
+
await atomicWrite(temporary, content, mode);
|
|
90
90
|
// Hard link publishes atomically and fails EEXIST instead of overwriting a raced save.
|
|
91
91
|
await link(temporary, destination);
|
|
92
92
|
}
|
|
@@ -2,3 +2,15 @@
|
|
|
2
2
|
* Renderers must obtain native dialog grants; never expose these as arbitrary IPC paths. */
|
|
3
3
|
export * from "./files.js";
|
|
4
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';
|
package/dist/local-core/index.js
CHANGED
|
@@ -2,3 +2,14 @@
|
|
|
2
2
|
* Renderers must obtain native dialog grants; never expose these as arbitrary IPC paths. */
|
|
3
3
|
export * from "./files.js";
|
|
4
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { pathWithin } from './skill-service.js';
|
|
4
|
+
/** Events are hints; periodic reconciliation repairs missed events and root changes. */
|
|
5
|
+
export class SkillIndex {
|
|
6
|
+
service;
|
|
7
|
+
changed;
|
|
8
|
+
reconciliationMs;
|
|
9
|
+
watchers = [];
|
|
10
|
+
timer;
|
|
11
|
+
interval;
|
|
12
|
+
pending = new Set();
|
|
13
|
+
full = false;
|
|
14
|
+
running = false;
|
|
15
|
+
active;
|
|
16
|
+
closed = false;
|
|
17
|
+
started;
|
|
18
|
+
warnings = [];
|
|
19
|
+
constructor(service, changed = () => { }, reconciliationMs = 60_000) {
|
|
20
|
+
this.service = service;
|
|
21
|
+
this.changed = changed;
|
|
22
|
+
this.reconciliationMs = reconciliationMs;
|
|
23
|
+
}
|
|
24
|
+
start() {
|
|
25
|
+
return this.started ??= (async () => {
|
|
26
|
+
this.full = true;
|
|
27
|
+
await this.flush();
|
|
28
|
+
if (!this.closed) {
|
|
29
|
+
this.interval = setInterval(() => this.invalidate(), this.reconciliationMs);
|
|
30
|
+
this.interval.unref();
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
}
|
|
34
|
+
async inventory() { await this.start(); return { ...await this.service.cachedInventory(), warnings: this.warnings }; }
|
|
35
|
+
invalidate(path) {
|
|
36
|
+
if (this.closed)
|
|
37
|
+
return;
|
|
38
|
+
const storePath = 'path' in this.service.store ? String(this.service.store.path) : undefined;
|
|
39
|
+
if (path && storePath && (path === storePath || path.startsWith(`${storePath}-`)))
|
|
40
|
+
return;
|
|
41
|
+
if (path && this.pending.size < 1000)
|
|
42
|
+
this.pending.add(path);
|
|
43
|
+
else
|
|
44
|
+
this.full = true;
|
|
45
|
+
if (!this.timer)
|
|
46
|
+
this.timer = setTimeout(() => { this.timer = undefined; void this.flush(); }, 150);
|
|
47
|
+
}
|
|
48
|
+
async configure() {
|
|
49
|
+
for (const watcher of this.watchers)
|
|
50
|
+
watcher.close();
|
|
51
|
+
this.watchers = [];
|
|
52
|
+
const sources = await this.service.discoverySources();
|
|
53
|
+
for (const source of sources.slice(0, 128)) {
|
|
54
|
+
try {
|
|
55
|
+
const watcher = watch(source.path, { recursive: true, persistent: false }, (_event, name) => this.invalidate(name ? resolve(source.path, String(name)) : undefined));
|
|
56
|
+
watcher.on('error', () => this.invalidate());
|
|
57
|
+
this.watchers.push(watcher);
|
|
58
|
+
}
|
|
59
|
+
catch { /* Missing roots are retried by reconciliation, never deleted from the catalog. */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async refresh() {
|
|
63
|
+
await this.start();
|
|
64
|
+
if (this.active)
|
|
65
|
+
await this.active;
|
|
66
|
+
this.full = true;
|
|
67
|
+
await this.flush();
|
|
68
|
+
return { ...await this.service.cachedInventory(), warnings: this.warnings };
|
|
69
|
+
}
|
|
70
|
+
flush() {
|
|
71
|
+
if (this.active)
|
|
72
|
+
return this.active;
|
|
73
|
+
this.active = this.perform().finally(() => { this.active = undefined; });
|
|
74
|
+
return this.active;
|
|
75
|
+
}
|
|
76
|
+
async perform() {
|
|
77
|
+
if (this.running || this.closed)
|
|
78
|
+
return;
|
|
79
|
+
this.running = true;
|
|
80
|
+
const full = this.full;
|
|
81
|
+
this.full = false;
|
|
82
|
+
const paths = [...this.pending];
|
|
83
|
+
this.pending.clear();
|
|
84
|
+
let failed = false;
|
|
85
|
+
try {
|
|
86
|
+
const catalog = await this.service.store.read();
|
|
87
|
+
const ids = catalog.locations.filter(l => paths.some(p => pathWithin(l.path, p))).map(l => l.id);
|
|
88
|
+
const unknown = paths.some(p => !catalog.locations.some(l => pathWithin(l.path, p)));
|
|
89
|
+
if (full || unknown) {
|
|
90
|
+
const result = await this.service.inventory();
|
|
91
|
+
this.warnings = result.warnings;
|
|
92
|
+
await this.configure();
|
|
93
|
+
}
|
|
94
|
+
else if (ids.length)
|
|
95
|
+
await this.service.refreshLocations(ids);
|
|
96
|
+
if (!this.closed)
|
|
97
|
+
this.changed();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
failed = true;
|
|
101
|
+
this.warnings = [error.message];
|
|
102
|
+
this.full = true;
|
|
103
|
+
if (!this.closed)
|
|
104
|
+
this.changed();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
this.running = false;
|
|
108
|
+
}
|
|
109
|
+
// Do not drop events received during inspection, including external edits during our own writes.
|
|
110
|
+
if ((this.pending.size || (this.full && !failed)) && !this.closed && !this.timer)
|
|
111
|
+
this.timer = setTimeout(() => { this.timer = undefined; void this.flush(); }, 150);
|
|
112
|
+
}
|
|
113
|
+
close() { this.closed = true; clearTimeout(this.timer); clearInterval(this.interval); for (const watcher of this.watchers)
|
|
114
|
+
watcher.close(); this.watchers = []; }
|
|
115
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type PublishedSkillSnapshot } from './skill-sync.js';
|
|
2
|
+
import { SkillOperationJournal, type SkillTransferOperation } from './skill-operations.js';
|
|
3
|
+
import type { SkillStore } from './skill-store.js';
|
|
4
|
+
export interface SkillLocalPorts {
|
|
5
|
+
snapshot(project: string, name: string): Promise<PublishedSkillSnapshot>;
|
|
6
|
+
/** Fault/telemetry seam, after durable steps; production never infers success from it. */
|
|
7
|
+
checkpoint?: (phase: string) => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/** Recoverable local installation. Old bytes remain in an operation-specific backup.
|
|
10
|
+
* Destination creation and every file publication are exclusive: no foreign file is overwritten. */
|
|
11
|
+
export declare class SkillLocalExecutor {
|
|
12
|
+
readonly store: SkillStore;
|
|
13
|
+
readonly journal: SkillOperationJournal;
|
|
14
|
+
readonly cloud: SkillLocalPorts;
|
|
15
|
+
constructor(store: SkillStore, journal: SkillOperationJournal, cloud: SkillLocalPorts);
|
|
16
|
+
apply(id: string, recover?: boolean): Promise<SkillTransferOperation>;
|
|
17
|
+
private run;
|
|
18
|
+
}
|