@meistrari/tela-cli 1.1.0 → 1.2.1

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
+ }