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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface SkillFile {
|
|
2
|
+
path: string;
|
|
3
|
+
contentBase64: string;
|
|
4
|
+
sha256: string;
|
|
5
|
+
size: number;
|
|
6
|
+
executable: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface SkillBundle {
|
|
9
|
+
schemaVersion: 1;
|
|
10
|
+
name: string;
|
|
11
|
+
files: SkillFile[];
|
|
12
|
+
hash: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SkillInstallation {
|
|
15
|
+
schemaVersion: 1;
|
|
16
|
+
path: string;
|
|
17
|
+
name: string;
|
|
18
|
+
hash: string;
|
|
19
|
+
installedAt: number;
|
|
20
|
+
source?: string;
|
|
21
|
+
sourceVersion?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface SkillDifference {
|
|
24
|
+
path: string;
|
|
25
|
+
kind: "added" | "removed" | "modified";
|
|
26
|
+
}
|
|
27
|
+
export declare const SKILL_LIMITS: {
|
|
28
|
+
files: number;
|
|
29
|
+
fileBytes: number;
|
|
30
|
+
totalBytes: number;
|
|
31
|
+
};
|
|
32
|
+
export declare function validateSkillName(name: string): void;
|
|
33
|
+
export declare function validateSkillPath(path: string): void;
|
|
34
|
+
export declare function skillBundleHash(files: SkillFile[]): string;
|
|
35
|
+
export declare function validateSkillBundle(bundle: SkillBundle): void;
|
|
36
|
+
export declare function readSkillBundle(directory: string, name?: string): Promise<SkillBundle>;
|
|
37
|
+
/** Legacy array API; the shared discovery engine owns all traversal. */
|
|
38
|
+
export declare function scanLocalSkills(roots?: string[]): Promise<Array<{
|
|
39
|
+
path: string;
|
|
40
|
+
name: string;
|
|
41
|
+
hash?: string;
|
|
42
|
+
error?: string;
|
|
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
|
+
}>;
|
|
54
|
+
export declare function installSkillBundle(bundle: SkillBundle, targetRoot: string, source?: string, sourceVersion?: number): Promise<SkillInstallation>;
|
|
55
|
+
export declare function uninstallSkill(directory: string): Promise<void>;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { atomicWrite, LocalConflictError, sha256, withLocalLock } from "./files.js";
|
|
6
|
+
export const SKILL_LIMITS = { files: 1000, fileBytes: 5 * 1024 * 1024, totalBytes: 20 * 1024 * 1024 };
|
|
7
|
+
export function validateSkillName(name) {
|
|
8
|
+
if (typeof name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 64)
|
|
9
|
+
throw new Error("Skill name must be 1–64 lowercase letters, digits and single hyphens.");
|
|
10
|
+
}
|
|
11
|
+
export function validateSkillPath(path) {
|
|
12
|
+
if (typeof path !== "string" || !path || path.length > 512 || path.includes("\\") || path.includes(":") || /[\x00-\x1f]/.test(path) || path.split("/").some(p => !p || p === "." || p === ".." || p.endsWith(".") || p.endsWith(" ")))
|
|
13
|
+
throw new Error(`Unsafe skill path: ${path}`);
|
|
14
|
+
}
|
|
15
|
+
export function skillBundleHash(files) {
|
|
16
|
+
return sha256(JSON.stringify([...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(f => [f.path, f.sha256, f.size, f.executable])));
|
|
17
|
+
}
|
|
18
|
+
export function validateSkillBundle(bundle) {
|
|
19
|
+
if (!bundle || bundle.schemaVersion !== 1)
|
|
20
|
+
throw new Error("Unsupported skill bundle schema.");
|
|
21
|
+
validateSkillName(bundle.name);
|
|
22
|
+
if (!Array.isArray(bundle.files) || !bundle.files.length || bundle.files.length > SKILL_LIMITS.files)
|
|
23
|
+
throw new Error("Invalid skill file count.");
|
|
24
|
+
const paths = new Set();
|
|
25
|
+
let total = 0;
|
|
26
|
+
for (const file of bundle.files) {
|
|
27
|
+
if (!file || typeof file.contentBase64 !== "string" || file.contentBase64.length > Math.ceil(SKILL_LIMITS.fileBytes / 3) * 4)
|
|
28
|
+
throw new Error("Invalid skill file encoding or size.");
|
|
29
|
+
validateSkillPath(file.path);
|
|
30
|
+
const key = file.path.normalize("NFC").toLowerCase();
|
|
31
|
+
if (paths.has(key))
|
|
32
|
+
throw new Error(`Duplicate skill path: ${file.path}`);
|
|
33
|
+
paths.add(key);
|
|
34
|
+
const bytes = Buffer.from(file.contentBase64, "base64");
|
|
35
|
+
if (bytes.toString("base64") !== file.contentBase64 || bytes.length !== file.size || sha256(bytes) !== file.sha256 || typeof file.executable !== "boolean")
|
|
36
|
+
throw new Error(`Invalid skill file integrity: ${file.path}`);
|
|
37
|
+
total += bytes.length;
|
|
38
|
+
if (bytes.length > SKILL_LIMITS.fileBytes || total > SKILL_LIMITS.totalBytes)
|
|
39
|
+
throw new Error("Skill exceeds size limits.");
|
|
40
|
+
}
|
|
41
|
+
for (const path of paths) {
|
|
42
|
+
const parts = path.split("/");
|
|
43
|
+
while (parts.length > 1) {
|
|
44
|
+
parts.pop();
|
|
45
|
+
if (paths.has(parts.join("/")))
|
|
46
|
+
throw new Error("Skill file/directory collision.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!bundle.files.some(f => f.path === "SKILL.md"))
|
|
50
|
+
throw new Error("A skill must contain SKILL.md.");
|
|
51
|
+
if (skillBundleHash(bundle.files) !== bundle.hash)
|
|
52
|
+
throw new Error("Invalid skill bundle hash.");
|
|
53
|
+
}
|
|
54
|
+
export async function readSkillBundle(directory, name = basename(resolve(directory))) {
|
|
55
|
+
validateSkillName(name);
|
|
56
|
+
if ((await lstat(directory)).isSymbolicLink())
|
|
57
|
+
throw new Error("Skill root cannot be a symbolic link.");
|
|
58
|
+
const root = await realpath(directory);
|
|
59
|
+
const files = [];
|
|
60
|
+
let total = 0;
|
|
61
|
+
let directories = 0;
|
|
62
|
+
async function walk(relative) {
|
|
63
|
+
if (++directories > 1000 || relative.split("/").length > 32)
|
|
64
|
+
throw new Error("Skill directory nesting exceeds limits.");
|
|
65
|
+
const folder = join(root, relative);
|
|
66
|
+
if ((await realpath(folder)) !== folder)
|
|
67
|
+
throw new Error("Symbolic links are not supported.");
|
|
68
|
+
for (const entry of await readdir(folder, { withFileTypes: true })) {
|
|
69
|
+
const path = relative ? `${relative}/${entry.name}` : entry.name;
|
|
70
|
+
validateSkillPath(path);
|
|
71
|
+
if (entry.isSymbolicLink())
|
|
72
|
+
throw new Error(`Symbolic links are not supported: ${path}`);
|
|
73
|
+
if (entry.isDirectory()) {
|
|
74
|
+
await walk(path);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!entry.isFile())
|
|
78
|
+
throw new Error(`Non-regular skill file: ${path}`);
|
|
79
|
+
const absolute = join(root, path);
|
|
80
|
+
const info = await lstat(absolute);
|
|
81
|
+
if (info.size > SKILL_LIMITS.fileBytes || total + info.size > SKILL_LIMITS.totalBytes || files.length >= SKILL_LIMITS.files)
|
|
82
|
+
throw new Error("Skill exceeds size limits.");
|
|
83
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
84
|
+
let bytes;
|
|
85
|
+
try {
|
|
86
|
+
bytes = await handle.readFile();
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await handle.close();
|
|
90
|
+
}
|
|
91
|
+
total += bytes.length;
|
|
92
|
+
files.push({ path, contentBase64: bytes.toString("base64"), size: bytes.length, sha256: sha256(bytes), executable: !!(info.mode & 0o111) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await walk("");
|
|
96
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
97
|
+
const bundle = { schemaVersion: 1, name, files, hash: skillBundleHash(files) };
|
|
98
|
+
validateSkillBundle(bundle);
|
|
99
|
+
return bundle;
|
|
100
|
+
}
|
|
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;
|
|
108
|
+
}
|
|
109
|
+
export function diffSkillBundles(local, incoming) {
|
|
110
|
+
validateSkillBundle(local);
|
|
111
|
+
validateSkillBundle(incoming);
|
|
112
|
+
const old = new Map(local.files.map(f => [f.path, f]));
|
|
113
|
+
const next = new Map(incoming.files.map(f => [f.path, f]));
|
|
114
|
+
return [...new Set([...old.keys(), ...next.keys()])].sort().flatMap(path => {
|
|
115
|
+
const a = old.get(path), b = next.get(path);
|
|
116
|
+
if (!a)
|
|
117
|
+
return [{ path, kind: "added" }];
|
|
118
|
+
if (!b)
|
|
119
|
+
return [{ path, kind: "removed" }];
|
|
120
|
+
return a.sha256 !== b.sha256 || a.executable !== b.executable ? [{ path, kind: "modified" }] : [];
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function recordPath(destination) { return join(dirname(destination), `.${basename(destination)}.sfora-install.json`); }
|
|
124
|
+
export async function readInstallation(destination) {
|
|
125
|
+
try {
|
|
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)))
|
|
142
|
+
throw new Error("Unsupported or mismatched installation record.");
|
|
143
|
+
return record;
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (e.code === "ENOENT")
|
|
147
|
+
return undefined;
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
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
|
+
}
|
|
175
|
+
export async function installSkillBundle(bundle, targetRoot, source, sourceVersion) {
|
|
176
|
+
validateSkillBundle(bundle);
|
|
177
|
+
if (sourceVersion !== undefined && (!Number.isSafeInteger(sourceVersion) || sourceVersion < 1))
|
|
178
|
+
throw new Error("Source version must be a positive integer.");
|
|
179
|
+
await mkdir(targetRoot, { recursive: true });
|
|
180
|
+
const root = await realpath(targetRoot);
|
|
181
|
+
const destination = join(root, bundle.name);
|
|
182
|
+
return withLocalLock(destination, async () => {
|
|
183
|
+
const previous = await readInstallation(destination);
|
|
184
|
+
let exists = false;
|
|
185
|
+
try {
|
|
186
|
+
await lstat(destination);
|
|
187
|
+
exists = true;
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
if (e.code !== "ENOENT")
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
if (exists) {
|
|
194
|
+
if (!previous)
|
|
195
|
+
throw new LocalConflictError("Destination is not owned by Sfora. Choose another target.");
|
|
196
|
+
if ((await readSkillBundle(destination)).hash !== previous.hash)
|
|
197
|
+
throw new LocalConflictError("Installed skill has local changes. Push or copy them before updating.");
|
|
198
|
+
}
|
|
199
|
+
const staging = join(root, `.${bundle.name}.${randomUUID()}.stage`);
|
|
200
|
+
const backup = `${staging}.previous`;
|
|
201
|
+
await mkdir(staging, { mode: 0o700 });
|
|
202
|
+
try {
|
|
203
|
+
for (const file of bundle.files) {
|
|
204
|
+
const path = join(staging, file.path);
|
|
205
|
+
await mkdir(dirname(path), { recursive: true });
|
|
206
|
+
await writeFile(path, Buffer.from(file.contentBase64, "base64"), { flag: "wx", mode: file.executable ? 0o755 : 0o644 });
|
|
207
|
+
await chmod(path, file.executable ? 0o755 : 0o644);
|
|
208
|
+
}
|
|
209
|
+
if (exists)
|
|
210
|
+
await rename(destination, backup);
|
|
211
|
+
try {
|
|
212
|
+
await rename(staging, destination);
|
|
213
|
+
const record = { schemaVersion: 1, path: destination, name: bundle.name, hash: bundle.hash, installedAt: Date.now(), source, sourceVersion };
|
|
214
|
+
await atomicWrite(recordPath(destination), `${JSON.stringify(record, null, 2)}\n`);
|
|
215
|
+
// Record publication completes the transaction. Failed obsolete-backup cleanup
|
|
216
|
+
// must never roll the destination back under a newly committed record.
|
|
217
|
+
await rm(backup, { recursive: true, force: true }).catch(() => { });
|
|
218
|
+
return record;
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
await rm(destination, { recursive: true, force: true });
|
|
222
|
+
if (exists)
|
|
223
|
+
await rename(backup, destination);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await rm(staging, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
export async function uninstallSkill(directory) {
|
|
233
|
+
const destination = join(await realpath(dirname(resolve(directory))), basename(directory));
|
|
234
|
+
await withLocalLock(destination, async () => {
|
|
235
|
+
const record = await readInstallation(destination);
|
|
236
|
+
if (!record)
|
|
237
|
+
throw new Error("This skill installation is not owned by Sfora.");
|
|
238
|
+
if ((await readSkillBundle(destination)).hash !== record.hash)
|
|
239
|
+
throw new LocalConflictError("Installed skill has local changes; uninstall refused.");
|
|
240
|
+
const trash = `${destination}.${randomUUID()}.removed`;
|
|
241
|
+
await rename(destination, trash);
|
|
242
|
+
try {
|
|
243
|
+
await rm(recordPath(destination));
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
await rename(trash, destination);
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
await rm(trash, { recursive: true });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type SkillBundle } from "./local-core/skills.js";
|
|
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
|
+
}
|
|
12
|
+
export declare class SkillsClient {
|
|
13
|
+
private readonly settings;
|
|
14
|
+
constructor(settings: ResolvedSettings);
|
|
15
|
+
request<T>(route: string, init?: RequestInit): Promise<T>;
|
|
16
|
+
list(project: string): Promise<unknown>;
|
|
17
|
+
detail(project: string, name: string): Promise<{
|
|
18
|
+
skill: {
|
|
19
|
+
version: number;
|
|
20
|
+
draftRevision: number;
|
|
21
|
+
};
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}>;
|
|
24
|
+
download(project: string, name: string, version?: number): Promise<SkillBundle>;
|
|
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<{
|
|
28
|
+
draftRevision: number;
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
}>;
|
|
31
|
+
publish(project: string, name: string, expectedVersion: number, expectedRevision?: number, expectedSkillId?: string): Promise<unknown>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
}
|
|
18
|
+
export class SkillsClient {
|
|
19
|
+
settings;
|
|
20
|
+
constructor(settings) {
|
|
21
|
+
this.settings = settings;
|
|
22
|
+
}
|
|
23
|
+
async request(route, init) {
|
|
24
|
+
if (!this.settings.apiKey)
|
|
25
|
+
throw new Error("Run sfora login to connect to cloud Skills.");
|
|
26
|
+
const response = await fetch(new URL(route, this.settings.url), {
|
|
27
|
+
...init, headers: { Authorization: `Bearer ${this.settings.apiKey}`, "Content-Type": "application/json", "X-Sfora-Client": "cli", ...init?.headers },
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok)
|
|
30
|
+
throw new SkillsApiError(response.status, await response.text());
|
|
31
|
+
return await response.json();
|
|
32
|
+
}
|
|
33
|
+
list(project) { return this.request(`/v1/skills?${new URLSearchParams({ project })}`); }
|
|
34
|
+
detail(project, name) {
|
|
35
|
+
return this.request(`/v1/skills?${new URLSearchParams({ project, name })}`);
|
|
36
|
+
}
|
|
37
|
+
async download(project, name, version) {
|
|
38
|
+
const query = new URLSearchParams({ project, name, download: "1" });
|
|
39
|
+
if (version !== undefined)
|
|
40
|
+
query.set("version", String(version));
|
|
41
|
+
const bundle = await this.request(`/v1/skills?${query}`);
|
|
42
|
+
validateSkillBundle(bundle);
|
|
43
|
+
return bundle;
|
|
44
|
+
}
|
|
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) {
|
|
82
|
+
validateSkillBundle(bundle);
|
|
83
|
+
return this.request("/v1/skills", { method: "POST", body: JSON.stringify({ project, name: bundle.name, bundle, expectedVersion, expectedRevision, expectedSkillId }) });
|
|
84
|
+
}
|
|
85
|
+
publish(project, name, expectedVersion, expectedRevision, expectedSkillId) {
|
|
86
|
+
return this.request("/v1/skills/publish", { method: "POST", body: JSON.stringify({ project, name, expectedVersion, expectedRevision, expectedSkillId }) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CliArgs } from "./cli-args.js";
|
|
2
|
+
import type { ResolvedSettings } from "./config.js";
|
|
3
|
+
export declare const SKILLS_HELP = "Skills:\n sfora skills 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
|
+
export declare function runSkillsCommand(args: CliArgs, settings: ResolvedSettings): Promise<void>;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import { diffSkillBundles, installSkillBundle, inspectSkillTarget, readSkillBundle, discoverSkills, SkillService, uninstallSkill, validateSkillBundle } from "./local-core/index.js";
|
|
4
|
+
import { SkillOperationJournal } from "./local-core/skill-operations.js";
|
|
5
|
+
import { SkillOperationExecutor } from "./local-core/skill-executor.js";
|
|
6
|
+
import { defaultSkillCatalogPath } from "./local-core/skill-store.js";
|
|
7
|
+
import { SkillsClient } from "./skills-client.js";
|
|
8
|
+
import { SkillBindingService, assertSkillBindingSnapshot, compareSkillStates, planSkillTransfer, planSkillInstallation } from './local-core/skill-sync.js';
|
|
9
|
+
export const SKILLS_HELP = `Skills:
|
|
10
|
+
sfora skills inventory --json Persistent local inventory with warnings and identities
|
|
11
|
+
sfora skills roots [add <folder> [label] | add-project <folder> [label] | remove <id>]
|
|
12
|
+
Manage registered discovery folders (never deletes files)
|
|
13
|
+
sfora skills scan [root ...] --json Discover complete local skill bundles
|
|
14
|
+
sfora skills list --project <slug> List cloud project skills
|
|
15
|
+
sfora skills index --json Read cached local inventory without scanning
|
|
16
|
+
sfora skills operations List durable transfer outcomes and recovery needs
|
|
17
|
+
sfora skills queue <plan.json> Queue a reviewed push, pull or install
|
|
18
|
+
sfora skills cancel <operation-id> Cancel an unstarted operation
|
|
19
|
+
sfora skills apply <plan.json|operation-id> Execute a reviewed plan
|
|
20
|
+
sfora skills recover <operation-id> Reconcile an interrupted operation
|
|
21
|
+
sfora skills apply-batch <ids.json> Run queued operation IDs with per-item outcomes
|
|
22
|
+
sfora skills recover-batch <ids.json> Resume incomplete items without replaying successes
|
|
23
|
+
sfora skills bindings List explicit cloud mappings and baselines
|
|
24
|
+
sfora skills bind <location-id> <cloud-name> --project <slug>
|
|
25
|
+
Explicitly adopt a published cloud identity
|
|
26
|
+
sfora skills status <binding-id> Compare current files with the bound published revision
|
|
27
|
+
sfora skills plan <binding-id> <push|pull> Preview IDs, hashes, conflicts and per-file changes
|
|
28
|
+
sfora skills plan-install <name> --project <slug> --skills-target <existing-directory>
|
|
29
|
+
Preview a new installation without modifying files
|
|
30
|
+
sfora skills relocate <location-id> <folder> Preserve identity after an explicit folder move
|
|
31
|
+
sfora skills push <folder> --project <slug> --expected-version <N> [--expected-revision <N>]
|
|
32
|
+
Upload draft and publish (0 for a new skill)
|
|
33
|
+
sfora skills pull <name> <bundle.json> --project <slug> [--version <N>]
|
|
34
|
+
sfora skills install <name> --project <slug> --skills-target <directory> [--version <N>]
|
|
35
|
+
sfora skills install-file <bundle.json> --skills-target <directory>
|
|
36
|
+
sfora skills diff <folder> <name> --project <slug> [--version <N>]
|
|
37
|
+
sfora skills uninstall <installed-folder> Remove only an unchanged Sfora-owned install
|
|
38
|
+
`;
|
|
39
|
+
export async function runSkillsCommand(args, settings) {
|
|
40
|
+
const [verb, first, second] = args.rest;
|
|
41
|
+
for (const [label, value] of [["version", args.skillVersion], ["expected-version", args.expectedVersion], ["expected-revision", args.expectedRevision]]) {
|
|
42
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < (label === "version" ? 1 : 0)))
|
|
43
|
+
throw new Error(`--${label} must be a ${label === "version" ? "positive" : "non-negative"} integer.`);
|
|
44
|
+
}
|
|
45
|
+
const print = (value) => console.log(JSON.stringify(value, null, args.json ? undefined : 2));
|
|
46
|
+
const need = (value, label) => { if (!value)
|
|
47
|
+
throw new Error(`${label} is required.\n${SKILLS_HELP}`); return value; };
|
|
48
|
+
if (verb === "roots") {
|
|
49
|
+
const service = new SkillService();
|
|
50
|
+
if (!first)
|
|
51
|
+
print(await service.roots());
|
|
52
|
+
else if (first === "add" || first === "add-project")
|
|
53
|
+
print(await service.registerRoot(need(second, "Folder"), args.rest[3], first === "add-project" ? "project" : "custom"));
|
|
54
|
+
else if (first === "remove") {
|
|
55
|
+
await service.removeRoot(need(second, "Root ID"));
|
|
56
|
+
print({ removed: second });
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
throw new Error("Use skills roots, skills roots add <folder> [label], or skills roots remove <id>.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (verb === "index") {
|
|
63
|
+
print(await new SkillService().cachedInventory());
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (verb === "inventory" || verb === "scan") {
|
|
67
|
+
const result = verb === "scan" && args.rest.length > 1
|
|
68
|
+
? await discoverSkills(args.rest.slice(1).map(path => ({ path, label: "Chosen folder", depth: 4 })))
|
|
69
|
+
: await new SkillService().inventory();
|
|
70
|
+
if (verb === "scan") {
|
|
71
|
+
print(result.skills);
|
|
72
|
+
if (result.warnings.length)
|
|
73
|
+
console.error(JSON.stringify({ warnings: result.warnings }));
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
print(result);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (verb === "uninstall") {
|
|
80
|
+
await uninstallSkill(need(first, "Installed folder"));
|
|
81
|
+
print({ uninstalled: first });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (['operations', 'queue', 'cancel', 'apply', 'recover', 'apply-batch', 'recover-batch'].includes(verb)) {
|
|
85
|
+
const journal = new SkillOperationJournal(join(dirname(defaultSkillCatalogPath()), 'operations.sqlite'));
|
|
86
|
+
if (verb === 'operations') {
|
|
87
|
+
print(await journal.list());
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (verb === 'cancel') {
|
|
91
|
+
print(await journal.cancel(need(first, 'Operation ID')));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (verb === 'queue') {
|
|
95
|
+
print(await journal.create(JSON.parse(await readFile(need(first, 'Plan JSON'), 'utf8'))));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const service = new SkillService();
|
|
99
|
+
const client = new SkillsClient(settings);
|
|
100
|
+
const executor = new SkillOperationExecutor(service.store, journal, client);
|
|
101
|
+
if (verb === 'apply-batch' || verb === 'recover-batch') {
|
|
102
|
+
const ids = JSON.parse(await readFile(need(first, 'Operation IDs JSON'), 'utf8'));
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const stop = () => { controller.abort(); console.error('Stopping after the current item; unstarted work will be cancelled.'); };
|
|
105
|
+
process.once('SIGINT', stop);
|
|
106
|
+
try {
|
|
107
|
+
const results = await executor.batch(ids, { recover: verb === 'recover-batch', signal: controller.signal });
|
|
108
|
+
print(results);
|
|
109
|
+
if (results.some(r => r.error))
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
process.removeListener('SIGINT', stop);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const operation = verb === 'apply' && !/^[a-f0-9-]{36}$/.test(first ?? '') ? await journal.create(JSON.parse(await readFile(need(first, 'Plan JSON'), 'utf8'))) : undefined;
|
|
118
|
+
const id = operation?.id ?? need(first, 'Operation ID');
|
|
119
|
+
try {
|
|
120
|
+
print(await executor.apply(id, verb === 'recover'));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw new Error(`${error.message} Operation ${id} was retained; inspect skills operations, then use skills recover ${id}.`);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (['bindings', 'bind', 'status', 'plan', 'relocate'].includes(verb)) {
|
|
128
|
+
const service = new SkillService();
|
|
129
|
+
const bindings = new SkillBindingService(service.store);
|
|
130
|
+
if (verb === 'bindings') {
|
|
131
|
+
print(await bindings.list());
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (verb === 'relocate') {
|
|
135
|
+
print(await service.relocateLocation(need(first, 'Location ID'), need(second, 'Folder')));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const catalog = await service.store.read();
|
|
139
|
+
const binding = verb === 'bind' ? undefined : catalog.bindings.find(b => b.id === need(first, 'Binding ID'));
|
|
140
|
+
if (verb !== 'bind' && !binding)
|
|
141
|
+
throw new Error('Skill binding not found.');
|
|
142
|
+
if (binding && args.project && args.project !== binding.locator.project)
|
|
143
|
+
throw new Error('--project differs from the explicit skill binding.');
|
|
144
|
+
const location = catalog.locations.find(l => verb === 'bind' ? l.id === first : l.skillId === binding.localSkillId);
|
|
145
|
+
if (!location)
|
|
146
|
+
throw new Error('Local skill location not found. Register its folder and refresh the inventory first.');
|
|
147
|
+
if (verb === 'plan' && second !== 'push' && second !== 'pull')
|
|
148
|
+
throw new Error('Choose skills plan <binding-id> <push|pull>.');
|
|
149
|
+
const client = new SkillsClient(settings);
|
|
150
|
+
const snapshot = await client.snapshot(binding?.locator.project ?? need(args.project, '--project'), binding?.locator.name ?? need(second, 'Cloud skill name'));
|
|
151
|
+
if (binding)
|
|
152
|
+
assertSkillBindingSnapshot(binding, snapshot);
|
|
153
|
+
let local;
|
|
154
|
+
try {
|
|
155
|
+
local = { state: 'available', bundle: await readSkillBundle(location.path) };
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
local = { state: error.code === 'ENOENT' ? 'missing' : 'unavailable', reason: error.message };
|
|
159
|
+
}
|
|
160
|
+
if (verb === 'bind') {
|
|
161
|
+
if (local.state !== 'available')
|
|
162
|
+
throw new Error(local.reason);
|
|
163
|
+
print(await bindings.adopt(location.skillId, snapshot, local.bundle));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (verb === 'status') {
|
|
167
|
+
print({ bindingId: binding.id, status: compareSkillStates(local, { state: 'available', bundle: snapshot.bundle }, binding.baseline),
|
|
168
|
+
local: local.state === 'available' ? { state: local.state, hash: local.bundle.hash } : local, remote: snapshot.revision, baseline: binding.baseline ?? null });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const target = await inspectSkillTarget(location.path);
|
|
172
|
+
print(planSkillTransfer({ direction: second, binding: binding, location, local, remote: snapshot, ownership: target.ownership }));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (verb === "install-file") {
|
|
176
|
+
const bundle = JSON.parse(await readFile(need(first, "Bundle JSON"), "utf8"));
|
|
177
|
+
validateSkillBundle(bundle);
|
|
178
|
+
print(await installSkillBundle(bundle, need(args.skillsTarget, "--skills-target"), first));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const project = need(args.project, "--project");
|
|
182
|
+
const client = new SkillsClient(settings);
|
|
183
|
+
if (verb === 'plan-install') {
|
|
184
|
+
const remote = await client.snapshot(project, need(first, 'Skill name'));
|
|
185
|
+
const target = join(await realpath(need(args.skillsTarget, '--skills-target')), remote.bundle.name);
|
|
186
|
+
print(planSkillInstallation(remote, target, await inspectSkillTarget(target)));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (verb === "list") {
|
|
190
|
+
print(await client.list(project));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (verb === "push") {
|
|
194
|
+
if (args.expectedVersion === undefined)
|
|
195
|
+
throw new Error("--expected-version is required (0 for a new skill). Read the current version with skills list first.");
|
|
196
|
+
const bundle = await readSkillBundle(need(first, "Skill folder"));
|
|
197
|
+
const draft = await client.saveDraft(project, bundle, args.expectedVersion, args.expectedRevision);
|
|
198
|
+
print(await client.publish(project, bundle.name, args.expectedVersion, draft.draftRevision));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (verb === "pull") {
|
|
202
|
+
const bundle = await client.download(project, need(first, "Skill name"), args.skillVersion);
|
|
203
|
+
// Export refuses to overwrite unrelated files; explicit repeated exports can choose a new path.
|
|
204
|
+
const { atomicCreate } = await import("./local-core/files.js");
|
|
205
|
+
await atomicCreate(need(second, "Output bundle JSON"), `${JSON.stringify(bundle, null, 2)}\n`);
|
|
206
|
+
print({ path: second, hash: bundle.hash });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (verb === "install") {
|
|
210
|
+
const name = need(first, "Skill name");
|
|
211
|
+
const version = args.skillVersion ?? (await client.detail(project, name)).skill.version;
|
|
212
|
+
if (!Number.isSafeInteger(version) || version < 1)
|
|
213
|
+
throw new Error("This skill has no published version to install.");
|
|
214
|
+
const bundle = await client.download(project, name, version);
|
|
215
|
+
print(await installSkillBundle(bundle, need(args.skillsTarget, "--skills-target"), `${settings.url}/${project}/${name}`, version));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (verb === "diff") {
|
|
219
|
+
print(diffSkillBundles(await readSkillBundle(need(first, "Local folder")), await client.download(project, need(second, "Skill name"), args.skillVersion)));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
throw new Error(SKILLS_HELP);
|
|
223
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const CLI_VERSION = "0.16.0";
|