@ahood/cli 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -15
- package/dist/commands/add.js +100 -37
- package/dist/commands/completion.js +46 -0
- package/dist/commands/edit.js +19 -13
- package/dist/commands/list-mine.js +7 -2
- package/dist/commands/login.js +23 -3
- package/dist/commands/publish.js +159 -38
- package/dist/commands/remove.js +18 -13
- package/dist/commands/search.js +25 -4
- package/dist/commands/star.js +11 -14
- package/dist/commands/token.js +10 -5
- package/dist/commands/unpublish.js +39 -22
- package/dist/commands/update.js +20 -3
- package/dist/commands/view.js +60 -0
- package/dist/commands/whoami.js +33 -8
- package/dist/config.js +30 -1
- package/dist/credentials.js +10 -3
- package/dist/flags.js +13 -0
- package/dist/help.js +98 -14
- package/dist/http.js +37 -4
- package/dist/index.js +80 -24
- package/dist/lockfile.js +55 -10
- package/dist/spec.js +43 -0
- package/dist/version.js +5 -0
- package/package.json +1 -1
package/dist/commands/publish.js
CHANGED
|
@@ -1,8 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { pack } from "tar-stream";
|
|
4
4
|
import { createGzip } from "node:zlib";
|
|
5
|
-
import { apiJson } from "../http.js";
|
|
5
|
+
import { apiJson, ApiError } from "../http.js";
|
|
6
|
+
import { flagValue } from "../flags.js";
|
|
7
|
+
import { parseOwnerSkill, SEMVER_RE } from "../spec.js";
|
|
8
|
+
// Processing (decompress/validate/checksum/scan/upload) now happens in a
|
|
9
|
+
// background workflow, not inline in versions/complete's response (server
|
|
10
|
+
// issue #120) -- complete returns 202 immediately, so this polls
|
|
11
|
+
// GET .../versions/{version} (which now returns {status, failure_reason}
|
|
12
|
+
// even for a non-published row) until the version leaves 'processing'.
|
|
13
|
+
const POLL_INTERVAL_MS = 1500;
|
|
14
|
+
const POLL_TIMEOUT_MS = 120_000;
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
async function pollVersionStatus(owner, skill, version) {
|
|
19
|
+
const path = `/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/${encodeURIComponent(version)}`;
|
|
20
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
21
|
+
while (Date.now() < deadline) {
|
|
22
|
+
const result = await apiJson(path);
|
|
23
|
+
if (result.status !== "processing")
|
|
24
|
+
return result;
|
|
25
|
+
await sleep(POLL_INTERVAL_MS);
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood view ${owner}/${skill}\` later for the result.`);
|
|
28
|
+
}
|
|
29
|
+
const USAGE = "Usage: ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]\n" +
|
|
30
|
+
" or: ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>";
|
|
6
31
|
// Matched by ENTRY NAME at every depth, not by path prefix, so a nested
|
|
7
32
|
// `vendor/thing/.git` is skipped the same as a top-level one. Not a
|
|
8
33
|
// .gitignore parser (out of scope) -- just the fixed set of names that must
|
|
@@ -13,17 +38,31 @@ import { apiJson } from "../http.js";
|
|
|
13
38
|
// 32+ hex) do not match.
|
|
14
39
|
// node_modules -- never part of a skill, and megabytes of it.
|
|
15
40
|
// .env / .env.* -- the single most likely place a real secret lives.
|
|
41
|
+
// .npmrc / .yarnrc.yml / .netrc / .pypirc -- package-manager/network auth
|
|
42
|
+
// tokens (npm_..., ghp_...) that don't match the server
|
|
43
|
+
// scanner's regexes either.
|
|
44
|
+
// id_rsa / id_ed25519 / id_ecdsa / id_dsa (+ .pub) / .ssh -- SSH keys.
|
|
45
|
+
// .aws / .docker -- cloud and registry credentials.
|
|
16
46
|
// .DS_Store -- noise.
|
|
17
|
-
const EXCLUDED_NAMES = new Set([
|
|
47
|
+
const EXCLUDED_NAMES = new Set([
|
|
48
|
+
".git",
|
|
49
|
+
"node_modules",
|
|
50
|
+
".DS_Store",
|
|
51
|
+
".npmrc",
|
|
52
|
+
".yarnrc.yml",
|
|
53
|
+
".netrc",
|
|
54
|
+
".pypirc",
|
|
55
|
+
".ssh",
|
|
56
|
+
".aws",
|
|
57
|
+
".docker",
|
|
58
|
+
]);
|
|
59
|
+
const EXCLUDED_PREFIXES = [".env", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"];
|
|
18
60
|
function isExcluded(name) {
|
|
19
61
|
if (EXCLUDED_NAMES.has(name))
|
|
20
62
|
return true;
|
|
21
|
-
|
|
22
|
-
return true;
|
|
23
|
-
return false;
|
|
63
|
+
return EXCLUDED_PREFIXES.some((prefix) => name === prefix || name.startsWith(`${prefix}.`));
|
|
24
64
|
}
|
|
25
65
|
async function tarGzDirectory(dir) {
|
|
26
|
-
const { readdirSync, statSync } = await import("node:fs");
|
|
27
66
|
const tar = pack();
|
|
28
67
|
const gzip = createGzip();
|
|
29
68
|
const chunks = [];
|
|
@@ -33,11 +72,19 @@ async function tarGzDirectory(dir) {
|
|
|
33
72
|
continue;
|
|
34
73
|
const fullPath = join(current, entry);
|
|
35
74
|
const relPath = prefix ? `${prefix}/${entry}` : entry;
|
|
36
|
-
|
|
75
|
+
// lstat, not stat -- following a symlink here would pack whatever it
|
|
76
|
+
// points at (anywhere on disk, or a parent directory causing unbounded
|
|
77
|
+
// recursion) under an innocuous-looking name, completely bypassing the
|
|
78
|
+
// exclusion list above, which only ever matches by name.
|
|
79
|
+
const stat = lstatSync(fullPath);
|
|
80
|
+
if (stat.isSymbolicLink()) {
|
|
81
|
+
console.warn(`WARNING: skipping symlink, not publishing it: ${relPath}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
37
84
|
if (stat.isDirectory()) {
|
|
38
85
|
addDir(fullPath, relPath);
|
|
39
86
|
}
|
|
40
|
-
else {
|
|
87
|
+
else if (stat.isFile()) {
|
|
41
88
|
tar.entry({ name: relPath, size: stat.size }, readFileSync(fullPath));
|
|
42
89
|
}
|
|
43
90
|
}
|
|
@@ -51,36 +98,99 @@ async function tarGzDirectory(dir) {
|
|
|
51
98
|
tar.pipe(gzip);
|
|
52
99
|
});
|
|
53
100
|
}
|
|
101
|
+
function parsePublishArgs(args) {
|
|
102
|
+
const pathFlag = flagValue(args, "--path");
|
|
103
|
+
let owner = flagValue(args, "--owner");
|
|
104
|
+
let skill = flagValue(args, "--slug");
|
|
105
|
+
let version = flagValue(args, "--version");
|
|
106
|
+
const name = flagValue(args, "--name");
|
|
107
|
+
const tagline = flagValue(args, "--tagline");
|
|
108
|
+
const tags = flagValue(args, "--tags");
|
|
109
|
+
const license = flagValue(args, "--license");
|
|
110
|
+
const homepage = flagValue(args, "--homepage");
|
|
111
|
+
const repository = flagValue(args, "--repository");
|
|
112
|
+
let legacyPath;
|
|
113
|
+
const first = args[0];
|
|
114
|
+
if (first && !first.startsWith("--")) {
|
|
115
|
+
if (first.includes("/") && first.includes("@")) {
|
|
116
|
+
// Primary form: ahood publish <owner>/<skill>@<version>
|
|
117
|
+
const parsed = parseOwnerSkill(first.slice(0, first.lastIndexOf("@")), USAGE);
|
|
118
|
+
owner = owner ?? parsed.owner;
|
|
119
|
+
skill = skill ?? parsed.skill;
|
|
120
|
+
version = version ?? first.slice(first.lastIndexOf("@") + 1);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Legacy form: ahood publish <path> --owner ... --slug ... --version ...
|
|
124
|
+
legacyPath = first;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!owner || !skill || !version)
|
|
128
|
+
throw new Error(USAGE);
|
|
129
|
+
if (!SEMVER_RE.test(version)) {
|
|
130
|
+
throw new Error(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
|
|
131
|
+
}
|
|
132
|
+
return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository };
|
|
133
|
+
}
|
|
134
|
+
// Creates the skill under the caller's own account when versions/init 404s
|
|
135
|
+
// -- skill creation is CLI-only now (there is no separate `ahood create`;
|
|
136
|
+
// publish creates on first use). --name is required for this path since
|
|
137
|
+
// createSkill requires a name; --tagline/--tags/--license/--homepage/
|
|
138
|
+
// --repository are optional and only used here, never applied to an
|
|
139
|
+
// already-existing skill.
|
|
140
|
+
async function createSkillForPublish(owner, skill, opts) {
|
|
141
|
+
if (!opts.name) {
|
|
142
|
+
throw new Error(`${owner}/${skill} doesn't exist yet -- pass --name to create it as part of this publish (e.g. --name "My Skill").`);
|
|
143
|
+
}
|
|
144
|
+
const body = { slug: skill, name: opts.name };
|
|
145
|
+
if (opts.tagline !== undefined)
|
|
146
|
+
body.tagline = opts.tagline;
|
|
147
|
+
if (opts.tags !== undefined)
|
|
148
|
+
body.tags = opts.tags.split(",").map((t) => t.trim()).filter(Boolean);
|
|
149
|
+
if (opts.license !== undefined)
|
|
150
|
+
body.license = opts.license;
|
|
151
|
+
if (opts.homepage !== undefined)
|
|
152
|
+
body.homepage = opts.homepage;
|
|
153
|
+
if (opts.repository !== undefined)
|
|
154
|
+
body.repository = opts.repository;
|
|
155
|
+
const created = await apiJson("/api/v1/skills", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify(body),
|
|
159
|
+
});
|
|
160
|
+
// POST /api/v1/skills always creates under the authenticated caller's own
|
|
161
|
+
// account -- it never reads an owner from the request body. If the caller
|
|
162
|
+
// typed a different owner segment than their own username, the created
|
|
163
|
+
// skill lives at a path that doesn't match what they asked to publish to.
|
|
164
|
+
if (created.owner !== owner) {
|
|
165
|
+
throw new Error(`Created ${created.owner}/${created.slug}, but this publish targeted "${owner}/${skill}" -- skills ` +
|
|
166
|
+
`are always created under your own account (${created.owner}). Retry with: ` +
|
|
167
|
+
`ahood publish ${created.owner}/${skill}@<version>.`);
|
|
168
|
+
}
|
|
169
|
+
console.log(`Created ${created.owner}/${created.slug} -- publishing its first version now.`);
|
|
170
|
+
}
|
|
171
|
+
async function initVersion(owner, skill, version, packageSizeBytes, createOpts) {
|
|
172
|
+
const path = `/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/init`;
|
|
173
|
+
const body = JSON.stringify({ version, package_size_bytes: packageSizeBytes });
|
|
174
|
+
try {
|
|
175
|
+
return await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (!(error instanceof ApiError) || error.status !== 404)
|
|
179
|
+
throw error;
|
|
180
|
+
await createSkillForPublish(owner, skill, createOpts);
|
|
181
|
+
// Retry exactly once. If this ALSO 404s (e.g. a race, or the create
|
|
182
|
+
// silently didn't take), let that error propagate rather than looping.
|
|
183
|
+
return await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
54
186
|
export async function publish(args) {
|
|
55
|
-
const path = args
|
|
187
|
+
const { owner, skill, version, path, name, tagline, tags, license, homepage, repository } = parsePublishArgs(args);
|
|
56
188
|
const skillMdPath = join(path, "SKILL.md");
|
|
57
189
|
if (!existsSync(skillMdPath)) {
|
|
58
190
|
throw new Error(`No SKILL.md found at ${skillMdPath} -- publish must point at a skill folder's root.`);
|
|
59
191
|
}
|
|
60
|
-
// A real ahood CLI would parse owner/slug/version/name out of SKILL.md
|
|
61
|
-
// frontmatter or a companion manifest -- this MVP takes them as explicit
|
|
62
|
-
// flags, matching how `versions/init`'s API itself requires them
|
|
63
|
-
// separately from the archive. Kept simple: --slug, --owner, --version are
|
|
64
|
-
// required; metadata creation (POST /skills) is assumed already done via
|
|
65
|
-
// the web UI or a prior publish -- this command only pushes a NEW VERSION
|
|
66
|
-
// of an existing skill, matching the ADR's "publish tars, calls
|
|
67
|
-
// versions/init -> upload -> versions/complete" description exactly (it
|
|
68
|
-
// does not create the skill's top-level metadata row).
|
|
69
|
-
const ownerIndex = args.indexOf("--owner");
|
|
70
|
-
const slugIndex = args.indexOf("--slug");
|
|
71
|
-
const versionIndex = args.indexOf("--version");
|
|
72
|
-
const owner = ownerIndex >= 0 ? args[ownerIndex + 1] : undefined;
|
|
73
|
-
const slug = slugIndex >= 0 ? args[slugIndex + 1] : undefined;
|
|
74
|
-
const version = versionIndex >= 0 ? args[versionIndex + 1] : undefined;
|
|
75
|
-
if (!owner || !slug || !version) {
|
|
76
|
-
throw new Error("Usage: ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>");
|
|
77
|
-
}
|
|
78
192
|
const archive = await tarGzDirectory(path);
|
|
79
|
-
const init = await
|
|
80
|
-
method: "POST",
|
|
81
|
-
headers: { "Content-Type": "application/json" },
|
|
82
|
-
body: JSON.stringify({ version, package_size_bytes: archive.length }),
|
|
83
|
-
});
|
|
193
|
+
const init = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository });
|
|
84
194
|
// TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
|
|
85
195
|
// "lib" override, so DOM is included alongside the Node types) type-checks
|
|
86
196
|
// Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
|
|
@@ -92,13 +202,24 @@ export async function publish(args) {
|
|
|
92
202
|
// restriction. Asserting through BodyInit here, rather than reshaping
|
|
93
203
|
// tsconfig's "lib" for the whole package, keeps this fix local to the one
|
|
94
204
|
// call site.
|
|
95
|
-
const putRes = await fetch(init.upload_url, {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
205
|
+
const putRes = await fetch(init.upload_url, {
|
|
206
|
+
method: "PUT",
|
|
207
|
+
body: archive,
|
|
208
|
+
signal: AbortSignal.timeout(120_000),
|
|
209
|
+
});
|
|
210
|
+
if (!putRes.ok) {
|
|
211
|
+
const body = await putRes.text().catch(() => "");
|
|
212
|
+
throw new Error(`Upload failed with status ${putRes.status}${body ? `: ${body}` : ""} (version_id: ${init.version_id}, retry with the same command once the underlying issue is fixed).`);
|
|
213
|
+
}
|
|
214
|
+
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/complete`, {
|
|
99
215
|
method: "POST",
|
|
100
216
|
headers: { "Content-Type": "application/json" },
|
|
101
217
|
body: JSON.stringify({ version_id: init.version_id }),
|
|
102
218
|
});
|
|
103
|
-
console.log(`
|
|
219
|
+
console.log(`Uploaded ${owner}/${skill}@${version} -- processing...`);
|
|
220
|
+
const result = await pollVersionStatus(owner, skill, version);
|
|
221
|
+
if (result.status === "failed") {
|
|
222
|
+
throw new Error(`Publish failed: ${result.failure_reason ?? "unknown reason"}`);
|
|
223
|
+
}
|
|
224
|
+
console.log(`Published ${owner}/${skill}@${result.version} (${result.status})`);
|
|
104
225
|
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readdirSync, rmSync, rmdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
3
|
import { removeLockfileEntry } from "../lockfile.js";
|
|
4
|
+
import { LOCKFILE_PATH, parseOwnerSkill, skillDir } from "../spec.js";
|
|
5
|
+
const USAGE = "Usage: ahood remove <owner>/<skill>";
|
|
4
6
|
export async function remove(args) {
|
|
5
7
|
const spec = args[0];
|
|
6
8
|
if (!spec)
|
|
7
|
-
throw new Error(
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const dir = join(".claude", "skills", owner, skill);
|
|
14
|
-
if (existsSync(dir))
|
|
9
|
+
throw new Error(USAGE);
|
|
10
|
+
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
11
|
+
const key = `${owner}/${skill}`;
|
|
12
|
+
const dir = skillDir(owner, skill);
|
|
13
|
+
const dirExisted = existsSync(dir);
|
|
14
|
+
if (dirExisted)
|
|
15
15
|
rmSync(dir, { recursive: true, force: true });
|
|
16
16
|
// Sweep the owner directory once it holds nothing, so uninstalling an
|
|
17
17
|
// owner's last skill doesn't leave an empty namespace folder behind (npm
|
|
18
18
|
// does the same with node_modules/@scope). Guarded on emptiness, so another
|
|
19
19
|
// skill by the same owner is never touched.
|
|
20
|
-
const ownerDir =
|
|
20
|
+
const ownerDir = dirname(dir);
|
|
21
21
|
if (existsSync(ownerDir) && readdirSync(ownerDir).length === 0)
|
|
22
22
|
rmdirSync(ownerDir);
|
|
23
|
-
removeLockfileEntry(
|
|
24
|
-
|
|
23
|
+
const hadLockfileEntry = removeLockfileEntry(LOCKFILE_PATH, key);
|
|
24
|
+
if (!dirExisted && !hadLockfileEntry) {
|
|
25
|
+
console.error(`${key} was not installed -- nothing to remove.`);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
console.log(`Removed ${key}`);
|
|
25
30
|
}
|
package/dist/commands/search.js
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
|
+
import { flagValue } from "../flags.js";
|
|
3
|
+
const USAGE = "Usage: ahood search <query> [--json] [--limit <n>]";
|
|
2
4
|
export async function search(args) {
|
|
3
|
-
const
|
|
5
|
+
const jsonOutput = args.includes("--json");
|
|
6
|
+
const limit = flagValue(args, "--limit");
|
|
7
|
+
const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && args[i - 1] !== "--limit");
|
|
8
|
+
const unknownFlag = queryParts.find((a) => a.startsWith("--"));
|
|
9
|
+
if (unknownFlag)
|
|
10
|
+
throw new Error(`Unknown flag: ${unknownFlag}\n${USAGE}`);
|
|
11
|
+
const query = queryParts.join(" ");
|
|
4
12
|
if (!query)
|
|
5
|
-
throw new Error(
|
|
6
|
-
|
|
13
|
+
throw new Error(USAGE);
|
|
14
|
+
if (limit !== undefined && (!/^\d+$/.test(limit) || Number(limit) < 1)) {
|
|
15
|
+
throw new Error(`--limit must be a positive integer (got "${limit}").\n${USAGE}`);
|
|
16
|
+
}
|
|
17
|
+
const qs = new URLSearchParams({ q: query });
|
|
18
|
+
if (limit !== undefined)
|
|
19
|
+
qs.set("limit", limit);
|
|
20
|
+
const { skills } = await apiJson(`/api/v1/skills?${qs}`);
|
|
21
|
+
if (jsonOutput) {
|
|
22
|
+
console.log(JSON.stringify(skills));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
7
25
|
if (skills.length === 0) {
|
|
8
26
|
console.log("No skills found.");
|
|
9
27
|
return;
|
|
10
28
|
}
|
|
11
29
|
for (const skill of skills) {
|
|
12
|
-
console.log(`${skill.profiles.username}/${skill.slug}
|
|
30
|
+
console.log(`${skill.profiles.username}/${skill.slug} - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
|
|
31
|
+
}
|
|
32
|
+
if (limit !== undefined && skills.length >= Number(limit)) {
|
|
33
|
+
console.log(`(showing up to ${limit} results -- pass a higher --limit for more)`);
|
|
13
34
|
}
|
|
14
35
|
}
|
package/dist/commands/star.js
CHANGED
|
@@ -1,26 +1,23 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
|
+
import { parseOwnerSkill } from "../spec.js";
|
|
2
3
|
// Mirrors POST/DELETE /api/v1/skills/{owner}/{skill}/star -- both are
|
|
3
4
|
// idempotent server-side (POST upserts on the stars table's
|
|
4
5
|
// unique(skill_id, user_id) constraint with ignoreDuplicates, DELETE just
|
|
5
6
|
// deletes any matching row), so starring an already-starred skill or
|
|
6
7
|
// unstarring one you never starred is a no-op 200, not an error.
|
|
7
8
|
export async function star(args) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
throw new Error("Usage: ahood star <owner>/<skill>");
|
|
14
|
-
await apiJson(`/api/v1/skills/${owner}/${skill}/star`, { method: "POST" });
|
|
9
|
+
const USAGE = "Usage: ahood star <owner>/<skill>";
|
|
10
|
+
const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
|
|
11
|
+
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
});
|
|
15
14
|
console.log(`Starred ${owner}/${skill}.`);
|
|
16
15
|
}
|
|
17
16
|
export async function unstar(args) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
throw new Error("Usage: ahood unstar <owner>/<skill>");
|
|
24
|
-
await apiJson(`/api/v1/skills/${owner}/${skill}/star`, { method: "DELETE" });
|
|
17
|
+
const USAGE = "Usage: ahood unstar <owner>/<skill>";
|
|
18
|
+
const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
|
|
19
|
+
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
|
|
20
|
+
method: "DELETE",
|
|
21
|
+
});
|
|
25
22
|
console.log(`Unstarred ${owner}/${skill}.`);
|
|
26
23
|
}
|
package/dist/commands/token.js
CHANGED
|
@@ -5,11 +5,11 @@ export async function token(args) {
|
|
|
5
5
|
case "create":
|
|
6
6
|
return tokenCreate(rest);
|
|
7
7
|
case "list":
|
|
8
|
-
return tokenList();
|
|
8
|
+
return tokenList(rest);
|
|
9
9
|
case "revoke":
|
|
10
10
|
return tokenRevoke(rest);
|
|
11
11
|
default:
|
|
12
|
-
throw new Error("Usage: ahood token create
|
|
12
|
+
throw new Error("Usage: ahood token create <name>|list [--json]|revoke <id>");
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
async function tokenCreate(args) {
|
|
@@ -32,20 +32,25 @@ async function tokenCreate(args) {
|
|
|
32
32
|
console.log(`Created token "${result.name}": ${result.token}`);
|
|
33
33
|
console.log("Copy this now -- it will not be shown again.");
|
|
34
34
|
}
|
|
35
|
-
async function tokenList() {
|
|
35
|
+
async function tokenList(args) {
|
|
36
|
+
const jsonOutput = args.includes("--json");
|
|
36
37
|
const { tokens } = await apiJson("/api/v1/auth/tokens");
|
|
38
|
+
if (jsonOutput) {
|
|
39
|
+
console.log(JSON.stringify(tokens));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
37
42
|
if (tokens.length === 0) {
|
|
38
43
|
console.log("No tokens.");
|
|
39
44
|
return;
|
|
40
45
|
}
|
|
41
46
|
for (const t of tokens) {
|
|
42
|
-
console.log(`${t.id} ${t.name} ${t.token_prefix}
|
|
47
|
+
console.log(`${t.id} ${t.name} ${t.token_prefix}... ${t.scopes.join(",")}${t.revoked_at ? " (revoked)" : ""}`);
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
async function tokenRevoke(args) {
|
|
46
51
|
const id = args[0];
|
|
47
52
|
if (!id)
|
|
48
53
|
throw new Error("Usage: ahood token revoke <id>");
|
|
49
|
-
await apiJson(`/api/v1/auth/tokens/${id}`, { method: "DELETE" });
|
|
54
|
+
await apiJson(`/api/v1/auth/tokens/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
50
55
|
console.log(`Revoked ${id}`);
|
|
51
56
|
}
|
|
@@ -1,32 +1,49 @@
|
|
|
1
|
-
import { createInterface } from "node:readline
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
2
|
import { apiJson } from "../http.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
3
|
+
import { parseOwnerSkill } from "../spec.js";
|
|
4
|
+
const USAGE = "Usage: ahood unpublish <owner>/<skill> [--yes]";
|
|
5
|
+
// On closed/non-interactive stdin, readline/promises' rl.question() promise
|
|
6
|
+
// never settles -- but since nothing else keeps the event loop alive at that
|
|
7
|
+
// point, the process used to exit 0 anyway once the loop drained, having
|
|
8
|
+
// printed only the prompt (no "Aborted.", no deletion, no error: a silent
|
|
9
|
+
// false success). The plain callback-style API is used instead specifically
|
|
10
|
+
// so the "close" listener can be a reliable signal for "closed without ever
|
|
11
|
+
// answering": `settled` is set synchronously inside the answer callback,
|
|
12
|
+
// before rl.close() is called, so that same close doesn't self-reject.
|
|
13
|
+
function confirm(promptText) {
|
|
9
14
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
let settled = false;
|
|
17
|
+
rl.once("close", () => {
|
|
18
|
+
if (settled)
|
|
19
|
+
return;
|
|
20
|
+
settled = true;
|
|
21
|
+
reject(new Error("Confirmation was not answered (stdin closed)."));
|
|
22
|
+
});
|
|
23
|
+
rl.question(promptText, (answer) => {
|
|
24
|
+
settled = true;
|
|
25
|
+
rl.close();
|
|
26
|
+
resolve(answer.trim().toLowerCase() === "yes");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
17
29
|
}
|
|
18
30
|
export async function unpublish(args) {
|
|
19
|
-
const
|
|
31
|
+
const yes = args.includes("--yes");
|
|
32
|
+
const spec = args.find((a) => !a.startsWith("--"));
|
|
20
33
|
if (!spec)
|
|
21
|
-
throw new Error(
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
throw new Error(USAGE);
|
|
35
|
+
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
36
|
+
const key = `${owner}/${skill}`;
|
|
37
|
+
// --yes bypasses the prompt for scripts/CI; otherwise a closed stdin (no
|
|
38
|
+
// TTY, no piped answer either) now throws via confirm()'s "close" handling
|
|
39
|
+
// instead of silently exiting 0 with nothing prompted, confirmed, or done.
|
|
40
|
+
const confirmed = yes
|
|
41
|
+
? true
|
|
42
|
+
: await confirm(`This will permanently delete ${key} for everyone who has installed it. Type "yes" to confirm: `);
|
|
26
43
|
if (!confirmed) {
|
|
27
44
|
console.log("Aborted.");
|
|
28
45
|
return;
|
|
29
46
|
}
|
|
30
|
-
await apiJson(`/api/v1/skills/${owner}/${skill}`, { method: "DELETE" });
|
|
31
|
-
console.log(`Unpublished ${
|
|
47
|
+
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`, { method: "DELETE" });
|
|
48
|
+
console.log(`Unpublished ${key}. Run \`ahood remove ${key}\` to also remove your local copy.`);
|
|
32
49
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
|
+
import { LOCKFILE_PATH } from "../spec.js";
|
|
1
2
|
import { readLockfile } from "../lockfile.js";
|
|
2
3
|
import { add } from "./add.js";
|
|
3
4
|
// `update` is the only command that moves the lockfile pin forward -- `add`
|
|
4
5
|
// always resolves and pins whatever version it's given (or latest, once,
|
|
5
6
|
// at install time), matching the ADR's "reproducible by default" design.
|
|
6
7
|
export async function update(args) {
|
|
7
|
-
const lockfile = readLockfile(
|
|
8
|
+
const lockfile = readLockfile(LOCKFILE_PATH);
|
|
8
9
|
const targets = args.length > 0 ? args : Object.keys(lockfile);
|
|
9
10
|
if (targets.length === 0) {
|
|
10
11
|
console.log("No installed skills to update.");
|
|
11
12
|
return;
|
|
12
13
|
}
|
|
14
|
+
const failures = [];
|
|
13
15
|
for (const ownerSlashSkill of targets) {
|
|
14
|
-
if (!lockfile[ownerSlashSkill]) {
|
|
16
|
+
if (!lockfile[ownerSlashSkill] && args.length === 0) {
|
|
17
|
+
// Only reachable when targets came from the lockfile itself, so this
|
|
18
|
+
// would mean the lockfile changed under us mid-loop -- skip, don't abort.
|
|
15
19
|
console.warn(`Skipping ${ownerSlashSkill}: not currently installed.`);
|
|
16
20
|
continue;
|
|
17
21
|
}
|
|
18
|
-
|
|
22
|
+
try {
|
|
23
|
+
await add([ownerSlashSkill]); // no @version -- resolves to latest again
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
// One skill being removed/yanked/unreachable must not stop every other
|
|
27
|
+
// skill in the batch from updating.
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
console.error(`Failed to update ${ownerSlashSkill}: ${message}`);
|
|
30
|
+
failures.push(ownerSlashSkill);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (failures.length > 0) {
|
|
34
|
+
console.error(`${failures.length} of ${targets.length} skill(s) failed to update: ${failures.join(", ")}`);
|
|
35
|
+
process.exitCode = 1;
|
|
19
36
|
}
|
|
20
37
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { apiJson } from "../http.js";
|
|
3
|
+
import { getApiUrl } from "../config.js";
|
|
4
|
+
import { parseOwnerSkill } from "../spec.js";
|
|
5
|
+
const USAGE = "Usage: ahood view <owner>/<skill> [--json] [--web]";
|
|
6
|
+
// Spawned with argv as an array (not a shell string), so a crafted owner/skill
|
|
7
|
+
// can never inject shell syntax -- and both segments are already restricted
|
|
8
|
+
// to a safe charset by parseOwnerSkill before this ever runs.
|
|
9
|
+
function openBrowser(url) {
|
|
10
|
+
try {
|
|
11
|
+
if (process.platform === "win32") {
|
|
12
|
+
spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
|
|
13
|
+
}
|
|
14
|
+
else if (process.platform === "darwin") {
|
|
15
|
+
spawn("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
console.log(`Open this URL in your browser: ${url}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function view(args) {
|
|
26
|
+
const jsonOutput = args.includes("--json");
|
|
27
|
+
const web = args.includes("--web");
|
|
28
|
+
const spec = args.find((a) => !a.startsWith("--"));
|
|
29
|
+
if (!spec)
|
|
30
|
+
throw new Error(USAGE);
|
|
31
|
+
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
32
|
+
const url = `${getApiUrl()}/${owner}/${skill}`;
|
|
33
|
+
if (web) {
|
|
34
|
+
openBrowser(url);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
|
|
38
|
+
if (jsonOutput) {
|
|
39
|
+
console.log(JSON.stringify(detail));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const field = (label, value) => console.log(` ${label.padEnd(13)}${value}`);
|
|
43
|
+
console.log(`${detail.owner}/${detail.slug}`);
|
|
44
|
+
field("name:", detail.name);
|
|
45
|
+
if (detail.tagline)
|
|
46
|
+
field("tagline:", detail.tagline);
|
|
47
|
+
field("tags:", detail.tags.length > 0 ? detail.tags.join(", ") : "-");
|
|
48
|
+
field("license:", detail.license ?? "-");
|
|
49
|
+
field("visibility:", detail.visibility);
|
|
50
|
+
field("homepage:", detail.homepage ?? "-");
|
|
51
|
+
field("repository:", detail.repository ?? "-");
|
|
52
|
+
field("version:", detail.skill_versions ? detail.skill_versions.version : "no published version");
|
|
53
|
+
field("downloads:", String(detail.downloads_count));
|
|
54
|
+
field("stars:", String(detail.stars_count));
|
|
55
|
+
field("created:", detail.created_at);
|
|
56
|
+
field("updated:", detail.updated_at);
|
|
57
|
+
if (detail.is_starred !== null)
|
|
58
|
+
field("starred:", detail.is_starred ? "yes" : "no");
|
|
59
|
+
console.log(url);
|
|
60
|
+
}
|