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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { SkillCloudBinding, SkillRevision } from './skill-domain.js';
|
|
2
|
+
import type { SkillStore } from './skill-store.js';
|
|
3
|
+
import { type SkillBundle, type inspectSkillTarget } from './skills.js';
|
|
4
|
+
export interface SkillCloudScope {
|
|
5
|
+
deployment: string;
|
|
6
|
+
accountId: string;
|
|
7
|
+
organizationId: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PublishedSkillSnapshot {
|
|
10
|
+
remote: SkillCloudBinding['remote'];
|
|
11
|
+
accountId: string;
|
|
12
|
+
locator: SkillCloudBinding['locator'];
|
|
13
|
+
revision: SkillRevision;
|
|
14
|
+
draftRevision: number;
|
|
15
|
+
hasDraft: boolean;
|
|
16
|
+
draftHash?: string;
|
|
17
|
+
supportsIdentityGuards?: boolean;
|
|
18
|
+
bundle: SkillBundle;
|
|
19
|
+
}
|
|
20
|
+
export type SkillTransferPlan = ReturnType<typeof planSkillTransfer>;
|
|
21
|
+
/** A new installation needs an explicitly selected empty destination, not a guessed name match. */
|
|
22
|
+
export declare function planSkillInstallation(remote: PublishedSkillSnapshot, destination: string, target: Awaited<ReturnType<typeof inspectSkillTarget>>): {
|
|
23
|
+
schemaVersion: 1;
|
|
24
|
+
kind: "skill-install-preview";
|
|
25
|
+
source: {
|
|
26
|
+
accountId: string;
|
|
27
|
+
deployment: string;
|
|
28
|
+
organizationId: string;
|
|
29
|
+
skillId: string;
|
|
30
|
+
projectId: string;
|
|
31
|
+
};
|
|
32
|
+
locator: {
|
|
33
|
+
project: string;
|
|
34
|
+
name: string;
|
|
35
|
+
};
|
|
36
|
+
destination: string;
|
|
37
|
+
expected: {
|
|
38
|
+
remote: {
|
|
39
|
+
hash: string;
|
|
40
|
+
contentPolicy: "bundle-v1";
|
|
41
|
+
cloudVersion?: number;
|
|
42
|
+
};
|
|
43
|
+
targetState: "file" | "link" | "unavailable" | "managed" | "unmanaged" | "modified" | "missing";
|
|
44
|
+
localHash: string | null;
|
|
45
|
+
};
|
|
46
|
+
ownership: "unknown" | "managed" | "unmanaged";
|
|
47
|
+
outcome: string;
|
|
48
|
+
blockers: string[];
|
|
49
|
+
files: {
|
|
50
|
+
path: string;
|
|
51
|
+
kind: "added";
|
|
52
|
+
after: {
|
|
53
|
+
hash: string;
|
|
54
|
+
size: number;
|
|
55
|
+
executable: boolean;
|
|
56
|
+
};
|
|
57
|
+
}[];
|
|
58
|
+
};
|
|
59
|
+
export type SkillContentState = {
|
|
60
|
+
state: 'available';
|
|
61
|
+
bundle: SkillBundle;
|
|
62
|
+
} | {
|
|
63
|
+
state: 'missing';
|
|
64
|
+
reason: string;
|
|
65
|
+
} | {
|
|
66
|
+
state: 'unavailable';
|
|
67
|
+
reason: string;
|
|
68
|
+
};
|
|
69
|
+
export type SkillSyncStatus = 'local-only' | 'synced' | 'local-changed' | 'remote-changed' | 'conflict' | 'missing' | 'unknown' | 'unavailable';
|
|
70
|
+
export declare function assertSkillBindingScope(binding: SkillCloudBinding, scope: SkillCloudScope): void;
|
|
71
|
+
export declare function validatePublishedSkillSnapshot(snapshot: PublishedSkillSnapshot): void;
|
|
72
|
+
export declare function assertSkillBindingSnapshot(binding: SkillCloudBinding, snapshot: PublishedSkillSnapshot): void;
|
|
73
|
+
/** Adoption is explicit. Matching names, install receipts and stale observations cannot establish a baseline. */
|
|
74
|
+
export declare class SkillBindingService {
|
|
75
|
+
readonly store: SkillStore;
|
|
76
|
+
constructor(store: SkillStore);
|
|
77
|
+
list(): Promise<SkillCloudBinding[]>;
|
|
78
|
+
adopt(localSkillId: string, snapshot: PublishedSkillSnapshot, verifiedLocal: SkillBundle): Promise<SkillCloudBinding>;
|
|
79
|
+
}
|
|
80
|
+
/** Pure three-way comparison. Unavailable input is never interpreted as deletion or synchronization. */
|
|
81
|
+
export declare function compareSkillStates(local: SkillContentState, remote: SkillContentState | undefined, baseline?: SkillRevision): SkillSyncStatus;
|
|
82
|
+
/** Review artifact only. A future executor must revalidate every token and record a durable operation. */
|
|
83
|
+
export declare function planSkillTransfer(input: {
|
|
84
|
+
direction: 'push' | 'pull';
|
|
85
|
+
binding: SkillCloudBinding;
|
|
86
|
+
location: {
|
|
87
|
+
id: string;
|
|
88
|
+
path: string;
|
|
89
|
+
skillId: string;
|
|
90
|
+
};
|
|
91
|
+
local: SkillContentState;
|
|
92
|
+
remote: PublishedSkillSnapshot;
|
|
93
|
+
ownership: 'managed' | 'unmanaged' | 'unknown';
|
|
94
|
+
}): {
|
|
95
|
+
schemaVersion: 1;
|
|
96
|
+
kind: "skill-transfer-preview";
|
|
97
|
+
direction: "push" | "pull";
|
|
98
|
+
status: SkillSyncStatus;
|
|
99
|
+
outcome: string;
|
|
100
|
+
blockers: string[];
|
|
101
|
+
binding: SkillCloudBinding;
|
|
102
|
+
location: {
|
|
103
|
+
id: string;
|
|
104
|
+
path: string;
|
|
105
|
+
skillId: string;
|
|
106
|
+
};
|
|
107
|
+
ownership: "unknown" | "managed" | "unmanaged";
|
|
108
|
+
expected: {
|
|
109
|
+
localHash: string | null;
|
|
110
|
+
remote: {
|
|
111
|
+
hash: string;
|
|
112
|
+
contentPolicy: "bundle-v1";
|
|
113
|
+
cloudVersion?: number;
|
|
114
|
+
};
|
|
115
|
+
draftRevision: number;
|
|
116
|
+
};
|
|
117
|
+
files: {
|
|
118
|
+
before: {
|
|
119
|
+
hash: string;
|
|
120
|
+
size: number;
|
|
121
|
+
executable: boolean;
|
|
122
|
+
} | null;
|
|
123
|
+
after: {
|
|
124
|
+
hash: string;
|
|
125
|
+
size: number;
|
|
126
|
+
executable: boolean;
|
|
127
|
+
} | null;
|
|
128
|
+
path: string;
|
|
129
|
+
kind: "added" | "removed" | "modified";
|
|
130
|
+
}[];
|
|
131
|
+
};
|
|
132
|
+
export type SkillInstallPlan = ReturnType<typeof planSkillInstallation>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { diffSkillBundles, validateSkillBundle } from './skills.js';
|
|
4
|
+
/** A new installation needs an explicitly selected empty destination, not a guessed name match. */
|
|
5
|
+
export function planSkillInstallation(remote, destination, target) {
|
|
6
|
+
validatePublishedSkillSnapshot(remote);
|
|
7
|
+
if (!isAbsolute(destination))
|
|
8
|
+
throw new Error('Installation destination must be absolute.');
|
|
9
|
+
const blockers = target.state === 'missing' ? [] : ['Destination is occupied or unavailable. Choose a new folder, or explicitly bind and review an update.'];
|
|
10
|
+
return { schemaVersion: 1, kind: 'skill-install-preview', source: { ...remote.remote, accountId: remote.accountId }, locator: { ...remote.locator }, destination,
|
|
11
|
+
expected: { remote: { ...remote.revision }, targetState: target.state, localHash: target.hash ?? null }, ownership: target.ownership,
|
|
12
|
+
outcome: blockers.length ? 'blocked' : 'reviewable', blockers,
|
|
13
|
+
files: remote.bundle.files.map(f => ({ path: f.path, kind: 'added', after: { hash: f.sha256, size: f.size, executable: f.executable } })) };
|
|
14
|
+
}
|
|
15
|
+
export function assertSkillBindingScope(binding, scope) {
|
|
16
|
+
if (binding.accountId !== scope.accountId || binding.remote.deployment !== scope.deployment || binding.remote.organizationId !== scope.organizationId) {
|
|
17
|
+
throw new Error('Skill binding belongs to a different account, organization or deployment. Explicitly link the intended destination.');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function validatePublishedSkillSnapshot(snapshot) {
|
|
21
|
+
validateSkillBundle(snapshot.bundle);
|
|
22
|
+
if (snapshot.revision.contentPolicy !== 'bundle-v1' || snapshot.revision.hash !== snapshot.bundle.hash
|
|
23
|
+
|| !Number.isSafeInteger(snapshot.revision.cloudVersion) || Number(snapshot.revision.cloudVersion) < 1
|
|
24
|
+
|| !Number.isSafeInteger(snapshot.draftRevision) || snapshot.draftRevision < 0 || typeof snapshot.hasDraft !== 'boolean') {
|
|
25
|
+
throw new Error('Published skill revision does not match its verified bundle.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function assertSkillBindingSnapshot(binding, snapshot) {
|
|
29
|
+
validatePublishedSkillSnapshot(snapshot);
|
|
30
|
+
assertSkillBindingScope(binding, { ...snapshot.remote, accountId: snapshot.accountId });
|
|
31
|
+
if (binding.remote.skillId !== snapshot.remote.skillId || binding.remote.projectId !== snapshot.remote.projectId) {
|
|
32
|
+
throw new Error('Cloud lookup resolved to a different skill identity. No binding was changed.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Adoption is explicit. Matching names, install receipts and stale observations cannot establish a baseline. */
|
|
36
|
+
export class SkillBindingService {
|
|
37
|
+
store;
|
|
38
|
+
constructor(store) {
|
|
39
|
+
this.store = store;
|
|
40
|
+
}
|
|
41
|
+
async list() { return (await this.store.read()).bindings; }
|
|
42
|
+
async adopt(localSkillId, snapshot, verifiedLocal) {
|
|
43
|
+
validatePublishedSkillSnapshot(snapshot);
|
|
44
|
+
validateSkillBundle(verifiedLocal);
|
|
45
|
+
return this.store.transaction(c => {
|
|
46
|
+
if (!c.identities.some(i => i.id === localSkillId))
|
|
47
|
+
throw new Error('Local skill identity not found. Refresh the inventory first.');
|
|
48
|
+
const existing = c.bindings.find(b => b.localSkillId === localSkillId && b.accountId === snapshot.accountId
|
|
49
|
+
&& b.remote.deployment === snapshot.remote.deployment && b.remote.organizationId === snapshot.remote.organizationId && b.remote.projectId === snapshot.remote.projectId);
|
|
50
|
+
if (existing)
|
|
51
|
+
assertSkillBindingSnapshot(existing, snapshot);
|
|
52
|
+
const binding = existing ?? { id: randomUUID(), localSkillId, remote: { ...snapshot.remote }, accountId: snapshot.accountId, locator: { ...snapshot.locator } };
|
|
53
|
+
binding.locator = { ...snapshot.locator };
|
|
54
|
+
// Differing bytes retain the last confirmed baseline, or remain unknown.
|
|
55
|
+
if (verifiedLocal.hash === snapshot.revision.hash)
|
|
56
|
+
binding.baseline = { ...snapshot.revision };
|
|
57
|
+
if (!existing)
|
|
58
|
+
c.bindings.push(binding);
|
|
59
|
+
return structuredClone(binding);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Pure three-way comparison. Unavailable input is never interpreted as deletion or synchronization. */
|
|
64
|
+
export function compareSkillStates(local, remote, baseline) {
|
|
65
|
+
if (local.state === 'available')
|
|
66
|
+
validateSkillBundle(local.bundle);
|
|
67
|
+
if (remote?.state === 'available')
|
|
68
|
+
validateSkillBundle(remote.bundle);
|
|
69
|
+
if (local.state === 'unavailable' || remote?.state === 'unavailable')
|
|
70
|
+
return 'unavailable';
|
|
71
|
+
if (local.state === 'missing' || remote?.state === 'missing')
|
|
72
|
+
return 'missing';
|
|
73
|
+
if (!remote)
|
|
74
|
+
return 'local-only';
|
|
75
|
+
if (!baseline || baseline.contentPolicy !== 'bundle-v1' || !/^[a-f0-9]{64}$/.test(baseline.hash))
|
|
76
|
+
return 'unknown';
|
|
77
|
+
if (local.bundle.hash === remote.bundle.hash)
|
|
78
|
+
return 'synced';
|
|
79
|
+
if (local.bundle.hash === baseline.hash)
|
|
80
|
+
return 'remote-changed';
|
|
81
|
+
if (remote.bundle.hash === baseline.hash)
|
|
82
|
+
return 'local-changed';
|
|
83
|
+
return 'conflict';
|
|
84
|
+
}
|
|
85
|
+
/** Review artifact only. A future executor must revalidate every token and record a durable operation. */
|
|
86
|
+
export function planSkillTransfer(input) {
|
|
87
|
+
const { binding, local, remote, direction, location, ownership } = input;
|
|
88
|
+
assertSkillBindingSnapshot(binding, remote);
|
|
89
|
+
if (location.skillId !== binding.localSkillId)
|
|
90
|
+
throw new Error('Location does not belong to the bound local skill.');
|
|
91
|
+
const status = compareSkillStates(local, { state: 'available', bundle: remote.bundle }, binding.baseline);
|
|
92
|
+
const blockers = [];
|
|
93
|
+
if (!['synced', direction === 'push' ? 'local-changed' : 'remote-changed'].includes(status))
|
|
94
|
+
blockers.push(`Cannot ${direction} automatically from ${status}; review the divergence first.`);
|
|
95
|
+
if (status !== 'synced' && direction === 'pull' && ownership !== 'managed')
|
|
96
|
+
blockers.push('Replacing local files requires verified Sfora ownership or an explicitly chosen new destination.');
|
|
97
|
+
if (status !== 'synced' && direction === 'push' && remote.hasDraft)
|
|
98
|
+
blockers.push('The cloud contains an unpublished draft; review it before replacing it.');
|
|
99
|
+
const before = direction === 'push' ? remote.bundle : local.state === 'available' ? local.bundle : undefined;
|
|
100
|
+
const after = direction === 'pull' ? remote.bundle : local.state === 'available' ? local.bundle : undefined;
|
|
101
|
+
const files = before && after ? diffSkillBundles(before, after).map(change => {
|
|
102
|
+
const metadata = (bundle) => { const f = bundle.files.find(f => f.path === change.path); return f ? { hash: f.sha256, size: f.size, executable: f.executable } : null; };
|
|
103
|
+
return { ...change, before: metadata(before), after: metadata(after) };
|
|
104
|
+
}) : [];
|
|
105
|
+
return {
|
|
106
|
+
schemaVersion: 1, kind: 'skill-transfer-preview', direction, status,
|
|
107
|
+
outcome: blockers.length ? 'blocked' : status === 'synced' ? 'no-op' : 'reviewable', blockers,
|
|
108
|
+
binding: structuredClone(binding), location: { ...location }, ownership,
|
|
109
|
+
expected: { localHash: local.state === 'available' ? local.bundle.hash : null, remote: { ...remote.revision }, draftRevision: remote.draftRevision }, files,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -34,6 +34,7 @@ export declare function validateSkillPath(path: string): void;
|
|
|
34
34
|
export declare function skillBundleHash(files: SkillFile[]): string;
|
|
35
35
|
export declare function validateSkillBundle(bundle: SkillBundle): void;
|
|
36
36
|
export declare function readSkillBundle(directory: string, name?: string): Promise<SkillBundle>;
|
|
37
|
+
/** Legacy array API; the shared discovery engine owns all traversal. */
|
|
37
38
|
export declare function scanLocalSkills(roots?: string[]): Promise<Array<{
|
|
38
39
|
path: string;
|
|
39
40
|
name: string;
|
|
@@ -41,5 +42,14 @@ export declare function scanLocalSkills(roots?: string[]): Promise<Array<{
|
|
|
41
42
|
error?: string;
|
|
42
43
|
}>>;
|
|
43
44
|
export declare function diffSkillBundles(local: SkillBundle, incoming: SkillBundle): SkillDifference[];
|
|
45
|
+
export declare function readInstallation(destination: string): Promise<SkillInstallation | undefined>;
|
|
46
|
+
/** A receipt is historical intent. Classify today's filesystem object before trusting it. */
|
|
47
|
+
export declare function inspectSkillTarget(directory: string): Promise<{
|
|
48
|
+
state: 'missing' | 'link' | 'file' | 'managed' | 'modified' | 'unmanaged' | 'unavailable';
|
|
49
|
+
ownership: 'managed' | 'unmanaged' | 'unknown';
|
|
50
|
+
hash?: string;
|
|
51
|
+
receipt?: SkillInstallation;
|
|
52
|
+
reason?: string;
|
|
53
|
+
}>;
|
|
44
54
|
export declare function installSkillBundle(bundle: SkillBundle, targetRoot: string, source?: string, sourceVersion?: number): Promise<SkillInstallation>;
|
|
45
55
|
export declare function uninstallSkill(directory: string): Promise<void>;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { chmod, lstat, mkdir, open, readdir,
|
|
2
|
+
import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
4
|
import { randomUUID } from "node:crypto";
|
|
6
5
|
import { atomicWrite, LocalConflictError, sha256, withLocalLock } from "./files.js";
|
|
7
6
|
export const SKILL_LIMITS = { files: 1000, fileBytes: 5 * 1024 * 1024, totalBytes: 20 * 1024 * 1024 };
|
|
@@ -99,38 +98,13 @@ export async function readSkillBundle(directory, name = basename(resolve(directo
|
|
|
99
98
|
validateSkillBundle(bundle);
|
|
100
99
|
return bundle;
|
|
101
100
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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;
|
|
101
|
+
/** Legacy array API; the shared discovery engine owns all traversal. */
|
|
102
|
+
export async function scanLocalSkills(roots) {
|
|
103
|
+
const { discoverSkills } = await import("./skill-discovery.js");
|
|
104
|
+
const result = await discoverSkills(roots?.map(path => ({ path, label: "Chosen folder", depth: 4 })));
|
|
105
|
+
if (result.warnings.length)
|
|
106
|
+
throw new Error(result.warnings.join("\n"));
|
|
107
|
+
return result.skills;
|
|
134
108
|
}
|
|
135
109
|
export function diffSkillBundles(local, incoming) {
|
|
136
110
|
validateSkillBundle(local);
|
|
@@ -147,10 +121,24 @@ export function diffSkillBundles(local, incoming) {
|
|
|
147
121
|
});
|
|
148
122
|
}
|
|
149
123
|
function recordPath(destination) { return join(dirname(destination), `.${basename(destination)}.sfora-install.json`); }
|
|
150
|
-
async function readInstallation(destination) {
|
|
124
|
+
export async function readInstallation(destination) {
|
|
151
125
|
try {
|
|
152
|
-
const
|
|
153
|
-
|
|
126
|
+
const handle = await open(recordPath(destination), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
127
|
+
let raw;
|
|
128
|
+
try {
|
|
129
|
+
const info = await handle.stat();
|
|
130
|
+
if (!info.isFile() || info.size > 65536)
|
|
131
|
+
throw new Error('Invalid installation record file.');
|
|
132
|
+
raw = await handle.readFile('utf8');
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await handle.close();
|
|
136
|
+
}
|
|
137
|
+
const record = JSON.parse(raw);
|
|
138
|
+
if (record.schemaVersion !== 1 || record.path !== destination || record.name !== basename(destination)
|
|
139
|
+
|| !/^[a-f0-9]{64}$/.test(record.hash) || !Number.isSafeInteger(record.installedAt) || record.installedAt < 0
|
|
140
|
+
|| (record.source !== undefined && (typeof record.source !== 'string' || record.source.length > 8192))
|
|
141
|
+
|| (record.sourceVersion !== undefined && (!Number.isSafeInteger(record.sourceVersion) || record.sourceVersion < 1)))
|
|
154
142
|
throw new Error("Unsupported or mismatched installation record.");
|
|
155
143
|
return record;
|
|
156
144
|
}
|
|
@@ -160,6 +148,30 @@ async function readInstallation(destination) {
|
|
|
160
148
|
throw e;
|
|
161
149
|
}
|
|
162
150
|
}
|
|
151
|
+
/** A receipt is historical intent. Classify today's filesystem object before trusting it. */
|
|
152
|
+
export async function inspectSkillTarget(directory) {
|
|
153
|
+
try {
|
|
154
|
+
const destination = join(await realpath(dirname(resolve(directory))), basename(directory));
|
|
155
|
+
const info = await lstat(destination).catch(e => { if (e.code === 'ENOENT')
|
|
156
|
+
return null; throw e; });
|
|
157
|
+
if (!info)
|
|
158
|
+
return { state: 'missing', ownership: 'unknown' };
|
|
159
|
+
if (info.isSymbolicLink())
|
|
160
|
+
return { state: 'link', ownership: 'unmanaged', reason: 'A link cannot be replaced using a copy installation receipt.' };
|
|
161
|
+
if (!info.isDirectory())
|
|
162
|
+
return { state: 'file', ownership: 'unmanaged', reason: 'The target is no longer a skill directory.' };
|
|
163
|
+
const bundle = await readSkillBundle(destination);
|
|
164
|
+
const receipt = await readInstallation(destination);
|
|
165
|
+
if (!receipt)
|
|
166
|
+
return { state: 'unmanaged', ownership: 'unmanaged', hash: bundle.hash };
|
|
167
|
+
if (receipt.hash !== bundle.hash)
|
|
168
|
+
return { state: 'modified', ownership: 'unmanaged', hash: bundle.hash, receipt, reason: 'The managed copy has local changes.' };
|
|
169
|
+
return { state: 'managed', ownership: 'managed', hash: bundle.hash, receipt };
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
return { state: 'unavailable', ownership: 'unknown', reason: error.message };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
163
175
|
export async function installSkillBundle(bundle, targetRoot, source, sourceVersion) {
|
|
164
176
|
validateSkillBundle(bundle);
|
|
165
177
|
if (sourceVersion !== undefined && (!Number.isSafeInteger(sourceVersion) || sourceVersion < 1))
|
package/dist/skills-client.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { type SkillBundle } from "./local-core/skills.js";
|
|
2
2
|
import type { ResolvedSettings } from "./config.js";
|
|
3
|
+
import { type PublishedSkillSnapshot } from "./local-core/skill-sync.js";
|
|
4
|
+
export declare class SkillsApiError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
constructor(status: number, body: string);
|
|
7
|
+
}
|
|
8
|
+
export declare class SkillPublicationUnavailableError extends Error {
|
|
9
|
+
readonly code: 'SKILL_NOT_FOUND' | 'SKILL_NOT_PUBLISHED';
|
|
10
|
+
constructor(code: 'SKILL_NOT_FOUND' | 'SKILL_NOT_PUBLISHED', message: string);
|
|
11
|
+
}
|
|
3
12
|
export declare class SkillsClient {
|
|
4
13
|
private readonly settings;
|
|
5
14
|
constructor(settings: ResolvedSettings);
|
|
@@ -13,9 +22,11 @@ export declare class SkillsClient {
|
|
|
13
22
|
[key: string]: unknown;
|
|
14
23
|
}>;
|
|
15
24
|
download(project: string, name: string, version?: number): Promise<SkillBundle>;
|
|
16
|
-
|
|
25
|
+
/** Pin a published revision and verify IDs/hash; lookup labels confer no update authority. */
|
|
26
|
+
snapshot(project: string, name: string): Promise<PublishedSkillSnapshot>;
|
|
27
|
+
saveDraft(project: string, bundle: SkillBundle, expectedVersion: number, expectedRevision?: number, expectedSkillId?: string): Promise<{
|
|
17
28
|
draftRevision: number;
|
|
18
29
|
[key: string]: unknown;
|
|
19
30
|
}>;
|
|
20
|
-
publish(project: string, name: string, expectedVersion: number, expectedRevision?: number): Promise<unknown>;
|
|
31
|
+
publish(project: string, name: string, expectedVersion: number, expectedRevision?: number, expectedSkillId?: string): Promise<unknown>;
|
|
21
32
|
}
|
package/dist/skills-client.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import { validateSkillBundle } from "./local-core/skills.js";
|
|
2
|
+
import { SforaApiClient } from "./api-client.js";
|
|
3
|
+
import { validatePublishedSkillSnapshot } from "./local-core/skill-sync.js";
|
|
4
|
+
export class SkillsApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(status, body) {
|
|
7
|
+
super(`Skills API ${status}: ${body}`);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class SkillPublicationUnavailableError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
2
18
|
export class SkillsClient {
|
|
3
19
|
settings;
|
|
4
20
|
constructor(settings) {
|
|
@@ -11,7 +27,7 @@ export class SkillsClient {
|
|
|
11
27
|
...init, headers: { Authorization: `Bearer ${this.settings.apiKey}`, "Content-Type": "application/json", "X-Sfora-Client": "cli", ...init?.headers },
|
|
12
28
|
});
|
|
13
29
|
if (!response.ok)
|
|
14
|
-
throw new
|
|
30
|
+
throw new SkillsApiError(response.status, await response.text());
|
|
15
31
|
return await response.json();
|
|
16
32
|
}
|
|
17
33
|
list(project) { return this.request(`/v1/skills?${new URLSearchParams({ project })}`); }
|
|
@@ -26,11 +42,47 @@ export class SkillsClient {
|
|
|
26
42
|
validateSkillBundle(bundle);
|
|
27
43
|
return bundle;
|
|
28
44
|
}
|
|
29
|
-
|
|
45
|
+
/** Pin a published revision and verify IDs/hash; lookup labels confer no update authority. */
|
|
46
|
+
async snapshot(project, name) {
|
|
47
|
+
if (!this.settings.apiKey)
|
|
48
|
+
throw new Error("Run sfora login to connect to cloud Skills.");
|
|
49
|
+
const me = await new SforaApiClient({ baseUrl: this.settings.url, apiKey: this.settings.apiKey }).readMe();
|
|
50
|
+
const accountId = /^memberId: ([a-zA-Z0-9_-]+)$/m.exec(me)?.[1];
|
|
51
|
+
if (!accountId)
|
|
52
|
+
throw new Error('Cloud identity response is missing a member ID.');
|
|
53
|
+
let detail;
|
|
54
|
+
try {
|
|
55
|
+
detail = await this.detail(project, name);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof SkillsApiError && error.status === 404)
|
|
59
|
+
throw new SkillPublicationUnavailableError('SKILL_NOT_FOUND', 'This project has no published skill with that name.');
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
const skill = detail.skill;
|
|
63
|
+
if (![skill._id, skill.orgId, skill.projectId].every(v => typeof v === 'string' && v.length > 0))
|
|
64
|
+
throw new Error('Cloud skill response is missing qualified identity fields.');
|
|
65
|
+
if (!Number.isSafeInteger(skill.version) || skill.version < 1)
|
|
66
|
+
throw new SkillPublicationUnavailableError('SKILL_NOT_PUBLISHED', 'This skill has no published version to compare.');
|
|
67
|
+
const versions = detail.versions;
|
|
68
|
+
const revision = Array.isArray(versions) ? versions.find(v => v.version === skill.version && v.skillId === skill._id) : undefined;
|
|
69
|
+
if (!revision || typeof revision.hash !== 'string')
|
|
70
|
+
throw new Error('Cloud skill response is missing the published revision.');
|
|
71
|
+
const bundle = await this.download(project, name, skill.version);
|
|
72
|
+
const snapshot = {
|
|
73
|
+
remote: { deployment: new URL(this.settings.url).origin, organizationId: skill.orgId, projectId: skill.projectId, skillId: skill._id },
|
|
74
|
+
accountId, locator: { project, name }, revision: { hash: revision.hash, cloudVersion: skill.version, contentPolicy: 'bundle-v1' },
|
|
75
|
+
draftRevision: skill.draftRevision, hasDraft: !!skill.draftStorageId, draftHash: skill.draftHash,
|
|
76
|
+
supportsIdentityGuards: Array.isArray(detail.capabilities) && detail.capabilities.includes('qualified-skill-write-v1'), bundle,
|
|
77
|
+
};
|
|
78
|
+
validatePublishedSkillSnapshot(snapshot);
|
|
79
|
+
return snapshot;
|
|
80
|
+
}
|
|
81
|
+
saveDraft(project, bundle, expectedVersion, expectedRevision, expectedSkillId) {
|
|
30
82
|
validateSkillBundle(bundle);
|
|
31
|
-
return this.request("/v1/skills", { method: "POST", body: JSON.stringify({ project, name: bundle.name, bundle, expectedVersion, expectedRevision }) });
|
|
83
|
+
return this.request("/v1/skills", { method: "POST", body: JSON.stringify({ project, name: bundle.name, bundle, expectedVersion, expectedRevision, expectedSkillId }) });
|
|
32
84
|
}
|
|
33
|
-
publish(project, name, expectedVersion, expectedRevision) {
|
|
34
|
-
return this.request("/v1/skills/publish", { method: "POST", body: JSON.stringify({ project, name, expectedVersion, expectedRevision }) });
|
|
85
|
+
publish(project, name, expectedVersion, expectedRevision, expectedSkillId) {
|
|
86
|
+
return this.request("/v1/skills/publish", { method: "POST", body: JSON.stringify({ project, name, expectedVersion, expectedRevision, expectedSkillId }) });
|
|
35
87
|
}
|
|
36
88
|
}
|
package/dist/skills-command.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { CliArgs } from "./cli-args.js";
|
|
2
2
|
import type { ResolvedSettings } from "./config.js";
|
|
3
|
-
export declare const SKILLS_HELP = "Skills:\n sfora skills scan [root ...] --json Discover complete local skill bundles\n sfora skills list --project <slug> List cloud project skills\n sfora skills push <folder> --project <slug> --expected-version <N> [--expected-revision <N>]\n Upload draft and publish (0 for a new skill)\n sfora skills pull <name> <bundle.json> --project <slug> [--version <N>]\n sfora skills install <name> --project <slug> --skills-target <directory> [--version <N>]\n sfora skills install-file <bundle.json> --skills-target <directory>\n sfora skills diff <folder> <name> --project <slug> [--version <N>]\n sfora skills uninstall <installed-folder> Remove only an unchanged Sfora-owned install\n";
|
|
3
|
+
export declare const SKILLS_HELP = "Skills:\n sfora skills inventory --json Persistent local inventory with warnings and identities\n sfora skills roots [add <folder> [label] | add-project <folder> [label] | remove <id>]\n Manage registered discovery folders (never deletes files)\n sfora skills scan [root ...] --json Discover complete local skill bundles\n sfora skills list --project <slug> List cloud project skills\n sfora skills index --json Read cached local inventory without scanning\n sfora skills operations List durable transfer outcomes and recovery needs\n sfora skills queue <plan.json> Queue a reviewed push, pull or install\n sfora skills cancel <operation-id> Cancel an unstarted operation\n sfora skills apply <plan.json|operation-id> Execute a reviewed plan\n sfora skills recover <operation-id> Reconcile an interrupted operation\n sfora skills apply-batch <ids.json> Run queued operation IDs with per-item outcomes\n sfora skills recover-batch <ids.json> Resume incomplete items without replaying successes\n sfora skills bindings List explicit cloud mappings and baselines\n sfora skills bind <location-id> <cloud-name> --project <slug>\n Explicitly adopt a published cloud identity\n sfora skills status <binding-id> Compare current files with the bound published revision\n sfora skills plan <binding-id> <push|pull> Preview IDs, hashes, conflicts and per-file changes\n sfora skills plan-install <name> --project <slug> --skills-target <existing-directory>\n Preview a new installation without modifying files\n sfora skills relocate <location-id> <folder> Preserve identity after an explicit folder move\n sfora skills push <folder> --project <slug> --expected-version <N> [--expected-revision <N>]\n Upload draft and publish (0 for a new skill)\n sfora skills pull <name> <bundle.json> --project <slug> [--version <N>]\n sfora skills install <name> --project <slug> --skills-target <directory> [--version <N>]\n sfora skills install-file <bundle.json> --skills-target <directory>\n sfora skills diff <folder> <name> --project <slug> [--version <N>]\n sfora skills uninstall <installed-folder> Remove only an unchanged Sfora-owned install\n";
|
|
4
4
|
export declare function runSkillsCommand(args: CliArgs, settings: ResolvedSettings): Promise<void>;
|