@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
package/dist/skills.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
package/skills/tela-cli/SKILL.md
CHANGED
|
@@ -16,9 +16,9 @@ safe in non-interactive agent sessions.
|
|
|
16
16
|
## Core Rules
|
|
17
17
|
|
|
18
18
|
- Use `tela agent ...` commands, not legacy top-level `sync`, `run`, `push`, or `feedback`.
|
|
19
|
-
- Run `tela update` before relying on
|
|
20
|
-
- Query Pantry before building a service integration from scratch: `tela api pantry GET /ingredients query==<term>`.
|
|
21
|
-
- Read endpoint or library docs before calling a service: `tela docs endpoints <slug>` then `tela docs reference <slug> <METHOD> <PATH>`.
|
|
19
|
+
- Run `tela service update` before relying on service slugs; it refreshes the local inventory under `TELA_HOME`.
|
|
20
|
+
- Query Pantry before building a service integration from scratch: `tela service api pantry GET /ingredients query==<term>`.
|
|
21
|
+
- Read endpoint or library docs before calling a service: `tela service docs endpoints <slug>` then `tela service docs reference <slug> <METHOD> <PATH>`.
|
|
22
22
|
- Prefer `--json` for machine-readable output and errors.
|
|
23
23
|
- Do not rely on prompts in coding-agent sessions; pass every required value by flag.
|
|
24
24
|
- Do not print token material. Use `tela auth token --json` for metadata and `--raw` only when a shell command explicitly needs the token.
|
|
@@ -26,18 +26,26 @@ safe in non-interactive agent sessions.
|
|
|
26
26
|
## Pantry and Service Commands
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
tela update --json
|
|
30
|
-
tela
|
|
31
|
-
tela
|
|
32
|
-
tela
|
|
33
|
-
tela docs
|
|
34
|
-
tela
|
|
35
|
-
tela
|
|
29
|
+
tela service update --json
|
|
30
|
+
tela service list --json
|
|
31
|
+
tela service show <slug> --json
|
|
32
|
+
tela service api <slug> <METHOD> <PATH> [args...] --service-env <slug> --body <json> --pretty
|
|
33
|
+
tela service docs endpoints <slug> --pretty
|
|
34
|
+
tela service docs reference <slug> [METHOD PATH|file-path] --pretty
|
|
35
|
+
tela service docs guide <slug> [path]
|
|
36
|
+
tela service env list <slug> --pretty
|
|
37
|
+
tela service env use <slug> <environment>
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
HTTPie-style args for `tela api`:
|
|
40
|
+
HTTPie-style args for `tela service api`:
|
|
39
41
|
|
|
40
42
|
- `name=value` sends a JSON string field.
|
|
41
43
|
- `name:=value` sends a raw JSON field.
|
|
42
44
|
- `name==value` sends a query parameter.
|
|
43
45
|
- `Header:value` sends a request header.
|
|
46
|
+
|
|
47
|
+
## Authentication
|
|
48
|
+
|
|
49
|
+
Use `tela auth login --no-interactive` in an agent session. Open the URL it
|
|
50
|
+
prints, authorize the code, then run the exact `tela auth login --device-code`
|
|
51
|
+
follow-up command. Use `tela auth whoami --json` to inspect the selected context.
|