@terminus-ai/cli 0.0.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/LICENSE +21 -0
- package/README.md +1055 -0
- package/bin/agent-discovery.mjs +71 -0
- package/bin/agent-icon.mjs +77 -0
- package/bin/agent-models.mjs +77 -0
- package/bin/agent-type.mjs +51 -0
- package/bin/agentdev.mjs +657 -0
- package/bin/app-route-script.mjs +59 -0
- package/bin/app-runtime-contract.mjs +2 -0
- package/bin/appdev-remote.mjs +346 -0
- package/bin/appdev.mjs +4446 -0
- package/bin/apps.mjs +5512 -0
- package/bin/capability-calls.mjs +437 -0
- package/bin/capsule-data.mjs +260 -0
- package/bin/client.mjs +189 -0
- package/bin/commands.mjs +1194 -0
- package/bin/dev-capsules.mjs +1599 -0
- package/bin/dev-contract.mjs +262 -0
- package/bin/dev-data.mjs +287 -0
- package/bin/dev-members.mjs +18 -0
- package/bin/dev-net.mjs +316 -0
- package/bin/dev-notification-popup.mjs +628 -0
- package/bin/dev-ports.mjs +567 -0
- package/bin/dev-server-binding.mjs +35 -0
- package/bin/dev-server-ops.mjs +1086 -0
- package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
- package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
- package/bin/dev-ui/OFL.txt +92 -0
- package/bin/dev-ui/agent-robot.webp +0 -0
- package/bin/dev-ui/app.js +5217 -0
- package/bin/dev-ui/highlight.js +195 -0
- package/bin/dev-ui/index.html +34 -0
- package/bin/dev-ui/style.css +3640 -0
- package/bin/devlint.mjs +112 -0
- package/bin/devserver.mjs +2127 -0
- package/bin/devtriggers.mjs +367 -0
- package/bin/endpoints.mjs +156 -0
- package/bin/errors.mjs +61 -0
- package/bin/files.mjs +169 -0
- package/bin/horizontal-capabilities/v1/contract.json +280 -0
- package/bin/http.mjs +500 -0
- package/bin/lint-manifests/justbash-commands.json +88 -0
- package/bin/lint-manifests/python-stdlib.json +295 -0
- package/bin/login-page.mjs +488 -0
- package/bin/schedules.mjs +664 -0
- package/bin/server-sandbox.mjs +204 -0
- package/bin/servicedev.mjs +425 -0
- package/bin/sync.mjs +357 -0
- package/bin/terminus.js +3666 -0
- package/bin/toolchain.mjs +125 -0
- package/bin/vendor/app-runtime-v1/app-host.json +124 -0
- package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
- package/bin/vendor/app-runtime-v1/doors.json +2867 -0
- package/bin/vendor/appd/node-harness.mjs +209 -0
- package/bin/vendor/appd/python-harness.py +12 -0
- package/bin/vendor/appd/server-protocol.json +84 -0
- package/bin/vendor/where.mjs +541 -0
- package/bin/versioning.mjs +72 -0
- package/bin/write-rules.mjs +398 -0
- package/package.json +41 -0
package/bin/terminus.js
ADDED
|
@@ -0,0 +1,3666 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { createInterface } from "node:readline";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
commandUsageError,
|
|
15
|
+
creationPageUrl,
|
|
16
|
+
emitJson,
|
|
17
|
+
newCreationUrl,
|
|
18
|
+
notConnectedHint,
|
|
19
|
+
parseFlags,
|
|
20
|
+
saveSession,
|
|
21
|
+
sessionRecordFromBrowserLogin,
|
|
22
|
+
webBase,
|
|
23
|
+
} from "./client.mjs";
|
|
24
|
+
import {
|
|
25
|
+
commandNamed,
|
|
26
|
+
helpRequested,
|
|
27
|
+
renderCommands,
|
|
28
|
+
renderHelp,
|
|
29
|
+
renderOverview,
|
|
30
|
+
unknownCommandMessage,
|
|
31
|
+
} from "./commands.mjs";
|
|
32
|
+
import { CliError, authError, errorEnvelope, usageError } from "./errors.mjs";
|
|
33
|
+
import { exists, pathCompare, sha256, walkTree } from "./files.mjs";
|
|
34
|
+
import {
|
|
35
|
+
Api,
|
|
36
|
+
CLI_VERSION,
|
|
37
|
+
DEFAULT_TERMINUS_API_BASE,
|
|
38
|
+
commandApiBase,
|
|
39
|
+
connect,
|
|
40
|
+
normalizeApiBase,
|
|
41
|
+
presentValue,
|
|
42
|
+
readSession,
|
|
43
|
+
sessionPath,
|
|
44
|
+
sessionTokenIsUsable,
|
|
45
|
+
} from "./http.mjs";
|
|
46
|
+
import { SYNC_DIRECTORY, byKey, entryKey, localChanges, readSyncRecord, writeSyncRecord } from "./sync.mjs";
|
|
47
|
+
import { formatReleaseVersion, parseReleaseVersion } from "./versioning.mjs";
|
|
48
|
+
|
|
49
|
+
const DEFAULT_MAX_FILES = 512;
|
|
50
|
+
const DEFAULT_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
|
51
|
+
const DEFAULT_MAX_TOTAL_SIZE_BYTES = 25 * 1024 * 1024;
|
|
52
|
+
const MAX_REFERENCE_DEPTH = 3;
|
|
53
|
+
const LOGIN_CALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
// What a skill package ships, decided the way terminus-backend decides it
|
|
56
|
+
// (crates/terminus-skill-parser: `should_skip_skill_package_path` and
|
|
57
|
+
// `is_standard_skill_package_path`) and pinned by
|
|
58
|
+
// test/contracts/backend-skill-package-path-vectors.json. A path is relative
|
|
59
|
+
// to the skill's root; a skipped path is never walked into or shipped, and
|
|
60
|
+
// skipping wins over keeping.
|
|
61
|
+
|
|
62
|
+
/** Directories a package never ships from, at any depth. */
|
|
63
|
+
const PACKAGE_IGNORED_DIRECTORIES = new Set([
|
|
64
|
+
".git",
|
|
65
|
+
".github",
|
|
66
|
+
".claude-plugin",
|
|
67
|
+
".terminus",
|
|
68
|
+
".terminus-dev",
|
|
69
|
+
".workspace",
|
|
70
|
+
"node_modules",
|
|
71
|
+
".venv",
|
|
72
|
+
"venv",
|
|
73
|
+
"__pypackages__",
|
|
74
|
+
"dist",
|
|
75
|
+
"build",
|
|
76
|
+
"target",
|
|
77
|
+
"coverage",
|
|
78
|
+
".next",
|
|
79
|
+
".turbo",
|
|
80
|
+
".vercel",
|
|
81
|
+
"__pycache__",
|
|
82
|
+
]);
|
|
83
|
+
const PACKAGE_IGNORED_FILE_NAMES = new Set([".DS_Store", "Thumbs.db"]);
|
|
84
|
+
/** Top-level folders a package keeps whole. */
|
|
85
|
+
const PACKAGE_STANDARD_FOLDERS = new Set(["assets", "references", "scripts", "templates", "examples"]);
|
|
86
|
+
|
|
87
|
+
const CONTENT_TYPES = new Map([
|
|
88
|
+
[".md", "text/markdown; charset=utf-8"],
|
|
89
|
+
[".txt", "text/plain; charset=utf-8"],
|
|
90
|
+
[".yaml", "application/yaml"],
|
|
91
|
+
[".yml", "application/yaml"],
|
|
92
|
+
[".json", "application/json"],
|
|
93
|
+
[".py", "text/x-python; charset=utf-8"],
|
|
94
|
+
[".js", "text/javascript; charset=utf-8"],
|
|
95
|
+
[".ts", "text/typescript; charset=utf-8"],
|
|
96
|
+
[".html", "text/html; charset=utf-8"],
|
|
97
|
+
[".htm", "text/html; charset=utf-8"],
|
|
98
|
+
[".svg", "image/svg+xml"],
|
|
99
|
+
[".png", "image/png"],
|
|
100
|
+
[".jpg", "image/jpeg"],
|
|
101
|
+
[".jpeg", "image/jpeg"],
|
|
102
|
+
[".webp", "image/webp"],
|
|
103
|
+
[".gif", "image/gif"],
|
|
104
|
+
[".pdf", "application/pdf"],
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
/** Command name → handler. `help` and `version` are answered before
|
|
108
|
+
* dispatch; every other entry in bin/commands.mjs must appear here (a test
|
|
109
|
+
* holds the two tables together). */
|
|
110
|
+
const HANDLERS = {
|
|
111
|
+
login: (args) => loginCommand(args),
|
|
112
|
+
logout: (args) => logoutCommand(args),
|
|
113
|
+
account: (args) => accountCommand(args),
|
|
114
|
+
notifications: (args) => notificationsCommand(args),
|
|
115
|
+
creations: (args) => creationsCommand(args),
|
|
116
|
+
drafts: (args) => draftsCommand(args),
|
|
117
|
+
status: (args) => statusCommand(args),
|
|
118
|
+
search: (args) => searchCommand(args),
|
|
119
|
+
connectors: (args) => connectorsCommand(args),
|
|
120
|
+
skills: (args) => skillsCommand(args),
|
|
121
|
+
init: (args) => initCommand(args),
|
|
122
|
+
validate: (args) => validateCommand(args),
|
|
123
|
+
pull: (args) => pullCommand(args),
|
|
124
|
+
push: (args) => pushCommand(args),
|
|
125
|
+
secrets: async (args) => (await import("./apps.mjs")).secretsCommand(args),
|
|
126
|
+
remote: (args) => remoteCommand(args),
|
|
127
|
+
build: async (args) => (await import("./apps.mjs")).buildCommand(args),
|
|
128
|
+
dev: async (args) => (await import("./appdev.mjs")).devCommand(args),
|
|
129
|
+
data: async (args) => (await import("./capsule-data.mjs")).dataCommand(args),
|
|
130
|
+
service: (args) => serviceCommand(args),
|
|
131
|
+
inspect: async (args) => (await import("./apps.mjs")).appCommand("inspect", args),
|
|
132
|
+
logs: async (args) => (await import("./apps.mjs")).appCommand("logs", args),
|
|
133
|
+
restore: (args) => restoreCommand(args),
|
|
134
|
+
log: (args) => logCommand(args),
|
|
135
|
+
diff: (args) => diffCommand(args),
|
|
136
|
+
outdated: async (args) => (await import("./apps.mjs")).outdatedCommand(args),
|
|
137
|
+
fork: async (args) => (await import("./apps.mjs")).forkCommand(args, { cloneOwnedSkill, cloneSkillCopy }),
|
|
138
|
+
// Apps, agents, and services clone from their draft doors in apps.mjs;
|
|
139
|
+
// skills have no draft and live here, so the dispatcher hands their clone
|
|
140
|
+
// in rather than making either module import the other.
|
|
141
|
+
clone: async (args) => (await import("./apps.mjs")).cloneCommand(args, { cloneOwnedSkill, cloneSkillCopy }),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const COMMAND_HANDLERS = Object.freeze(Object.keys(HANDLERS));
|
|
145
|
+
|
|
146
|
+
async function main(argv = process.argv.slice(2)) {
|
|
147
|
+
const [name, ...rest] = argv;
|
|
148
|
+
const topic = name === "help" ? rest.filter((arg) => arg !== "--help" && arg !== "-h") : [];
|
|
149
|
+
if (name === undefined || name === "--help" || name === "-h" || (name === "help" && !topic.length)) {
|
|
150
|
+
console.log(renderOverview({ version: CLI_VERSION }));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (name === "help") {
|
|
154
|
+
try {
|
|
155
|
+
console.log(renderHelp(topic));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw error.usage ? usageError(error.message) : error;
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (name === "commands") {
|
|
162
|
+
if (helpRequested(name, rest)) {
|
|
163
|
+
console.log(renderHelp(["commands"]));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (parseFlags(rest, "commands")._.length) {
|
|
167
|
+
throw commandUsageError("commands", { reason: "This command takes no arguments." });
|
|
168
|
+
}
|
|
169
|
+
console.log(renderCommands());
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (name === "version" || name === "--version" || name === "-v") {
|
|
173
|
+
await versionCommand(name === "version" ? rest : []);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const command = commandNamed(name);
|
|
177
|
+
if (!command || !HANDLERS[command.name]) {
|
|
178
|
+
throw usageError(unknownCommandMessage(name));
|
|
179
|
+
}
|
|
180
|
+
if (helpRequested(command.name, rest)) {
|
|
181
|
+
const sub = command.subcommands?.length ? rest.find((arg) => !arg.startsWith("-")) : undefined;
|
|
182
|
+
const topic = sub && command.subcommands.some((entry) => entry.name === sub)
|
|
183
|
+
? [command.name, sub]
|
|
184
|
+
: [command.name];
|
|
185
|
+
console.log(renderHelp(topic));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await HANDLERS[command.name](rest);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function versionCommand(args) {
|
|
192
|
+
const flags = parseFlags(args, "version");
|
|
193
|
+
const version = CLI_VERSION;
|
|
194
|
+
if (!flags.json) {
|
|
195
|
+
console.log(`terminus ${version}`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const { APP_SDK_PACKAGE, APP_SDK_VERSION } = await import("./apps.mjs");
|
|
199
|
+
const { APP_RUNTIME_API_VERSION } = await import("./app-runtime-contract.mjs");
|
|
200
|
+
emitJson(flags, {
|
|
201
|
+
version,
|
|
202
|
+
app_sdk: { package: APP_SDK_PACKAGE, version: APP_SDK_VERSION, install: `npm install ${APP_SDK_PACKAGE}` },
|
|
203
|
+
runtime_api_version: APP_RUNTIME_API_VERSION,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Whether `args` name an app, agent, or service package (terminus.json).
|
|
208
|
+
* Flags are parsed first so `publish --strategy paid ./pkg` looks at ./pkg,
|
|
209
|
+
* not at the flag's value. */
|
|
210
|
+
async function packageTarget(name, args) {
|
|
211
|
+
const target = parseFlags(args, name)._[0] ?? ".";
|
|
212
|
+
const apps = await import("./apps.mjs");
|
|
213
|
+
return { apps, isPackage: Boolean(await apps.packageKind(target)), target };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** In a package or a skill folder, compare it with its draft; anywhere else
|
|
217
|
+
* there is nothing to compare, and the account lives under `terminus
|
|
218
|
+
* account`. */
|
|
219
|
+
async function statusCommand(args) {
|
|
220
|
+
const { apps, isPackage, target } = await packageTarget("status", args);
|
|
221
|
+
if (isPackage) {
|
|
222
|
+
await apps.statusAppCommand(args);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (await exists(path.join(path.resolve(target), "SKILL.md"))) {
|
|
226
|
+
await statusSkillCommand(args);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
throw usageError(
|
|
230
|
+
`nothing to compare in ${path.resolve(target)} (no terminus.json or SKILL.md)\n`
|
|
231
|
+
+ "status compares a working copy with its draft. To see your account, run `terminus account`.",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `terminus pull`: bring the draft's commits into this folder. */
|
|
236
|
+
async function pullCommand(args) {
|
|
237
|
+
const { apps, isPackage, target } = await packageTarget("pull", args);
|
|
238
|
+
if (isPackage) await apps.pullAppCommand(args);
|
|
239
|
+
else if (await exists(path.join(path.resolve(target), "SKILL.md"))) await pullSkillCommand(args);
|
|
240
|
+
else await apps.pullAppCommand(args);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `terminus log` / `terminus diff` / `terminus restore`: with an address
|
|
244
|
+
* they read the catalog, and in a folder they read that working copy — a
|
|
245
|
+
* package's draft, or a skill's. */
|
|
246
|
+
async function logCommand(args) {
|
|
247
|
+
const apps = await import("./apps.mjs");
|
|
248
|
+
if (parseFlags(args, "log")._.length || !(await exists(path.join(process.cwd(), "SKILL.md")))) {
|
|
249
|
+
await apps.logCommand(args);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await logSkillCommand(args);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function diffCommand(args) {
|
|
256
|
+
const apps = await import("./apps.mjs");
|
|
257
|
+
if (parseFlags(args, "diff")._.length || !(await exists(path.join(process.cwd(), "SKILL.md")))) {
|
|
258
|
+
await apps.diffCommand(args);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
await diffSkillCommand(args);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function restoreCommand(args) {
|
|
265
|
+
const apps = await import("./apps.mjs");
|
|
266
|
+
// `restore <commit> [<dir>]`: the folder is the second word, not the first.
|
|
267
|
+
const dir = path.resolve(parseFlags(args, "restore")._[1] ?? ".");
|
|
268
|
+
if (!(await apps.packageKind(dir)) && await exists(path.join(dir, "SKILL.md"))) {
|
|
269
|
+
await restoreSkillCommand(args);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
await apps.restoreCommand(args);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function validateCommand(args) {
|
|
276
|
+
const { apps, isPackage } = await packageTarget("validate", args);
|
|
277
|
+
if (isPackage) await apps.validateAppCommand(args);
|
|
278
|
+
else await validateSkillCommand(args);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** `terminus push`: upload a folder to the creation it is linked to. An app,
|
|
282
|
+
* agent, or service folder holds terminus.json; a skill folder holds
|
|
283
|
+
* SKILL.md. Push never publishes — that is done on the web. */
|
|
284
|
+
async function pushCommand(args) {
|
|
285
|
+
const { apps, isPackage } = await packageTarget("push", args);
|
|
286
|
+
if (isPackage) await apps.pushAppCommand(args);
|
|
287
|
+
else await pushSkillCommand(args);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `terminus remote` — the connection under git's own name.
|
|
292
|
+
*
|
|
293
|
+
* `remote add` records the connection; on its own it reads what the folder
|
|
294
|
+
* holds and says which creation that is, offline; `remote remove` forgets it.
|
|
295
|
+
*/
|
|
296
|
+
async function remoteCommand(args) {
|
|
297
|
+
const [verb, ...rest] = args;
|
|
298
|
+
if (verb === "add") return remoteAddCommand(rest);
|
|
299
|
+
if (verb === "remove") return removeRemoteCommand(rest);
|
|
300
|
+
// Anything else is the folder to read — `terminus remote ./my-app` — so a
|
|
301
|
+
// directory never has to be spelled as a verb it is not.
|
|
302
|
+
return showRemoteCommand(args);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** What a folder is connected to, read from the folder alone. */
|
|
306
|
+
async function folderRemote(dir) {
|
|
307
|
+
const apps = await import("./apps.mjs");
|
|
308
|
+
const record = await readSyncRecord(dir);
|
|
309
|
+
const kind = await apps.packageKind(dir);
|
|
310
|
+
if (kind) {
|
|
311
|
+
const document = JSON.parse(await readFile(path.join(dir, "terminus.json"), "utf8"));
|
|
312
|
+
return { address: document.id ?? record?.address ?? null, kind, record };
|
|
313
|
+
}
|
|
314
|
+
if (await exists(path.join(dir, "SKILL.md"))) {
|
|
315
|
+
const metadata = parseFrontmatterMetadata(await readFile(path.join(dir, "SKILL.md"), "utf8"));
|
|
316
|
+
// A skill records its id; the address is what a person reads, and the
|
|
317
|
+
// sync record is where a working copy keeps it.
|
|
318
|
+
return { address: record?.address ?? metadata.id ?? null, id: metadata.id ?? null, kind: "skill", record };
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function showRemoteCommand(args) {
|
|
324
|
+
const flags = parseFlags(args, "remote");
|
|
325
|
+
const dir = path.resolve(flags._[0] ?? ".");
|
|
326
|
+
const found = await folderRemote(dir);
|
|
327
|
+
if (!found) {
|
|
328
|
+
throw usageError(
|
|
329
|
+
`nothing in ${dir} to connect (no terminus.json or SKILL.md)\n`
|
|
330
|
+
+ "A creation is made on the web; `terminus clone <address>` brings one down.",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const { shortCommit } = await import("./apps.mjs");
|
|
334
|
+
const readOnly = found.record?.source === "release";
|
|
335
|
+
// terminus.json's `id` is what push targets; the sync record says which
|
|
336
|
+
// address the folder was last in step with. Editing the id by hand parts
|
|
337
|
+
// them, and the commit then belongs to the creation the folder came FROM.
|
|
338
|
+
// Printing it under the new address states a fact about neither.
|
|
339
|
+
//
|
|
340
|
+
// Two addresses disagreeing is NOT proof of two creations: an address is
|
|
341
|
+
// renameable (`PATCH /apps/{id}/address`), so the same creation can answer
|
|
342
|
+
// to a new one. Only push can tell, because only push resolves an address
|
|
343
|
+
// to the id underneath it; this reads the folder and nothing else, so it
|
|
344
|
+
// reports the disagreement and lets `remote add` settle it.
|
|
345
|
+
const recorded = found.record?.source === "draft" ? found.record.address ?? null : null;
|
|
346
|
+
const retargeted = Boolean(recorded && found.address && recorded !== found.address);
|
|
347
|
+
const result = {
|
|
348
|
+
address: found.address,
|
|
349
|
+
kind: found.kind,
|
|
350
|
+
dir,
|
|
351
|
+
commit: !retargeted && found.record?.source === "draft" ? found.record.commit ?? null : null,
|
|
352
|
+
came_from: retargeted ? recorded : null,
|
|
353
|
+
read_only: readOnly,
|
|
354
|
+
release: readOnly ? found.record.release?.label ?? found.record.release?.version ?? null : null,
|
|
355
|
+
};
|
|
356
|
+
emitJson(flags, result, () => {
|
|
357
|
+
if (!result.address) {
|
|
358
|
+
console.log(`${dir} isn't connected to a creation.`);
|
|
359
|
+
console.log("Make one on the web, then: terminus remote add <address>");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (readOnly) {
|
|
363
|
+
const at = result.release ? ` ${creationVersion(result.release)}` : "";
|
|
364
|
+
console.log(`${dir} → ${result.address} (${result.kind}${at}, a read-only copy).`);
|
|
365
|
+
console.log(`It isn't yours to push to; to change it: terminus fork ${result.address}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const at = result.commit ? `, at commit ${shortCommit(result.commit)}` : "";
|
|
369
|
+
console.log(`${dir} → ${result.address} (${result.kind})${at}.`);
|
|
370
|
+
// git's own shape for this: state what is true, then the ways out as
|
|
371
|
+
// indented `(use "...")` lines — the form `git status` uses to tell you
|
|
372
|
+
// your branch has diverged.
|
|
373
|
+
if (result.came_from) {
|
|
374
|
+
console.log(`This folder was last in step with ${result.came_from}, which terminus.json no longer names.`);
|
|
375
|
+
console.log(` (use "terminus remote add ${result.address}" to settle which creation it belongs to)`);
|
|
376
|
+
console.log(` (or set "id" back to "${result.came_from}" in terminus.json)`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (!result.commit) {
|
|
380
|
+
console.log("This folder has no record of which commit it came from; `terminus pull` brings one down.");
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Forget the connection. The files stay exactly as they are — this is
|
|
386
|
+
* `git remote remove`, not a delete. */
|
|
387
|
+
async function removeRemoteCommand(args) {
|
|
388
|
+
const flags = parseFlags(args, "remote");
|
|
389
|
+
const dir = path.resolve(flags._[0] ?? ".");
|
|
390
|
+
const found = await folderRemote(dir);
|
|
391
|
+
if (!found) {
|
|
392
|
+
throw usageError(`nothing in ${dir} to disconnect (no terminus.json or SKILL.md)`);
|
|
393
|
+
}
|
|
394
|
+
if (!found.address) {
|
|
395
|
+
throw new CliError(`${dir} isn't connected to a creation`);
|
|
396
|
+
}
|
|
397
|
+
if (found.kind === "skill") {
|
|
398
|
+
const skillPath = path.join(dir, "SKILL.md");
|
|
399
|
+
await writeFile(skillPath, withoutSkillId(await readFile(skillPath, "utf8")));
|
|
400
|
+
} else {
|
|
401
|
+
const manifestPath = path.join(dir, "terminus.json");
|
|
402
|
+
const document = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
403
|
+
delete document.id;
|
|
404
|
+
await writeFile(manifestPath, `${JSON.stringify(document, null, 2)}\n`);
|
|
405
|
+
}
|
|
406
|
+
// The folder no longer follows anything, so what it matched is not a fact
|
|
407
|
+
// about it any more.
|
|
408
|
+
await rm(path.join(dir, SYNC_DIRECTORY), { force: true, recursive: true });
|
|
409
|
+
const result = { address: found.address, kind: found.kind, dir, connected: false };
|
|
410
|
+
emitJson(flags, result, () => {
|
|
411
|
+
console.log(`Disconnected ${dir} from ${result.address}. Every file is where it was.`);
|
|
412
|
+
console.log(`Connect it again with: terminus remote add ${result.address}`);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** SKILL.md without its `id:` line, everything else as written. */
|
|
417
|
+
function withoutSkillId(markdown) {
|
|
418
|
+
const text = String(markdown);
|
|
419
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
420
|
+
if (!match) return text;
|
|
421
|
+
const lines = match[1].split(/\r?\n/).filter((line) => frontmatterLineKey(line) !== "id");
|
|
422
|
+
return `---\n${lines.join("\n")}\n---${text.slice(match[0].length - (match[0].endsWith("\n") ? 1 : 0))}`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** `terminus remote add <address> [<dir>]`: connect a folder to a creation
|
|
426
|
+
* made on the web, the way `git remote add` connects a repository. */
|
|
427
|
+
async function remoteAddCommand(args) {
|
|
428
|
+
const flags = parseFlags(args, "remote add");
|
|
429
|
+
const dir = path.resolve(flags._[1] ?? ".");
|
|
430
|
+
const apps = await import("./apps.mjs");
|
|
431
|
+
if (await apps.packageKind(dir)) return apps.remoteAddAppCommand(args);
|
|
432
|
+
// A skill is its SKILL.md, so a folder holding one is linked as a skill.
|
|
433
|
+
if (await exists(path.join(dir, "SKILL.md"))) return remoteAddSkillCommand(args);
|
|
434
|
+
// Neither: code that has never met Terminus. The app family writes the
|
|
435
|
+
// manifest from what the creation says it is; a skill address in such a
|
|
436
|
+
// folder has nothing to stamp an id into, and says so.
|
|
437
|
+
try {
|
|
438
|
+
await apps.remoteAddAppCommand(args);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (!/is not one of your creations/u.test(String(error?.message ?? ""))) throw error;
|
|
441
|
+
await remoteAddSkillCommand(args);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function serviceCommand(args) {
|
|
446
|
+
const [verb, ...serviceArgs] = args;
|
|
447
|
+
const apps = await import("./apps.mjs");
|
|
448
|
+
if (verb === "test") await apps.testServiceCommand(serviceArgs);
|
|
449
|
+
else if (verb === "inspect") await apps.inspectServiceCommand(serviceArgs);
|
|
450
|
+
else if (["submit", "job", "jobs", "cancel", "save"].includes(verb)) await apps.serviceJobCommand(verb, serviceArgs);
|
|
451
|
+
else {
|
|
452
|
+
throw commandUsageError("service", verb ? { reason: `'${verb}' is not a service command.` } : {});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async function loginCommand(args) {
|
|
459
|
+
const flags = parseFlags(args, "login");
|
|
460
|
+
const base = await commandApiBase(flags);
|
|
461
|
+
|
|
462
|
+
const existingLogin = await loginFromSavedSession(base);
|
|
463
|
+
if (existingLogin) {
|
|
464
|
+
console.log(loginLabel(existingLogin));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
await loginWithBrowser(base, flags);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function loginFromSavedSession(base) {
|
|
472
|
+
const session = await readSession();
|
|
473
|
+
if (!sessionTokenIsUsable(session)) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
const login = await loginWithCurrentProfile(new Api({ base, token: session.token.trim() }), session);
|
|
478
|
+
await saveSession(sessionRecordFromBrowserLogin(base, login, session));
|
|
479
|
+
return login;
|
|
480
|
+
} catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function loginWithBrowser(base, flags = {}) {
|
|
486
|
+
const state = randomToken();
|
|
487
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
488
|
+
const codeChallenge = sha256(codeVerifier, "base64url");
|
|
489
|
+
// The callback page wears the same logo as the login page the user just
|
|
490
|
+
// left, served from that same origin (the email does this too).
|
|
491
|
+
const brandOrigin = webBase(flags);
|
|
492
|
+
const callback = await startLoginCallbackServer(state, brandOrigin);
|
|
493
|
+
try {
|
|
494
|
+
const loginUrl = browserLoginUrl({
|
|
495
|
+
webBase: brandOrigin,
|
|
496
|
+
callbackUrl: callback.url,
|
|
497
|
+
state,
|
|
498
|
+
apiBaseUrl: base,
|
|
499
|
+
codeChallenge,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
console.log("Opening your browser to log in to Terminus.");
|
|
503
|
+
console.log(`If it does not open, visit:\n${loginUrl}`);
|
|
504
|
+
if (!flags.no_browser) {
|
|
505
|
+
await openBrowser(loginUrl);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const callbackResult = await callback.wait();
|
|
509
|
+
try {
|
|
510
|
+
// The web login page hands back a short-lived one-time CLI login code.
|
|
511
|
+
// Exchange it directly for a seven-day, server-revocable device session.
|
|
512
|
+
const sessionBase = callbackApiBase(base, callbackResult.apiUrl);
|
|
513
|
+
const device = nativeClientMetadata();
|
|
514
|
+
const exchanged = await exchangeCliLoginCode(
|
|
515
|
+
sessionBase,
|
|
516
|
+
callbackResult.code,
|
|
517
|
+
codeVerifier,
|
|
518
|
+
device,
|
|
519
|
+
);
|
|
520
|
+
const signedIn = new Api({ base: sessionBase, token: exchanged.token });
|
|
521
|
+
const user = await signedIn.json("GET /v1/auth/me");
|
|
522
|
+
const login = await loginWithCurrentProfile(signedIn, {
|
|
523
|
+
...exchanged,
|
|
524
|
+
...device,
|
|
525
|
+
}, user);
|
|
526
|
+
await saveSession(
|
|
527
|
+
sessionRecordFromBrowserLogin(sessionBase, login, {
|
|
528
|
+
email: user.email ?? callbackResult.email,
|
|
529
|
+
username: user.username,
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
532
|
+
// The browser tab says "signed in" only once that is true here.
|
|
533
|
+
callbackResult.succeed(user.email ?? callbackResult.email);
|
|
534
|
+
console.log(loginLabel(login));
|
|
535
|
+
} catch (error) {
|
|
536
|
+
callbackResult.fail(error instanceof CliError ? error.message : "The CLI could not finish signing in.");
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
} finally {
|
|
540
|
+
callback.close();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function exchangeCliLoginCode(base, code, codeVerifier, device) {
|
|
545
|
+
let login;
|
|
546
|
+
try {
|
|
547
|
+
login = await new Api({ base }).json("POST /v1/auth/cli-login-code/exchange", {
|
|
548
|
+
body: { code, code_verifier: codeVerifier, ...device },
|
|
549
|
+
});
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (error.status === 400 || error.status === 401) {
|
|
552
|
+
throw authError("Terminus did not accept this sign-in (the code may have expired). Run `terminus login` again.");
|
|
553
|
+
}
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
if (!login?.token) {
|
|
557
|
+
throw new CliError("Terminus API did not return a session token for the CLI login code");
|
|
558
|
+
}
|
|
559
|
+
return login;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function nativeClientMetadata() {
|
|
563
|
+
return {
|
|
564
|
+
client_kind: "cli",
|
|
565
|
+
device_name: os.hostname() || "Terminus CLI",
|
|
566
|
+
platform: `${process.platform}-${process.arch}`,
|
|
567
|
+
client_version: CLI_VERSION,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// The callback may pin the API base the web app actually authenticated
|
|
572
|
+
// against; trust it only when it stays on the origin the login started on.
|
|
573
|
+
function callbackApiBase(base, apiUrl) {
|
|
574
|
+
if (!apiUrl) {
|
|
575
|
+
return base;
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
if (new URL(apiUrl).origin === new URL(base).origin) {
|
|
579
|
+
return normalizeApiBase(apiUrl);
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
// Fall through to the base the login started with.
|
|
583
|
+
}
|
|
584
|
+
return base;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function browserLoginUrl({ webBase, callbackUrl, state, apiBaseUrl, codeChallenge }) {
|
|
588
|
+
const url = new URL("/login", webBase);
|
|
589
|
+
url.searchParams.set("cli_redirect", callbackUrl);
|
|
590
|
+
url.searchParams.set("cli_state", state);
|
|
591
|
+
url.searchParams.set("cli_api_url", apiBaseUrl);
|
|
592
|
+
url.searchParams.set("cli_code_challenge", codeChallenge);
|
|
593
|
+
return url.toString();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// The last frame of the login flow the user actually looks at, so it is the
|
|
597
|
+
// auth panel rather than a bare sentence — see bin/login-page.mjs. Loaded here
|
|
598
|
+
// rather than at module scope: it is a page of CSS that only a browser login
|
|
599
|
+
// ever needs, and this is already the async step before the browser opens.
|
|
600
|
+
async function startLoginCallbackServer(expectedState, brandOrigin) {
|
|
601
|
+
const { loginCompletePage, loginFailedPage } = await import("./login-page.mjs");
|
|
602
|
+
let settled = false;
|
|
603
|
+
let server;
|
|
604
|
+
let resolveCallback;
|
|
605
|
+
let rejectCallback;
|
|
606
|
+
const waitPromise = new Promise((resolve, reject) => {
|
|
607
|
+
resolveCallback = resolve;
|
|
608
|
+
rejectCallback = reject;
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// A one-shot page carrying the account's address: never store it, and never
|
|
612
|
+
// let a proxy or the browser sniff it into something else.
|
|
613
|
+
const sendPage = (response, status, html) => {
|
|
614
|
+
response.writeHead(status, {
|
|
615
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
616
|
+
"Content-Length": Buffer.byteLength(html),
|
|
617
|
+
"Cache-Control": "no-store",
|
|
618
|
+
"X-Content-Type-Options": "nosniff",
|
|
619
|
+
});
|
|
620
|
+
response.end(html);
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// The tab that delivered the code waits on its response until the CLI has
|
|
624
|
+
// exchanged the code, so it can only ever say what actually happened.
|
|
625
|
+
const finish = (result, response) => {
|
|
626
|
+
if (settled) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
settled = true;
|
|
630
|
+
let answered = false;
|
|
631
|
+
const answer = (status, html) => {
|
|
632
|
+
if (answered || response.headersSent) return;
|
|
633
|
+
answered = true;
|
|
634
|
+
sendPage(response, status, html);
|
|
635
|
+
};
|
|
636
|
+
resolveCallback({
|
|
637
|
+
...result,
|
|
638
|
+
succeed: (email) => answer(200, loginCompletePage({ email: email ?? result.email, brandOrigin })),
|
|
639
|
+
fail: (reason) => answer(400, loginFailedPage({ reason, brandOrigin })),
|
|
640
|
+
});
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const fail = (message, response) => {
|
|
644
|
+
if (response && !response.headersSent) {
|
|
645
|
+
sendPage(response, 400, loginFailedPage({ reason: message, brandOrigin }));
|
|
646
|
+
}
|
|
647
|
+
if (!settled) {
|
|
648
|
+
settled = true;
|
|
649
|
+
rejectCallback(new CliError(message));
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
server = createServer((request, response) => {
|
|
654
|
+
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`);
|
|
655
|
+
if (request.method !== "GET" || requestUrl.pathname !== "/callback") {
|
|
656
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
657
|
+
response.end("Not found");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const state = requestUrl.searchParams.get("state") ?? "";
|
|
662
|
+
const code = requestUrl.searchParams.get("code") ?? "";
|
|
663
|
+
const email = requestUrl.searchParams.get("email") ?? undefined;
|
|
664
|
+
const apiUrl = requestUrl.searchParams.get("api_url") ?? undefined;
|
|
665
|
+
const error = requestUrl.searchParams.get("error");
|
|
666
|
+
if (state !== expectedState) {
|
|
667
|
+
// A stray request to the loopback port must not cancel the real login.
|
|
668
|
+
// Only a callback that proves knowledge of the cryptographic state may
|
|
669
|
+
// settle the pending attempt — so this answers the browser and leaves
|
|
670
|
+
// the attempt pending, rather than going through `fail`.
|
|
671
|
+
sendPage(
|
|
672
|
+
response,
|
|
673
|
+
400,
|
|
674
|
+
loginFailedPage({
|
|
675
|
+
reason: "This browser tab doesn't match the login the CLI is waiting for.",
|
|
676
|
+
brandOrigin,
|
|
677
|
+
}),
|
|
678
|
+
);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (error) {
|
|
682
|
+
fail(`Terminus browser login failed: ${error}`, response);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (!code) {
|
|
686
|
+
fail("Terminus browser login failed: callback did not include a login code", response);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
finish({ code, email, apiUrl }, response);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
server.on("error", (error) => {
|
|
693
|
+
if (!settled) {
|
|
694
|
+
settled = true;
|
|
695
|
+
rejectCallback(error);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
await new Promise((resolve, reject) => {
|
|
700
|
+
server.once("error", reject);
|
|
701
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
702
|
+
});
|
|
703
|
+
server.removeAllListeners("error");
|
|
704
|
+
server.on("error", (error) => {
|
|
705
|
+
if (!settled) {
|
|
706
|
+
settled = true;
|
|
707
|
+
rejectCallback(error);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const address = server.address();
|
|
712
|
+
if (!address || typeof address === "string") {
|
|
713
|
+
server.close();
|
|
714
|
+
throw new CliError("failed to start local Terminus login callback server");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const timeout = setTimeout(() => {
|
|
718
|
+
if (!settled) {
|
|
719
|
+
settled = true;
|
|
720
|
+
rejectCallback(new CliError("Timed out waiting for browser login to finish"));
|
|
721
|
+
server.close();
|
|
722
|
+
}
|
|
723
|
+
}, LOGIN_CALLBACK_TIMEOUT_MS);
|
|
724
|
+
waitPromise.then(
|
|
725
|
+
() => clearTimeout(timeout),
|
|
726
|
+
() => clearTimeout(timeout),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
return {
|
|
730
|
+
url: `http://127.0.0.1:${address.port}/callback`,
|
|
731
|
+
wait: () => waitPromise,
|
|
732
|
+
close: () => {
|
|
733
|
+
try {
|
|
734
|
+
server.close();
|
|
735
|
+
} catch {
|
|
736
|
+
// Server may already be closed after timeout or callback completion.
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function openBrowser(url) {
|
|
743
|
+
const command =
|
|
744
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
745
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
746
|
+
return new Promise((resolve) => {
|
|
747
|
+
try {
|
|
748
|
+
const child = execFile(command, args, { detached: true, stdio: "ignore" }, (error) => {
|
|
749
|
+
resolve(!error);
|
|
750
|
+
});
|
|
751
|
+
child.unref();
|
|
752
|
+
} catch {
|
|
753
|
+
resolve(false);
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** A login with the profile of the account it signs in as. */
|
|
759
|
+
async function loginWithCurrentProfile(api, login, currentProfile = null) {
|
|
760
|
+
const profile = currentProfile ?? await api.me();
|
|
761
|
+
return {
|
|
762
|
+
...login,
|
|
763
|
+
profile,
|
|
764
|
+
display_name: profile.display_name,
|
|
765
|
+
username: profile.username ?? login.username,
|
|
766
|
+
email: profile.email ?? login.email,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function randomToken() {
|
|
771
|
+
return randomBytes(24).toString("base64url");
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function logoutCommand(args = []) {
|
|
775
|
+
if (args.length > 0) {
|
|
776
|
+
throw commandUsageError("logout", { reason: "This command takes no arguments." });
|
|
777
|
+
}
|
|
778
|
+
const session = await readSession();
|
|
779
|
+
if (sessionTokenIsUsable(session)) {
|
|
780
|
+
try {
|
|
781
|
+
// This device's session, revoked on the API it was made against.
|
|
782
|
+
await new Api({
|
|
783
|
+
base: session.api_base ?? DEFAULT_TERMINUS_API_BASE,
|
|
784
|
+
token: session.token.trim(),
|
|
785
|
+
}).json("POST /v1/auth/logout");
|
|
786
|
+
} catch {
|
|
787
|
+
console.error(
|
|
788
|
+
"Warning: the remote device session could not be revoked; it will expire automatically.",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
await rm(sessionPath(), { force: true });
|
|
793
|
+
console.log("Logged out.");
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function accountCommand(args) {
|
|
797
|
+
const flags = parseFlags(args, "account");
|
|
798
|
+
if (flags._.length) throw commandUsageError("account", { reason: "This command takes no arguments." });
|
|
799
|
+
const api = await connect(flags);
|
|
800
|
+
// TERMINUS_TOKEN is a session the environment supplies: say whose it is,
|
|
801
|
+
// but never read or rewrite the saved login on its behalf.
|
|
802
|
+
const override = api.identity.source === "env";
|
|
803
|
+
const saved = api.identity.session;
|
|
804
|
+
// A count that cannot be read drops out of the line; who you are never does.
|
|
805
|
+
const [login, box, creations] = await Promise.all([
|
|
806
|
+
loginWithCurrentProfile(api, override ? { token: api.token } : saved),
|
|
807
|
+
readNotificationBox(api).catch(() => null),
|
|
808
|
+
readCreations(api).catch(() => null),
|
|
809
|
+
]);
|
|
810
|
+
if (!override) await saveSession(sessionRecordFromBrowserLogin(api.base, login, saved));
|
|
811
|
+
|
|
812
|
+
const unread = box ? unreadCount(box) : null;
|
|
813
|
+
const published = creations ? creations.filter((creation) => creation.status === "published").length : null;
|
|
814
|
+
const output = {
|
|
815
|
+
...safeStatusResponse(api.base, login, override ? { auth_method: "token" } : saved),
|
|
816
|
+
notifications: { unread },
|
|
817
|
+
creations: { published },
|
|
818
|
+
...(override ? { source: "TERMINUS_TOKEN" } : {}),
|
|
819
|
+
};
|
|
820
|
+
emitJson(flags, output, () => {
|
|
821
|
+
const name = accountName(login);
|
|
822
|
+
console.log(name ? `Hi, ${name}` : "Hi!");
|
|
823
|
+
const counts = [
|
|
824
|
+
unread === null ? "" : `Notifications: ${unread}`,
|
|
825
|
+
published === null ? "" : `Creations: ${published}`,
|
|
826
|
+
].filter(Boolean);
|
|
827
|
+
if (counts.length) console.log(counts.join(" · "));
|
|
828
|
+
if (override) {
|
|
829
|
+
console.log("Using the access token in TERMINUS_TOKEN.");
|
|
830
|
+
} else if (sessionExpiresWithinADay(saved.expires_at)) {
|
|
831
|
+
console.log(sessionExpiryLine(saved.expires_at));
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** "This device's session expires in 6 days." — with a nudge on the last day,
|
|
837
|
+
* because nothing refreshes a CLI session and it ends mid-task otherwise. */
|
|
838
|
+
export function sessionExpiryLine(expiresAt, now = Date.now()) {
|
|
839
|
+
const at = Date.parse(expiresAt ?? "");
|
|
840
|
+
if (!Number.isFinite(at)) return "";
|
|
841
|
+
const left = at - now;
|
|
842
|
+
if (left <= 0) return "";
|
|
843
|
+
const hours = Math.floor(left / 3_600_000);
|
|
844
|
+
const days = Math.floor(hours / 24);
|
|
845
|
+
if (days >= 1) return `This device's session expires in ${days} day${days === 1 ? "" : "s"}.`;
|
|
846
|
+
const span = hours >= 1 ? `${hours} hour${hours === 1 ? "" : "s"}` : "less than an hour";
|
|
847
|
+
return `This device's session expires in ${span}; run \`terminus login\` again soon.`;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** `account` stays two lines until the session is into its last day. */
|
|
851
|
+
export function sessionExpiresWithinADay(expiresAt, now = Date.now()) {
|
|
852
|
+
const at = Date.parse(expiresAt ?? "");
|
|
853
|
+
return Number.isFinite(at) && at > now && at - now < 86_400_000;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** The notification box, counted the way the web's feed counts its badge:
|
|
857
|
+
* pending invitations to collaborate or to join a chat, plus notifications
|
|
858
|
+
* (unread ones unless `all`). A chat invitation also leaves an `invite`
|
|
859
|
+
* notification behind; the box shows it once, as the invitation. */
|
|
860
|
+
async function readNotificationBox(api, { all = false, limit = 200 } = {}) {
|
|
861
|
+
const [feed, collaborations, chats] = await Promise.all([
|
|
862
|
+
api.json("GET /v1/apps/notifications", { query: { limit, unread: all ? undefined : "true" } }),
|
|
863
|
+
api.json("GET /v1/collaborations/invitations"),
|
|
864
|
+
api.json("GET /v1/spaces/invitations"),
|
|
865
|
+
]);
|
|
866
|
+
const listOf = (value) => (Array.isArray(value) ? value.filter(Boolean) : []);
|
|
867
|
+
return {
|
|
868
|
+
collaborations: listOf(collaborations?.invitations),
|
|
869
|
+
chats: listOf(chats?.invitations),
|
|
870
|
+
notifications: listOf(feed?.notifications).filter((item) => !(
|
|
871
|
+
item.kind === "invite" && item.data?.kind === "space.invited"
|
|
872
|
+
)),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function unreadCount(box) {
|
|
877
|
+
return box.collaborations.length
|
|
878
|
+
+ box.chats.length
|
|
879
|
+
+ box.notifications.filter((item) => !item.read_at).length;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Your creations, as Creations on the web lists them: the apps, agents, and
|
|
883
|
+
* services you own (not the platform's official rows, not ones shared with
|
|
884
|
+
* you) and your own skills — the developer overview lists only those. */
|
|
885
|
+
async function readCreations(api) {
|
|
886
|
+
const [registry, overview] = await Promise.all([
|
|
887
|
+
api.json("GET /v1/apps", { query: { mine: "true" } }),
|
|
888
|
+
api.json("GET /v1/dashboard/developer", { query: { days: 1 } }),
|
|
889
|
+
]);
|
|
890
|
+
const creations = [];
|
|
891
|
+
for (const app of registry.apps ?? []) {
|
|
892
|
+
if (app.is_official === true || app.viewer_role === "collaborator") continue;
|
|
893
|
+
creations.push({
|
|
894
|
+
kind: app.kind,
|
|
895
|
+
name: app.name,
|
|
896
|
+
address: app.address,
|
|
897
|
+
status: app.status,
|
|
898
|
+
version: creationVersion(app.release_label ?? app.release_version),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
for (const skill of overview.skills ?? []) {
|
|
902
|
+
creations.push({
|
|
903
|
+
kind: "skill",
|
|
904
|
+
name: skill.name,
|
|
905
|
+
address: skill.address,
|
|
906
|
+
// A skill Terminus is holding reads as suspended, as it does on the web.
|
|
907
|
+
status: skill.suspended_at && skill.status !== "deleted" ? "suspended" : skill.status,
|
|
908
|
+
version: creationVersion(skill.release_version),
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
return creations.filter((creation) => creation.status !== "deleted");
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** "v1.2.0", read the way the web reads versions: a legacy ordinal or short
|
|
915
|
+
* label grows the parts it left out, a codename keeps its spelling, and a
|
|
916
|
+
* draft (0, null, "") has none. */
|
|
917
|
+
export function creationVersion(value) {
|
|
918
|
+
if (value === null || value === undefined || value === 0 || String(value).trim() === "") return null;
|
|
919
|
+
const raw = String(value).trim().replace(/^[vV]/, "");
|
|
920
|
+
const parsed = parseReleaseVersion(raw)
|
|
921
|
+
?? (/^\d+$/.test(raw) ? parseReleaseVersion(`${raw}.0.0`) : null)
|
|
922
|
+
?? (/^\d+\.\d+$/.test(raw) ? parseReleaseVersion(`${raw}.0`) : null);
|
|
923
|
+
return parsed ? formatReleaseVersion(parsed) : `v${raw}`;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function notificationsCommand(args) {
|
|
927
|
+
const flags = parseFlags(args, "notifications");
|
|
928
|
+
if (flags._.length) throw commandUsageError("notifications", { reason: "This command takes no arguments." });
|
|
929
|
+
const limit = positiveLimit(flags.limit, 20, "notifications");
|
|
930
|
+
const api = await connect(flags);
|
|
931
|
+
// Read the whole box (the server caps it at 200) so the count and the
|
|
932
|
+
// "and N more" line are true; --limit only trims what is printed.
|
|
933
|
+
const box = await readNotificationBox(api, { all: Boolean(flags.all) });
|
|
934
|
+
const shown = box.notifications.slice(0, limit);
|
|
935
|
+
emitJson(flags, {
|
|
936
|
+
unread: unreadCount(box),
|
|
937
|
+
invitations: { collaborations: box.collaborations, chats: box.chats },
|
|
938
|
+
notifications: shown,
|
|
939
|
+
}, () => printNotificationBox(box, shown, { all: Boolean(flags.all) }));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function positiveLimit(raw, fallback, command) {
|
|
943
|
+
if (raw === undefined) return fallback;
|
|
944
|
+
const value = Number(raw);
|
|
945
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
946
|
+
throw commandUsageError(command, { reason: "--limit takes a whole number above 0." });
|
|
947
|
+
}
|
|
948
|
+
return value;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function printNotificationBox(box, shown, { all }) {
|
|
952
|
+
const sections = [];
|
|
953
|
+
const invitations = [
|
|
954
|
+
...box.collaborations.map((invite) => {
|
|
955
|
+
const from = invite.inviter_username ? `, from @${invite.inviter_username}` : "";
|
|
956
|
+
return `Collaborate on ${invite.name} (${invite.address})${from} · ${ago(invite.invited_at)}`;
|
|
957
|
+
}),
|
|
958
|
+
...box.chats.map((invite) => {
|
|
959
|
+
const inviter = invite.inviter?.handle
|
|
960
|
+
? `, from ${invite.inviter.name ? `${invite.inviter.name} (@${invite.inviter.handle})` : `@${invite.inviter.handle}`}`
|
|
961
|
+
: "";
|
|
962
|
+
return `Join the chat "${invite.space_name}"${inviter} · ${ago(invite.created_at)}`;
|
|
963
|
+
}),
|
|
964
|
+
];
|
|
965
|
+
if (invitations.length) {
|
|
966
|
+
sections.push([`Invitations (${invitations.length}):`, ...invitations.map((line) => ` ${clip(line, 78)}`)]);
|
|
967
|
+
}
|
|
968
|
+
const unread = box.notifications.filter((item) => !item.read_at).length;
|
|
969
|
+
const title = all ? `Recent (${box.notifications.length}):` : `Unread (${unread}):`;
|
|
970
|
+
if (shown.length) {
|
|
971
|
+
const rows = [];
|
|
972
|
+
for (const item of shown) {
|
|
973
|
+
const mark = all && !item.read_at ? "•" : " ";
|
|
974
|
+
const when = ago(item.created_at).padEnd(8);
|
|
975
|
+
rows.push(clip(`${mark} ${when} ${item.app_name ? `${item.app_name}: ` : ""}${item.title}`, 80));
|
|
976
|
+
if (item.body) rows.push(clip(` ${item.body.replace(/\s+/g, " ").trim()}`, 80));
|
|
977
|
+
}
|
|
978
|
+
const hidden = box.notifications.length - shown.length;
|
|
979
|
+
if (hidden > 0) rows.push(` …and ${hidden} more (use --limit)`);
|
|
980
|
+
sections.push([title, ...rows]);
|
|
981
|
+
}
|
|
982
|
+
if (!sections.length) {
|
|
983
|
+
console.log(all ? "No notifications yet." : "No unread notifications.");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
console.log(sections.map((lines) => lines.join("\n")).join("\n\n"));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function creationsCommand(args) {
|
|
990
|
+
const flags = parseFlags(args, "creations");
|
|
991
|
+
if (flags._.length) throw commandUsageError("creations", { reason: "This command takes no arguments." });
|
|
992
|
+
const creations = await readCreationsFor(flags);
|
|
993
|
+
// Published, and any Terminus has suspended; drafts have their own command.
|
|
994
|
+
const shown = creations.filter((creation) => creation.status !== "draft");
|
|
995
|
+
emitJson(flags, { creations: shown }, () => {
|
|
996
|
+
if (shown.length) return printCreationGroups(shown);
|
|
997
|
+
const drafts = creations.length;
|
|
998
|
+
console.log("You haven't published anything yet.");
|
|
999
|
+
if (drafts) console.log(`You have ${drafts} draft${drafts === 1 ? "" : "s"}: terminus drafts`);
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async function draftsCommand(args) {
|
|
1004
|
+
const flags = parseFlags(args, "drafts");
|
|
1005
|
+
if (flags._.length) throw commandUsageError("drafts", { reason: "This command takes no arguments." });
|
|
1006
|
+
const drafts = (await readCreationsFor(flags)).filter((creation) => creation.status === "draft");
|
|
1007
|
+
emitJson(flags, { drafts }, () => {
|
|
1008
|
+
if (drafts.length) return printCreationGroups(drafts);
|
|
1009
|
+
console.log("You have no drafts. Start one with terminus init.");
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function readCreationsFor(flags) {
|
|
1014
|
+
return readCreations(await connect(flags));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const CREATION_GROUPS = [
|
|
1018
|
+
["published", "Published"],
|
|
1019
|
+
["suspended", "Suspended"],
|
|
1020
|
+
["draft", "Drafts"],
|
|
1021
|
+
];
|
|
1022
|
+
const KIND_ORDER = ["app", "agent", "service", "skill"];
|
|
1023
|
+
|
|
1024
|
+
/** Creations as aligned rows (name, kind, address, version) under one
|
|
1025
|
+
* heading per status, apps before agents, services, and skills. */
|
|
1026
|
+
function printCreationGroups(rows) {
|
|
1027
|
+
const order = (creation) => [KIND_ORDER.indexOf(creation.kind), creation.name.toLowerCase()];
|
|
1028
|
+
const sorted = [...rows].sort((a, b) => {
|
|
1029
|
+
const [ka, na] = order(a);
|
|
1030
|
+
const [kb, nb] = order(b);
|
|
1031
|
+
return ka - kb || na.localeCompare(nb);
|
|
1032
|
+
});
|
|
1033
|
+
const width = (key) => Math.max(...sorted.map((creation) => String(creation[key] ?? "").length));
|
|
1034
|
+
const [nameWidth, kindWidth, addressWidth] = [width("name"), width("kind"), width("address")];
|
|
1035
|
+
const row = (creation) => clip(
|
|
1036
|
+
` ${creation.name.padEnd(nameWidth)} ${creation.kind.padEnd(kindWidth)} ${creation.address.padEnd(addressWidth)} ${creation.version ?? ""}`.trimEnd(),
|
|
1037
|
+
100,
|
|
1038
|
+
);
|
|
1039
|
+
const known = new Set(CREATION_GROUPS.map(([status]) => status));
|
|
1040
|
+
const groups = [
|
|
1041
|
+
...CREATION_GROUPS,
|
|
1042
|
+
...[...new Set(sorted.map((creation) => creation.status))].filter((status) => !known.has(status)).map((status) => [status, status]),
|
|
1043
|
+
];
|
|
1044
|
+
const sections = [];
|
|
1045
|
+
for (const [status, title] of groups) {
|
|
1046
|
+
const members = sorted.filter((creation) => creation.status === status);
|
|
1047
|
+
if (members.length) sections.push([`${title} (${members.length}):`, ...members.map(row)].join("\n"));
|
|
1048
|
+
}
|
|
1049
|
+
console.log(sections.join("\n\n"));
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** "3h ago" for a timestamp, a date past a month. */
|
|
1053
|
+
function ago(iso, now = Date.now()) {
|
|
1054
|
+
const at = Date.parse(iso ?? "");
|
|
1055
|
+
if (!Number.isFinite(at)) return "";
|
|
1056
|
+
const minutes = Math.floor(Math.max(0, now - at) / 60_000);
|
|
1057
|
+
if (minutes < 1) return "just now";
|
|
1058
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
1059
|
+
const hours = Math.floor(minutes / 60);
|
|
1060
|
+
if (hours < 24) return `${hours}h ago`;
|
|
1061
|
+
const days = Math.floor(hours / 24);
|
|
1062
|
+
if (days < 30) return `${days}d ago`;
|
|
1063
|
+
return new Date(at).toISOString().slice(0, 10);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function clip(text, width) {
|
|
1067
|
+
return text.length <= width ? text : `${text.slice(0, width - 1)}…`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** How many results `terminus search` shows without --limit. */
|
|
1071
|
+
const SEARCH_DEFAULT_LIMIT = 10;
|
|
1072
|
+
|
|
1073
|
+
/** Where a search came from, in the search door's own vocabulary (web,
|
|
1074
|
+
* chat, agent, cli, api): the CLI is `cli`, run on its own or inside a
|
|
1075
|
+
* coding agent alike. */
|
|
1076
|
+
const SEARCH_SOURCE = "cli";
|
|
1077
|
+
|
|
1078
|
+
async function searchCommand(args) {
|
|
1079
|
+
const flags = parseFlags(args, "search");
|
|
1080
|
+
const query = flags._.join(" ").trim();
|
|
1081
|
+
if (!query) {
|
|
1082
|
+
throw commandUsageError("search");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const kind = flags.kind === undefined ? undefined : String(flags.kind);
|
|
1086
|
+
if (kind !== undefined && !["skill", "service", "app", "agent"].includes(kind)) {
|
|
1087
|
+
throw usageError("--kind must be skill, service, app, or agent");
|
|
1088
|
+
}
|
|
1089
|
+
const api = await connect(flags);
|
|
1090
|
+
const results = await api.json("GET /v1/skills/search", {
|
|
1091
|
+
query: {
|
|
1092
|
+
q: query,
|
|
1093
|
+
source: SEARCH_SOURCE,
|
|
1094
|
+
limit: flags.limit ?? SEARCH_DEFAULT_LIMIT,
|
|
1095
|
+
kinds: kind,
|
|
1096
|
+
},
|
|
1097
|
+
});
|
|
1098
|
+
emitJson(flags, results, () => printSearchResults(results));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function connectorsCommand(args) {
|
|
1102
|
+
const flags = parseFlags(args, "connectors");
|
|
1103
|
+
if (flags._.length > 1) {
|
|
1104
|
+
throw commandUsageError("connectors", { reason: "This command takes at most one SLUG argument." });
|
|
1105
|
+
}
|
|
1106
|
+
const requestedSlug = flags._[0]?.trim().toLowerCase();
|
|
1107
|
+
const api = await connect(flags);
|
|
1108
|
+
const response = await api.json("GET /v1/connectors");
|
|
1109
|
+
const connectors = Array.isArray(response.connectors) ? response.connectors : [];
|
|
1110
|
+
if (requestedSlug) {
|
|
1111
|
+
const connector = connectors.find((entry) => entry?.slug === requestedSlug);
|
|
1112
|
+
if (!connector) {
|
|
1113
|
+
throw new CliError(`connector '${requestedSlug}' is not available to this account`);
|
|
1114
|
+
}
|
|
1115
|
+
emitJson(flags, connector, () => printConnectorDetail(connector));
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const result = { connectors };
|
|
1119
|
+
emitJson(flags, result, () => printConnectorList(connectors));
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function connectorProfiles(connector) {
|
|
1123
|
+
return Array.isArray(connector?.profiles) ? connector.profiles : [];
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function printConnectorList(connectors) {
|
|
1127
|
+
if (connectors.length === 0) {
|
|
1128
|
+
console.log("No connectors are available.");
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
for (const connector of connectors) {
|
|
1132
|
+
const profiles = connectorProfiles(connector);
|
|
1133
|
+
const status = connector.enabled === false ? " · disabled" : "";
|
|
1134
|
+
console.log(`${connector.name ?? connector.slug} (${connector.slug})${status}`);
|
|
1135
|
+
console.log(` Tools: ${profiles.length ? profiles.map((profile) => profile.id).join(", ") : "none"}`);
|
|
1136
|
+
}
|
|
1137
|
+
console.log("\nRun `terminus connectors <slug>` to see operations and required scopes.");
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function printConnectorDetail(connector) {
|
|
1141
|
+
const profiles = connectorProfiles(connector);
|
|
1142
|
+
const status = connector.enabled === false ? " · disabled" : "";
|
|
1143
|
+
console.log(`${connector.name ?? connector.slug} (${connector.slug})${status}`);
|
|
1144
|
+
if (connector.description) console.log(connector.description);
|
|
1145
|
+
if (profiles.length === 0) {
|
|
1146
|
+
console.log("\nAgent tools: none (exact resource grants are still available).");
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
console.log(`\nAgent tools (${profiles.length}):`);
|
|
1150
|
+
for (const profile of profiles) {
|
|
1151
|
+
console.log(` ${profile.id} — ${profile.name}`);
|
|
1152
|
+
if (profile.description) console.log(` ${profile.description}`);
|
|
1153
|
+
const operations = Array.isArray(profile.operations) ? profile.operations : [];
|
|
1154
|
+
console.log(` Operations: ${operations.length ? operations.join("; ") : "none listed"}`);
|
|
1155
|
+
const scopes = Array.isArray(profile.required_scopes) ? profile.required_scopes : [];
|
|
1156
|
+
console.log(` Required scopes: ${scopes.length ? scopes.join(", ") : "connector or token configuration"}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const SKILL_COMMANDS = {
|
|
1161
|
+
install: (args) => installCommand(args),
|
|
1162
|
+
list: (args) => installedCommand(args),
|
|
1163
|
+
update: (args) => updateCommand(args),
|
|
1164
|
+
uninstall: (args) => uninstallCommand(args),
|
|
1165
|
+
use: (args) => useCommand(args),
|
|
1166
|
+
fetch: (args) => fetchCommand(args),
|
|
1167
|
+
files: (args) => filesCommand(args),
|
|
1168
|
+
file: (args) => fileCommand(args),
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
export const SKILL_COMMAND_HANDLERS = Object.freeze(Object.keys(SKILL_COMMANDS));
|
|
1172
|
+
|
|
1173
|
+
/** `terminus skills <command>`. On its own it is the page of every skill
|
|
1174
|
+
* command, the same as `terminus help skills`. */
|
|
1175
|
+
async function skillsCommand(args) {
|
|
1176
|
+
const [verb, ...rest] = args;
|
|
1177
|
+
if (verb === undefined) {
|
|
1178
|
+
console.log(renderHelp(["skills"]));
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const run = verb.startsWith("-") ? null : SKILL_COMMANDS[verb];
|
|
1182
|
+
if (!run) {
|
|
1183
|
+
throw commandUsageError("skills", verb.startsWith("-") ? {} : { reason: `'${verb}' is not a skills command.` });
|
|
1184
|
+
}
|
|
1185
|
+
await run(rest);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function useCommand(args) {
|
|
1189
|
+
const flags = parseFlags(args, "skills use");
|
|
1190
|
+
const query = flags._.join(" ").trim();
|
|
1191
|
+
if (!query) {
|
|
1192
|
+
throw commandUsageError("skills", { sub: "use" });
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const limit = Number.parseInt(flags.limit ?? "5", 10);
|
|
1196
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
1197
|
+
throw usageError("--limit must be a positive integer");
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const api = await connect(flags);
|
|
1201
|
+
|
|
1202
|
+
// Addresses and ids skip search: installed pointer skills use exactly the
|
|
1203
|
+
// skill they were installed for. A 404 falls back to the search flow.
|
|
1204
|
+
if (!flags.search && looksLikeSkillReference(query)) {
|
|
1205
|
+
const direct = await useSkillAsAccount(api, query, query).catch((error) => {
|
|
1206
|
+
if (error.status === 404) return null;
|
|
1207
|
+
throw error;
|
|
1208
|
+
});
|
|
1209
|
+
if (direct) {
|
|
1210
|
+
emitJson(flags, direct, () => printSkillUseResult(accountUseResult(direct)));
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const results = await api.json("GET /v1/skills/search", {
|
|
1216
|
+
query: { q: query, limit, source: SEARCH_SOURCE },
|
|
1217
|
+
});
|
|
1218
|
+
const skills = Array.isArray(results.skills) ? results.skills : [];
|
|
1219
|
+
if (skills.length === 0) {
|
|
1220
|
+
emitJson(flags, { query, selected: null, result_count: 0 }, () => printSearchResults(results));
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
let selectedIndex = selectedSkillIndex(flags, skills.length);
|
|
1225
|
+
if (selectedIndex == null) {
|
|
1226
|
+
if (flags.json) {
|
|
1227
|
+
throw usageError("terminus skills use --json requires --select <n> or --use-first");
|
|
1228
|
+
}
|
|
1229
|
+
printSearchResults(results);
|
|
1230
|
+
selectedIndex = await promptSkillSelection(skills.length);
|
|
1231
|
+
if (selectedIndex == null) {
|
|
1232
|
+
console.log("Canceled.");
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const useResult = await useSkillAsAccount(api, skills[selectedIndex].uid, query, {
|
|
1238
|
+
searchEventId: results.search_event_id,
|
|
1239
|
+
name: skills[selectedIndex].address,
|
|
1240
|
+
});
|
|
1241
|
+
emitJson(flags, useResult, () => printSkillUseResult(accountUseResult(useResult)));
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function filesCommand(args) {
|
|
1245
|
+
const flags = parseFlags(args, "skills files");
|
|
1246
|
+
const skillRef = flags._[0]?.trim();
|
|
1247
|
+
if (!skillRef) {
|
|
1248
|
+
throw commandUsageError("skills", { sub: "files" });
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const api = await connect(flags, { auth: "optional" });
|
|
1252
|
+
const skill = await skillDetail(api, skillRef);
|
|
1253
|
+
await refuseClosedSkill(api, skill, "list its files");
|
|
1254
|
+
const result = await skillFiles(api, skill.uid, { includeContent: false });
|
|
1255
|
+
emitJson(flags, { skill_ref: skill.uid, ...result }, () => printSkillFileManifest(skill.uid, result));
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
async function fileCommand(args) {
|
|
1259
|
+
const flags = parseFlags(args, "skills file");
|
|
1260
|
+
const skillRef = flags._[0]?.trim();
|
|
1261
|
+
const filePath = flags._.slice(1).join(" ").trim();
|
|
1262
|
+
if (!skillRef || !filePath) {
|
|
1263
|
+
throw commandUsageError("skills", { sub: "file" });
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const api = await connect(flags, { auth: "optional" });
|
|
1267
|
+
const skill = await skillDetail(api, skillRef);
|
|
1268
|
+
await refuseClosedSkill(api, skill, "print its files");
|
|
1269
|
+
const resolvedRef = skill.uid;
|
|
1270
|
+
const result = await skillFiles(api, resolvedRef, { includeContent: true, path: filePath });
|
|
1271
|
+
const file = Array.isArray(result.files) ? result.files[0] : null;
|
|
1272
|
+
if (!file) {
|
|
1273
|
+
throw new CliError(`remote skill file not found: ${filePath}`);
|
|
1274
|
+
}
|
|
1275
|
+
const content = skillFileText(file);
|
|
1276
|
+
emitJson(
|
|
1277
|
+
flags,
|
|
1278
|
+
{
|
|
1279
|
+
skill_ref: resolvedRef,
|
|
1280
|
+
path: file.path,
|
|
1281
|
+
content_type: file.content_type,
|
|
1282
|
+
size_bytes: file.size_bytes,
|
|
1283
|
+
asset_url: file.asset_url,
|
|
1284
|
+
content,
|
|
1285
|
+
},
|
|
1286
|
+
() => printSkillFileContent(resolvedRef, file, content),
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const INSTALL_MANIFEST_NAME = ".terminus-install.json";
|
|
1291
|
+
|
|
1292
|
+
// Skill directories the coding agents preload at session start; mirrors the
|
|
1293
|
+
// layout `npx skills` installs into.
|
|
1294
|
+
const AGENT_SKILL_DIRS = {
|
|
1295
|
+
claude: [".claude", "skills"],
|
|
1296
|
+
codex: [".codex", "skills"],
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
function defaultInstallAgent() {
|
|
1300
|
+
if (process.env.CODEX_SHELL || process.env.CODEX_THREAD_ID || process.env.CODEX_CI) {
|
|
1301
|
+
return "codex";
|
|
1302
|
+
}
|
|
1303
|
+
return "claude";
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function installAgents(flags) {
|
|
1307
|
+
const agent = String(flags.agent ?? defaultInstallAgent()).toLowerCase();
|
|
1308
|
+
if (agent === "both" || agent === "all") {
|
|
1309
|
+
return Object.keys(AGENT_SKILL_DIRS);
|
|
1310
|
+
}
|
|
1311
|
+
if (!AGENT_SKILL_DIRS[agent]) {
|
|
1312
|
+
throw usageError("--agent must be claude, codex, or both");
|
|
1313
|
+
}
|
|
1314
|
+
return [agent];
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function installTargetDirs(flags) {
|
|
1318
|
+
if (flags.dir) {
|
|
1319
|
+
return [{ agent: "custom", dir: path.resolve(flags.dir) }];
|
|
1320
|
+
}
|
|
1321
|
+
const scopeRoot = flags.global ? os.homedir() : process.cwd();
|
|
1322
|
+
return installAgents(flags).map((agent) => ({
|
|
1323
|
+
agent,
|
|
1324
|
+
dir: path.join(scopeRoot, ...AGENT_SKILL_DIRS[agent]),
|
|
1325
|
+
}));
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function installScanDirs(flags) {
|
|
1329
|
+
if (flags.dir) {
|
|
1330
|
+
return [{ agent: "custom", scope: "custom", dir: path.resolve(flags.dir) }];
|
|
1331
|
+
}
|
|
1332
|
+
const scans = [];
|
|
1333
|
+
for (const agent of Object.keys(AGENT_SKILL_DIRS)) {
|
|
1334
|
+
scans.push({ agent, scope: "project", dir: path.join(process.cwd(), ...AGENT_SKILL_DIRS[agent]) });
|
|
1335
|
+
scans.push({ agent, scope: "global", dir: path.join(os.homedir(), ...AGENT_SKILL_DIRS[agent]) });
|
|
1336
|
+
}
|
|
1337
|
+
return scans;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/** The catalog's row for a skill named by its address, uid, or id — or, when
|
|
1341
|
+
* nothing answers to that name, the best search match for it. */
|
|
1342
|
+
async function skillDetail(api, ref) {
|
|
1343
|
+
const direct = await api.json("GET /v1/skills/{ref}", { params: { ref } }).catch((error) => {
|
|
1344
|
+
if (error.status === 404) return null;
|
|
1345
|
+
throw error;
|
|
1346
|
+
});
|
|
1347
|
+
if (direct) return direct;
|
|
1348
|
+
// `track=false` keeps ref resolution out of the search analytics.
|
|
1349
|
+
const search = await api.json("GET /v1/skills/search", {
|
|
1350
|
+
query: { q: ref, limit: 1, track: "false", source: SEARCH_SOURCE },
|
|
1351
|
+
});
|
|
1352
|
+
const skill = Array.isArray(search.skills) ? search.skills[0] : null;
|
|
1353
|
+
if (!skill) {
|
|
1354
|
+
throw new CliError(`skill not found on Terminus: ${ref}`);
|
|
1355
|
+
}
|
|
1356
|
+
return skill;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function skillDirName(skill) {
|
|
1360
|
+
return slugify(skill.slug);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** `name` is the skill's folder, which the frontmatter must match; `skill
|
|
1364
|
+
* update` passes the folder it found so a renamed slug never moves it. */
|
|
1365
|
+
function pointerStubMarkdown(skill, address, name = skillDirName(skill)) {
|
|
1366
|
+
const description = String(skill.description || `Terminus skill ${address}`)
|
|
1367
|
+
.replace(/\s+/g, " ")
|
|
1368
|
+
.trim()
|
|
1369
|
+
.slice(0, 1024);
|
|
1370
|
+
return `---
|
|
1371
|
+
name: ${JSON.stringify(name)}
|
|
1372
|
+
description: ${JSON.stringify(description)}
|
|
1373
|
+
---
|
|
1374
|
+
|
|
1375
|
+
# ${skill.name ?? name}
|
|
1376
|
+
|
|
1377
|
+
This skill lives on the Terminus platform. Load it at use time instead of keeping a local copy.
|
|
1378
|
+
|
|
1379
|
+
When this skill triggers, run:
|
|
1380
|
+
|
|
1381
|
+
\`\`\`bash
|
|
1382
|
+
terminus skills use ${JSON.stringify(address)}
|
|
1383
|
+
\`\`\`
|
|
1384
|
+
|
|
1385
|
+
If \`terminus\` is not on PATH, run \`npx -y @terminus-ai/cli skills use ${JSON.stringify(address)}\`.
|
|
1386
|
+
|
|
1387
|
+
Read the printed TERMINUS SKILL CONTENT block and follow it as the active skill instructions. If those
|
|
1388
|
+
instructions reference supporting files, run \`terminus skills fetch ${JSON.stringify(address)}\` and read the files
|
|
1389
|
+
from the printed local cache path.
|
|
1390
|
+
`;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/** Whether Terminus hands out this skill's content: the same rule as the
|
|
1394
|
+
* server's use door. */
|
|
1395
|
+
function skillContentIsOpen(skill) {
|
|
1396
|
+
return skill.content_visibility === "open_source" || Boolean(skill.is_external);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* A closed skill's content stays on Terminus, where it is used — metered,
|
|
1401
|
+
* and for a paid skill, paid for. `skills fetch`, `files`, and `file` refuse
|
|
1402
|
+
* one up front rather than fail halfway, unless the caller is the skill's
|
|
1403
|
+
* owner, who reads their own files the way Terminus serves them.
|
|
1404
|
+
*/
|
|
1405
|
+
async function refuseClosedSkill(api, skill, action) {
|
|
1406
|
+
if (skillContentIsOpen(skill)) return;
|
|
1407
|
+
if (api.token && skill.owner_user_id && (await api.me()).id === skill.owner_user_id) return;
|
|
1408
|
+
throw closedSkillError(skill, action);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function closedSkillError(skill, action) {
|
|
1412
|
+
return new CliError(
|
|
1413
|
+
`${skill.address} isn't open source: its content stays on Terminus, so terminus can't ${action}`,
|
|
1414
|
+
{ code: "forbidden" },
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** The use door's refusal of a closed skill, said as what it is. */
|
|
1419
|
+
function skillUseRefusal(reference) {
|
|
1420
|
+
return (error) => {
|
|
1421
|
+
if (error?.status !== 403) throw error;
|
|
1422
|
+
throw new CliError(
|
|
1423
|
+
`${reference} isn't open source: its content stays on Terminus, and \`terminus skills use\` loads only open-source skills`,
|
|
1424
|
+
{ code: "forbidden", status: error.status, apiCode: error.apiCode, details: error.details },
|
|
1425
|
+
);
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
async function writeLocalSkillPackage(target, copy) {
|
|
1430
|
+
await writeSkillFiles(target, copy.files);
|
|
1431
|
+
if (copy.skillMd != null) {
|
|
1432
|
+
await writeFile(path.join(target, "SKILL.md"), copy.skillMd);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** A --local copy's content, checked before anything touches the disk: the
|
|
1437
|
+
* package's files, plus the delivered markdown as SKILL.md when the package
|
|
1438
|
+
* has none. Copying content to disk is a delivery, so it is counted like a
|
|
1439
|
+
* use. */
|
|
1440
|
+
async function downloadSkillPackage(api, skill, address) {
|
|
1441
|
+
const useResult = await useSkillAsAccount(api, skill.uid, address, { name: address });
|
|
1442
|
+
const manifest = await skillFiles(api, skill.uid, { includeContent: true });
|
|
1443
|
+
const files = (Array.isArray(manifest.files) ? manifest.files : []).filter(
|
|
1444
|
+
(file) => file.content_available && file.content_base64 != null,
|
|
1445
|
+
);
|
|
1446
|
+
const hasSkillMd = files.some((file) => normalizePackageFilePath(file.path) === "SKILL.md");
|
|
1447
|
+
const contentMarkdown = useResult.content ?? "";
|
|
1448
|
+
if (!hasSkillMd && !contentMarkdown.trim()) {
|
|
1449
|
+
throw new CliError("skill package did not include SKILL.md content");
|
|
1450
|
+
}
|
|
1451
|
+
return { files, skillMd: hasSkillMd ? null : contentMarkdown };
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** The `.terminus-install.json` beside every skill terminus installed: what
|
|
1455
|
+
* `skills list`, `skills update`, and `skills uninstall` look for. */
|
|
1456
|
+
async function writeInstallManifest(skillDir, { mode, address, skill, base, agent, installedAt, updatedAt }) {
|
|
1457
|
+
await writeFile(
|
|
1458
|
+
path.join(skillDir, INSTALL_MANIFEST_NAME),
|
|
1459
|
+
JSON.stringify(
|
|
1460
|
+
{
|
|
1461
|
+
installer: "terminus",
|
|
1462
|
+
installer_version: CLI_VERSION,
|
|
1463
|
+
mode,
|
|
1464
|
+
address,
|
|
1465
|
+
uid: skill.uid,
|
|
1466
|
+
skill_id: skill.id,
|
|
1467
|
+
change_hash: skill.change_hash,
|
|
1468
|
+
api_base: base,
|
|
1469
|
+
agent,
|
|
1470
|
+
installed_at: installedAt,
|
|
1471
|
+
...(updatedAt ? { updated_at: updatedAt } : {}),
|
|
1472
|
+
},
|
|
1473
|
+
null,
|
|
1474
|
+
2,
|
|
1475
|
+
),
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
async function installCommand(args) {
|
|
1480
|
+
const flags = parseFlags(args, "skills install");
|
|
1481
|
+
const ref = flags._.join(" ").trim();
|
|
1482
|
+
if (!ref) {
|
|
1483
|
+
throw commandUsageError("skills", { sub: "install" });
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const api = await connect(flags);
|
|
1487
|
+
const skill = await skillDetail(api, ref);
|
|
1488
|
+
// Both kinds of install load through the use door — a pointer each time
|
|
1489
|
+
// it is used, a --local copy as it is made — and that door serves only
|
|
1490
|
+
// open-source skills, to everyone, their owner included.
|
|
1491
|
+
if (!skillContentIsOpen(skill)) throw closedSkillError(skill, "install it");
|
|
1492
|
+
const { address } = skill;
|
|
1493
|
+
const dirName = skillDirName(skill);
|
|
1494
|
+
const mode = flags.local ? "local" : "pointer";
|
|
1495
|
+
|
|
1496
|
+
// Every target is checked before a --local download counts a use.
|
|
1497
|
+
const targets = installTargetDirs(flags).map((target) => ({ ...target, skillDir: path.join(target.dir, dirName) }));
|
|
1498
|
+
for (const { skillDir } of flags.force ? [] : targets) {
|
|
1499
|
+
if (await exists(skillDir)) {
|
|
1500
|
+
throw new CliError(`already installed: ${skillDir} (terminus skills update refreshes it; --force replaces it)`);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
let copy = null;
|
|
1505
|
+
if (mode === "local") {
|
|
1506
|
+
copy = await downloadSkillPackage(api, skill, address);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const installed = [];
|
|
1510
|
+
for (const { agent, skillDir } of targets) {
|
|
1511
|
+
await rm(skillDir, { recursive: true, force: true });
|
|
1512
|
+
await mkdir(skillDir, { recursive: true });
|
|
1513
|
+
if (mode === "pointer") {
|
|
1514
|
+
await writeFile(path.join(skillDir, "SKILL.md"), pointerStubMarkdown(skill, address));
|
|
1515
|
+
} else {
|
|
1516
|
+
await writeLocalSkillPackage(skillDir, copy);
|
|
1517
|
+
}
|
|
1518
|
+
await writeInstallManifest(skillDir, {
|
|
1519
|
+
mode,
|
|
1520
|
+
address,
|
|
1521
|
+
skill,
|
|
1522
|
+
base: api.base,
|
|
1523
|
+
agent,
|
|
1524
|
+
installedAt: new Date().toISOString(),
|
|
1525
|
+
});
|
|
1526
|
+
installed.push({ agent, dir: skillDir });
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
emitJson(flags, { mode, address, name: dirName, installed }, () => {
|
|
1530
|
+
for (const entry of installed) {
|
|
1531
|
+
console.log(`Installed ${mode === "pointer" ? "pointer for" : "local copy of"} ${address} → ${entry.dir}`);
|
|
1532
|
+
}
|
|
1533
|
+
if (mode === "pointer") {
|
|
1534
|
+
console.log("Content stays on Terminus and loads at use time (counted per use).");
|
|
1535
|
+
} else {
|
|
1536
|
+
console.log("Local copies do not count uses; run terminus skills update to refresh them.");
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
async function readInstallManifest(skillDir) {
|
|
1542
|
+
try {
|
|
1543
|
+
const manifest = JSON.parse(await readFile(path.join(skillDir, INSTALL_MANIFEST_NAME), "utf8"));
|
|
1544
|
+
return manifest?.installer === "terminus" ? manifest : null;
|
|
1545
|
+
} catch {
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async function collectInstalledSkills(flags = {}) {
|
|
1551
|
+
const found = [];
|
|
1552
|
+
for (const scan of installScanDirs(flags)) {
|
|
1553
|
+
let entries;
|
|
1554
|
+
try {
|
|
1555
|
+
entries = await readdir(scan.dir, { withFileTypes: true });
|
|
1556
|
+
} catch {
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
for (const entry of entries) {
|
|
1560
|
+
if (!entry.isDirectory()) continue;
|
|
1561
|
+
const skillDir = path.join(scan.dir, entry.name);
|
|
1562
|
+
const manifest = await readInstallManifest(skillDir);
|
|
1563
|
+
if (manifest) {
|
|
1564
|
+
found.push({ name: entry.name, dir: skillDir, scope: scan.scope, manifest });
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return found;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function installedSkillMatches(entry, ref) {
|
|
1572
|
+
const wanted = ref.trim().toLowerCase();
|
|
1573
|
+
const candidates = [
|
|
1574
|
+
entry.name,
|
|
1575
|
+
entry.manifest.address,
|
|
1576
|
+
entry.manifest.uid,
|
|
1577
|
+
entry.manifest.skill_id,
|
|
1578
|
+
];
|
|
1579
|
+
return candidates.some((candidate) => String(candidate ?? "").toLowerCase() === wanted);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
async function uninstallCommand(args) {
|
|
1583
|
+
const flags = parseFlags(args, "skills uninstall");
|
|
1584
|
+
const ref = flags._.join(" ").trim();
|
|
1585
|
+
if (!ref) {
|
|
1586
|
+
throw commandUsageError("skills", { sub: "uninstall" });
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
const installed = await collectInstalledSkills(flags);
|
|
1590
|
+
const matches = installed.filter((entry) => installedSkillMatches(entry, ref));
|
|
1591
|
+
if (matches.length === 0) {
|
|
1592
|
+
throw new CliError(`no skill installed by terminus matches ${ref} (see terminus skills list)`);
|
|
1593
|
+
}
|
|
1594
|
+
const removed = [];
|
|
1595
|
+
for (const entry of matches) {
|
|
1596
|
+
await rm(entry.dir, { recursive: true, force: true });
|
|
1597
|
+
removed.push({ address: entry.manifest.address ?? null, name: entry.name, dir: entry.dir, scope: entry.scope });
|
|
1598
|
+
}
|
|
1599
|
+
emitJson(flags, { removed }, () => {
|
|
1600
|
+
for (const entry of removed) console.log(`Removed ${entry.address ?? entry.name} → ${entry.dir}`);
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** `terminus skills update [<skill>]`: brings what terminus installed in step
|
|
1605
|
+
* with Terminus. A pointer already loads the latest instructions at every
|
|
1606
|
+
* use, so only its stub is rewritten, and only when it would read
|
|
1607
|
+
* differently (a new name, description, or address, or older wording). A
|
|
1608
|
+
* --local copy downloads again when the skill's change hash moved, counted as
|
|
1609
|
+
* a use like its install. A skill that is gone is reported and left alone. */
|
|
1610
|
+
async function updateCommand(args) {
|
|
1611
|
+
const flags = parseFlags(args, "skills update");
|
|
1612
|
+
const ref = flags._.join(" ").trim();
|
|
1613
|
+
const installed = await collectInstalledSkills(flags);
|
|
1614
|
+
const entries = ref ? installed.filter((entry) => installedSkillMatches(entry, ref)) : installed;
|
|
1615
|
+
if (ref && entries.length === 0) {
|
|
1616
|
+
throw new CliError(`no skill installed by terminus matches ${ref} (see terminus skills list)`);
|
|
1617
|
+
}
|
|
1618
|
+
if (entries.length === 0) {
|
|
1619
|
+
emitJson(flags, { skills: [] }, () => {
|
|
1620
|
+
console.log("No skills installed by terminus yet. Try: terminus skills install <skill> --agent claude");
|
|
1621
|
+
});
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// One lookup, and at most one download, per skill, however many folders
|
|
1626
|
+
// it is installed in. Signed in, like install: private catalog grants and
|
|
1627
|
+
// the use door both need the account.
|
|
1628
|
+
const context = {
|
|
1629
|
+
api: await connect(flags),
|
|
1630
|
+
details: new Map(),
|
|
1631
|
+
copies: new Map(),
|
|
1632
|
+
};
|
|
1633
|
+
const skills = [];
|
|
1634
|
+
for (const entry of entries) {
|
|
1635
|
+
skills.push(await updateInstalledSkill(entry, context));
|
|
1636
|
+
}
|
|
1637
|
+
emitJson(flags, { skills }, () => printSkillUpdates(skills));
|
|
1638
|
+
const failed = skills.filter((skill) => skill.status === "failed").length;
|
|
1639
|
+
if (failed) {
|
|
1640
|
+
throw new CliError(`${failed === 1 ? "1 skill" : `${failed} skills`} could not be updated`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function memoized(cache, key, load) {
|
|
1645
|
+
if (!cache.has(key)) {
|
|
1646
|
+
cache.set(key, load());
|
|
1647
|
+
}
|
|
1648
|
+
return cache.get(key);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/** The catalog's current row for an installed skill, by the uid its install
|
|
1652
|
+
* recorded (an address can be renamed). Unlike skillDetail this never falls
|
|
1653
|
+
* back to search: a skill that is gone must not be swapped for a look-alike. */
|
|
1654
|
+
async function installedSkillDetail(api, ref) {
|
|
1655
|
+
try {
|
|
1656
|
+
return { skill: await api.json("GET /v1/skills/{ref}", { params: { ref } }) };
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
if (error.status === 404) return { problem: "It is no longer on Terminus." };
|
|
1659
|
+
if (error.status === 403) return { problem: "You no longer have access to it." };
|
|
1660
|
+
throw error;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
async function updateInstalledSkill(entry, { api, details, copies }) {
|
|
1665
|
+
const { manifest } = entry;
|
|
1666
|
+
const mode = manifest.mode === "local" ? "local" : "pointer";
|
|
1667
|
+
const report = (status, extra = {}) => ({
|
|
1668
|
+
name: entry.name,
|
|
1669
|
+
address: manifest.address ?? null,
|
|
1670
|
+
dir: entry.dir,
|
|
1671
|
+
scope: entry.scope,
|
|
1672
|
+
mode,
|
|
1673
|
+
status,
|
|
1674
|
+
...extra,
|
|
1675
|
+
});
|
|
1676
|
+
const ref = manifest.uid ?? manifest.skill_id ?? manifest.address;
|
|
1677
|
+
if (!ref) {
|
|
1678
|
+
return report("failed", { reason: "Its install record does not say which skill it is; install it again." });
|
|
1679
|
+
}
|
|
1680
|
+
const found = await memoized(details, ref, () => installedSkillDetail(api, ref));
|
|
1681
|
+
if (!found.skill) {
|
|
1682
|
+
return report("failed", { reason: `${found.problem} Remove it with: terminus skills uninstall ${entry.name}` });
|
|
1683
|
+
}
|
|
1684
|
+
const { skill } = found;
|
|
1685
|
+
const address = skill.address;
|
|
1686
|
+
const now = new Date().toISOString();
|
|
1687
|
+
const record = {
|
|
1688
|
+
mode,
|
|
1689
|
+
address,
|
|
1690
|
+
skill,
|
|
1691
|
+
base: api.base,
|
|
1692
|
+
agent: manifest.agent ?? null,
|
|
1693
|
+
installedAt: manifest.installed_at ?? now,
|
|
1694
|
+
updatedAt: now,
|
|
1695
|
+
};
|
|
1696
|
+
|
|
1697
|
+
if (mode === "pointer") {
|
|
1698
|
+
const stub = pointerStubMarkdown(skill, address, entry.name);
|
|
1699
|
+
const skillMd = path.join(entry.dir, "SKILL.md");
|
|
1700
|
+
if ((await readFile(skillMd, "utf8").catch(() => null)) === stub) {
|
|
1701
|
+
return report("up_to_date", { address });
|
|
1702
|
+
}
|
|
1703
|
+
await writeFile(skillMd, stub);
|
|
1704
|
+
await writeInstallManifest(entry.dir, record);
|
|
1705
|
+
return report("updated", { address });
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (manifest.change_hash && manifest.change_hash === skill.change_hash) {
|
|
1709
|
+
return report("up_to_date", { address });
|
|
1710
|
+
}
|
|
1711
|
+
if (!skillContentIsOpen(skill)) {
|
|
1712
|
+
return report("failed", { address, reason: "It is no longer open source, so terminus cannot refresh this copy." });
|
|
1713
|
+
}
|
|
1714
|
+
const copy = await memoized(copies, skill.uid, () => downloadSkillPackage(api, skill, address));
|
|
1715
|
+
await rm(entry.dir, { recursive: true, force: true });
|
|
1716
|
+
await mkdir(entry.dir, { recursive: true });
|
|
1717
|
+
await writeLocalSkillPackage(entry.dir, copy);
|
|
1718
|
+
await writeInstallManifest(entry.dir, record);
|
|
1719
|
+
return report("updated", { address });
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function printSkillUpdates(skills) {
|
|
1723
|
+
for (const skill of skills) {
|
|
1724
|
+
const label = skill.address ?? skill.name;
|
|
1725
|
+
if (skill.status === "updated") {
|
|
1726
|
+
console.log(`Updated ${label} → ${skill.dir}`);
|
|
1727
|
+
} else if (skill.status === "up_to_date") {
|
|
1728
|
+
console.log(`Up to date: ${label} → ${skill.dir}`);
|
|
1729
|
+
} else {
|
|
1730
|
+
console.log(`Could not update ${label} → ${skill.dir}`);
|
|
1731
|
+
console.log(` ${skill.reason}`);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
async function installedCommand(args) {
|
|
1737
|
+
const flags = parseFlags(args, "skills list");
|
|
1738
|
+
const installed = await collectInstalledSkills(flags);
|
|
1739
|
+
emitJson(flags, { installed }, () => {
|
|
1740
|
+
if (installed.length === 0) {
|
|
1741
|
+
console.log("No skills installed by terminus yet. Try: terminus skills install <skill> --agent claude");
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
console.log(`Installed Terminus skills (${installed.length}):`);
|
|
1745
|
+
for (const entry of installed) {
|
|
1746
|
+
const mode = entry.manifest.mode ?? "pointer";
|
|
1747
|
+
console.log(`- ${entry.name} [${mode}, ${entry.scope}] ${entry.manifest.address ?? ""}`);
|
|
1748
|
+
console.log(` ${entry.dir}`);
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
function cacheRoot() {
|
|
1754
|
+
return path.join(os.homedir(), ".terminus", "cache");
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function sanitizeCacheSegment(value) {
|
|
1758
|
+
return String(value ?? "unknown").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80) || "unknown";
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async function fetchCommand(args) {
|
|
1762
|
+
const flags = parseFlags(args, "skills fetch");
|
|
1763
|
+
const ref = flags._.join(" ").trim();
|
|
1764
|
+
if (!ref) {
|
|
1765
|
+
throw commandUsageError("skills", { sub: "fetch" });
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
const api = await connect(flags);
|
|
1769
|
+
const skill = await skillDetail(api, ref);
|
|
1770
|
+
await refuseClosedSkill(api, skill, "download its files");
|
|
1771
|
+
const { uid } = skill;
|
|
1772
|
+
const revision = sanitizeCacheSegment(skill.change_hash?.slice(0, 16) || "latest");
|
|
1773
|
+
const cacheDir = path.join(cacheRoot(), `${sanitizeCacheSegment(uid)}@${revision}`);
|
|
1774
|
+
const marker = path.join(cacheDir, ".terminus-cache.json");
|
|
1775
|
+
|
|
1776
|
+
let fromCache = !flags.refresh && (await exists(marker));
|
|
1777
|
+
let fileCount;
|
|
1778
|
+
if (fromCache) {
|
|
1779
|
+
fileCount = JSON.parse(await readFile(marker, "utf8")).file_count;
|
|
1780
|
+
} else {
|
|
1781
|
+
const manifest = await skillFiles(api, uid, { includeContent: true });
|
|
1782
|
+
const files = (Array.isArray(manifest.files) ? manifest.files : []).filter(
|
|
1783
|
+
(file) => file.content_available && file.content_base64 != null,
|
|
1784
|
+
);
|
|
1785
|
+
if (files.length === 0) {
|
|
1786
|
+
throw new CliError(`skill has no downloadable files: ${ref}`);
|
|
1787
|
+
}
|
|
1788
|
+
await rm(cacheDir, { recursive: true, force: true });
|
|
1789
|
+
await writeSkillFiles(cacheDir, files);
|
|
1790
|
+
fileCount = files.length;
|
|
1791
|
+
await writeFile(
|
|
1792
|
+
marker,
|
|
1793
|
+
JSON.stringify(
|
|
1794
|
+
{
|
|
1795
|
+
uid,
|
|
1796
|
+
address: skill.address,
|
|
1797
|
+
change_hash: skill.change_hash,
|
|
1798
|
+
file_count: fileCount,
|
|
1799
|
+
cached_at: new Date().toISOString(),
|
|
1800
|
+
},
|
|
1801
|
+
null,
|
|
1802
|
+
2,
|
|
1803
|
+
),
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
emitJson(
|
|
1808
|
+
flags,
|
|
1809
|
+
{ skill_ref: uid, cache_dir: cacheDir, file_count: fileCount, from_cache: fromCache },
|
|
1810
|
+
() => {
|
|
1811
|
+
console.log(`Skill files ${fromCache ? "already cached" : "cached"}: ${cacheDir}`);
|
|
1812
|
+
console.log(`Files: ${formatNumber(fileCount)} (read them from this directory; safe to delete anytime)`);
|
|
1813
|
+
},
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* `terminus clone <@handle/skill>` for a skill this account owns — the skill
|
|
1819
|
+
* half of the app-family clone in apps.mjs, which calls this when no owned
|
|
1820
|
+
* app, agent, or service answers the address. Returns null when the account
|
|
1821
|
+
* owns no such skill, so the caller can report one "nothing matches" error.
|
|
1822
|
+
*
|
|
1823
|
+
* A skill's draft is written through `PATCH /dashboard/developer/skills/{id}`
|
|
1824
|
+
* (each change a commit in its history), so the local loop is clone → edit →
|
|
1825
|
+
* `terminus push`, and the web publishes it.
|
|
1826
|
+
*/
|
|
1827
|
+
async function cloneOwnedSkill({ api, flags, ref, dir: requestedDir, extra = {} }) {
|
|
1828
|
+
const skill = await ownedSkill(api, ref, flags, { missing: null });
|
|
1829
|
+
if (!skill) {
|
|
1830
|
+
return null;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
const skillRef = skill.uid;
|
|
1834
|
+
const manifest = await skillFiles(api, skillRef, { includeContent: true, developer: true });
|
|
1835
|
+
const packageFiles = Array.isArray(manifest.files) ? manifest.files : [];
|
|
1836
|
+
|
|
1837
|
+
const address = skill.address;
|
|
1838
|
+
const dir = path.resolve(requestedDir ?? skill.slug);
|
|
1839
|
+
await mkdir(dir, { recursive: true });
|
|
1840
|
+
if ((await readdir(dir)).length) {
|
|
1841
|
+
throw new CliError(`${dir} is not empty — clone into a fresh directory`);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
let packageSkillMarkdown = null;
|
|
1845
|
+
const files = [];
|
|
1846
|
+
for (const file of packageFiles) {
|
|
1847
|
+
const relative = normalizePackageFilePath(file.path);
|
|
1848
|
+
const bytes = skillFileBytes(
|
|
1849
|
+
file.content_available && file.content_base64 != null
|
|
1850
|
+
? file
|
|
1851
|
+
: await remoteSkillFile(api, skillRef, relative),
|
|
1852
|
+
);
|
|
1853
|
+
// SKILL.md is the skill itself rather than one of its files: the platform
|
|
1854
|
+
// keeps it as `content_markdown` (frontmatter parsed off), and web edits
|
|
1855
|
+
// never resend it, so the package copy is only good for its frontmatter.
|
|
1856
|
+
if (relative === "SKILL.md") {
|
|
1857
|
+
packageSkillMarkdown = bytes.toString("utf8");
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
files.push({ relative, bytes });
|
|
1861
|
+
}
|
|
1862
|
+
files.unshift({
|
|
1863
|
+
relative: "SKILL.md",
|
|
1864
|
+
bytes: Buffer.from(skillWorkingCopyMarkdown(skill, packageSkillMarkdown), "utf8"),
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
for (const file of files) {
|
|
1868
|
+
const target = path.join(dir, ...file.relative.split("/"));
|
|
1869
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1870
|
+
await writeFile(target, file.bytes);
|
|
1871
|
+
}
|
|
1872
|
+
// Like git's origin/main: the clone knows which commit it holds, so push
|
|
1873
|
+
// can refuse to clobber later ones and pull can merge them in.
|
|
1874
|
+
const entries = files
|
|
1875
|
+
.map((file) => ({ path: file.relative, sha256: sha256(file.bytes) }))
|
|
1876
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
1877
|
+
const history = await skillHistory(api, skillRef, 1).catch(() => ({}));
|
|
1878
|
+
await recordSkillSync(dir, {
|
|
1879
|
+
skill,
|
|
1880
|
+
commit: history.head_revision_id ?? null,
|
|
1881
|
+
draft: entries,
|
|
1882
|
+
local: entries,
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
const { status } = skill;
|
|
1886
|
+
const result = {
|
|
1887
|
+
...extra,
|
|
1888
|
+
address,
|
|
1889
|
+
dir,
|
|
1890
|
+
files: files.length,
|
|
1891
|
+
kind: "skill",
|
|
1892
|
+
status,
|
|
1893
|
+
publish_url: creationPageUrl(flags, skill.uid),
|
|
1894
|
+
};
|
|
1895
|
+
emitJson(flags, result, () => {
|
|
1896
|
+
console.log(`Cloned ${address} → ${dir} (${formatNumber(files.length)} file${files.length === 1 ? "" : "s"})`);
|
|
1897
|
+
console.log("Next:");
|
|
1898
|
+
console.log(` cd ${path.relative(process.cwd(), dir) || "."}`);
|
|
1899
|
+
console.log(" terminus push # send your changes to its draft");
|
|
1900
|
+
console.log(status === "draft"
|
|
1901
|
+
? `Publish it on the web when it is ready: ${result.publish_url}`
|
|
1902
|
+
: `It is published: pushes land on its draft until you publish again — ${result.publish_url}`);
|
|
1903
|
+
});
|
|
1904
|
+
return result;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
async function remoteSkillFile(api, skillRef, filePath) {
|
|
1908
|
+
const result = await skillFiles(api, skillRef, {
|
|
1909
|
+
includeContent: true,
|
|
1910
|
+
developer: true,
|
|
1911
|
+
path: filePath,
|
|
1912
|
+
});
|
|
1913
|
+
const files = Array.isArray(result.files) ? result.files : [];
|
|
1914
|
+
// The path door answers with that one file; match it by name anyway so a
|
|
1915
|
+
// server that answers with the whole package cannot cross the contents over.
|
|
1916
|
+
const file = files.find((candidate) => String(candidate?.path ?? "").replaceAll("\\", "/") === filePath);
|
|
1917
|
+
if (!file) {
|
|
1918
|
+
throw new CliError(`remote skill file not found: ${filePath}`);
|
|
1919
|
+
}
|
|
1920
|
+
return file;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function normalizeSkillReference(value) {
|
|
1924
|
+
const text = String(value ?? "").trim().toLowerCase();
|
|
1925
|
+
return text.startsWith("@") ? text.slice(1) : text;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/** Every name one skill answers to: uid, id, slug, and address (with or
|
|
1929
|
+
* without the leading `@`). */
|
|
1930
|
+
function skillReferenceMatches(skill, ref) {
|
|
1931
|
+
const wanted = normalizeSkillReference(ref);
|
|
1932
|
+
if (!wanted) {
|
|
1933
|
+
return false;
|
|
1934
|
+
}
|
|
1935
|
+
return [skill?.uid, skill?.id, skill?.slug, skill?.address, skill?.address_path]
|
|
1936
|
+
.some((candidate) => candidate && normalizeSkillReference(candidate) === wanted);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* The SKILL.md a working copy gets. The platform stores a skill's markdown
|
|
1941
|
+
* body without its frontmatter, so the file is rebuilt: the package copy's
|
|
1942
|
+
* own frontmatter when the skill was published from a package, otherwise the
|
|
1943
|
+
* two fields the platform keeps, plus the `id` a later `terminus push`
|
|
1944
|
+
* resolves back to this exact skill.
|
|
1945
|
+
*/
|
|
1946
|
+
function skillWorkingCopyMarkdown(skill, packageSkillMarkdown, options = {}) {
|
|
1947
|
+
const frontmatter = skillWorkingCopyFrontmatter(skill, packageSkillMarkdown, options);
|
|
1948
|
+
const body = String(skill.content_markdown ?? "").trim();
|
|
1949
|
+
return body ? `${frontmatter}\n\n${body}\n` : `${frontmatter}\n`;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
function skillWorkingCopyFrontmatter(skill, packageSkillMarkdown, { linked = true } = {}) {
|
|
1953
|
+
const metadata = packageSkillMarkdown ? parseFrontmatterMetadata(packageSkillMarkdown) : {};
|
|
1954
|
+
let lines = frontmatterLines(packageSkillMarkdown);
|
|
1955
|
+
// Name and description are the platform's copies; a rename on the web must
|
|
1956
|
+
// not travel back as a rename to the name the package still carries.
|
|
1957
|
+
if (skill.name?.trim() && metadata.name !== skill.name.trim()) {
|
|
1958
|
+
lines = withFrontmatterEntry(lines, "name", skill.name.trim());
|
|
1959
|
+
}
|
|
1960
|
+
if (skill.description?.trim() && metadata.description !== skill.description.trim()) {
|
|
1961
|
+
lines = withFrontmatterEntry(lines, "description", skill.description.trim());
|
|
1962
|
+
}
|
|
1963
|
+
// The publish door takes uid, id, slug, or address; record the id unless the
|
|
1964
|
+
// package already names this same skill, so an edited name still updates it.
|
|
1965
|
+
// A read-only copy of someone else's skill records none: it is linked to
|
|
1966
|
+
// nothing this account can push to.
|
|
1967
|
+
if (!linked) {
|
|
1968
|
+
lines = lines.filter((line) => frontmatterLineKey(line) !== "id");
|
|
1969
|
+
} else if (!skillReferenceMatches(skill, metadata.id)) {
|
|
1970
|
+
lines = withFrontmatterEntry(lines, "id", skill.id ?? skill.uid);
|
|
1971
|
+
}
|
|
1972
|
+
return ["---", ...lines, "---"].join("\n");
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
function frontmatterLines(content) {
|
|
1976
|
+
const match = String(content ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
1977
|
+
return match ? match[1].split(/\r?\n/) : [];
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function frontmatterLineKey(line) {
|
|
1981
|
+
const trimmed = line.trim();
|
|
1982
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
1983
|
+
return null;
|
|
1984
|
+
}
|
|
1985
|
+
const index = trimmed.indexOf(":");
|
|
1986
|
+
return index < 0 ? null : trimmed.slice(0, index).trim();
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function withFrontmatterEntry(lines, key, value) {
|
|
1990
|
+
const entry = `${key}: ${JSON.stringify(String(value))}`;
|
|
1991
|
+
const index = lines.findIndex((line) => frontmatterLineKey(line) === key);
|
|
1992
|
+
return index < 0 ? [...lines, entry] : lines.map((line, at) => (at === index ? entry : line));
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
async function initCommand(args) {
|
|
1996
|
+
const flags = parseFlags(args, "init");
|
|
1997
|
+
const kindAliases = new Map([
|
|
1998
|
+
["skill", "skill"], ["skills", "skill"],
|
|
1999
|
+
["app", "app"], ["apps", "app"],
|
|
2000
|
+
["agent", "agent"], ["agents", "agent"],
|
|
2001
|
+
["service", "service"], ["services", "service"],
|
|
2002
|
+
]);
|
|
2003
|
+
const positionalKind = kindAliases.get(String(flags._[0] ?? "").toLowerCase());
|
|
2004
|
+
const requestedKind = flags.kind
|
|
2005
|
+
? (kindAliases.get(String(flags.kind).toLowerCase()) ?? String(flags.kind).toLowerCase())
|
|
2006
|
+
: null;
|
|
2007
|
+
if (positionalKind && requestedKind && positionalKind !== requestedKind) {
|
|
2008
|
+
throw usageError(`positional kind '${positionalKind}' conflicts with --kind '${requestedKind}'`);
|
|
2009
|
+
}
|
|
2010
|
+
const kind = positionalKind ?? requestedKind ?? "skill";
|
|
2011
|
+
const targetIndex = positionalKind ? 1 : 0;
|
|
2012
|
+
if (flags._.length > targetIndex + 1) {
|
|
2013
|
+
throw usageError("init accepts at most one target directory");
|
|
2014
|
+
}
|
|
2015
|
+
const target = path.resolve(flags._[targetIndex] ?? ".");
|
|
2016
|
+
if (kind !== "skill") {
|
|
2017
|
+
await (await import("./apps.mjs")).initAppCommand(target, kind, flags);
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
const skillPath = path.join(target, "SKILL.md");
|
|
2021
|
+
// A skill cloned from an empty draft is frontmatter alone: which skill it
|
|
2022
|
+
// is, with its name and description. init writes the body around them and
|
|
2023
|
+
// never drops the id a skill folder is linked by.
|
|
2024
|
+
const existing = (await exists(skillPath)) ? await readFile(skillPath, "utf8") : null;
|
|
2025
|
+
const existingMeta = existing === null ? {} : parseFrontmatterMetadata(existing);
|
|
2026
|
+
const bodyless = existing !== null
|
|
2027
|
+
&& !existing.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "").trim();
|
|
2028
|
+
if (!flags.force && existing !== null && !bodyless) {
|
|
2029
|
+
throw new CliError(`SKILL.md already exists in ${target} (use --force to overwrite)`);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
const name = flags.name ? slugify(flags.name) : existingMeta.name ?? slugify(path.basename(target));
|
|
2033
|
+
const description = String(
|
|
2034
|
+
flags.description ?? existingMeta.description
|
|
2035
|
+
?? "Replace with one sentence describing when an agent should use this skill.",
|
|
2036
|
+
).trim();
|
|
2037
|
+
const markdown = initialSkillMarkdown(name, description);
|
|
2038
|
+
await mkdir(target, { recursive: true });
|
|
2039
|
+
await writeFile(skillPath, existingMeta.id ? withSkillId(markdown, existingMeta.id) : markdown);
|
|
2040
|
+
|
|
2041
|
+
emitJson(flags, { path: skillPath, name, description, id: existingMeta.id ?? null }, () => {
|
|
2042
|
+
console.log(`Created ${skillPath}`);
|
|
2043
|
+
console.log("Next steps:");
|
|
2044
|
+
console.log(" 1. Edit SKILL.md; the frontmatter name/description drive search and agent triggering.");
|
|
2045
|
+
console.log(` 2. terminus validate ${target}`);
|
|
2046
|
+
if (!existingMeta.id) {
|
|
2047
|
+
console.log(` 3. Create it on the web (${newCreationUrl(flags)}), then: terminus remote add <address> ${target}`);
|
|
2048
|
+
}
|
|
2049
|
+
console.log(` ${existingMeta.id ? 3 : 4}. terminus push ${target} # upload it; publish it on the web`);
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function initialSkillMarkdown(name, description) {
|
|
2054
|
+
return `---
|
|
2055
|
+
name: ${JSON.stringify(name)}
|
|
2056
|
+
description: ${JSON.stringify(description)}
|
|
2057
|
+
---
|
|
2058
|
+
|
|
2059
|
+
# ${name}
|
|
2060
|
+
|
|
2061
|
+
Explain step by step how an agent should perform this skill. Write instructions,
|
|
2062
|
+
not marketing: assume the reader is an AI coding agent that will follow them literally.
|
|
2063
|
+
|
|
2064
|
+
## When to use
|
|
2065
|
+
|
|
2066
|
+
Describe the trigger conditions in the frontmatter description above; expand edge cases here.
|
|
2067
|
+
|
|
2068
|
+
## Steps
|
|
2069
|
+
|
|
2070
|
+
1. First step.
|
|
2071
|
+
2. Second step.
|
|
2072
|
+
|
|
2073
|
+
## Supporting files (optional)
|
|
2074
|
+
|
|
2075
|
+
Reference extra files with relative paths (for example \`references/style.md\` or
|
|
2076
|
+
\`scripts/export.sh\`). Files referenced from SKILL.md are packaged automatically by
|
|
2077
|
+
\`terminus push\`; unreferenced files are dropped.
|
|
2078
|
+
`;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
/** "Winston Gao (@winstongu)": the display name and the address people see
|
|
2082
|
+
* on your creations (the publisher handle, else the username). */
|
|
2083
|
+
function accountName(session) {
|
|
2084
|
+
const displayName = presentValue(
|
|
2085
|
+
session.display_name ?? session.profile?.display_name ?? session.user?.display_name,
|
|
2086
|
+
);
|
|
2087
|
+
const handle = presentValue(
|
|
2088
|
+
session.profile?.publisher_handle ?? session.publisher_handle
|
|
2089
|
+
?? session.username ?? session.profile?.username ?? session.user?.username,
|
|
2090
|
+
)?.replace(/^@/, "");
|
|
2091
|
+
if (displayName && handle) return `${displayName} (@${handle})`;
|
|
2092
|
+
return handle ? `@${handle}` : displayName ?? "";
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function loginLabel(session) {
|
|
2096
|
+
const name = accountName(session);
|
|
2097
|
+
return name ? `Logged in as ${name}` : "Logged in";
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
function safeStatusResponse(base, login, savedSession, artifactSummary = null) {
|
|
2101
|
+
const output = {
|
|
2102
|
+
authenticated: true,
|
|
2103
|
+
auth_method: savedSession?.auth_method ?? "browser",
|
|
2104
|
+
api_base: base,
|
|
2105
|
+
expires_at: savedSession?.expires_at ?? login.expires_at ?? null,
|
|
2106
|
+
};
|
|
2107
|
+
const profile = login.profile ?? {};
|
|
2108
|
+
return {
|
|
2109
|
+
...output,
|
|
2110
|
+
device: {
|
|
2111
|
+
name: savedSession?.device_name ?? null,
|
|
2112
|
+
platform: savedSession?.platform ?? null,
|
|
2113
|
+
client_version: savedSession?.client_version ?? null,
|
|
2114
|
+
},
|
|
2115
|
+
user: {
|
|
2116
|
+
id: profile.id ?? login.user?.id ?? null,
|
|
2117
|
+
display_name: profile.display_name ?? login.display_name ?? null,
|
|
2118
|
+
username: profile.username ?? login.username ?? login.user?.username ?? null,
|
|
2119
|
+
publisher_handle: profile.publisher_handle ?? null,
|
|
2120
|
+
avatar_url: Object.hasOwn(profile, "avatar_url")
|
|
2121
|
+
? profile.avatar_url
|
|
2122
|
+
: login.user?.avatar_url ?? null,
|
|
2123
|
+
},
|
|
2124
|
+
...(artifactSummary ? { artifacts: artifactSummary } : {}),
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
function printSearchResults(results) {
|
|
2129
|
+
const skills = Array.isArray(results.skills) ? results.skills : [];
|
|
2130
|
+
console.log(
|
|
2131
|
+
`Terminus search: ${JSON.stringify(results.query ?? "")} (${formatNumber(results.result_count ?? skills.length)} results)`,
|
|
2132
|
+
);
|
|
2133
|
+
if (skills.length === 0) {
|
|
2134
|
+
console.log("No matching skills.");
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
skills.forEach((skill, index) => {
|
|
2139
|
+
const rank = skill.search_rank ?? index + 1;
|
|
2140
|
+
if (index > 0) {
|
|
2141
|
+
console.log("");
|
|
2142
|
+
}
|
|
2143
|
+
console.log(`${rank}. ${skill.name ?? "Untitled skill"}`);
|
|
2144
|
+
for (const row of searchResultRows(skill)) {
|
|
2145
|
+
printSearchResultField(row.label, row.value);
|
|
2146
|
+
}
|
|
2147
|
+
const summary = skill.description;
|
|
2148
|
+
if (summary) {
|
|
2149
|
+
printSearchResultField("Summary", summary);
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function selectedSkillIndex(flags, skillCount) {
|
|
2155
|
+
if (flags.use_first) {
|
|
2156
|
+
return 0;
|
|
2157
|
+
}
|
|
2158
|
+
if (flags.select == null) {
|
|
2159
|
+
return null;
|
|
2160
|
+
}
|
|
2161
|
+
const selected = Number.parseInt(flags.select, 10);
|
|
2162
|
+
if (!Number.isInteger(selected) || selected < 1 || selected > skillCount) {
|
|
2163
|
+
throw usageError(`--select must be between 1 and ${skillCount}`);
|
|
2164
|
+
}
|
|
2165
|
+
return selected - 1;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
async function promptSkillSelection(skillCount) {
|
|
2169
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2170
|
+
throw usageError("terminus skills use needs a selection in non-interactive shells; pass --select <n> or --use-first");
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const rl = createInterface({
|
|
2174
|
+
input: process.stdin,
|
|
2175
|
+
output: process.stdout,
|
|
2176
|
+
terminal: true,
|
|
2177
|
+
});
|
|
2178
|
+
try {
|
|
2179
|
+
while (true) {
|
|
2180
|
+
const answer = (await promptLine(rl, "Choose a skill number, or press Enter to cancel: ")).trim();
|
|
2181
|
+
if (!answer) {
|
|
2182
|
+
return null;
|
|
2183
|
+
}
|
|
2184
|
+
const selected = Number.parseInt(answer, 10);
|
|
2185
|
+
if (Number.isInteger(selected) && selected >= 1 && selected <= skillCount) {
|
|
2186
|
+
return selected - 1;
|
|
2187
|
+
}
|
|
2188
|
+
console.log(`Enter a number from 1 to ${skillCount}.`);
|
|
2189
|
+
}
|
|
2190
|
+
} finally {
|
|
2191
|
+
rl.close();
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function promptLine(rl, prompt) {
|
|
2196
|
+
return new Promise((resolve) => {
|
|
2197
|
+
rl.question(prompt, resolve);
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/** One counted use of a skill, as the account: the door answers with its
|
|
2202
|
+
* content. `searchEventId` ties the use to the search it was picked from;
|
|
2203
|
+
* `name` is how a refusal names the skill (its address, when known). */
|
|
2204
|
+
function useSkillAsAccount(api, reference, query, { searchEventId, name } = {}) {
|
|
2205
|
+
return api.json("POST /v1/terminus/skills/use", {
|
|
2206
|
+
body: { reference, query, search_event_id: searchEventId },
|
|
2207
|
+
}).catch(skillUseRefusal(name ?? reference));
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/** The account use door answers flat; reshape it for the shared printer. */
|
|
2211
|
+
function accountUseResult(body) {
|
|
2212
|
+
return {
|
|
2213
|
+
use_id: body.use_id,
|
|
2214
|
+
counted: true,
|
|
2215
|
+
skill: {
|
|
2216
|
+
name: body.name,
|
|
2217
|
+
address: body.address,
|
|
2218
|
+
uid: body.uid,
|
|
2219
|
+
description: body.description,
|
|
2220
|
+
content_markdown: body.content,
|
|
2221
|
+
},
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
function printSkillUseResult(result) {
|
|
2226
|
+
const skill = result.skill ?? {};
|
|
2227
|
+
const address = skillReference(skill);
|
|
2228
|
+
const content = typeof skill.content_markdown === "string" ? skill.content_markdown.trimEnd() : "";
|
|
2229
|
+
|
|
2230
|
+
console.log(`Selected: ${skill.name ?? "Terminus skill"} ${address}`);
|
|
2231
|
+
console.log(`Use ID: ${result.use_id ?? "unknown"} (${result.counted ? "counted" : "not counted"})`);
|
|
2232
|
+
const metadata = searchResultMetadata(skill);
|
|
2233
|
+
if (metadata) {
|
|
2234
|
+
console.log(metadata);
|
|
2235
|
+
}
|
|
2236
|
+
const remoteRef = skill.uid;
|
|
2237
|
+
if (remoteRef) {
|
|
2238
|
+
console.log(`Supporting files: terminus skills fetch ${remoteRef} (or terminus skills files ${remoteRef} to browse remotely)`);
|
|
2239
|
+
}
|
|
2240
|
+
const summary = skill.description;
|
|
2241
|
+
if (summary) {
|
|
2242
|
+
console.log(summary);
|
|
2243
|
+
}
|
|
2244
|
+
if (!content.trim()) {
|
|
2245
|
+
console.log("No skill content was returned.");
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
console.log("");
|
|
2249
|
+
console.log("--- TERMINUS SKILL CONTENT START ---");
|
|
2250
|
+
console.log(content);
|
|
2251
|
+
console.log("--- TERMINUS SKILL CONTENT END ---");
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function searchResultMetadata(skill) {
|
|
2255
|
+
const parts = [];
|
|
2256
|
+
if (skill.publisher_handle) {
|
|
2257
|
+
parts.push(`publisher ${skill.publisher_handle}`);
|
|
2258
|
+
}
|
|
2259
|
+
if (typeof skill.external_repository_stars === "number") {
|
|
2260
|
+
parts.push(`${formatNumber(skill.external_repository_stars)} stars`);
|
|
2261
|
+
}
|
|
2262
|
+
const updated = skill.external_repository_updated_at ?? skill.external_last_indexed_at ?? skill.updated_at;
|
|
2263
|
+
if (updated) {
|
|
2264
|
+
parts.push(`updated ${formatDate(updated)}`);
|
|
2265
|
+
}
|
|
2266
|
+
if (skill.version || skill.release_version) {
|
|
2267
|
+
parts.push(`version ${skill.release_version ?? skill.version}`);
|
|
2268
|
+
}
|
|
2269
|
+
if (skill.billing_mode && skill.billing_mode !== "free") {
|
|
2270
|
+
parts.push(`billing ${skill.billing_mode}`);
|
|
2271
|
+
} else if (skill.pricing) {
|
|
2272
|
+
parts.push(`pricing ${skill.pricing}`);
|
|
2273
|
+
}
|
|
2274
|
+
return parts.join(" · ");
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
function searchResultRows(skill) {
|
|
2278
|
+
const rows = [{ label: "Address", value: skillReference(skill) }];
|
|
2279
|
+
if (skill.publisher_handle) {
|
|
2280
|
+
rows.push({ label: "Publisher", value: skill.publisher_handle });
|
|
2281
|
+
}
|
|
2282
|
+
if (typeof skill.external_repository_stars === "number") {
|
|
2283
|
+
rows.push({ label: "Stars", value: formatNumber(skill.external_repository_stars) });
|
|
2284
|
+
}
|
|
2285
|
+
const updated = skill.external_repository_updated_at ?? skill.external_last_indexed_at ?? skill.updated_at;
|
|
2286
|
+
if (updated) {
|
|
2287
|
+
rows.push({ label: "Updated", value: formatDate(updated) });
|
|
2288
|
+
}
|
|
2289
|
+
if (skill.version || skill.release_version) {
|
|
2290
|
+
rows.push({ label: "Version", value: skill.release_version ?? skill.version });
|
|
2291
|
+
}
|
|
2292
|
+
if (skill.billing_mode && skill.billing_mode !== "free") {
|
|
2293
|
+
rows.push({ label: "Billing", value: skill.billing_mode });
|
|
2294
|
+
} else if (skill.pricing) {
|
|
2295
|
+
rows.push({ label: "Pricing", value: skill.pricing });
|
|
2296
|
+
}
|
|
2297
|
+
return rows;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
function printSearchResultField(label, value) {
|
|
2301
|
+
const labelText = `${label}:`.padEnd(10);
|
|
2302
|
+
const firstPrefix = ` ${labelText} `;
|
|
2303
|
+
const nextPrefix = " ".repeat(visibleWidth(firstPrefix));
|
|
2304
|
+
for (const line of wrapText(value, { firstPrefix, nextPrefix })) {
|
|
2305
|
+
console.log(line);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
function wrapText(value, { firstPrefix = "", nextPrefix = "", columns = outputColumns() } = {}) {
|
|
2310
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
2311
|
+
if (!text) {
|
|
2312
|
+
return [];
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const lines = [];
|
|
2316
|
+
let current = "";
|
|
2317
|
+
let limit = Math.max(20, columns - visibleWidth(firstPrefix));
|
|
2318
|
+
|
|
2319
|
+
for (const word of text.split(" ")) {
|
|
2320
|
+
if (!word) {
|
|
2321
|
+
continue;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
if (!current) {
|
|
2325
|
+
if (visibleWidth(word) <= limit) {
|
|
2326
|
+
current = word;
|
|
2327
|
+
continue;
|
|
2328
|
+
}
|
|
2329
|
+
const chunks = splitByDisplayWidth(word, limit);
|
|
2330
|
+
lines.push(...chunks.slice(0, -1));
|
|
2331
|
+
current = chunks.at(-1) ?? "";
|
|
2332
|
+
limit = Math.max(20, columns - visibleWidth(nextPrefix));
|
|
2333
|
+
continue;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
if (visibleWidth(current) + 1 + visibleWidth(word) <= limit) {
|
|
2337
|
+
current = `${current} ${word}`;
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
lines.push(current);
|
|
2342
|
+
current = "";
|
|
2343
|
+
limit = Math.max(20, columns - visibleWidth(nextPrefix));
|
|
2344
|
+
|
|
2345
|
+
if (visibleWidth(word) <= limit) {
|
|
2346
|
+
current = word;
|
|
2347
|
+
} else {
|
|
2348
|
+
const chunks = splitByDisplayWidth(word, limit);
|
|
2349
|
+
lines.push(...chunks.slice(0, -1));
|
|
2350
|
+
current = chunks.at(-1) ?? "";
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
if (current) {
|
|
2355
|
+
lines.push(current);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
return lines.map((line, index) => `${index === 0 ? firstPrefix : nextPrefix}${line}`);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function splitByDisplayWidth(value, limit) {
|
|
2362
|
+
const chunks = [];
|
|
2363
|
+
let current = "";
|
|
2364
|
+
for (const char of Array.from(String(value))) {
|
|
2365
|
+
if (current && visibleWidth(current) + visibleWidth(char) > limit) {
|
|
2366
|
+
chunks.push(current);
|
|
2367
|
+
current = char;
|
|
2368
|
+
} else {
|
|
2369
|
+
current += char;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (current) {
|
|
2373
|
+
chunks.push(current);
|
|
2374
|
+
}
|
|
2375
|
+
return chunks;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function outputColumns() {
|
|
2379
|
+
const explicit = Number.parseInt(process.env.COLUMNS ?? "", 10);
|
|
2380
|
+
const columns = Number.isInteger(explicit) ? explicit : process.stdout.columns;
|
|
2381
|
+
if (!Number.isInteger(columns) || columns <= 0) {
|
|
2382
|
+
return 100;
|
|
2383
|
+
}
|
|
2384
|
+
return Math.max(60, Math.min(columns, 120));
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
function visibleWidth(value) {
|
|
2388
|
+
let width = 0;
|
|
2389
|
+
for (const char of Array.from(String(value))) {
|
|
2390
|
+
width += codePointWidth(char.codePointAt(0));
|
|
2391
|
+
}
|
|
2392
|
+
return width;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function codePointWidth(codePoint) {
|
|
2396
|
+
if (codePoint == null || codePoint === 0) {
|
|
2397
|
+
return 0;
|
|
2398
|
+
}
|
|
2399
|
+
if (codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
|
2400
|
+
return 0;
|
|
2401
|
+
}
|
|
2402
|
+
if (
|
|
2403
|
+
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
|
2404
|
+
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
|
2405
|
+
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
|
2406
|
+
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
|
2407
|
+
(codePoint >= 0xfe20 && codePoint <= 0xfe2f)
|
|
2408
|
+
) {
|
|
2409
|
+
return 0;
|
|
2410
|
+
}
|
|
2411
|
+
return isFullwidthCodePoint(codePoint) ? 2 : 1;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function isFullwidthCodePoint(codePoint) {
|
|
2415
|
+
return (
|
|
2416
|
+
codePoint >= 0x1100 &&
|
|
2417
|
+
(codePoint <= 0x115f ||
|
|
2418
|
+
codePoint === 0x2329 ||
|
|
2419
|
+
codePoint === 0x232a ||
|
|
2420
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
|
2421
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
2422
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
2423
|
+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
|
2424
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
2425
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
2426
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6))
|
|
2427
|
+
);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function formatDate(value) {
|
|
2431
|
+
const date = new Date(value);
|
|
2432
|
+
if (Number.isNaN(date.getTime())) {
|
|
2433
|
+
return String(value);
|
|
2434
|
+
}
|
|
2435
|
+
return date.toISOString().slice(0, 10);
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
function looksLikeSkillReference(value) {
|
|
2439
|
+
const text = String(value).trim();
|
|
2440
|
+
if (!text || text.includes(" ")) {
|
|
2441
|
+
return false;
|
|
2442
|
+
}
|
|
2443
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) {
|
|
2444
|
+
return true;
|
|
2445
|
+
}
|
|
2446
|
+
return /^@?[a-z0-9][a-z0-9_.-]*\/[a-z0-9_.:@-]+$/i.test(text);
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
/** The package files behind a skill. `developer: true` reads the owner's own
|
|
2450
|
+
* door, which serves a skill's files whatever its source visibility; the
|
|
2451
|
+
* catalog door is the public one every read command uses. */
|
|
2452
|
+
function skillFiles(api, skillRef, { includeContent = false, path: filePath, developer = false } = {}) {
|
|
2453
|
+
return api.json(
|
|
2454
|
+
developer ? "GET /v1/dashboard/developer/skills/{ref}/skill-files" : "GET /v1/skills/{ref}/skill-files",
|
|
2455
|
+
{
|
|
2456
|
+
params: { ref: skillRef },
|
|
2457
|
+
query: { include_content: includeContent ? "true" : "false", path: filePath },
|
|
2458
|
+
},
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
function printSkillFileManifest(skillRef, result) {
|
|
2463
|
+
const files = Array.isArray(result.files) ? result.files : [];
|
|
2464
|
+
console.log(`Remote skill files: ${skillRef} (${formatNumber(files.length)} files)`);
|
|
2465
|
+
if (files.length === 0) {
|
|
2466
|
+
console.log("No skill files.");
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
for (const file of files) {
|
|
2470
|
+
const size = typeof file.size_bytes === "number" ? ` ${formatNumber(file.size_bytes)} bytes` : "";
|
|
2471
|
+
const type = file.content_type ? ` ${file.content_type}` : "";
|
|
2472
|
+
console.log(`- ${file.path}${size}${type}`);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function printSkillFileContent(skillRef, file, content) {
|
|
2477
|
+
console.log(`Remote skill file: ${skillRef}/${file.path}`);
|
|
2478
|
+
if (file.content_type) {
|
|
2479
|
+
console.log(`Content-Type: ${file.content_type}`);
|
|
2480
|
+
}
|
|
2481
|
+
if (typeof file.size_bytes === "number") {
|
|
2482
|
+
console.log(`Size: ${formatNumber(file.size_bytes)} bytes`);
|
|
2483
|
+
}
|
|
2484
|
+
console.log("");
|
|
2485
|
+
console.log("--- TERMINUS REMOTE FILE START ---");
|
|
2486
|
+
console.log(content);
|
|
2487
|
+
console.log("--- TERMINUS REMOTE FILE END ---");
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
function skillFileBytes(file) {
|
|
2491
|
+
if (!file.content_available || !file.content_base64) {
|
|
2492
|
+
throw new CliError(`remote skill file content is not available: ${file.path ?? "unknown"}`);
|
|
2493
|
+
}
|
|
2494
|
+
return Buffer.from(file.content_base64, "base64");
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
function skillFileText(file) {
|
|
2498
|
+
return skillFileBytes(file).toString("utf8");
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
function formatNumber(value) {
|
|
2502
|
+
return new Intl.NumberFormat("en-US").format(Number.isFinite(Number(value)) ? Number(value) : 0);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
async function validateSkillCommand(args) {
|
|
2506
|
+
const flags = parseFlags(args, "validate");
|
|
2507
|
+
const target = flags._[0] ?? ".";
|
|
2508
|
+
const packages = await validatePath(target, SKILL_PACKAGE_POLICY);
|
|
2509
|
+
const response = {
|
|
2510
|
+
path: path.resolve(target),
|
|
2511
|
+
skill_count: packages.length,
|
|
2512
|
+
skills: packages.map(packageSummary),
|
|
2513
|
+
};
|
|
2514
|
+
emitJson(flags, response, () => {
|
|
2515
|
+
for (const pkg of response.skills) {
|
|
2516
|
+
console.log(`Valid: ${pkg.name} (${pkg.file_count} files, ${pkg.total_bytes} bytes)`);
|
|
2517
|
+
}
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
/**
|
|
2522
|
+
* A skill of yours, by the uid, id, slug, or address a folder names — the
|
|
2523
|
+
* owner's door answers only the caller's own skills. One that is not yours
|
|
2524
|
+
* is refused with the web hint, or answered as `missing` when a caller asks
|
|
2525
|
+
* for that instead. The door also knows the catalog rows of your apps,
|
|
2526
|
+
* agents, and services; those are not skills.
|
|
2527
|
+
*/
|
|
2528
|
+
async function ownedSkill(api, ref, flags, options = {}) {
|
|
2529
|
+
let found;
|
|
2530
|
+
try {
|
|
2531
|
+
found = await api.json("GET /v1/dashboard/developer/skills/{ref}", { params: { ref } });
|
|
2532
|
+
} catch (error) {
|
|
2533
|
+
if (error.status !== 404) throw error;
|
|
2534
|
+
if (Object.hasOwn(options, "missing")) return options.missing;
|
|
2535
|
+
throw new CliError(`${ref} is not one of your skills on Terminus — ${notConnectedHint(flags)}`);
|
|
2536
|
+
}
|
|
2537
|
+
const { skill } = found;
|
|
2538
|
+
if (skill.kind !== "skill") {
|
|
2539
|
+
throw new CliError(`${ref} is ${skill.kind === "app" || skill.kind === "agent" ? `an ${skill.kind}` : `a ${skill.kind}`}, not a skill`);
|
|
2540
|
+
}
|
|
2541
|
+
return skill;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
/* ── A skill folder as a working copy ─────────────────────────────────── */
|
|
2545
|
+
|
|
2546
|
+
/**
|
|
2547
|
+
* A skill folder against the skill it is linked to: which commit it matches,
|
|
2548
|
+
* what it holds now, and what the skill's draft holds.
|
|
2549
|
+
*
|
|
2550
|
+
* Apps keep their files in a package manifest; a skill keeps SKILL.md — the
|
|
2551
|
+
* skill itself, which the platform stores as its markdown — plus the files it
|
|
2552
|
+
* references. Everything else here is the same working copy the app family
|
|
2553
|
+
* has: `.terminus/sync.json` remembers the commit, `status`, `diff`, `log`,
|
|
2554
|
+
* `pull` and `restore` read against it, and `push` refuses to clobber commits
|
|
2555
|
+
* it has not seen.
|
|
2556
|
+
*/
|
|
2557
|
+
async function skillWorkingCopy(flags, { dir: requestedDir, action = "status" } = {}) {
|
|
2558
|
+
const dir = path.resolve(requestedDir ?? ".");
|
|
2559
|
+
const skillPath = path.join(dir, "SKILL.md");
|
|
2560
|
+
if (!(await exists(skillPath))) {
|
|
2561
|
+
throw usageError(
|
|
2562
|
+
`no skill in ${dir} (no SKILL.md)\n`
|
|
2563
|
+
+ `${action} reads a working copy against the creation it is linked to.`,
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
const packages = await validatePath(dir, SKILL_PACKAGE_POLICY);
|
|
2567
|
+
if (packages.length !== 1) {
|
|
2568
|
+
throw new CliError(`${action} takes one skill at a time; ${dir} holds ${packages.length}`);
|
|
2569
|
+
}
|
|
2570
|
+
const [pkg] = packages;
|
|
2571
|
+
const record = await readSyncRecord(dir);
|
|
2572
|
+
const ref = String(pkg.metadata.id ?? "").trim() || record?.id;
|
|
2573
|
+
if (!ref) {
|
|
2574
|
+
throw new CliError(`this folder is not connected to a skill on Terminus — ${notConnectedHint(flags)}`);
|
|
2575
|
+
}
|
|
2576
|
+
const api = await connect(flags);
|
|
2577
|
+
const skill = await ownedSkill(api, ref, flags);
|
|
2578
|
+
return { dir, pkg, record, api, skill, ref: skill.uid };
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/** What the folder holds, as the sync record counts files. */
|
|
2582
|
+
function localSkillEntries(pkg) {
|
|
2583
|
+
return pkg.files
|
|
2584
|
+
.map((file) => ({ path: normalizePackageFilePath(file.path), sha256: sha256(file.contentBytes) }))
|
|
2585
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/**
|
|
2589
|
+
* The skill's draft as a folder holds it: its package files, and SKILL.md
|
|
2590
|
+
* rebuilt from the platform's copy the way `clone` writes it.
|
|
2591
|
+
*
|
|
2592
|
+
* `at` pins an earlier commit by its change hash, which is how a three-way
|
|
2593
|
+
* merge reads the state both sides started from.
|
|
2594
|
+
*/
|
|
2595
|
+
async function remoteSkillTree(api, skill, { at } = {}) {
|
|
2596
|
+
const ref = at ? `${skill.address}@${at}` : skill.uid;
|
|
2597
|
+
const manifest = await skillFiles(api, ref, { includeContent: true, developer: true });
|
|
2598
|
+
const bytes = new Map();
|
|
2599
|
+
let packageSkillMarkdown = null;
|
|
2600
|
+
for (const file of Array.isArray(manifest.files) ? manifest.files : []) {
|
|
2601
|
+
const relative = normalizePackageFilePath(file.path);
|
|
2602
|
+
const content = skillFileBytes(
|
|
2603
|
+
file.content_available && file.content_base64 != null
|
|
2604
|
+
? file
|
|
2605
|
+
: await remoteSkillFile(api, ref, relative),
|
|
2606
|
+
);
|
|
2607
|
+
// SKILL.md is the skill itself, not one of its files: the platform keeps
|
|
2608
|
+
// it as content_markdown, so the folder's copy is rebuilt rather than
|
|
2609
|
+
// downloaded, exactly as `terminus clone` writes it.
|
|
2610
|
+
if (relative === "SKILL.md") packageSkillMarkdown = content.toString("utf8");
|
|
2611
|
+
else bytes.set(relative, content);
|
|
2612
|
+
}
|
|
2613
|
+
bytes.set("SKILL.md", Buffer.from(skillWorkingCopyMarkdown(skill, packageSkillMarkdown), "utf8"));
|
|
2614
|
+
const entries = [...bytes.keys()]
|
|
2615
|
+
.sort((left, right) => left.localeCompare(right))
|
|
2616
|
+
.map((file) => ({ path: file, sha256: sha256(bytes.get(file)) }));
|
|
2617
|
+
return { entries, bytes, read: (entry) => bytes.get(entry.path) ?? null };
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/** The skill's history, newest first, and where its draft stands. */
|
|
2621
|
+
function skillHistory(api, ref, limit = 100) {
|
|
2622
|
+
return api.json("GET /v1/dashboard/developer/skills/{ref}/revisions", {
|
|
2623
|
+
params: { ref },
|
|
2624
|
+
query: { limit },
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/** The commits recorded after `since`, and whether it is in the history at
|
|
2629
|
+
* all — what a folder has not pulled. */
|
|
2630
|
+
function skillCommitsSince(listing, since) {
|
|
2631
|
+
const revisions = listing.revisions ?? [];
|
|
2632
|
+
if (!since) return { commits: revisions, found: false };
|
|
2633
|
+
const index = revisions.findIndex((revision) => revision.id === since);
|
|
2634
|
+
return index === -1
|
|
2635
|
+
? { commits: revisions, found: false }
|
|
2636
|
+
: { commits: revisions.slice(0, index), found: true };
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
async function recordSkillSync(dir, { skill, commit, draft, local }) {
|
|
2640
|
+
const localByKey = byKey(local);
|
|
2641
|
+
await writeSyncRecord(dir, {
|
|
2642
|
+
source: "draft",
|
|
2643
|
+
kind: "skill",
|
|
2644
|
+
address: skill.address,
|
|
2645
|
+
id: skill.uid,
|
|
2646
|
+
commit: commit ?? null,
|
|
2647
|
+
// The content the commit holds: how an earlier state is fetched back.
|
|
2648
|
+
change_hash: skill.change_hash,
|
|
2649
|
+
files: draft.map((entry) => ({
|
|
2650
|
+
path: entry.path,
|
|
2651
|
+
draft: entry.sha256,
|
|
2652
|
+
local: localByKey.get(entryKey(entry))?.sha256 ?? entry.sha256,
|
|
2653
|
+
})),
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/**
|
|
2658
|
+
* `terminus clone <@handle/skill>` for someone else's open-source skill —
|
|
2659
|
+
* a read-only copy, the way git clones a public repository you cannot push
|
|
2660
|
+
* to. The app family's clone in apps.mjs calls this when the address is a
|
|
2661
|
+
* skill nobody in this account owns.
|
|
2662
|
+
*
|
|
2663
|
+
* Returns false when the skill's source is closed, so the caller reports one
|
|
2664
|
+
* honest error instead of writing half a folder.
|
|
2665
|
+
*/
|
|
2666
|
+
async function cloneSkillCopy({ api, flags, detail, dir: requestedDir, extra = {} }) {
|
|
2667
|
+
if (!skillContentIsOpen(detail)) return false;
|
|
2668
|
+
const { address } = detail;
|
|
2669
|
+
const tree = await publicSkillTree(api, detail.uid, detail);
|
|
2670
|
+
const dir = path.resolve(requestedDir ?? detail.slug);
|
|
2671
|
+
await mkdir(dir, { recursive: true });
|
|
2672
|
+
if ((await readdir(dir)).length) throw new CliError(`${dir} is not empty — clone into a fresh directory`);
|
|
2673
|
+
for (const entry of tree.entries) {
|
|
2674
|
+
const target = path.join(dir, ...entry.path.split("/"));
|
|
2675
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
2676
|
+
await writeFile(target, tree.read(entry));
|
|
2677
|
+
}
|
|
2678
|
+
const label = detail.release_version || detail.version || "";
|
|
2679
|
+
await writeSyncRecord(dir, {
|
|
2680
|
+
source: "release",
|
|
2681
|
+
kind: "skill",
|
|
2682
|
+
address,
|
|
2683
|
+
id: detail.uid,
|
|
2684
|
+
release: { version: label, label },
|
|
2685
|
+
change_hash: detail.change_hash,
|
|
2686
|
+
files: tree.entries.map((entry) => ({ path: entry.path, draft: entry.sha256, local: entry.sha256 })),
|
|
2687
|
+
});
|
|
2688
|
+
const summary = {
|
|
2689
|
+
...extra,
|
|
2690
|
+
address,
|
|
2691
|
+
kind: "skill",
|
|
2692
|
+
dir,
|
|
2693
|
+
files: tree.entries.length,
|
|
2694
|
+
release: label || null,
|
|
2695
|
+
read_only: true,
|
|
2696
|
+
};
|
|
2697
|
+
emitJson(flags, summary, () => {
|
|
2698
|
+
const at = label ? ` ${creationVersion(label)}` : "";
|
|
2699
|
+
console.log(`Cloned ${address}${at} → ${dir} (${tree.entries.length} file${tree.entries.length === 1 ? "" : "s"}).`);
|
|
2700
|
+
console.log("It isn't yours, so this copy can't be pushed. `terminus pull` brings in its newer releases.");
|
|
2701
|
+
console.log(`To make changes you can publish, fork it instead: terminus fork ${address}`);
|
|
2702
|
+
});
|
|
2703
|
+
return summary;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/** An open-source skill as a folder holds it, from the public doors. */
|
|
2707
|
+
async function publicSkillTree(api, skillRef, skill) {
|
|
2708
|
+
const manifest = await skillFiles(api, skillRef, { includeContent: true });
|
|
2709
|
+
const bytes = new Map();
|
|
2710
|
+
let packageSkillMarkdown = null;
|
|
2711
|
+
for (const file of Array.isArray(manifest.files) ? manifest.files : []) {
|
|
2712
|
+
const relative = normalizePackageFilePath(file.path);
|
|
2713
|
+
const content = skillFileBytes(file);
|
|
2714
|
+
if (relative === "SKILL.md") packageSkillMarkdown = content.toString("utf8");
|
|
2715
|
+
else bytes.set(relative, content);
|
|
2716
|
+
}
|
|
2717
|
+
// A copy of someone else's skill carries no `id:`: it is not linked to
|
|
2718
|
+
// anything this account can push to.
|
|
2719
|
+
const markdown = skillWorkingCopyMarkdown(skill, packageSkillMarkdown, { linked: false });
|
|
2720
|
+
bytes.set("SKILL.md", Buffer.from(markdown, "utf8"));
|
|
2721
|
+
const entries = [...bytes.keys()]
|
|
2722
|
+
.sort((left, right) => left.localeCompare(right))
|
|
2723
|
+
.map((file) => ({ path: file, sha256: sha256(bytes.get(file)) }));
|
|
2724
|
+
return { entries, bytes, read: (entry) => bytes.get(entry.path) ?? null };
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
/** A read-only skill copy against the release it holds: what changed here,
|
|
2728
|
+
* and whether a newer version has been published. */
|
|
2729
|
+
async function readOnlySkillCopy(flags, { dir, record, action }) {
|
|
2730
|
+
const api = await connect(flags, { auth: "optional" });
|
|
2731
|
+
const detail = await api.json("GET /v1/skills/{ref}", { params: { ref: record.address } });
|
|
2732
|
+
const packages = await validatePath(dir, SKILL_PACKAGE_POLICY);
|
|
2733
|
+
const tracked = packages.length === 1 ? localSkillEntries(packages[0]) : [];
|
|
2734
|
+
const published = detail.release_version || detail.version || "";
|
|
2735
|
+
const behind = Boolean(published) && published !== (record.release?.version ?? "");
|
|
2736
|
+
return { api, detail, tracked, published, behind, action };
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
/** `terminus status` in a read-only copy of someone else's skill. */
|
|
2740
|
+
async function statusReadOnlySkill(flags, dir, record) {
|
|
2741
|
+
const { detail, tracked, published, behind } = await readOnlySkillCopy(flags, { dir, record, action: "status" });
|
|
2742
|
+
const changes = localChanges(record, tracked);
|
|
2743
|
+
const held = record.release?.label || record.release?.version || "";
|
|
2744
|
+
const result = {
|
|
2745
|
+
address: record.address,
|
|
2746
|
+
kind: "skill",
|
|
2747
|
+
dir,
|
|
2748
|
+
read_only: true,
|
|
2749
|
+
release: held || null,
|
|
2750
|
+
behind: behind ? published : false,
|
|
2751
|
+
changes,
|
|
2752
|
+
synchronized: !changes.length && !behind,
|
|
2753
|
+
};
|
|
2754
|
+
emitJson(flags, result, () => {
|
|
2755
|
+
console.log(`${record.address} (skill, read-only) — ${dir}`);
|
|
2756
|
+
console.log(held ? `This copy holds ${creationVersion(held)}.` : "This copy holds its published state.");
|
|
2757
|
+
if (behind) console.log(`${creationVersion(published)} has been published — run \`terminus pull\`.`);
|
|
2758
|
+
if (!changes.length) return;
|
|
2759
|
+
console.log("Changes here (this copy can't be pushed):");
|
|
2760
|
+
for (const change of changes) console.log(` ${`${change.status}:`.padEnd(10)}${change.path}`);
|
|
2761
|
+
console.log(`To make changes you can publish, fork it: terminus fork ${record.address}`);
|
|
2762
|
+
void detail;
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
/** `terminus diff` in a read-only skill copy. */
|
|
2767
|
+
async function diffReadOnlySkill(flags, dir, record) {
|
|
2768
|
+
const { api, detail, tracked } = await readOnlySkillCopy(flags, { dir, record, action: "diff" });
|
|
2769
|
+
const { printWorkingDiff } = await import("./apps.mjs");
|
|
2770
|
+
const held = record.change_hash ? `${record.address}@${record.change_hash}` : detail.uid;
|
|
2771
|
+
const tree = await publicSkillTree(api, held, detail);
|
|
2772
|
+
return printWorkingDiff({
|
|
2773
|
+
dir,
|
|
2774
|
+
flags,
|
|
2775
|
+
changes: localChanges(record, tracked),
|
|
2776
|
+
readBefore: (entry) => tree.read(entry),
|
|
2777
|
+
since: record.release?.label ? creationVersion(record.release.label) : "its published state",
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
/** `terminus pull` in a read-only skill copy: bring in its newer release. */
|
|
2782
|
+
async function pullReadOnlySkill(flags, dir, record) {
|
|
2783
|
+
const { api, detail, tracked, published, behind } = await readOnlySkillCopy(flags, { dir, record, action: "pull" });
|
|
2784
|
+
const { mergeIntoFolder, mergeSummary } = await import("./apps.mjs");
|
|
2785
|
+
const label = published || record.release?.label || "";
|
|
2786
|
+
const finish = (mode, extra = {}, lines = []) => emitJson(
|
|
2787
|
+
flags,
|
|
2788
|
+
{ address: record.address, kind: "skill", read_only: true, release: label || null, mode, ...extra },
|
|
2789
|
+
() => { for (const line of lines) console.log(line); },
|
|
2790
|
+
);
|
|
2791
|
+
if (!behind && !flags.force) {
|
|
2792
|
+
return finish("already-current", {}, [`Already up to date with ${record.address}.`]);
|
|
2793
|
+
}
|
|
2794
|
+
const tree = await publicSkillTree(api, detail.uid, detail);
|
|
2795
|
+
const write = async (entry) => {
|
|
2796
|
+
const target = path.join(dir, ...entry.path.split("/"));
|
|
2797
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
2798
|
+
await writeFile(target, tree.read(entry));
|
|
2799
|
+
};
|
|
2800
|
+
const keep = (entries) => writeSyncRecord(dir, {
|
|
2801
|
+
...record,
|
|
2802
|
+
release: { version: published, label },
|
|
2803
|
+
change_hash: detail.change_hash,
|
|
2804
|
+
files: tree.entries.map((entry) => ({
|
|
2805
|
+
path: entry.path,
|
|
2806
|
+
draft: entry.sha256,
|
|
2807
|
+
local: byKey(entries).get(entryKey(entry))?.sha256 ?? entry.sha256,
|
|
2808
|
+
})),
|
|
2809
|
+
});
|
|
2810
|
+
if (flags.force) {
|
|
2811
|
+
for (const entry of tree.entries) await write(entry);
|
|
2812
|
+
await keep(tree.entries);
|
|
2813
|
+
return finish("replaced", {}, [`Replaced this copy with ${record.address} ${creationVersion(label)}.`]);
|
|
2814
|
+
}
|
|
2815
|
+
const held = record.change_hash ? await publicSkillTree(api, `${record.address}@${record.change_hash}`, detail).catch(() => null) : null;
|
|
2816
|
+
const merged = await mergeIntoFolder({
|
|
2817
|
+
dir,
|
|
2818
|
+
record,
|
|
2819
|
+
local: tracked,
|
|
2820
|
+
incoming: tree.entries,
|
|
2821
|
+
readIncoming: (entry) => tree.read(entry),
|
|
2822
|
+
readBase: (entry) => (held ? held.read(entry) : null),
|
|
2823
|
+
canonicalPackage: (bytes) => bytes,
|
|
2824
|
+
});
|
|
2825
|
+
await keep(merged.localBase);
|
|
2826
|
+
finish(merged.conflicts.length ? "conflicts" : "merged", {
|
|
2827
|
+
updated: merged.updated,
|
|
2828
|
+
added: merged.added,
|
|
2829
|
+
deleted: merged.deleted,
|
|
2830
|
+
merged: merged.merged,
|
|
2831
|
+
conflicts: merged.conflicts,
|
|
2832
|
+
}, [`Pulled ${record.address} ${creationVersion(label)}.`, ...mergeSummary(merged)]);
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
/** `terminus status` in a skill folder. */
|
|
2836
|
+
async function statusSkillCommand(args) {
|
|
2837
|
+
const flags = parseFlags(args, "status");
|
|
2838
|
+
const here = path.resolve(flags._[0] ?? ".");
|
|
2839
|
+
const copy = await readSyncRecord(here);
|
|
2840
|
+
if (copy?.source === "release") return statusReadOnlySkill(flags, here, copy);
|
|
2841
|
+
const { dir, pkg, record, api, skill, ref } = await skillWorkingCopy(flags, {
|
|
2842
|
+
dir: flags._[0],
|
|
2843
|
+
action: "status",
|
|
2844
|
+
});
|
|
2845
|
+
const tracked = localSkillEntries(pkg);
|
|
2846
|
+
const own = record?.source === "draft" && record.id === ref ? record : null;
|
|
2847
|
+
const listing = await skillHistory(api, ref, 100);
|
|
2848
|
+
const head = listing.head_revision_id ?? null;
|
|
2849
|
+
const unseen = own?.commit && own.commit !== head ? skillCommitsSince(listing, own.commit) : null;
|
|
2850
|
+
const changes = own ? localChanges(own, tracked) : [];
|
|
2851
|
+
const { address } = skill;
|
|
2852
|
+
const behind = unseen ? unseen.commits.length : 0;
|
|
2853
|
+
|
|
2854
|
+
const result = {
|
|
2855
|
+
address,
|
|
2856
|
+
kind: "skill",
|
|
2857
|
+
dir,
|
|
2858
|
+
commit: own?.commit ?? null,
|
|
2859
|
+
head,
|
|
2860
|
+
behind,
|
|
2861
|
+
changes,
|
|
2862
|
+
synchronized: Boolean(own) && !changes.length && own.commit === head,
|
|
2863
|
+
draft_changes: Boolean(skill.has_draft_changes),
|
|
2864
|
+
status: skill.status ?? null,
|
|
2865
|
+
};
|
|
2866
|
+
emitJson(flags, result, () => {
|
|
2867
|
+
console.log(`${address} (skill) — ${dir}`);
|
|
2868
|
+
if (!own) {
|
|
2869
|
+
console.log("This folder has no record of which commit it came from; `terminus pull` brings one down.");
|
|
2870
|
+
} else if (behind) {
|
|
2871
|
+
console.log(
|
|
2872
|
+
`The draft has ${behind} commit${behind === 1 ? "" : "s"} this folder hasn't pulled — run \`terminus pull\`.`,
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
if (!changes.length) {
|
|
2876
|
+
console.log(own ? "Nothing changed here since the last sync." : "");
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
console.log("Changes not pushed:");
|
|
2880
|
+
for (const change of changes) console.log(` ${`${change.status}:`.padEnd(10)}${change.path}`);
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
/** `terminus log` in a skill folder. */
|
|
2885
|
+
async function logSkillCommand(args) {
|
|
2886
|
+
const flags = parseFlags(args, "log");
|
|
2887
|
+
const copy = await readSyncRecord(path.resolve("."));
|
|
2888
|
+
if (copy?.source === "release") {
|
|
2889
|
+
const apps = await import("./apps.mjs");
|
|
2890
|
+
return apps.logCommand([copy.address, ...(flags.json ? ["--json"] : [])]);
|
|
2891
|
+
}
|
|
2892
|
+
const { record, api, skill, ref } = await skillWorkingCopy(flags, { action: "log" });
|
|
2893
|
+
const { shortCommit } = await import("./apps.mjs");
|
|
2894
|
+
const limit = Math.min(Math.max(Number.parseInt(flags.limit ?? "20", 10) || 20, 1), 100);
|
|
2895
|
+
const listing = await skillHistory(api, ref, limit);
|
|
2896
|
+
const { commitPhrase } = await import("./apps.mjs");
|
|
2897
|
+
const here = record?.id === ref ? record.commit : undefined;
|
|
2898
|
+
const { address } = skill;
|
|
2899
|
+
emitJson(flags, { address, ...listing }, () => {
|
|
2900
|
+
const revisions = listing.revisions ?? [];
|
|
2901
|
+
if (!revisions.length) {
|
|
2902
|
+
console.log(`${address}: no history yet — it starts with the first push or save.`);
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
for (const revision of revisions) {
|
|
2906
|
+
const marks = [
|
|
2907
|
+
...(revision.head ? ["latest"] : []),
|
|
2908
|
+
...(revision.version ? [creationVersion(revision.version)] : []),
|
|
2909
|
+
...(revision.id === here ? ["this folder"] : []),
|
|
2910
|
+
];
|
|
2911
|
+
console.log(`commit ${shortCommit(revision.id)}${marks.length ? ` (${marks.join(", ")})` : ""}`);
|
|
2912
|
+
const who = commitPhrase({ author: revision.author, origin: revision.origin });
|
|
2913
|
+
console.log(`Author: ${who.replace(/^(.*) by (.*)$/, "$2 · $1")}`);
|
|
2914
|
+
console.log(`Date: ${new Date(revision.created_at).toLocaleString()}`);
|
|
2915
|
+
const said = revision.message || revision.publish_message;
|
|
2916
|
+
if (said) console.log(`\n ${said}`);
|
|
2917
|
+
console.log("");
|
|
2918
|
+
}
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
/** `terminus diff` in a skill folder: what a push would send. */
|
|
2923
|
+
async function diffSkillCommand(args) {
|
|
2924
|
+
const flags = parseFlags(args, "diff");
|
|
2925
|
+
const here = path.resolve(".");
|
|
2926
|
+
const copy = await readSyncRecord(here);
|
|
2927
|
+
if (copy?.source === "release") return diffReadOnlySkill(flags, here, copy);
|
|
2928
|
+
const { dir, pkg, record, api, skill, ref } = await skillWorkingCopy(flags, { action: "diff" });
|
|
2929
|
+
const tracked = localSkillEntries(pkg);
|
|
2930
|
+
const own = record?.source === "draft" && record.id === ref ? record : null;
|
|
2931
|
+
const remote = await remoteSkillTree(api, skill, { at: own?.change_hash });
|
|
2932
|
+
const changes = own
|
|
2933
|
+
? localChanges(own, tracked)
|
|
2934
|
+
: localChanges({ files: remote.entries.map((entry) => ({ ...entry, draft: entry.sha256, local: entry.sha256 })) }, tracked);
|
|
2935
|
+
const { printWorkingDiff, shortCommit } = await import("./apps.mjs");
|
|
2936
|
+
return printWorkingDiff({
|
|
2937
|
+
dir,
|
|
2938
|
+
flags,
|
|
2939
|
+
changes,
|
|
2940
|
+
readBefore: (entry) => remote.read(entry),
|
|
2941
|
+
since: own?.commit ? `the draft at ${shortCommit(own.commit)}` : "the draft",
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
/** `terminus pull` in a skill folder: bring the draft's commits in, merging
|
|
2946
|
+
* rather than replacing what changed here. */
|
|
2947
|
+
async function pullSkillCommand(args) {
|
|
2948
|
+
const flags = parseFlags(args, "pull");
|
|
2949
|
+
const here = path.resolve(flags._[0] ?? ".");
|
|
2950
|
+
const copy = await readSyncRecord(here);
|
|
2951
|
+
if (copy?.source === "release") return pullReadOnlySkill(flags, here, copy);
|
|
2952
|
+
const { dir, pkg, record, api, skill, ref } = await skillWorkingCopy(flags, {
|
|
2953
|
+
dir: flags._[0],
|
|
2954
|
+
action: "pull",
|
|
2955
|
+
});
|
|
2956
|
+
const { mergeIntoFolder, mergeSummary, shortCommit } = await import("./apps.mjs");
|
|
2957
|
+
const listing = await skillHistory(api, ref, 100);
|
|
2958
|
+
const head = listing.head_revision_id ?? null;
|
|
2959
|
+
const own = record?.source === "draft" && record.id === ref ? record : null;
|
|
2960
|
+
const { address } = skill;
|
|
2961
|
+
const draft = await remoteSkillTree(api, skill);
|
|
2962
|
+
const finish = (mode, extra = {}, lines = []) => emitJson(
|
|
2963
|
+
flags,
|
|
2964
|
+
{ address, kind: "skill", commit: head, mode, ...extra },
|
|
2965
|
+
() => { for (const line of lines) console.log(line); },
|
|
2966
|
+
);
|
|
2967
|
+
|
|
2968
|
+
const writeTree = async () => {
|
|
2969
|
+
for (const entry of draft.entries) {
|
|
2970
|
+
const target = path.join(dir, ...entry.path.split("/"));
|
|
2971
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
2972
|
+
await writeFile(target, draft.read(entry));
|
|
2973
|
+
}
|
|
2974
|
+
await recordSkillSync(dir, { dir, skill, commit: head, draft: draft.entries, local: draft.entries });
|
|
2975
|
+
};
|
|
2976
|
+
|
|
2977
|
+
if (flags.force) {
|
|
2978
|
+
await writeTree();
|
|
2979
|
+
return finish("replaced", {}, [`Replaced this folder with ${address}'s draft.`]);
|
|
2980
|
+
}
|
|
2981
|
+
const tracked = localSkillEntries(pkg);
|
|
2982
|
+
if (!own) {
|
|
2983
|
+
const same = byKey(draft.entries);
|
|
2984
|
+
const differs = tracked.length !== draft.entries.length
|
|
2985
|
+
|| tracked.some((entry) => same.get(entryKey(entry))?.sha256 !== entry.sha256);
|
|
2986
|
+
if (differs) {
|
|
2987
|
+
throw new CliError(
|
|
2988
|
+
"this folder has no record of which commit it came from, and it differs from the draft, so there is nothing to merge against. "
|
|
2989
|
+
+ "`terminus pull --force` replaces this folder with the draft; `terminus push --force` replaces the draft with this folder.",
|
|
2990
|
+
);
|
|
2991
|
+
}
|
|
2992
|
+
await recordSkillSync(dir, { skill, commit: head, draft: draft.entries, local: tracked });
|
|
2993
|
+
return finish("already-current", {}, [`Already up to date with ${address}.`]);
|
|
2994
|
+
}
|
|
2995
|
+
if (own.commit === head) {
|
|
2996
|
+
return finish("already-current", {}, [`Already up to date with ${address}.`]);
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
const wasAt = own.change_hash
|
|
3000
|
+
? await remoteSkillTree(api, skill, { at: own.change_hash }).catch(() => null)
|
|
3001
|
+
: null;
|
|
3002
|
+
const merged = await mergeIntoFolder({
|
|
3003
|
+
dir,
|
|
3004
|
+
record: own,
|
|
3005
|
+
local: tracked,
|
|
3006
|
+
incoming: draft.entries,
|
|
3007
|
+
readIncoming: (entry) => draft.read(entry),
|
|
3008
|
+
readBase: (entry) => (wasAt ? wasAt.read(entry) : null),
|
|
3009
|
+
canonicalPackage: (bytes) => bytes,
|
|
3010
|
+
});
|
|
3011
|
+
await recordSkillSync(dir, { skill, commit: head, draft: draft.entries, local: merged.localBase });
|
|
3012
|
+
finish(
|
|
3013
|
+
merged.conflicts.length ? "conflicts" : "merged",
|
|
3014
|
+
{
|
|
3015
|
+
updated: merged.updated,
|
|
3016
|
+
added: merged.added,
|
|
3017
|
+
deleted: merged.deleted,
|
|
3018
|
+
merged: merged.merged,
|
|
3019
|
+
conflicts: merged.conflicts,
|
|
3020
|
+
},
|
|
3021
|
+
[`Pulled ${address} (${shortCommit(head)}).`, ...mergeSummary(merged)],
|
|
3022
|
+
);
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
/** `terminus restore <commit>` in a skill folder. */
|
|
3026
|
+
async function restoreSkillCommand(args) {
|
|
3027
|
+
const flags = parseFlags(args, "restore");
|
|
3028
|
+
const wanted = String(flags._[0] ?? "").trim().toLowerCase().replaceAll("-", "");
|
|
3029
|
+
if (!wanted) throw commandUsageError("restore");
|
|
3030
|
+
const { dir, pkg, record, api, skill, ref } = await skillWorkingCopy(flags, {
|
|
3031
|
+
dir: flags._[1],
|
|
3032
|
+
action: "restore",
|
|
3033
|
+
});
|
|
3034
|
+
const { shortCommit } = await import("./apps.mjs");
|
|
3035
|
+
const listing = await skillHistory(api, ref, 100);
|
|
3036
|
+
const revisions = listing.revisions ?? [];
|
|
3037
|
+
const matches = revisions.filter((revision) => shortCommit(revision.id).startsWith(wanted)
|
|
3038
|
+
|| String(revision.id).replaceAll("-", "") === wanted);
|
|
3039
|
+
if (!matches.length) {
|
|
3040
|
+
throw new CliError(`no commit in this skill's history starts with ${wanted} — run \`terminus log\` to see them`);
|
|
3041
|
+
}
|
|
3042
|
+
if (matches.length > 1) {
|
|
3043
|
+
throw new CliError(`${wanted} matches ${matches.length} commits — use more of the id`);
|
|
3044
|
+
}
|
|
3045
|
+
const [target] = matches;
|
|
3046
|
+
const tracked = localSkillEntries(pkg);
|
|
3047
|
+
const own = record?.source === "draft" && record.id === ref ? record : null;
|
|
3048
|
+
// A folder that matched the draft follows it back; one with its own
|
|
3049
|
+
// changes keeps them, and `terminus pull` merges the restore in.
|
|
3050
|
+
const followed = Boolean(own) && own.commit === (listing.head_revision_id ?? null)
|
|
3051
|
+
&& !localChanges(own, tracked).length;
|
|
3052
|
+
|
|
3053
|
+
const result = await api.json("POST /v1/dashboard/developer/skills/{ref}/draft/restore", {
|
|
3054
|
+
params: { ref },
|
|
3055
|
+
body: { revision: target.id, origin: "cli", ...(flags.message ? { message: flags.message } : {}) },
|
|
3056
|
+
});
|
|
3057
|
+
let folderUpdated = false;
|
|
3058
|
+
if (followed && result.changed) {
|
|
3059
|
+
const restored = await remoteSkillTree(api, await ownedSkill(api, ref, flags));
|
|
3060
|
+
for (const entry of restored.entries) {
|
|
3061
|
+
const file = path.join(dir, ...entry.path.split("/"));
|
|
3062
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
3063
|
+
await writeFile(file, restored.read(entry));
|
|
3064
|
+
}
|
|
3065
|
+
for (const entry of tracked) {
|
|
3066
|
+
if (!byKey(restored.entries).has(entryKey(entry))) {
|
|
3067
|
+
await rm(path.join(dir, ...entry.path.split("/")), { force: true });
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
await recordSkillSync(dir, {
|
|
3071
|
+
skill,
|
|
3072
|
+
commit: result.revision_id,
|
|
3073
|
+
draft: restored.entries,
|
|
3074
|
+
local: restored.entries,
|
|
3075
|
+
});
|
|
3076
|
+
folderUpdated = true;
|
|
3077
|
+
}
|
|
3078
|
+
const { address } = skill;
|
|
3079
|
+
emitJson(flags, { ...result, address, kind: "skill", folder_updated: folderUpdated }, () => {
|
|
3080
|
+
if (!result.changed) {
|
|
3081
|
+
console.log(`${address}'s draft already holds ${shortCommit(target.id)} — nothing to restore.`);
|
|
3082
|
+
return;
|
|
3083
|
+
}
|
|
3084
|
+
console.log(`Restored ${address}'s draft to ${shortCommit(target.id)} (a new commit: ${shortCommit(result.revision_id)}).`);
|
|
3085
|
+
console.log(folderUpdated
|
|
3086
|
+
? "This folder followed it back."
|
|
3087
|
+
: "This folder has changes of its own — run `terminus pull` to merge the restore in.");
|
|
3088
|
+
});
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
const PUBLISHED_SKILL_STATES = { published: "published", private: "published privately", suspended: "suspended" };
|
|
3092
|
+
|
|
3093
|
+
/**
|
|
3094
|
+
* `terminus push` in a skill folder: SKILL.md and the files it references,
|
|
3095
|
+
* uploaded to the skill the folder is linked to (the `id:` in its
|
|
3096
|
+
* frontmatter). Content only — name and description ride in SKILL.md, while
|
|
3097
|
+
* versions, visibility, and price stay on the web.
|
|
3098
|
+
*
|
|
3099
|
+
* A push lands on the skill's draft, never on what people are using: a
|
|
3100
|
+
* published skill keeps its live version until it is published again on the
|
|
3101
|
+
* web. Each push is a commit, and the draft refuses one that has not seen
|
|
3102
|
+
* the commits already there unless `--force` says to replace them.
|
|
3103
|
+
*/
|
|
3104
|
+
async function pushSkillCommand(args) {
|
|
3105
|
+
const flags = parseFlags(args, "push");
|
|
3106
|
+
const dir = path.resolve(flags._[0] ?? ".");
|
|
3107
|
+
if (!(await exists(path.join(dir, "SKILL.md")))) {
|
|
3108
|
+
throw new CliError(
|
|
3109
|
+
`nothing to push in ${dir}: it has no terminus.json (an app, agent, or service) or SKILL.md (a skill)`,
|
|
3110
|
+
);
|
|
3111
|
+
}
|
|
3112
|
+
// A read-only copy of someone else's work is refused before anything asks
|
|
3113
|
+
// Terminus about it: it was never yours to push.
|
|
3114
|
+
const copy = await readSyncRecord(dir);
|
|
3115
|
+
if (copy?.source === "release") {
|
|
3116
|
+
throw new CliError(
|
|
3117
|
+
`${copy.address ?? "this creation"} isn't yours, so this folder can't be pushed — `
|
|
3118
|
+
+ `fork it to make changes you can publish: terminus fork ${copy.address ?? "<address>"}`,
|
|
3119
|
+
);
|
|
3120
|
+
}
|
|
3121
|
+
const { shortCommit } = await import("./apps.mjs");
|
|
3122
|
+
const { pkg, record, api, skill, ref } = await skillWorkingCopy(flags, {
|
|
3123
|
+
dir: flags._[0],
|
|
3124
|
+
action: "push",
|
|
3125
|
+
});
|
|
3126
|
+
const { address } = skill;
|
|
3127
|
+
const page = creationPageUrl(flags, skill.uid);
|
|
3128
|
+
const own = record?.source === "draft" && record.id === ref ? record : null;
|
|
3129
|
+
await refuseUnresolvedSkillConflicts(dir, own, localSkillEntries(pkg));
|
|
3130
|
+
// The commit this push starts from, which the draft is told so that it
|
|
3131
|
+
// refuses the write rather than overwrite a commit this push has not seen.
|
|
3132
|
+
// A folder that remembers its commit must still be on the head; one with
|
|
3133
|
+
// no record yet (just linked) starts from the head it reads now — never
|
|
3134
|
+
// from nothing. Only --force writes over whatever is there.
|
|
3135
|
+
let head = null;
|
|
3136
|
+
if (!flags.force) {
|
|
3137
|
+
const listing = await skillHistory(api, ref, own ? 100 : 1);
|
|
3138
|
+
head = listing.head_revision_id ?? null;
|
|
3139
|
+
if (own && (own.commit ?? null) !== head) {
|
|
3140
|
+
const count = skillCommitsSince(listing, own.commit).commits.length;
|
|
3141
|
+
throw new CliError(
|
|
3142
|
+
`the draft has ${count || "commits"}${count ? ` commit${count === 1 ? "" : "s"}` : ""} this folder hasn't pulled. `
|
|
3143
|
+
+ "Run `terminus pull` to merge them, then push — or `terminus push --force` to replace them with this folder.",
|
|
3144
|
+
);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
const updated = await api.json("PATCH /v1/dashboard/developer/skills/{ref}", {
|
|
3148
|
+
params: { ref },
|
|
3149
|
+
body: {
|
|
3150
|
+
name: pkg.metadata.name,
|
|
3151
|
+
description: pkg.metadata.description,
|
|
3152
|
+
content_markdown: pkg.skillMarkdown,
|
|
3153
|
+
runtime_kind: "markdown",
|
|
3154
|
+
origin: "cli",
|
|
3155
|
+
...(flags.message ? { message: flags.message } : {}),
|
|
3156
|
+
...(head ? { expected_revision: head } : {}),
|
|
3157
|
+
graph_manifest: {
|
|
3158
|
+
skills: [],
|
|
3159
|
+
agents: pkg.files.filter((file) => file.path.startsWith("agents/")).map((file) => file.path),
|
|
3160
|
+
skill_files: pkg.files.map((file) => file.path),
|
|
3161
|
+
},
|
|
3162
|
+
skill_files: pkg.files.map((file) => ({
|
|
3163
|
+
path: file.path,
|
|
3164
|
+
content_base64: file.contentBytes.toString("base64"),
|
|
3165
|
+
content_type: file.contentType,
|
|
3166
|
+
size_bytes: file.contentBytes.length,
|
|
3167
|
+
})),
|
|
3168
|
+
},
|
|
3169
|
+
});
|
|
3170
|
+
// Like git's origin/main: the folder now knows which commit it matches —
|
|
3171
|
+
// the one this push recorded, or, when it changed nothing, the head.
|
|
3172
|
+
const commit = updated.recorded_revision_id
|
|
3173
|
+
?? head
|
|
3174
|
+
?? (await skillHistory(api, ref, 1)).head_revision_id
|
|
3175
|
+
?? null;
|
|
3176
|
+
const draft = await remoteSkillTree(api, updated);
|
|
3177
|
+
await recordSkillSync(dir, {
|
|
3178
|
+
skill: updated,
|
|
3179
|
+
commit,
|
|
3180
|
+
draft: draft.entries,
|
|
3181
|
+
local: localSkillEntries(pkg),
|
|
3182
|
+
});
|
|
3183
|
+
const result = {
|
|
3184
|
+
address: updated.address,
|
|
3185
|
+
id: updated.id,
|
|
3186
|
+
status: updated.status,
|
|
3187
|
+
files: pkg.files.length,
|
|
3188
|
+
change_hash: updated.change_hash,
|
|
3189
|
+
commit,
|
|
3190
|
+
changed: Boolean(updated.recorded_revision_id),
|
|
3191
|
+
publish_url: page,
|
|
3192
|
+
};
|
|
3193
|
+
emitJson(flags, result, () => {
|
|
3194
|
+
const at = commit ? ` (${shortCommit(commit)})` : "";
|
|
3195
|
+
console.log(`Pushed ${result.address}${at} — ${formatNumber(result.files)} file${result.files === 1 ? "" : "s"}.`);
|
|
3196
|
+
console.log(result.status === "draft"
|
|
3197
|
+
? `Publish it on the web when it is ready: ${page}`
|
|
3198
|
+
: `It lands on the draft; people keep the published version until you publish again: ${page}`);
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
/** A push never uploads the markers a pull left in a file it could not
|
|
3203
|
+
* merge: they mean the file still holds two versions. */
|
|
3204
|
+
async function refuseUnresolvedSkillConflicts(dir, record, tracked) {
|
|
3205
|
+
const { hasConflictMarkers } = await import("./sync.mjs");
|
|
3206
|
+
const changed = new Set(localChanges(record, tracked)
|
|
3207
|
+
.filter((change) => change.status !== "deleted")
|
|
3208
|
+
.map((change) => entryKey(change)));
|
|
3209
|
+
const unresolved = [];
|
|
3210
|
+
for (const entry of tracked) {
|
|
3211
|
+
if (record && !changed.has(entryKey(entry))) continue;
|
|
3212
|
+
const text = await readFile(path.join(dir, ...entry.path.split("/")), "utf8").catch(() => "");
|
|
3213
|
+
if (hasConflictMarkers(text)) unresolved.push(entry.path);
|
|
3214
|
+
}
|
|
3215
|
+
if (unresolved.length) {
|
|
3216
|
+
throw new CliError(
|
|
3217
|
+
`resolve the conflicts in ${unresolved.join(", ")} before pushing — `
|
|
3218
|
+
+ "each still holds both versions between the markers `terminus pull` wrote",
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
/** `terminus remote add <address> [<dir>]` in a skill folder: record which
|
|
3224
|
+
* skill it is as the `id:` in SKILL.md's frontmatter, the way terminus.json
|
|
3225
|
+
* holds an app's address. Nothing is uploaded. */
|
|
3226
|
+
async function remoteAddSkillCommand(args) {
|
|
3227
|
+
const flags = parseFlags(args, "remote add");
|
|
3228
|
+
const ref = flags._[0]?.trim();
|
|
3229
|
+
if (!ref) throw commandUsageError("remote", { sub: "add" });
|
|
3230
|
+
const dir = path.resolve(flags._[1] ?? ".");
|
|
3231
|
+
const skillPath = path.join(dir, "SKILL.md");
|
|
3232
|
+
if (!(await exists(skillPath))) {
|
|
3233
|
+
throw new CliError(`nothing to link in ${dir}: it has no terminus.json or SKILL.md`);
|
|
3234
|
+
}
|
|
3235
|
+
const skill = await ownedSkill(await connect(flags), ref, flags);
|
|
3236
|
+
const markdown = await readFile(skillPath, "utf8");
|
|
3237
|
+
await writeFile(skillPath, withSkillId(markdown, skill.id));
|
|
3238
|
+
const localName = parseFrontmatterMetadata(markdown).name;
|
|
3239
|
+
const result = {
|
|
3240
|
+
address: skill.address,
|
|
3241
|
+
id: skill.id,
|
|
3242
|
+
dir,
|
|
3243
|
+
status: skill.status,
|
|
3244
|
+
};
|
|
3245
|
+
emitJson(flags, result, () => {
|
|
3246
|
+
console.log(`Linked ${dir} → ${result.address}.`);
|
|
3247
|
+
if (skill.status !== "draft") {
|
|
3248
|
+
console.log(`It is published, so it changes on the web: ${creationPageUrl(flags, skill.uid)}`);
|
|
3249
|
+
return;
|
|
3250
|
+
}
|
|
3251
|
+
if (localName && skill.name && localName !== skill.name) {
|
|
3252
|
+
console.log(`SKILL.md calls it '${localName}' and Terminus '${skill.name}'; a push renames it to '${localName}'.`);
|
|
3253
|
+
}
|
|
3254
|
+
console.log("terminus push uploads it; publish it on the web when it is ready.");
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
/** SKILL.md with its frontmatter `id:` set, everything else as written. */
|
|
3259
|
+
function withSkillId(markdown, id) {
|
|
3260
|
+
const text = String(markdown);
|
|
3261
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
3262
|
+
const lines = withFrontmatterEntry(match ? match[1].split(/\r?\n/) : [], "id", id);
|
|
3263
|
+
const body = match ? text.slice(match[0].length) : `\n${text}`;
|
|
3264
|
+
return `---\n${lines.join("\n")}\n---\n${body}`;
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
/** How much a skill package may hold. What it ships is
|
|
3268
|
+
* `isStandardPackagePath` plus any file SKILL.md references, never a path
|
|
3269
|
+
* `shouldSkipPackagePath` skips. */
|
|
3270
|
+
export const SKILL_PACKAGE_POLICY = Object.freeze({
|
|
3271
|
+
maxFiles: DEFAULT_MAX_FILES,
|
|
3272
|
+
maxFileSizeBytes: DEFAULT_MAX_FILE_SIZE_BYTES,
|
|
3273
|
+
maxTotalSizeBytes: DEFAULT_MAX_TOTAL_SIZE_BYTES,
|
|
3274
|
+
});
|
|
3275
|
+
|
|
3276
|
+
function evidenceKeptPackagePaths(files) {
|
|
3277
|
+
const kept = new Set();
|
|
3278
|
+
for (const pathName of files.keys()) {
|
|
3279
|
+
if (isStandardPackagePath(pathName)) {
|
|
3280
|
+
kept.add(pathName);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
const processed = new Set();
|
|
3284
|
+
for (let depth = 0; depth < MAX_REFERENCE_DEPTH; depth += 1) {
|
|
3285
|
+
let changed = false;
|
|
3286
|
+
for (const pathName of [...kept]) {
|
|
3287
|
+
if (processed.has(pathName)) {
|
|
3288
|
+
continue;
|
|
3289
|
+
}
|
|
3290
|
+
processed.add(pathName);
|
|
3291
|
+
const file = files.get(pathName);
|
|
3292
|
+
if (!file) {
|
|
3293
|
+
continue;
|
|
3294
|
+
}
|
|
3295
|
+
const references = referencedPackagePaths(file);
|
|
3296
|
+
const baseDir = parentPath(pathName);
|
|
3297
|
+
for (const reference of references) {
|
|
3298
|
+
for (const candidate of resolveReferencedPackagePaths(files, baseDir, reference)) {
|
|
3299
|
+
if (!shouldSkipPackagePath(candidate) && !kept.has(candidate)) {
|
|
3300
|
+
kept.add(candidate);
|
|
3301
|
+
changed = true;
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
if (!changed) {
|
|
3307
|
+
break;
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
return kept;
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
function referencedPackagePaths(file) {
|
|
3314
|
+
const references = new Set();
|
|
3315
|
+
if (!isTextual(file.contentType, file.path)) {
|
|
3316
|
+
return references;
|
|
3317
|
+
}
|
|
3318
|
+
const content = file.contentBytes.toString("utf8");
|
|
3319
|
+
for (const reference of textRelativePathMentions(content)) {
|
|
3320
|
+
references.add(reference);
|
|
3321
|
+
}
|
|
3322
|
+
if (file.contentType.includes("json") || file.path.toLowerCase().endsWith(".json")) {
|
|
3323
|
+
try {
|
|
3324
|
+
collectJsonRelativePaths(JSON.parse(content), references);
|
|
3325
|
+
} catch {
|
|
3326
|
+
// Invalid JSON is still treated as text above.
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
return references;
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
function collectJsonRelativePaths(value, references) {
|
|
3333
|
+
if (typeof value === "string") {
|
|
3334
|
+
for (const reference of textRelativePathMentions(value)) {
|
|
3335
|
+
references.add(reference);
|
|
3336
|
+
}
|
|
3337
|
+
} else if (Array.isArray(value)) {
|
|
3338
|
+
for (const item of value) collectJsonRelativePaths(item, references);
|
|
3339
|
+
} else if (value && typeof value === "object") {
|
|
3340
|
+
for (const item of Object.values(value)) collectJsonRelativePaths(item, references);
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
function textRelativePathMentions(content) {
|
|
3345
|
+
const references = new Set();
|
|
3346
|
+
for (const rawToken of content.split(/[\s"'`\[\]\(\)<>{},]+/u)) {
|
|
3347
|
+
const token = rawToken
|
|
3348
|
+
.replace(/^[;:!?\|#\u0000-\u001F]+/u, "")
|
|
3349
|
+
.replace(/[;:!?\|#.\u0000-\u001F]+$/u, "")
|
|
3350
|
+
.split("#")[0]
|
|
3351
|
+
.split("?")[0]
|
|
3352
|
+
.trim();
|
|
3353
|
+
if (looksLikeRelativeReference(token)) {
|
|
3354
|
+
references.add(token);
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
return references;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
function looksLikeRelativeReference(value) {
|
|
3361
|
+
if (!value || value.startsWith("/") || value.startsWith("#") || value.includes("\0")) return false;
|
|
3362
|
+
if (/^(https?:|mailto:)/i.test(value)) return false;
|
|
3363
|
+
return value.includes("/") || /\.(md|json|css|js|ts|py|sh|txt|html|ya?ml|toml)$/i.test(value);
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
function resolveReferencedPackagePaths(files, baseDir, reference) {
|
|
3367
|
+
const resolved = new Set();
|
|
3368
|
+
for (const candidate of [
|
|
3369
|
+
normalizeRelativeReferencePath("", reference),
|
|
3370
|
+
normalizeRelativeReferencePath(baseDir, reference),
|
|
3371
|
+
]) {
|
|
3372
|
+
if (!candidate) continue;
|
|
3373
|
+
if (files.has(candidate)) resolved.add(candidate);
|
|
3374
|
+
for (const pathName of files.keys()) {
|
|
3375
|
+
if (pathName.startsWith(`${candidate}/`)) resolved.add(pathName);
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
return [...resolved].sort(pathCompare);
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
function normalizeRelativeReferencePath(baseDir, reference) {
|
|
3382
|
+
const clean = String(reference).trim().split("#")[0].split("?")[0].replaceAll("\\", "/");
|
|
3383
|
+
if (!clean || clean.startsWith("/") || clean.includes("\0")) return null;
|
|
3384
|
+
const segments = baseDir.split("/").filter(Boolean);
|
|
3385
|
+
for (const segment of clean.split("/")) {
|
|
3386
|
+
if (!segment || segment === ".") continue;
|
|
3387
|
+
if (segment === "..") {
|
|
3388
|
+
if (segments.length === 0) return null;
|
|
3389
|
+
segments.pop();
|
|
3390
|
+
} else {
|
|
3391
|
+
segments.push(segment);
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
if (segments.length === 0) return null;
|
|
3395
|
+
try {
|
|
3396
|
+
return normalizePackageFilePath(segments.join("/"));
|
|
3397
|
+
} catch {
|
|
3398
|
+
return null;
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
export async function validatePath(target, policy = SKILL_PACKAGE_POLICY) {
|
|
3403
|
+
const root = path.resolve(target);
|
|
3404
|
+
const validationBudget = { totalBytes: 0 };
|
|
3405
|
+
const rootStat = await stat(root);
|
|
3406
|
+
if (!rootStat.isDirectory()) {
|
|
3407
|
+
throw new CliError(`path is not a directory: ${root}`);
|
|
3408
|
+
}
|
|
3409
|
+
const rootSkill = path.join(root, "SKILL.md");
|
|
3410
|
+
if (await exists(rootSkill)) {
|
|
3411
|
+
return [await loadLocalSkillPackage(root, policy, validationBudget)];
|
|
3412
|
+
}
|
|
3413
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
3414
|
+
const packages = [];
|
|
3415
|
+
for (const entry of entries) {
|
|
3416
|
+
if (!entry.isDirectory()) continue;
|
|
3417
|
+
const dir = path.join(root, entry.name);
|
|
3418
|
+
if (await exists(path.join(dir, "SKILL.md"))) {
|
|
3419
|
+
packages.push(await loadLocalSkillPackage(dir, policy, validationBudget));
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
if (packages.length === 0) {
|
|
3423
|
+
throw new CliError(`no skill packages found in ${root}`);
|
|
3424
|
+
}
|
|
3425
|
+
return packages;
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
async function loadLocalSkillPackage(root, policy, validationBudget) {
|
|
3429
|
+
const files = await collectLocalFiles(root, policy, validationBudget);
|
|
3430
|
+
ensureTotalSize(files, policy.maxTotalSizeBytes);
|
|
3431
|
+
const map = new Map(files.map((file) => [file.path, file]));
|
|
3432
|
+
const kept = evidenceKeptPackagePaths(map);
|
|
3433
|
+
const packageFiles = [...kept].map((filePath) => map.get(filePath)).filter(Boolean);
|
|
3434
|
+
packageFiles.sort((a, b) => pathCompare(a.path, b.path));
|
|
3435
|
+
const skill = packageFiles.find((file) => file.path === "SKILL.md");
|
|
3436
|
+
if (!skill) {
|
|
3437
|
+
throw new CliError(`skill package must include SKILL.md: ${root}`);
|
|
3438
|
+
}
|
|
3439
|
+
const skillMarkdown = skill.contentBytes.toString("utf8");
|
|
3440
|
+
const metadata = parseFrontmatterMetadata(skillMarkdown);
|
|
3441
|
+
requireMetadata(metadata, "name", root);
|
|
3442
|
+
requireMetadata(metadata, "description", root);
|
|
3443
|
+
return {
|
|
3444
|
+
root,
|
|
3445
|
+
metadata,
|
|
3446
|
+
skillMarkdown,
|
|
3447
|
+
files: packageFiles,
|
|
3448
|
+
};
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
async function collectLocalFiles(root, policy, validationBudget) {
|
|
3452
|
+
const entries = await walkTree(root, {
|
|
3453
|
+
skip: (relative) => shouldSkipPackagePath(normalizePackageFilePath(relative)),
|
|
3454
|
+
onSymlink: (relative) => {
|
|
3455
|
+
throw new CliError(`symlinks are not supported in skill packages: ${relative}`);
|
|
3456
|
+
},
|
|
3457
|
+
});
|
|
3458
|
+
const files = [];
|
|
3459
|
+
for (const entry of entries) {
|
|
3460
|
+
const relative = normalizePackageFilePath(entry.path);
|
|
3461
|
+
if (files.length >= policy.maxFiles) {
|
|
3462
|
+
throw new CliError(`package contains more than ${policy.maxFiles} files`);
|
|
3463
|
+
}
|
|
3464
|
+
const contentBytes = await readFile(entry.absolute);
|
|
3465
|
+
if (contentBytes.length > policy.maxFileSizeBytes) {
|
|
3466
|
+
throw new CliError(`package file ${relative} exceeds ${policy.maxFileSizeBytes} bytes`);
|
|
3467
|
+
}
|
|
3468
|
+
if (validationBudget.totalBytes + contentBytes.length > policy.maxTotalSizeBytes) {
|
|
3469
|
+
throw new CliError(
|
|
3470
|
+
`validation input exceeds ${policy.maxTotalSizeBytes} bytes; split packages into separate runs`,
|
|
3471
|
+
);
|
|
3472
|
+
}
|
|
3473
|
+
validationBudget.totalBytes += contentBytes.length;
|
|
3474
|
+
files.push({
|
|
3475
|
+
path: relative,
|
|
3476
|
+
contentType: contentTypeForPath(relative),
|
|
3477
|
+
contentBytes,
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3480
|
+
return files;
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
async function writeSkillFiles(target, files) {
|
|
3484
|
+
await mkdir(target, { recursive: true });
|
|
3485
|
+
for (const file of files) {
|
|
3486
|
+
if (!file.content_available || !file.content_base64) {
|
|
3487
|
+
throw new CliError(`backend did not return content for ${file.path}`);
|
|
3488
|
+
}
|
|
3489
|
+
const relative = normalizePackageFilePath(file.path);
|
|
3490
|
+
const output = path.join(target, relative);
|
|
3491
|
+
await mkdir(path.dirname(output), { recursive: true });
|
|
3492
|
+
await writeFile(output, Buffer.from(file.content_base64, "base64"));
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
function skillReference(skill) {
|
|
3497
|
+
return skill.address;
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
function packageSummary(pkg) {
|
|
3501
|
+
return {
|
|
3502
|
+
name: pkg.metadata.name,
|
|
3503
|
+
description: pkg.metadata.description,
|
|
3504
|
+
root: pkg.root,
|
|
3505
|
+
file_count: pkg.files.length,
|
|
3506
|
+
total_bytes: totalBytes(pkg.files),
|
|
3507
|
+
files: pkg.files.map((file) => file.path),
|
|
3508
|
+
};
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
function parseFrontmatterMetadata(content) {
|
|
3512
|
+
const metadata = {};
|
|
3513
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
3514
|
+
if (!match) return metadata;
|
|
3515
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
3516
|
+
const trimmed = line.trim();
|
|
3517
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
3518
|
+
const index = trimmed.indexOf(":");
|
|
3519
|
+
if (index < 0) continue;
|
|
3520
|
+
const key = trimmed.slice(0, index).trim();
|
|
3521
|
+
const value = parseYamlScalar(trimmed.slice(index + 1));
|
|
3522
|
+
if (value == null) continue;
|
|
3523
|
+
if (["id", "name", "description", "version"].includes(key)) metadata[key] = value;
|
|
3524
|
+
}
|
|
3525
|
+
return metadata;
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
function parseYamlScalar(value) {
|
|
3529
|
+
const trimmed = value.trim();
|
|
3530
|
+
if (!trimmed) return null;
|
|
3531
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
3532
|
+
try {
|
|
3533
|
+
return JSON.parse(trimmed).trim() || null;
|
|
3534
|
+
} catch {
|
|
3535
|
+
return null;
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
3539
|
+
return trimmed.slice(1, -1).replaceAll("''", "'").trim() || null;
|
|
3540
|
+
}
|
|
3541
|
+
return trimmed;
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
function requireMetadata(metadata, key, root) {
|
|
3545
|
+
if (!metadata[key]?.trim()) {
|
|
3546
|
+
throw new CliError(`${path.join(root, "SKILL.md")} frontmatter must include ${key}`);
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
function contentTypeForPath(pathName) {
|
|
3551
|
+
const ext = path.extname(pathName).toLowerCase();
|
|
3552
|
+
if (CONTENT_TYPES.has(ext)) return CONTENT_TYPES.get(ext);
|
|
3553
|
+
if (fileName(pathName).toLowerCase().startsWith("license")) return "text/plain; charset=utf-8";
|
|
3554
|
+
return "application/octet-stream";
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
function isTextual(contentType, pathName) {
|
|
3558
|
+
return contentType.startsWith("text/") || /json|javascript|typescript|yaml/.test(contentType) || /\.(md|txt|json|toml|ya?ml|html?|js|ts)$/i.test(pathName);
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
/** The path's segments, slashes trimmed first and backslashes read as
|
|
3562
|
+
* slashes after — the backend's order. */
|
|
3563
|
+
function packagePathSegments(pathName) {
|
|
3564
|
+
const clean = String(pathName).replace(/^\/+|\/+$/g, "").replaceAll("\\", "/");
|
|
3565
|
+
return clean ? clean.split("/") : [];
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
/** ASCII letters lowered, nothing else — Rust's `to_ascii_lowercase`. */
|
|
3569
|
+
function asciiLowercase(value) {
|
|
3570
|
+
return value.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
/** Credentials by file name, in any case: `.env` files but their templates,
|
|
3574
|
+
* `.npmrc`, `.netrc`, and private keys. */
|
|
3575
|
+
function isCredentialFileName(name) {
|
|
3576
|
+
const lower = asciiLowercase(name);
|
|
3577
|
+
if (lower === ".npmrc" || lower === ".netrc" || /^id_(rsa|dsa|ecdsa|ed25519)$/.test(lower)) return true;
|
|
3578
|
+
if (/\.(pem|key|p12|pfx)$/.test(lower)) return true;
|
|
3579
|
+
return lower.startsWith(".env") && !/^\.env\.(example|sample|template)$/.test(lower);
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
/** Whether a package never ships `pathName`: it sits under an ignored
|
|
3583
|
+
* directory, or it is OS litter or a credential. */
|
|
3584
|
+
export function shouldSkipPackagePath(pathName) {
|
|
3585
|
+
const segments = packagePathSegments(pathName);
|
|
3586
|
+
if (segments.length === 0) return false;
|
|
3587
|
+
const name = segments.at(-1);
|
|
3588
|
+
if (PACKAGE_IGNORED_FILE_NAMES.has(name) || isCredentialFileName(name)) return true;
|
|
3589
|
+
return segments.some((segment) => PACKAGE_IGNORED_DIRECTORIES.has(segment));
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
/** Whether a package keeps `pathName` for what it is: any SKILL.md, README*
|
|
3593
|
+
* or LICENSE* (any depth, any case), or anything under a standard folder. */
|
|
3594
|
+
export function isStandardPackagePath(pathName) {
|
|
3595
|
+
const segments = packagePathSegments(pathName);
|
|
3596
|
+
if (segments.length === 0) return false;
|
|
3597
|
+
const name = asciiLowercase(segments.at(-1));
|
|
3598
|
+
if (name === "skill.md" || name.startsWith("readme") || name.startsWith("license")) return true;
|
|
3599
|
+
return PACKAGE_STANDARD_FOLDERS.has(segments[0]);
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
function normalizePackageFilePath(pathName) {
|
|
3603
|
+
const normalized = pathName.trim().replaceAll("\\", "/");
|
|
3604
|
+
if (!normalized || normalized.startsWith("/") || normalized.length > 512 || normalized.includes("\0")) {
|
|
3605
|
+
throw new CliError("package file path must be a relative path up to 512 characters");
|
|
3606
|
+
}
|
|
3607
|
+
for (const segment of normalized.split("/")) {
|
|
3608
|
+
if (!segment || segment === "." || segment === "..") {
|
|
3609
|
+
throw new CliError(`package file path ${normalized} is not allowed`);
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
return normalized;
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
function ensureTotalSize(files, maxBytes) {
|
|
3616
|
+
const total = totalBytes(files);
|
|
3617
|
+
if (total > maxBytes) {
|
|
3618
|
+
throw new CliError(`skill package is ${total} bytes, which exceeds ${maxBytes} bytes`);
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
function totalBytes(files) {
|
|
3623
|
+
return files.reduce((sum, file) => sum + file.contentBytes.length, 0);
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
function fileName(pathName) {
|
|
3627
|
+
return pathName.split("/").at(-1) ?? pathName;
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
function parentPath(pathName) {
|
|
3631
|
+
const index = pathName.lastIndexOf("/");
|
|
3632
|
+
return index < 0 ? "" : pathName.slice(0, index);
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
function slugify(value) {
|
|
3636
|
+
const slug = String(value)
|
|
3637
|
+
.toLowerCase()
|
|
3638
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
3639
|
+
.replace(/^-+|-+$/g, "");
|
|
3640
|
+
return slug || "skill";
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
async function isMainModule() {
|
|
3644
|
+
if (!process.argv[1]) {
|
|
3645
|
+
return false;
|
|
3646
|
+
}
|
|
3647
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
3648
|
+
try {
|
|
3649
|
+
return (await realpath(process.argv[1])) === (await realpath(modulePath));
|
|
3650
|
+
} catch {
|
|
3651
|
+
return path.resolve(process.argv[1]) === modulePath;
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
if (await isMainModule()) {
|
|
3656
|
+
main().catch((error) => {
|
|
3657
|
+
if (process.argv.includes("--json")) {
|
|
3658
|
+
console.error(JSON.stringify(errorEnvelope(error)));
|
|
3659
|
+
} else if (error instanceof CliError && error.help) {
|
|
3660
|
+
console.error(`${error.help}\n\nError: ${error.message}`);
|
|
3661
|
+
} else {
|
|
3662
|
+
console.error(error instanceof CliError ? error.message : error.stack || error.message);
|
|
3663
|
+
}
|
|
3664
|
+
process.exit(error instanceof CliError ? error.exitCode : 1);
|
|
3665
|
+
});
|
|
3666
|
+
}
|