@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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Tela command line tools for local state and agent workflows.",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -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 ingredient slugs; it refreshes the local Pantry cache under `TELA_HOME`.
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 api <slug> <METHOD> <PATH> [args...] --env <slug> --body <json> --pretty
31
- tela docs endpoints <slug> --pretty
32
- tela docs reference <slug> [METHOD PATH|file-path] --pretty
33
- tela docs guide <slug> [path]
34
- tela env list <ingredient-slug> --pretty
35
- tela env set <ingredient-slug> <environment>
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.