@meistrari/tela-cli 1.0.0 → 1.2.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 +21 -18
- package/dist/commands/auth.d.ts +8 -0
- package/dist/commands/auth.js +366 -4
- package/dist/commands/ingredients.d.ts +7 -35
- package/dist/commands/ingredients.js +241 -559
- package/dist/commands/skills.d.ts +33 -0
- package/dist/commands/skills.js +519 -0
- package/dist/docs/workflow-code/api-reference.md +81 -2
- package/dist/docs/workflow-code/manifest.json +1 -1
- package/dist/index.js +137617 -32063
- package/dist/markdown.d.ts +17 -0
- package/dist/markdown.js +116 -0
- package/dist/program.js +22 -14
- package/dist/session.d.ts +28 -0
- package/dist/session.js +109 -0
- package/dist/skills.d.ts +87 -0
- package/dist/skills.js +303 -0
- package/package.json +1 -1
- package/skills/tela-cli/SKILL.md +19 -11
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PantrySkillManifestEntry, PantrySkillSummary } from '@meistrari/tela-pantry';
|
|
2
|
+
import type { InstalledSkill } from '@meistrari/tela-local-config';
|
|
3
|
+
import type { CliIo, GlobalOptions } from '../output';
|
|
4
|
+
export declare function runSkillsCommand(args: string[], options: GlobalOptions, io: CliIo): Promise<number>;
|
|
5
|
+
type SkillListEntry = PantrySkillSummary & {
|
|
6
|
+
installed: boolean;
|
|
7
|
+
installedSha: string | null;
|
|
8
|
+
/** Installed, but Pantry has since published a different snapshot. */
|
|
9
|
+
outdated: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function annotateInstalled(skills: readonly PantrySkillSummary[], installed: readonly InstalledSkill[]): SkillListEntry[];
|
|
12
|
+
export declare function formatSkillList(entries: readonly SkillListEntry[]): string[];
|
|
13
|
+
export declare function formatManifestTree(entries: readonly PantrySkillManifestEntry[]): string[];
|
|
14
|
+
export declare function formatBytes(size: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* No slugs means every installed skill: upgrading is additive, and "all of
|
|
17
|
+
* them" is the only reading of a bare `skills upgrade`.
|
|
18
|
+
*
|
|
19
|
+
* A named slug the registry does not know is refused for the whole command
|
|
20
|
+
* rather than skipped, so a typo among several cannot leave some skills
|
|
21
|
+
* upgraded and report success.
|
|
22
|
+
*/
|
|
23
|
+
export declare function selectUpgradeTargets(installed: readonly InstalledSkill[], slugs: readonly string[]): InstalledSkill[];
|
|
24
|
+
/**
|
|
25
|
+
* Is this the same installation, rather than one that replaced it?
|
|
26
|
+
*
|
|
27
|
+
* `slug` and `dir` are both derived from the slug, so neither moves across a
|
|
28
|
+
* reinstall; `installedAt` and `sha` are what actually distinguish one install
|
|
29
|
+
* from the next.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isSameInstall(left: InstalledSkill, right: InstalledSkill): boolean;
|
|
32
|
+
export declare function scriptFailureMessage(entry: Pick<InstalledSkill, 'slug' | 'dir'>, phase: 'install' | 'uninstall'): string;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runSkillsCommand = runSkillsCommand;
|
|
4
|
+
exports.annotateInstalled = annotateInstalled;
|
|
5
|
+
exports.formatSkillList = formatSkillList;
|
|
6
|
+
exports.formatManifestTree = formatManifestTree;
|
|
7
|
+
exports.formatBytes = formatBytes;
|
|
8
|
+
exports.selectUpgradeTargets = selectUpgradeTargets;
|
|
9
|
+
exports.isSameInstall = isSameInstall;
|
|
10
|
+
exports.scriptFailureMessage = scriptFailureMessage;
|
|
11
|
+
const promises_1 = require("node:fs/promises");
|
|
12
|
+
const tela_pantry_1 = require("@meistrari/tela-pantry");
|
|
13
|
+
const tela_local_config_1 = require("@meistrari/tela-local-config");
|
|
14
|
+
const markdown_1 = require("../markdown");
|
|
15
|
+
const output_1 = require("../output");
|
|
16
|
+
const session_1 = require("../session");
|
|
17
|
+
const skills_1 = require("../skills");
|
|
18
|
+
async function runSkillsCommand(args, options, io) {
|
|
19
|
+
const [command, ...rest] = args;
|
|
20
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
21
|
+
writeSkillsHelp(io);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
if (command === 'list')
|
|
26
|
+
return await listSkills(rest, options, io);
|
|
27
|
+
if (command === 'search')
|
|
28
|
+
return await searchSkills(rest, options, io);
|
|
29
|
+
if (command === 'files')
|
|
30
|
+
return await showSkillFiles(rest, options, io);
|
|
31
|
+
if (command === 'cat')
|
|
32
|
+
return await catSkillFile(rest, options, io);
|
|
33
|
+
if (command === 'install')
|
|
34
|
+
return await installSkills(rest, options, io);
|
|
35
|
+
if (command === 'upgrade')
|
|
36
|
+
return await upgradeSkills(rest, options, io);
|
|
37
|
+
if (command === 'uninstall')
|
|
38
|
+
return await uninstallSkills(rest, options, io);
|
|
39
|
+
return usageError(`Unknown skills command: ${command}`, io);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
(0, output_1.writeError)(io, formatError(error));
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `list` and `search` are the same listing, and Pantry applies the free-text
|
|
48
|
+
* filter itself — so the only difference is whether a query is passed.
|
|
49
|
+
*/
|
|
50
|
+
async function listSkills(args, options, io) {
|
|
51
|
+
const flags = parseSkillFlags(args);
|
|
52
|
+
if ('error' in flags)
|
|
53
|
+
return usageError(flags.error, io);
|
|
54
|
+
if (flags.rest.length > 0)
|
|
55
|
+
return usageError(`Unknown skills list argument: ${flags.rest[0]}`, io);
|
|
56
|
+
return await writeCatalog(flags, options, io, {});
|
|
57
|
+
}
|
|
58
|
+
async function searchSkills(args, options, io) {
|
|
59
|
+
const flags = parseSkillFlags(args);
|
|
60
|
+
if ('error' in flags)
|
|
61
|
+
return usageError(flags.error, io);
|
|
62
|
+
const [query, ...extra] = flags.rest;
|
|
63
|
+
if (!query)
|
|
64
|
+
return usageError('Usage: tela skills search <query> [--json]', io);
|
|
65
|
+
if (extra.length > 0)
|
|
66
|
+
return usageError(`Unknown skills search argument: ${extra[0]}`, io);
|
|
67
|
+
return await writeCatalog(flags, options, io, { query });
|
|
68
|
+
}
|
|
69
|
+
async function writeCatalog(flags, options, io, filters) {
|
|
70
|
+
const { client } = await (0, session_1.loadPantry)(flags);
|
|
71
|
+
const [skills, installed] = await Promise.all([
|
|
72
|
+
(0, tela_pantry_1.fetchSkillCatalog)(client, filters),
|
|
73
|
+
(0, tela_local_config_1.listInstalledSkills)(),
|
|
74
|
+
]);
|
|
75
|
+
const entries = annotateInstalled(skills, installed);
|
|
76
|
+
if (options.json) {
|
|
77
|
+
(0, output_1.writeJson)(io, { skills: entries });
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
if (entries.length === 0) {
|
|
81
|
+
(0, output_1.writeLine)(io, '(no skills found)');
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
for (const line of formatSkillList(entries))
|
|
85
|
+
(0, output_1.writeLine)(io, line);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
function annotateInstalled(skills, installed) {
|
|
89
|
+
const bySlug = new Map(installed.map(entry => [entry.slug, entry]));
|
|
90
|
+
return skills.map((skill) => {
|
|
91
|
+
const entry = bySlug.get(skill.slug);
|
|
92
|
+
return {
|
|
93
|
+
...skill,
|
|
94
|
+
installed: entry !== undefined,
|
|
95
|
+
installedSha: entry?.sha ?? null,
|
|
96
|
+
// Only claim staleness when both shas are known: a skill Pantry has
|
|
97
|
+
// not snapshotted yet is not an outdated install.
|
|
98
|
+
outdated: entry !== undefined
|
|
99
|
+
&& skill.snapshotSha !== null
|
|
100
|
+
&& skill.snapshotSha !== entry.sha,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function formatSkillList(entries) {
|
|
105
|
+
const width = Math.max(...entries.map(entry => entry.slug.length));
|
|
106
|
+
return entries.map((entry) => {
|
|
107
|
+
// `↑` over `✓` for an install Pantry has moved past, so `skills list`
|
|
108
|
+
// answers "what should I upgrade?" on its own.
|
|
109
|
+
const marker = entry.outdated ? '↑ ' : entry.installed ? '✓ ' : ' ';
|
|
110
|
+
return `${marker}${entry.slug.padEnd(width)} ${entry.description}`;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async function showSkillFiles(args, options, io) {
|
|
114
|
+
const flags = parseSkillFlags(args);
|
|
115
|
+
if ('error' in flags)
|
|
116
|
+
return usageError(flags.error, io);
|
|
117
|
+
const [slug] = flags.rest;
|
|
118
|
+
if (!slug)
|
|
119
|
+
return usageError('Usage: tela skills files <slug> [--json]', io);
|
|
120
|
+
const { client } = await (0, session_1.loadPantry)(flags);
|
|
121
|
+
const { snapshot } = await (0, tela_pantry_1.fetchSkillFile)(client, slug);
|
|
122
|
+
if (options.json) {
|
|
123
|
+
(0, output_1.writeJson)(io, { slug, sha: snapshot.sha, entries: snapshot.manifest.entries });
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
for (const line of formatManifestTree(snapshot.manifest.entries))
|
|
127
|
+
(0, output_1.writeLine)(io, line);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
function formatManifestTree(entries) {
|
|
131
|
+
if (entries.length === 0)
|
|
132
|
+
return ['(no files)'];
|
|
133
|
+
return [...entries]
|
|
134
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
135
|
+
.map((entry) => {
|
|
136
|
+
const segments = entry.path.split('/');
|
|
137
|
+
const indent = ' '.repeat(segments.length - 1);
|
|
138
|
+
const name = segments[segments.length - 1] ?? entry.path;
|
|
139
|
+
return entry.kind === 'directory'
|
|
140
|
+
? `${indent}${name}/`
|
|
141
|
+
: `${indent}${name} ${formatBytes(entry.size)}`;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function formatBytes(size) {
|
|
145
|
+
if (size < 1024)
|
|
146
|
+
return `${size} B`;
|
|
147
|
+
if (size < 1024 * 1024)
|
|
148
|
+
return `${(size / 1024).toFixed(1)} KB`;
|
|
149
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
150
|
+
}
|
|
151
|
+
async function catSkillFile(args, _options, io) {
|
|
152
|
+
const flags = parseSkillFlags(args);
|
|
153
|
+
if ('error' in flags)
|
|
154
|
+
return usageError(flags.error, io);
|
|
155
|
+
const [slug, filePath] = flags.rest;
|
|
156
|
+
if (!slug)
|
|
157
|
+
return usageError('Usage: tela skills cat <slug> [path]', io);
|
|
158
|
+
const { client } = await (0, session_1.loadPantry)(flags);
|
|
159
|
+
const { file } = await (0, tela_pantry_1.fetchSkillFile)(client, slug, filePath);
|
|
160
|
+
if (file.content === null) {
|
|
161
|
+
return usageError(`${file.path} is not previewable (${file.size} bytes, ${file.mediaType ?? 'unknown type'}).\n`
|
|
162
|
+
+ `Download it directly: ${file.downloadUrl}`, io);
|
|
163
|
+
}
|
|
164
|
+
// Markdown is rendered for a terminal and passed through untouched for a
|
|
165
|
+
// pipe; anything else is already plain text.
|
|
166
|
+
(0, output_1.writeLine)(io, file.mediaType === 'text/markdown' ? await (0, markdown_1.renderMarkdown)(file.content) : file.content);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
async function installSkills(args, options, io) {
|
|
170
|
+
const flags = parseSkillFlags(args);
|
|
171
|
+
if ('error' in flags)
|
|
172
|
+
return usageError(flags.error, io);
|
|
173
|
+
if (flags.rest.length === 0)
|
|
174
|
+
return usageError('Usage: tela skills install <slug...> [--force] [--no-symlink] [--ignore-scripts]', io);
|
|
175
|
+
// Every slug is validated before any of them is installed: one bad slug
|
|
176
|
+
// among several must stop the command before the safe ones are already on
|
|
177
|
+
// disk, and before an unvalidated slug reaches a request path.
|
|
178
|
+
for (const slug of flags.rest)
|
|
179
|
+
(0, skills_1.assertSafeSlug)(slug, 'install');
|
|
180
|
+
const pantry = await (0, session_1.loadPantry)(flags);
|
|
181
|
+
const installed = [];
|
|
182
|
+
for (const slug of flags.rest) {
|
|
183
|
+
const entry = await installOne(pantry, slug, flags, io);
|
|
184
|
+
if (entry)
|
|
185
|
+
installed.push(entry);
|
|
186
|
+
}
|
|
187
|
+
if (options.json) {
|
|
188
|
+
(0, output_1.writeJson)(io, { installed });
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
for (const entry of installed)
|
|
192
|
+
(0, output_1.writeLine)(io, `Installed ${entry.slug}@${entry.sha.slice(0, 7)} to ${entry.dir}`);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
async function installOne(pantry, slug, flags, io, expectedCurrent) {
|
|
196
|
+
const roots = (0, skills_1.resolveSkillRoots)();
|
|
197
|
+
const target = (0, skills_1.resolveInstallDir)(roots, slug);
|
|
198
|
+
const { ingredient, snapshot } = await (0, tela_pantry_1.fetchSkillFile)(pantry.client, slug);
|
|
199
|
+
// Verified before anything is written: the order is the security property.
|
|
200
|
+
// Nothing is removed from disk until every file in the archive has been
|
|
201
|
+
// checked against the manifest, so a tampered or truncated download can
|
|
202
|
+
// never delete a working install. Downloading outside the lock also keeps
|
|
203
|
+
// the network round-trip out of the critical section.
|
|
204
|
+
const files = (0, tela_pantry_1.verifySnapshotFiles)((0, tela_pantry_1.parseTarGz)(await (0, tela_pantry_1.downloadSkillArchive)(snapshot.archiveUrl)), snapshot.manifest);
|
|
205
|
+
const stagedInstalls = [];
|
|
206
|
+
let skippedReason = null;
|
|
207
|
+
let entry = null;
|
|
208
|
+
try {
|
|
209
|
+
entry = await (0, tela_local_config_1.updateInstalledSkill)(slug, async (registered) => {
|
|
210
|
+
if (expectedCurrent && (registered === null || !isSameInstall(registered, expectedCurrent))) {
|
|
211
|
+
skippedReason = `Skipped ${slug}: it was changed while this upgrade ran`;
|
|
212
|
+
return registered;
|
|
213
|
+
}
|
|
214
|
+
// Re-checked against the registry as it stands inside the lock. Read
|
|
215
|
+
// before acquiring it, this guard would answer for a moment that has
|
|
216
|
+
// passed by the time the files are written.
|
|
217
|
+
//
|
|
218
|
+
// An upgrade needs no exemption here: the entry it is upgrading names
|
|
219
|
+
// this very directory, so the guard already recognises it as ours.
|
|
220
|
+
if ((0, skills_1.isUnownedInstallDir)(target, registered?.dir ?? null) && !flags.force) {
|
|
221
|
+
throw new Error(`${target} already exists and was not installed by tela.\n`
|
|
222
|
+
+ `Run \`tela skills install ${slug} --force\` to replace it, or remove it by hand.`);
|
|
223
|
+
}
|
|
224
|
+
const staged = await (0, skills_1.stageSkillFiles)(target, files);
|
|
225
|
+
stagedInstalls.push(staged);
|
|
226
|
+
const link = flags.symlink
|
|
227
|
+
? await (0, skills_1.linkSkill)(roots, slug, target, flags.force)
|
|
228
|
+
: await removeRecordedSymlinks(registered?.symlinks ?? [], target, io);
|
|
229
|
+
if (link.skipped)
|
|
230
|
+
(0, output_1.writeError)(io, `Skipped symlink: ${link.skipped}`);
|
|
231
|
+
return {
|
|
232
|
+
slug,
|
|
233
|
+
dir: target,
|
|
234
|
+
sha: snapshot.sha,
|
|
235
|
+
bundle: ingredient.bundle,
|
|
236
|
+
symlinks: link.linked,
|
|
237
|
+
scripts: ingredient.scripts,
|
|
238
|
+
installedAt: new Date().toISOString(),
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
await rollbackStagedInstall(stagedInstalls.at(-1) ?? null, error);
|
|
244
|
+
}
|
|
245
|
+
if (skippedReason) {
|
|
246
|
+
(0, output_1.writeError)(io, skippedReason);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
for (const staged of stagedInstalls)
|
|
250
|
+
await staged.commit();
|
|
251
|
+
if (!entry)
|
|
252
|
+
return null;
|
|
253
|
+
// Outside the lock on purpose: the script is arbitrary shell from the
|
|
254
|
+
// catalog and may run for as long as it likes, which is exactly what must
|
|
255
|
+
// not happen while every other tela process waits on the inventory.
|
|
256
|
+
//
|
|
257
|
+
// That leaves a window, knowingly: a concurrent install of the same slug
|
|
258
|
+
// can replace this directory once the lock is released, and this script
|
|
259
|
+
// would then run against the newer install's files. Reaching it takes two
|
|
260
|
+
// tela processes on one slug, overlapping, a skill that declares an install
|
|
261
|
+
// script, and two snapshots whose scripts differ enough for the older to do
|
|
262
|
+
// damage. Guarding it was tried and reverted: the check costs a registry
|
|
263
|
+
// read on every install to catch only the case where the replacement has
|
|
264
|
+
// already finished, and a replacement landing mid-script is undetectable
|
|
265
|
+
// without holding the lock across arbitrary shell — which the refresh now
|
|
266
|
+
// sustains for as long as the script runs, so every other tela process
|
|
267
|
+
// would wait behind it instead of the lock ageing out.
|
|
268
|
+
await runLifecycleScript(entry, 'install', flags.ignoreScripts, io);
|
|
269
|
+
return entry;
|
|
270
|
+
}
|
|
271
|
+
async function removeRecordedSymlinks(symlinks, target, io) {
|
|
272
|
+
for (const linkPath of symlinks) {
|
|
273
|
+
const outcome = await (0, skills_1.removeManagedSymlink)(linkPath, target);
|
|
274
|
+
if (outcome.reason)
|
|
275
|
+
(0, output_1.writeError)(io, outcome.reason);
|
|
276
|
+
}
|
|
277
|
+
return { linked: [], skipped: null };
|
|
278
|
+
}
|
|
279
|
+
async function rollbackStagedInstall(staged, error) {
|
|
280
|
+
if (!staged)
|
|
281
|
+
throw error;
|
|
282
|
+
try {
|
|
283
|
+
await staged.rollback();
|
|
284
|
+
}
|
|
285
|
+
catch (cleanupError) {
|
|
286
|
+
throw new Error(`${formatError(error)}\nCleanup failed: ${formatError(cleanupError)}`);
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* No slugs means every installed skill: upgrading is additive, and "all of
|
|
292
|
+
* them" is the only reading of a bare `skills upgrade`.
|
|
293
|
+
*
|
|
294
|
+
* A named slug the registry does not know is refused for the whole command
|
|
295
|
+
* rather than skipped, so a typo among several cannot leave some skills
|
|
296
|
+
* upgraded and report success.
|
|
297
|
+
*/
|
|
298
|
+
function selectUpgradeTargets(installed, slugs) {
|
|
299
|
+
if (slugs.length === 0)
|
|
300
|
+
return [...installed];
|
|
301
|
+
const bySlug = new Map(installed.map(entry => [entry.slug, entry]));
|
|
302
|
+
const missing = slugs.filter(slug => !bySlug.has(slug));
|
|
303
|
+
if (missing.length > 0)
|
|
304
|
+
throw new Error(`${missing.join(', ')} not installed by tela. Nothing to upgrade.`);
|
|
305
|
+
return slugs.map(slug => bySlug.get(slug));
|
|
306
|
+
}
|
|
307
|
+
async function upgradeSkills(args, options, io) {
|
|
308
|
+
const flags = parseSkillFlags(args);
|
|
309
|
+
if ('error' in flags)
|
|
310
|
+
return usageError(flags.error, io);
|
|
311
|
+
const targets = selectUpgradeTargets(await (0, tela_local_config_1.listInstalledSkills)(), flags.rest);
|
|
312
|
+
if (targets.length === 0) {
|
|
313
|
+
if (options.json)
|
|
314
|
+
(0, output_1.writeJson)(io, { upgraded: [], unchanged: [] });
|
|
315
|
+
else
|
|
316
|
+
(0, output_1.writeLine)(io, 'No skills installed.');
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
const pantry = await (0, session_1.loadPantry)(flags);
|
|
320
|
+
const upgraded = [];
|
|
321
|
+
const unchanged = [];
|
|
322
|
+
for (const entry of targets) {
|
|
323
|
+
const { snapshot } = await (0, tela_pantry_1.fetchSkillFile)(pantry.client, entry.slug);
|
|
324
|
+
// Same snapshot means the same bytes: reinstalling would re-run the
|
|
325
|
+
// install script for no change.
|
|
326
|
+
if (snapshot.sha === entry.sha) {
|
|
327
|
+
unchanged.push(entry.slug);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// `flags` as the user gave them. Synthesising `force: true` here would
|
|
331
|
+
// reach past the install directory the upgrade owns and into the link
|
|
332
|
+
// path, where a directory another tool put there would be deleted.
|
|
333
|
+
const upgradedEntry = await installOne(pantry, entry.slug, flags, io, entry);
|
|
334
|
+
if (upgradedEntry)
|
|
335
|
+
upgraded.push(upgradedEntry);
|
|
336
|
+
}
|
|
337
|
+
if (options.json) {
|
|
338
|
+
(0, output_1.writeJson)(io, { upgraded, unchanged });
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
for (const entry of upgraded)
|
|
342
|
+
(0, output_1.writeLine)(io, `Upgraded ${entry.slug} to ${entry.sha.slice(0, 7)}`);
|
|
343
|
+
for (const slug of unchanged)
|
|
344
|
+
(0, output_1.writeLine)(io, `${slug} is already up to date`);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
async function uninstallSkills(args, options, io) {
|
|
348
|
+
const flags = parseSkillFlags(args);
|
|
349
|
+
if ('error' in flags)
|
|
350
|
+
return usageError(flags.error, io);
|
|
351
|
+
if (flags.rest.length === 0)
|
|
352
|
+
return usageError('Usage: tela skills uninstall <slug...> [--ignore-scripts]', io);
|
|
353
|
+
const installed = await (0, tela_local_config_1.listInstalledSkills)();
|
|
354
|
+
const bySlug = new Map(installed.map(entry => [entry.slug, entry]));
|
|
355
|
+
const missing = flags.rest.filter(slug => !bySlug.has(slug));
|
|
356
|
+
// Refused before anything is deleted rather than skipped over: with several
|
|
357
|
+
// slugs, removing the ones that resolved and ignoring a typo leaves a
|
|
358
|
+
// partial uninstall behind a success exit code.
|
|
359
|
+
if (missing.length > 0)
|
|
360
|
+
return usageError(`${missing.join(', ')} not installed by tela. Nothing to remove.`, io);
|
|
361
|
+
const removed = [];
|
|
362
|
+
const roots = (0, skills_1.resolveSkillRoots)();
|
|
363
|
+
for (const slug of flags.rest) {
|
|
364
|
+
// Registry-driven: an unregistered slug never reaches this loop, so a
|
|
365
|
+
// recursive delete can only ever be pointed at a directory tela
|
|
366
|
+
// recorded writing.
|
|
367
|
+
const entry = bySlug.get(slug);
|
|
368
|
+
if (!entry)
|
|
369
|
+
continue;
|
|
370
|
+
assertManagedInstallDir(slug, entry, roots);
|
|
371
|
+
// Before the lock: the script is arbitrary shell, it runs in the
|
|
372
|
+
// directory about to be deleted, and holding the inventory for its
|
|
373
|
+
// duration would block every other tela process.
|
|
374
|
+
await runLifecycleScript(entry, 'uninstall', flags.ignoreScripts, io);
|
|
375
|
+
let gone = false;
|
|
376
|
+
await (0, tela_local_config_1.updateInstalledSkill)(slug, async (registered) => {
|
|
377
|
+
// The entry read before the script ran may be stale: a concurrent
|
|
378
|
+
// uninstall already finished, or a concurrent install replaced it.
|
|
379
|
+
if (registered === null)
|
|
380
|
+
return null;
|
|
381
|
+
// Compared whole rather than by `dir`, which is derived from the
|
|
382
|
+
// slug and so is the same string for a reinstall — it cannot tell
|
|
383
|
+
// "still the entry whose script just ran" from "someone reinstalled
|
|
384
|
+
// while it ran". A reinstall carries a fresh `installedAt`, and an
|
|
385
|
+
// entry matching in every field describes the same sha recorded at
|
|
386
|
+
// the same instant, which this uninstall is entitled to remove.
|
|
387
|
+
if (!isSameInstall(registered, entry)) {
|
|
388
|
+
(0, output_1.writeError)(io, `Skipped ${slug}: it was reinstalled while this uninstall ran`);
|
|
389
|
+
return registered;
|
|
390
|
+
}
|
|
391
|
+
assertManagedInstallDir(slug, registered, roots);
|
|
392
|
+
for (const linkPath of registered.symlinks) {
|
|
393
|
+
const outcome = await (0, skills_1.removeManagedSymlink)(linkPath, registered.dir);
|
|
394
|
+
if (outcome.reason)
|
|
395
|
+
(0, output_1.writeError)(io, outcome.reason);
|
|
396
|
+
}
|
|
397
|
+
await (0, promises_1.rm)(registered.dir, { recursive: true, force: true });
|
|
398
|
+
gone = true;
|
|
399
|
+
return null;
|
|
400
|
+
});
|
|
401
|
+
if (gone)
|
|
402
|
+
removed.push(slug);
|
|
403
|
+
}
|
|
404
|
+
if (options.json) {
|
|
405
|
+
(0, output_1.writeJson)(io, { removed });
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
for (const slug of removed)
|
|
409
|
+
(0, output_1.writeLine)(io, `Removed ${slug}`);
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
function assertManagedInstallDir(slug, entry, roots) {
|
|
413
|
+
const expectedDir = (0, skills_1.resolveInstallDir)(roots, slug);
|
|
414
|
+
if (entry.slug !== slug || entry.dir !== expectedDir) {
|
|
415
|
+
throw new Error(`${slug} is recorded at ${entry.dir}, not the managed install directory ${expectedDir}. `
|
|
416
|
+
+ 'Refusing to remove it.');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Is this the same installation, rather than one that replaced it?
|
|
421
|
+
*
|
|
422
|
+
* `slug` and `dir` are both derived from the slug, so neither moves across a
|
|
423
|
+
* reinstall; `installedAt` and `sha` are what actually distinguish one install
|
|
424
|
+
* from the next.
|
|
425
|
+
*/
|
|
426
|
+
function isSameInstall(left, right) {
|
|
427
|
+
return left.installedAt === right.installedAt
|
|
428
|
+
&& left.sha === right.sha
|
|
429
|
+
&& left.dir === right.dir;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Run a skill's install or uninstall script, unless asked not to.
|
|
433
|
+
*
|
|
434
|
+
* A failing install script does not undo the install: by the time it runs the
|
|
435
|
+
* files are already on disk, and dropping the registry entry would strand a
|
|
436
|
+
* directory `uninstall` could no longer be pointed at. The failure is reported
|
|
437
|
+
* and the command exits non-zero instead.
|
|
438
|
+
*/
|
|
439
|
+
async function runLifecycleScript(entry, phase, ignoreScripts, io) {
|
|
440
|
+
const script = entry.scripts?.[phase];
|
|
441
|
+
if (!script)
|
|
442
|
+
return;
|
|
443
|
+
if (ignoreScripts) {
|
|
444
|
+
(0, output_1.writeError)(io, `Skipped ${phase} script for ${entry.slug}: ${script}`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
(0, output_1.writeError)(io, `Running ${phase} script for ${entry.slug}: ${script}`);
|
|
448
|
+
const outcome = await (0, skills_1.runSkillScript)(script, entry.dir);
|
|
449
|
+
if (outcome.output)
|
|
450
|
+
(0, output_1.writeError)(io, outcome.output);
|
|
451
|
+
if (!outcome.ok)
|
|
452
|
+
throw new Error(scriptFailureMessage(entry, phase));
|
|
453
|
+
}
|
|
454
|
+
function scriptFailureMessage(entry, phase) {
|
|
455
|
+
const opener = `${phase} script failed for ${entry.slug}.`;
|
|
456
|
+
if (phase === 'uninstall')
|
|
457
|
+
return `${opener}\n${entry.slug} was left in place. Re-run with --ignore-scripts to remove it anyway.`;
|
|
458
|
+
return `${opener}\n`
|
|
459
|
+
+ `${entry.slug} was installed to ${entry.dir} and recorded, but its install script did not `
|
|
460
|
+
+ 'finish, so the skill may not work.\n'
|
|
461
|
+
+ `Remove it with \`tela skills uninstall ${entry.slug}\`, or reinstall without the script: `
|
|
462
|
+
+ `\`tela skills install ${entry.slug} --ignore-scripts\`.`;
|
|
463
|
+
}
|
|
464
|
+
function parseSkillFlags(args) {
|
|
465
|
+
const flags = { force: false, symlink: true, ignoreScripts: false, rest: [] };
|
|
466
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
467
|
+
const arg = args[index];
|
|
468
|
+
if (arg === undefined)
|
|
469
|
+
continue;
|
|
470
|
+
if (arg === '--context' || arg === '--environment' || arg === '--auth-env') {
|
|
471
|
+
const value = args[index + 1];
|
|
472
|
+
if (!value)
|
|
473
|
+
return { error: `Expected value after ${arg}` };
|
|
474
|
+
if (arg === '--context')
|
|
475
|
+
flags.contextId = value;
|
|
476
|
+
else
|
|
477
|
+
flags.environmentId = value;
|
|
478
|
+
index += 1;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (arg === '--force' || arg === '-f') {
|
|
482
|
+
flags.force = true;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (arg === '--no-symlink') {
|
|
486
|
+
flags.symlink = false;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (arg === '--ignore-scripts') {
|
|
490
|
+
flags.ignoreScripts = true;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (arg === '--json')
|
|
494
|
+
continue;
|
|
495
|
+
if (arg.startsWith('-'))
|
|
496
|
+
return { error: `Unknown skills option: ${arg}` };
|
|
497
|
+
flags.rest.push(arg);
|
|
498
|
+
}
|
|
499
|
+
return flags;
|
|
500
|
+
}
|
|
501
|
+
function usageError(message, io) {
|
|
502
|
+
(0, output_1.writeError)(io, message);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
function writeSkillsHelp(io) {
|
|
506
|
+
(0, output_1.writeLine)(io, 'Usage: tela skills <command> [args]');
|
|
507
|
+
(0, output_1.writeLine)(io);
|
|
508
|
+
(0, output_1.writeLine)(io, 'Commands:');
|
|
509
|
+
(0, output_1.writeLine)(io, ' skills list [--json]');
|
|
510
|
+
(0, output_1.writeLine)(io, ' skills search <query> [--json]');
|
|
511
|
+
(0, output_1.writeLine)(io, ' skills files <slug> [--json]');
|
|
512
|
+
(0, output_1.writeLine)(io, ' skills cat <slug> [path]');
|
|
513
|
+
(0, output_1.writeLine)(io, ' skills install <slug...> [-f|--force] [--no-symlink] [--ignore-scripts] [--json]');
|
|
514
|
+
(0, output_1.writeLine)(io, ' skills upgrade [slug...] [--ignore-scripts] [--json]');
|
|
515
|
+
(0, output_1.writeLine)(io, ' skills uninstall <slug...> [--ignore-scripts] [--json]');
|
|
516
|
+
}
|
|
517
|
+
function formatError(error) {
|
|
518
|
+
return error instanceof Error ? error.message : String(error);
|
|
519
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
build,
|
|
15
15
|
canvas,
|
|
16
16
|
code,
|
|
17
|
+
crop,
|
|
17
18
|
input,
|
|
18
19
|
llm,
|
|
19
20
|
splitter,
|
|
@@ -32,6 +33,8 @@ import type { WorkflowCodeConfig } from '@meistrari/workflow-code'
|
|
|
32
33
|
| [`input.file()`](#inputfileoptions) | File input supplied by upload or Vault reference. |
|
|
33
34
|
| [`llm()`](#llmoptions) | Single model-call transformation with optional structured output. |
|
|
34
35
|
| [`splitter()`](#splitteroptions) | Splitting file inputs, compatible prior outputs, or existing Vault files. |
|
|
36
|
+
| [`crop()`](#cropoptions) | Cropping selected PDF pages from compatible files or existing Vault files. |
|
|
37
|
+
| [`vault.asset()`](#vaultassetpath-options) | Declaring a local file that `sync` uploads and locks in Vault. |
|
|
35
38
|
| [`vault.ref()`](#vaultrefreference) | Declaring an inert reference to an existing `vault://...` file. |
|
|
36
39
|
| [`remote()`](#remote) | Generated catalog paths and explicit ID refs for inert remote authoring. |
|
|
37
40
|
| [`canvas()`](#canvasremoteref-options) | Executable Canvas or Workflow refs from the remote catalog. |
|
|
@@ -390,7 +393,7 @@ type SplitterOptions = {
|
|
|
390
393
|
function splitter(options: SplitterOptions): SplitterNodeHandle
|
|
391
394
|
```
|
|
392
395
|
|
|
393
|
-
Declares the Workflow V3 `document-splitter` action. Its `input.file` is an exclusive reference field: it accepts a compatible workflow file input or previous-node output, one `vault.ref()`, or an array of Vault
|
|
396
|
+
Declares the Workflow V3 `document-splitter` action. Its `input.file` is an exclusive reference field: it accepts a compatible workflow file input or previous-node output, one `vault.ref()` or `vault.asset()`, or an array containing one of those Vault declaration kinds. It does not accept arbitrary strings or text references.
|
|
394
397
|
|
|
395
398
|
| Option | Type | Required | Default | Description |
|
|
396
399
|
| --- | --- | --- | --- | --- |
|
|
@@ -471,6 +474,82 @@ const generatedCodePages = splitter({
|
|
|
471
474
|
|
|
472
475
|
The referenced runtime value still needs to describe a real file. Declaring `fileUrl` or `vaultReference` in an LLM schema does not upload or create a Vault file.
|
|
473
476
|
|
|
477
|
+
### `crop(options)`
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
type CropOptions = {
|
|
481
|
+
name: string
|
|
482
|
+
key?: string
|
|
483
|
+
input: {
|
|
484
|
+
file: WorkflowCodeExclusiveFileSource
|
|
485
|
+
indexes: readonly (number | string)[]
|
|
486
|
+
type?: 'pdf'
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function crop(options: CropOptions): CropNodeHandle
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Declares Workflow V3's existing `document-cropper` action. Its exclusive `input.file` supports the same compatible workflow input, prior-node output, `vault.ref()`, `vault.asset()`, and Vault-array forms as `splitter.input.file`.
|
|
494
|
+
|
|
495
|
+
| Option | Type | Required | Default | Description |
|
|
496
|
+
| --- | --- | --- | --- | --- |
|
|
497
|
+
| `name` | `string` | Yes | — | Display name and fallback node identity. |
|
|
498
|
+
| `key` | `string` | No | `name` | Stable identity when the display name may change. |
|
|
499
|
+
| `input.file` | `WorkflowCodeExclusiveFileSource` | Yes | — | File/File-array workflow reference, `vault.ref()`, `vault.asset()`, or an array of Vault declarations. |
|
|
500
|
+
| `input.indexes` | `readonly (number \| string)[]` | Yes | — | Integer page indexes or cropper-supported ranges such as `'3-5'`. |
|
|
501
|
+
| `input.type` | `'pdf'` | No | `'pdf'` | Cropper document type. |
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
const selectedPages = crop({
|
|
505
|
+
name: 'Crop selected invoice pages',
|
|
506
|
+
input: {
|
|
507
|
+
file: ({ ref }) => ref(invoice),
|
|
508
|
+
indexes: [1, '3-5'],
|
|
509
|
+
},
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
const cropAgain = crop({
|
|
513
|
+
name: 'Crop prior result',
|
|
514
|
+
input: {
|
|
515
|
+
file: ({ ref, paths }) => ref(selectedPages, paths(selectedPages)[0].file),
|
|
516
|
+
indexes: [2],
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
The result is a typed array whose items contain `indexes`, `originalFile`, and `file`. Compile validates file compatibility before lowering input and previous-node refs to Workflow V3 URIs; direct Vault refs lower to the cropper's transport-file shape. Source evaluation and offline compilation do not contact Vault or invoke prior nodes.
|
|
522
|
+
|
|
523
|
+
### `vault.asset(path, options?)`
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
const vault: {
|
|
527
|
+
asset: (
|
|
528
|
+
path: string,
|
|
529
|
+
options?: { name?: string, mimeType?: string },
|
|
530
|
+
) => WorkflowCodeVaultAsset
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Creates an inert declaration for a local file that should be available to `splitter()` or `crop()`. Source evaluation and offline compile do not read the file, upload it, or require credentials. `sync` resolves the path relative to `workflow-code.config.ts`, rejects paths outside that project directory, hashes the content, and uploads it before the first workflow lifecycle mutation. A matching content hash in the active context lock reuses the existing Vault ref.
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
const localInvoice = vault.asset('./fixtures/invoice.pdf', {
|
|
538
|
+
name: 'invoice.pdf',
|
|
539
|
+
mimeType: 'application/pdf',
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
const selectedPages = crop({
|
|
543
|
+
name: 'Crop local invoice',
|
|
544
|
+
input: {
|
|
545
|
+
file: localInvoice,
|
|
546
|
+
indexes: [1, '3-5'],
|
|
547
|
+
},
|
|
548
|
+
})
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
`path`, `name`, and `mimeType` must be non-empty strings when present. Offline compile without a matching explicit asset snapshot reports `vault.asset.missing_lock`; it never falls back to reading the local file. If the file changes, the next `sync` uploads the new content and updates the lock.
|
|
552
|
+
|
|
474
553
|
### `vault.ref(reference)`
|
|
475
554
|
|
|
476
555
|
```ts
|
|
@@ -494,7 +573,7 @@ const existingPages = splitter({
|
|
|
494
573
|
})
|
|
495
574
|
```
|
|
496
575
|
|
|
497
|
-
During compile, a direct Vault ref used by `splitter.input.file` lowers to a transport-file object containing both `fileUrl` and `vaultReference`. Compile remains offline. Before remote workflow mutation, sync and publish validate access to every compiled Vault ref in the active Tela context.
|
|
576
|
+
During compile, a direct Vault ref used by `splitter.input.file` or `crop.input.file` lowers to a transport-file object containing both `fileUrl` and `vaultReference`. Compile remains offline. Before remote workflow mutation, sync and publish validate access to every compiled Vault ref in the active Tela context.
|
|
498
577
|
|
|
499
578
|
### `agent(remoteRef, options)`
|
|
500
579
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"slug": "api-reference",
|
|
15
15
|
"title": "API Reference",
|
|
16
16
|
"file": "api-reference.md",
|
|
17
|
-
"sha256": "
|
|
17
|
+
"sha256": "fc121fd8c0e83e4f39ecef45b50d4fc9b0bc64888bd10d0590b3ca168af0a42d"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"slug": "cli",
|