@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.
@@ -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 refs. It does not accept arbitrary strings or text references.
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": "2306f6a21d23b4e3457262c3105e65e0292af5a59154aa15cf24188409db07f8"
17
+ "sha256": "fc121fd8c0e83e4f39ecef45b50d4fc9b0bc64888bd10d0590b3ca168af0a42d"
18
18
  },
19
19
  {
20
20
  "slug": "cli",