@meistrari/tela-cli 1.8.0 → 1.9.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.
- package/dist/commands/ingredients.js +1 -22
- package/dist/commands/skills.d.ts +2 -19
- package/dist/commands/skills.js +29 -251
- package/dist/index.js +84048 -82945
- package/package.json +1 -1
- package/dist/skills.d.ts +0 -87
- package/dist/skills.js +0 -303
package/package.json
CHANGED
package/dist/skills.d.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import type { SkillFileContent } from '@meistrari/tela-pantry';
|
|
2
|
-
export type SkillRoots = {
|
|
3
|
-
/** Where the skill's files actually live. */
|
|
4
|
-
installRoot: string;
|
|
5
|
-
/** Where a symlink back to the install directory is created. */
|
|
6
|
-
linkRoot: string;
|
|
7
|
-
};
|
|
8
|
-
export type StagedSkillFiles = {
|
|
9
|
-
commit: () => Promise<void>;
|
|
10
|
-
rollback: () => Promise<void>;
|
|
11
|
-
};
|
|
12
|
-
/**
|
|
13
|
-
* One real copy under `~/.agents/skills`, symlinked into `~/.claude/skills`.
|
|
14
|
-
*
|
|
15
|
-
* Agent tooling reads a fixed pair of locations, so there is nowhere else worth
|
|
16
|
-
* installing to; the roots stay parameterised only so tests can work inside a
|
|
17
|
-
* temporary home.
|
|
18
|
-
*/
|
|
19
|
-
export declare function resolveSkillRoots(home?: string): SkillRoots;
|
|
20
|
-
/**
|
|
21
|
-
* A slug reaches `path.join` and, eventually, a recursive delete. `path.join`
|
|
22
|
-
* collapses `..`, so validation has to happen before the join — and both
|
|
23
|
-
* clauses are needed: `isSafeSkillPath` is an archive-path predicate that
|
|
24
|
-
* accepts `a/b`, which as a slug is a traversal into a sibling directory.
|
|
25
|
-
*/
|
|
26
|
-
export declare function assertSafeSlug(slug: string, action: string): void;
|
|
27
|
-
export declare function resolveInstallDir(roots: SkillRoots, slug: string): string;
|
|
28
|
-
/**
|
|
29
|
-
* The install guard: is something already sitting at the target that this CLI
|
|
30
|
-
* did not put there?
|
|
31
|
-
*
|
|
32
|
-
* Compares the *recorded directory* rather than asking whether the slug is
|
|
33
|
-
* registered at all. A registration pointing somewhere else is not permission
|
|
34
|
-
* to replace what is at this path — which may be a directory the user created
|
|
35
|
-
* by hand, or one another tool owns.
|
|
36
|
-
*/
|
|
37
|
-
export declare function isUnownedInstallDir(target: string, registeredDir: string | null): boolean;
|
|
38
|
-
/**
|
|
39
|
-
* Write the verified files to `target`, replacing whatever is there.
|
|
40
|
-
*
|
|
41
|
-
* Nothing is deleted while the target is at risk. Everything is extracted to a
|
|
42
|
-
* staging directory first, the previous install is renamed aside, the staged
|
|
43
|
-
* copy is renamed in, and only then is the old copy dropped. Every destructive
|
|
44
|
-
* step is a rename — which either happens or does not — and each has an
|
|
45
|
-
* inverse, so an interrupted install can never leave a half-written skill where
|
|
46
|
-
* a working one used to be.
|
|
47
|
-
*/
|
|
48
|
-
export declare function materializeSkillFiles(target: string, files: readonly SkillFileContent[]): Promise<void>;
|
|
49
|
-
export declare function stageSkillFiles(target: string, files: readonly SkillFileContent[]): Promise<StagedSkillFiles>;
|
|
50
|
-
export type LinkOutcome = {
|
|
51
|
-
linked: string[];
|
|
52
|
-
/** Set when linking was skipped, explaining what is in the way. */
|
|
53
|
-
skipped: string | null;
|
|
54
|
-
};
|
|
55
|
-
/**
|
|
56
|
-
* Symlink the install directory into the link root.
|
|
57
|
-
*
|
|
58
|
-
* A real directory already at the link path is left alone unless `force` says
|
|
59
|
-
* otherwise: it is something another tool or the user put there, and replacing
|
|
60
|
-
* it silently would delete files this CLI never wrote. A symlink pointing
|
|
61
|
-
* somewhere other than the install directory gets the same treatment.
|
|
62
|
-
*/
|
|
63
|
-
export declare function linkSkill(roots: SkillRoots, slug: string, target: string, force: boolean): Promise<LinkOutcome>;
|
|
64
|
-
/**
|
|
65
|
-
* Remove a symlink this CLI created that still points at the install directory.
|
|
66
|
-
*
|
|
67
|
-
* Every other case is reported and skipped. The link may since have been
|
|
68
|
-
* replaced by a real directory belonging to something else, and uninstalling a
|
|
69
|
-
* skill is not licence to delete it.
|
|
70
|
-
*/
|
|
71
|
-
export declare function removeManagedSymlink(linkPath: string, target: string): Promise<{
|
|
72
|
-
removed: boolean;
|
|
73
|
-
reason: string | null;
|
|
74
|
-
}>;
|
|
75
|
-
export type ScriptOutcome = {
|
|
76
|
-
ok: boolean;
|
|
77
|
-
output: string;
|
|
78
|
-
};
|
|
79
|
-
/**
|
|
80
|
-
* Run a skill's declared install or uninstall script, in the skill's own
|
|
81
|
-
* directory.
|
|
82
|
-
*
|
|
83
|
-
* Both streams are captured and handed back rather than inherited: the script
|
|
84
|
-
* is arbitrary shell from the catalog, and letting it write to stdout would
|
|
85
|
-
* corrupt `--json` output. The caller relays them as diagnostics on stderr.
|
|
86
|
-
*/
|
|
87
|
-
export declare function runSkillScript(script: string, cwd: string): Promise<ScriptOutcome>;
|
package/dist/skills.js
DELETED
|
@@ -1,303 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.resolveSkillRoots = resolveSkillRoots;
|
|
7
|
-
exports.assertSafeSlug = assertSafeSlug;
|
|
8
|
-
exports.resolveInstallDir = resolveInstallDir;
|
|
9
|
-
exports.isUnownedInstallDir = isUnownedInstallDir;
|
|
10
|
-
exports.materializeSkillFiles = materializeSkillFiles;
|
|
11
|
-
exports.stageSkillFiles = stageSkillFiles;
|
|
12
|
-
exports.linkSkill = linkSkill;
|
|
13
|
-
exports.removeManagedSymlink = removeManagedSymlink;
|
|
14
|
-
exports.runSkillScript = runSkillScript;
|
|
15
|
-
const node_child_process_1 = require("node:child_process");
|
|
16
|
-
const node_fs_1 = require("node:fs");
|
|
17
|
-
const promises_1 = require("node:fs/promises");
|
|
18
|
-
const node_os_1 = require("node:os");
|
|
19
|
-
const node_path_1 = __importDefault(require("node:path"));
|
|
20
|
-
const node_process_1 = __importDefault(require("node:process"));
|
|
21
|
-
const node_util_1 = require("node:util");
|
|
22
|
-
const tela_pantry_1 = require("@meistrari/tela-pantry");
|
|
23
|
-
const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
24
|
-
/**
|
|
25
|
-
* One real copy under `~/.agents/skills`, symlinked into `~/.claude/skills`.
|
|
26
|
-
*
|
|
27
|
-
* Agent tooling reads a fixed pair of locations, so there is nowhere else worth
|
|
28
|
-
* installing to; the roots stay parameterised only so tests can work inside a
|
|
29
|
-
* temporary home.
|
|
30
|
-
*/
|
|
31
|
-
function resolveSkillRoots(home = (0, node_os_1.homedir)()) {
|
|
32
|
-
return {
|
|
33
|
-
installRoot: node_path_1.default.join(home, '.agents', 'skills'),
|
|
34
|
-
linkRoot: node_path_1.default.join(home, '.claude', 'skills'),
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* A slug reaches `path.join` and, eventually, a recursive delete. `path.join`
|
|
39
|
-
* collapses `..`, so validation has to happen before the join — and both
|
|
40
|
-
* clauses are needed: `isSafeSkillPath` is an archive-path predicate that
|
|
41
|
-
* accepts `a/b`, which as a slug is a traversal into a sibling directory.
|
|
42
|
-
*/
|
|
43
|
-
function assertSafeSlug(slug, action) {
|
|
44
|
-
if (!(0, tela_pantry_1.isSafeSkillPath)(slug) || slug.includes('/'))
|
|
45
|
-
throw new Error(`Refusing to ${action}: "${slug}" is not a valid skill slug.`);
|
|
46
|
-
}
|
|
47
|
-
function resolveInstallDir(roots, slug) {
|
|
48
|
-
assertSafeSlug(slug, 'resolve');
|
|
49
|
-
return node_path_1.default.join(roots.installRoot, slug);
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* The install guard: is something already sitting at the target that this CLI
|
|
53
|
-
* did not put there?
|
|
54
|
-
*
|
|
55
|
-
* Compares the *recorded directory* rather than asking whether the slug is
|
|
56
|
-
* registered at all. A registration pointing somewhere else is not permission
|
|
57
|
-
* to replace what is at this path — which may be a directory the user created
|
|
58
|
-
* by hand, or one another tool owns.
|
|
59
|
-
*/
|
|
60
|
-
function isUnownedInstallDir(target, registeredDir) {
|
|
61
|
-
return (0, node_fs_1.existsSync)(target) && registeredDir !== target;
|
|
62
|
-
}
|
|
63
|
-
// Both the incoming extraction and the displaced previous install are parked
|
|
64
|
-
// here. A slug can never contain `/`, so an install directory is always exactly
|
|
65
|
-
// one component below the root — which means a scratch path two components
|
|
66
|
-
// below it can never *be* some skill's install directory. The leading dot keeps
|
|
67
|
-
// it out of the `*/SKILL.md` sweep an agent runs over the skills root, so
|
|
68
|
-
// neither a half-written skill nor a displaced old one is ever advertised.
|
|
69
|
-
const STAGING_DIR_NAME = '.tela-staging';
|
|
70
|
-
function scratchDir(target, role) {
|
|
71
|
-
const unique = `${node_process_1.default.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
72
|
-
return node_path_1.default.join(node_path_1.default.dirname(target), STAGING_DIR_NAME, `${node_path_1.default.basename(target)}.${unique}.${role}`);
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Write the verified files to `target`, replacing whatever is there.
|
|
76
|
-
*
|
|
77
|
-
* Nothing is deleted while the target is at risk. Everything is extracted to a
|
|
78
|
-
* staging directory first, the previous install is renamed aside, the staged
|
|
79
|
-
* copy is renamed in, and only then is the old copy dropped. Every destructive
|
|
80
|
-
* step is a rename — which either happens or does not — and each has an
|
|
81
|
-
* inverse, so an interrupted install can never leave a half-written skill where
|
|
82
|
-
* a working one used to be.
|
|
83
|
-
*/
|
|
84
|
-
async function materializeSkillFiles(target, files) {
|
|
85
|
-
const staged = await stageSkillFiles(target, files);
|
|
86
|
-
await staged.commit();
|
|
87
|
-
}
|
|
88
|
-
async function stageSkillFiles(target, files) {
|
|
89
|
-
const staging = scratchDir(target, 'staging');
|
|
90
|
-
const backup = scratchDir(target, 'backup');
|
|
91
|
-
let displaced = false;
|
|
92
|
-
try {
|
|
93
|
-
// Created up front rather than left to the write loop: the shared
|
|
94
|
-
// scratch parent has to exist before the target can be renamed into it.
|
|
95
|
-
// A manifest declaring no files would otherwise never create it, and
|
|
96
|
-
// the displacement below would fail with an ENOENT for its
|
|
97
|
-
// *destination* that reads exactly like "there was no previous install".
|
|
98
|
-
await (0, promises_1.mkdir)(staging, { recursive: true });
|
|
99
|
-
for (const file of files) {
|
|
100
|
-
const filePath = node_path_1.default.join(staging, file.path);
|
|
101
|
-
await (0, promises_1.mkdir)(node_path_1.default.dirname(filePath), { recursive: true });
|
|
102
|
-
await (0, promises_1.writeFile)(filePath, file.data);
|
|
103
|
-
}
|
|
104
|
-
displaced = await swapInStagedInstall(staging, target, backup);
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
// Only the staging copy is dropped, and only after the swap has had its
|
|
108
|
-
// chance to put the previous install back: a backup still on disk here
|
|
109
|
-
// is one the rollback could not restore, and it is the user's only copy.
|
|
110
|
-
await (0, promises_1.rm)(staging, { recursive: true, force: true });
|
|
111
|
-
await removeScratchParent(staging);
|
|
112
|
-
throw error;
|
|
113
|
-
}
|
|
114
|
-
return {
|
|
115
|
-
commit: async () => {
|
|
116
|
-
if (displaced) {
|
|
117
|
-
// Best effort: the swap has landed, so what is on disk is
|
|
118
|
-
// already the correct install and a failure here costs a stray
|
|
119
|
-
// directory under the dotted scratch parent, nothing more.
|
|
120
|
-
await (0, promises_1.rm)(backup, { recursive: true, force: true }).catch(() => { });
|
|
121
|
-
}
|
|
122
|
-
await removeScratchParent(staging);
|
|
123
|
-
},
|
|
124
|
-
rollback: async () => {
|
|
125
|
-
try {
|
|
126
|
-
await restoreStagedInstall(target, backup, displaced);
|
|
127
|
-
}
|
|
128
|
-
finally {
|
|
129
|
-
await (0, promises_1.rm)(staging, { recursive: true, force: true }).catch(() => { });
|
|
130
|
-
await removeScratchParent(staging);
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
async function swapInStagedInstall(staging, target, backup) {
|
|
136
|
-
// `rename`, not `existsSync` then `rename`: whether something was there has
|
|
137
|
-
// to be answered by the operation that moves it, or the answer is a guess
|
|
138
|
-
// taken at a different instant.
|
|
139
|
-
let displaced = true;
|
|
140
|
-
try {
|
|
141
|
-
await (0, promises_1.rename)(target, backup);
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
if (error.code !== 'ENOENT')
|
|
145
|
-
throw error;
|
|
146
|
-
displaced = false;
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
await (0, promises_1.rename)(staging, target);
|
|
150
|
-
}
|
|
151
|
-
catch (error) {
|
|
152
|
-
if (!displaced)
|
|
153
|
-
throw error;
|
|
154
|
-
try {
|
|
155
|
-
await (0, promises_1.rename)(backup, target);
|
|
156
|
-
}
|
|
157
|
-
catch (restoreError) {
|
|
158
|
-
throw new Error(`could not move the new install into place (${describeError(error)}), and the `
|
|
159
|
-
+ `previous installation could not be moved back (${describeError(restoreError)}).\n`
|
|
160
|
-
+ `There is now nothing at ${target}. The previous installation is intact at `
|
|
161
|
-
+ `${backup} — move that directory back to ${target} by hand to recover it.`);
|
|
162
|
-
}
|
|
163
|
-
throw error;
|
|
164
|
-
}
|
|
165
|
-
return displaced;
|
|
166
|
-
}
|
|
167
|
-
async function restoreStagedInstall(target, backup, displaced) {
|
|
168
|
-
await (0, promises_1.rm)(target, { recursive: true, force: true });
|
|
169
|
-
if (!displaced)
|
|
170
|
-
return;
|
|
171
|
-
try {
|
|
172
|
-
await (0, promises_1.rename)(backup, target);
|
|
173
|
-
}
|
|
174
|
-
catch (error) {
|
|
175
|
-
throw new Error(`could not roll back ${target} to its previous installation (${describeError(error)}). `
|
|
176
|
-
+ `The previous installation is intact at ${backup} — move that directory back by hand to recover it.`);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
async function removeScratchParent(staging) {
|
|
180
|
-
// Non-recursive on purpose: `rmdir` only succeeds on an empty directory, so
|
|
181
|
-
// it tidies the shared scratch parent without any chance of taking a
|
|
182
|
-
// concurrent install's staging directory — or an unrestorable backup — with
|
|
183
|
-
// it.
|
|
184
|
-
await (0, promises_1.rmdir)(node_path_1.default.dirname(staging)).catch(() => { });
|
|
185
|
-
}
|
|
186
|
-
/**
|
|
187
|
-
* Symlink the install directory into the link root.
|
|
188
|
-
*
|
|
189
|
-
* A real directory already at the link path is left alone unless `force` says
|
|
190
|
-
* otherwise: it is something another tool or the user put there, and replacing
|
|
191
|
-
* it silently would delete files this CLI never wrote. A symlink pointing
|
|
192
|
-
* somewhere other than the install directory gets the same treatment.
|
|
193
|
-
*/
|
|
194
|
-
async function linkSkill(roots, slug, target, force) {
|
|
195
|
-
const linkPath = node_path_1.default.join(roots.linkRoot, slug);
|
|
196
|
-
const replaceInstead = `Run \`tela skills install ${slug} --force\` to replace it, `
|
|
197
|
-
+ 'or remove it by hand.';
|
|
198
|
-
try {
|
|
199
|
-
await (0, promises_1.mkdir)(roots.linkRoot, { recursive: true });
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
return {
|
|
203
|
-
linked: [],
|
|
204
|
-
skipped: `${linkPath} could not be created (${describeError(error)}). ${replaceInstead}`,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
const linkState = inspectLinkPath(linkPath);
|
|
208
|
-
if (linkState.kind === 'non-symlink' && !force)
|
|
209
|
-
return { linked: [], skipped: `${linkPath} exists and is not a symlink. ${replaceInstead}` };
|
|
210
|
-
if (linkState.kind === 'unreadable' && !force)
|
|
211
|
-
return { linked: [], skipped: `${linkPath} could not be inspected (${linkState.reason}). ${replaceInstead}` };
|
|
212
|
-
if (linkState.kind === 'symlink' && node_path_1.default.resolve(linkState.target) !== node_path_1.default.resolve(target) && !force) {
|
|
213
|
-
return {
|
|
214
|
-
linked: [],
|
|
215
|
-
skipped: `${linkPath} points at ${linkState.target}, not ${target}. ${replaceInstead}`,
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
try {
|
|
219
|
-
// Recursive only under `--force`. The check above is a separate syscall
|
|
220
|
-
// from this one, so a directory can appear at the path in between —
|
|
221
|
-
// and without force the only thing here that is ours to remove is a
|
|
222
|
-
// symlink. A non-recursive `rm` fails with EISDIR rather than deleting
|
|
223
|
-
// a tree nobody authorised replacing.
|
|
224
|
-
await (0, promises_1.rm)(linkPath, { recursive: force, force: true });
|
|
225
|
-
// Relative, so the pair survives the home directory being moved or
|
|
226
|
-
// mounted at a different path.
|
|
227
|
-
await (0, promises_1.symlink)(node_path_1.default.relative(roots.linkRoot, target), linkPath);
|
|
228
|
-
}
|
|
229
|
-
catch (error) {
|
|
230
|
-
return {
|
|
231
|
-
linked: [],
|
|
232
|
-
skipped: `${linkPath} could not be replaced (${describeError(error)}). ${replaceInstead}`,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
return { linked: [linkPath], skipped: null };
|
|
236
|
-
}
|
|
237
|
-
function inspectLinkPath(linkPath) {
|
|
238
|
-
try {
|
|
239
|
-
if (!(0, node_fs_1.lstatSync)(linkPath).isSymbolicLink())
|
|
240
|
-
return { kind: 'non-symlink' };
|
|
241
|
-
return {
|
|
242
|
-
kind: 'symlink',
|
|
243
|
-
target: node_path_1.default.resolve(node_path_1.default.dirname(linkPath), (0, node_fs_1.readlinkSync)(linkPath)),
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
catch (error) {
|
|
247
|
-
if (error.code === 'ENOENT')
|
|
248
|
-
return { kind: 'missing' };
|
|
249
|
-
return { kind: 'unreadable', reason: describeError(error) };
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Remove a symlink this CLI created that still points at the install directory.
|
|
254
|
-
*
|
|
255
|
-
* Every other case is reported and skipped. The link may since have been
|
|
256
|
-
* replaced by a real directory belonging to something else, and uninstalling a
|
|
257
|
-
* skill is not licence to delete it.
|
|
258
|
-
*/
|
|
259
|
-
async function removeManagedSymlink(linkPath, target) {
|
|
260
|
-
try {
|
|
261
|
-
if (!(0, node_fs_1.lstatSync)(linkPath).isSymbolicLink())
|
|
262
|
-
return { removed: false, reason: `Skipped ${linkPath}: not a symlink` };
|
|
263
|
-
const resolved = node_path_1.default.resolve(node_path_1.default.dirname(linkPath), (0, node_fs_1.readlinkSync)(linkPath));
|
|
264
|
-
if (node_path_1.default.resolve(resolved) !== node_path_1.default.resolve(target))
|
|
265
|
-
return { removed: false, reason: `Skipped ${linkPath}: points at ${resolved}, not ${target}` };
|
|
266
|
-
// Non-recursive on purpose: if the entry is swapped for a real
|
|
267
|
-
// directory between the check above and this call, `rm` fails with
|
|
268
|
-
// EISDIR rather than deleting a tree.
|
|
269
|
-
await (0, promises_1.rm)(linkPath, { force: true });
|
|
270
|
-
return { removed: true, reason: null };
|
|
271
|
-
}
|
|
272
|
-
catch (error) {
|
|
273
|
-
// The link can change under us between any two of these calls. A throw
|
|
274
|
-
// here would abort an otherwise fine uninstall, leaving the skill both
|
|
275
|
-
// registered and on disk.
|
|
276
|
-
return { removed: false, reason: `Skipped ${linkPath}: ${describeError(error)}` };
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Run a skill's declared install or uninstall script, in the skill's own
|
|
281
|
-
* directory.
|
|
282
|
-
*
|
|
283
|
-
* Both streams are captured and handed back rather than inherited: the script
|
|
284
|
-
* is arbitrary shell from the catalog, and letting it write to stdout would
|
|
285
|
-
* corrupt `--json` output. The caller relays them as diagnostics on stderr.
|
|
286
|
-
*/
|
|
287
|
-
async function runSkillScript(script, cwd) {
|
|
288
|
-
try {
|
|
289
|
-
const { stdout, stderr } = await run('sh', ['-c', script], { cwd });
|
|
290
|
-
return { ok: true, output: [stdout, stderr].map(text => text.trimEnd()).filter(Boolean).join('\n') };
|
|
291
|
-
}
|
|
292
|
-
catch (error) {
|
|
293
|
-
const shell = error;
|
|
294
|
-
const output = [shell.stdout ?? '', shell.stderr ?? '', describeError(error)]
|
|
295
|
-
.map(text => text.trimEnd())
|
|
296
|
-
.filter(Boolean)
|
|
297
|
-
.join('\n');
|
|
298
|
-
return { ok: false, output };
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
function describeError(error) {
|
|
302
|
-
return error instanceof Error ? error.message : String(error);
|
|
303
|
-
}
|