@ahood/cli 0.4.2 → 0.5.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 CHANGED
@@ -60,6 +60,8 @@ npm i -g @ahood/cli
60
60
  ahood <command>
61
61
  ```
62
62
 
63
+ **The `-g` flag matters.** `npm i @ahood/cli` (no `-g`) installs into the current directory's `node_modules/` instead, and `ahood` won't be on your `PATH` -- you'll get `ahood: command not found`. If that happens, either re-run with `-g`, or use `npx @ahood/cli@latest <command>` from that directory instead.
64
+
63
65
  Requires Node.js 18 or later.
64
66
 
65
67
  ## Why ahood
@@ -103,6 +105,15 @@ alice/pdf-tools
103
105
  version: 1.4.0
104
106
  ...
105
107
 
108
+ $ ahood skill read alice/pdf-tools
109
+ ---
110
+ name: pdf-tools
111
+ description: Merge, split, and compress PDFs from the command line
112
+ ---
113
+
114
+ # PDF Tools
115
+ ...
116
+
106
117
  $ ahood skill add alice/pdf-tools@1.4.0
107
118
  Installed alice/pdf-tools@1.4.0 to .claude/skills/alice/pdf-tools
108
119
  ```
@@ -154,6 +165,25 @@ ahood skill publish alice/pdf-tools@1.5.1 --path ./pdf-tools --json
154
165
  ```
155
166
  </details>
156
167
 
168
+ <details>
169
+ <summary><strong>Create a private group and share a skill with it</strong></summary>
170
+
171
+ ```
172
+ $ ahood group create "Design Team" --description "Shared skills for the design team"
173
+ Created group Design Team (design-team).
174
+
175
+ $ ahood group invite-link design-team
176
+ https://ahood.vercel.app/groups/join?token=abc123...
177
+ Expires: 2026-09-14T00:00:00.000Z
178
+ This link (and its token) is shown only this once -- save it now, it cannot be retrieved again.
179
+
180
+ $ ahood skill share alice/pdf-tools --group design-team
181
+ Shared alice/pdf-tools with design-team.
182
+ ```
183
+
184
+ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private visibility, it just makes it visible to everyone in `design-team` too. On the other side, whoever received the link runs `ahood group join <the link>` to join.
185
+ </details>
186
+
157
187
  ## Commands
158
188
 
159
189
  <!-- Generated from src/help.ts's COMMANDS_HELP by scripts/sync-readme.mjs --
@@ -177,6 +207,7 @@ ahood skill publish alice/pdf-tools@1.5.1 --path ./pdf-tools --json
177
207
  | --- | --- |
178
208
  | `ahood skill search <query> [--json] [--limit <n>]` | Search published skills. |
179
209
  | `ahood skill view\|show <owner>/<skill> [--json] [--web]` | Show a single skill's details -- tags, license, homepage, repository, dates, and more -- without installing it (alias: ahood skill show). |
210
+ | `ahood skill read <owner>/<skill> [--json]` | Print a skill's full SKILL.md content, without installing it. |
180
211
  | `ahood skill versions <owner>/<skill> [--json]` | List a skill's published-version history -- version, changelog, size, and publish date. |
181
212
  | `ahood skill list [--json]` | List your own skills, public and private. |
182
213
  | `ahood skill add <owner>/<skill>[@version]` | Install a skill into .claude/skills/, pinned in the lockfile. |
@@ -186,11 +217,26 @@ ahood skill publish alice/pdf-tools@1.5.1 --path ./pdf-tools --json
186
217
  | `ahood skill unpublish <owner>/<skill>[@version] [--yes]` | Delete a skill from the registry for every consumer, or yank a single version, not just your local install (prompts for confirmation unless --yes is passed). |
187
218
  | `ahood skill star <owner>/<skill>` | Star a skill. |
188
219
  | `ahood skill unstar <owner>/<skill>` | Remove your star from a skill. |
220
+ | `ahood skill share <owner>/<skill> --group <group>` | Share a skill you own with a group, without changing its public/private visibility. |
221
+ | `ahood skill unshare <owner>/<skill> --group <group>` | Stop sharing a skill you own with a group. |
189
222
  | `ahood skill init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
190
223
  | `ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]` | Publish a new version of a skill from a folder containing SKILL.md, creating the skill first if it doesn't already exist. |
224
+
225
+ ### Group
226
+
227
+ | Command | What it does |
228
+ | --- | --- |
229
+ | `ahood group create <name> [--description <text>]` | Create a private group, becoming its owner. |
230
+ | `ahood group list [--json]` | List groups you own or belong to. |
231
+ | `ahood group members <group> [--json]` | List a group's members and their role (owner-only visible to members). |
232
+ | `ahood group invite-link <group> [--json]` | Create (or regenerate) a shareable invite link for a group you own. |
233
+ | `ahood group join <invite-url-or-token>` | Join a group using an invite link or its raw token. |
234
+ | `ahood group remove-member <group> <username>` | Remove a member from a group you own. |
235
+ | `ahood group leave <group>` | Leave a group you belong to. |
236
+ | `ahood group delete <group> [--yes]` | Permanently delete a group you own (prompts for confirmation unless --yes is passed). |
191
237
  <!-- COMMANDS_TABLE_END -->
192
238
 
193
- Run `ahood --help`, `ahood skill --help`, or `ahood <command> --help` (also `ahood help <command>` / `ahood help skill <verb>`) for the same reference — including per-command flags and examples — directly in your terminal. `ahood --version` prints the installed CLI version.
239
+ Run `ahood --help`, `ahood skill --help`, `ahood group --help`, or `ahood <command> --help` (also `ahood help <command>` / `ahood help skill <verb>` / `ahood help group <verb>`) for the same reference — including per-command flags and examples — directly in your terminal. `ahood --version` prints the installed CLI version.
194
240
 
195
241
  ## Exit codes
196
242
 
@@ -0,0 +1,160 @@
1
+ import { apiJson } from "../http.js";
2
+ import { flagValue } from "../flags.js";
3
+ import { confirm } from "../confirm.js";
4
+ import { getApiUrl } from "../config.js";
5
+ const CREATE_USAGE = "Usage: ahood group create <name> [--description <text>]";
6
+ const LIST_USAGE = "Usage: ahood group list [--json]";
7
+ const MEMBERS_USAGE = "Usage: ahood group members <group> [--json]";
8
+ const INVITE_LINK_USAGE = "Usage: ahood group invite-link <group> [--json]";
9
+ const JOIN_USAGE = "Usage: ahood group join <invite-url-or-token>";
10
+ const REMOVE_MEMBER_USAGE = "Usage: ahood group remove-member <group> <username>";
11
+ const LEAVE_USAGE = "Usage: ahood group leave <group>";
12
+ const DELETE_USAGE = "Usage: ahood group delete <group> [--yes]";
13
+ // Creates a group (caller becomes owner). Follows edit.ts's convention of
14
+ // requiring the positional argument first, since --description takes a
15
+ // value and a "find the first non---flag token" scan would misidentify a
16
+ // bare description value (one that doesn't start with "--") as the name if
17
+ // --description happened to come first.
18
+ export async function createGroup(args) {
19
+ const name = args[0];
20
+ if (!name || name.startsWith("--"))
21
+ throw new Error(CREATE_USAGE);
22
+ const description = flagValue(args, "--description");
23
+ const body = { name };
24
+ if (description !== undefined)
25
+ body.description = description;
26
+ const created = await apiJson("/api/v1/groups", {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify(body),
30
+ });
31
+ console.log(`Created group ${created.name} (${created.slug}).`);
32
+ }
33
+ // Lists the caller's own groups (owner or member).
34
+ export async function listGroups(args = []) {
35
+ const jsonOutput = args.includes("--json");
36
+ const { groups } = await apiJson("/api/v1/groups");
37
+ if (jsonOutput) {
38
+ console.log(JSON.stringify(groups));
39
+ return;
40
+ }
41
+ if (groups.length === 0) {
42
+ console.log("You don't belong to any groups yet. Run `ahood group create <name>` to make one.");
43
+ return;
44
+ }
45
+ for (const g of groups) {
46
+ console.log(`${g.slug} - ${g.name}${g.description ? `: ${g.description}` : ""}`);
47
+ }
48
+ }
49
+ // Group detail + members -- 404s (mapped to exit code 5) if the caller
50
+ // isn't a member, same as any other not-found.
51
+ export async function groupMembers(args) {
52
+ const jsonOutput = args.includes("--json");
53
+ const slug = args.find((a) => !a.startsWith("--"));
54
+ if (!slug)
55
+ throw new Error(MEMBERS_USAGE);
56
+ const { group, members } = await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}`);
57
+ if (jsonOutput) {
58
+ console.log(JSON.stringify({ group, members }));
59
+ return;
60
+ }
61
+ console.log(`${group.slug} - ${group.name}${group.description ? `: ${group.description}` : ""}`);
62
+ for (const m of members) {
63
+ console.log(` ${m.username} (${m.role})`);
64
+ }
65
+ }
66
+ // Creates/regenerates an invite link (owner-only). The response's `token`
67
+ // is raw and shown only this once server-side -- only its hash is stored,
68
+ // so there is no way to retrieve it again later, hence the warning below.
69
+ // The shareable URL is built from getApiUrl() (not a hardcoded prod host)
70
+ // so it stays correct against a non-default AHOOD_API_URL, same reasoning
71
+ // as view.ts's --web URL.
72
+ export async function inviteLink(args) {
73
+ const jsonOutput = args.includes("--json");
74
+ const slug = args.find((a) => !a.startsWith("--"));
75
+ if (!slug)
76
+ throw new Error(INVITE_LINK_USAGE);
77
+ const { token, expiresAt } = await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/invites`, { method: "POST" });
78
+ const url = `${getApiUrl()}/groups/join?token=${encodeURIComponent(token)}`;
79
+ if (jsonOutput) {
80
+ console.log(JSON.stringify({ token, expiresAt, url }));
81
+ return;
82
+ }
83
+ console.log(url);
84
+ console.log(`Expires: ${expiresAt}`);
85
+ console.log("This link (and its token) is shown only this once -- save it now, it cannot be retrieved again.");
86
+ }
87
+ // Accepts either a full join URL (https://ahood.vercel.app/groups/join?token=...)
88
+ // or a bare raw token, for convenience -- whatever a user pastes from
89
+ // `ahood group invite-link` or forwards from someone else. Anything that
90
+ // doesn't parse as a URL, or parses but has no `token` query param, is
91
+ // treated as a raw token verbatim.
92
+ function extractToken(input) {
93
+ try {
94
+ const url = new URL(input);
95
+ const token = url.searchParams.get("token");
96
+ if (token)
97
+ return token;
98
+ }
99
+ catch {
100
+ // Not a URL at all -- fall through and treat the whole input as the
101
+ // raw token.
102
+ }
103
+ return input;
104
+ }
105
+ export async function joinGroup(args) {
106
+ const input = args.find((a) => !a.startsWith("--"));
107
+ if (!input)
108
+ throw new Error(JOIN_USAGE);
109
+ const token = extractToken(input);
110
+ const { groupSlug, groupName } = await apiJson(`/api/v1/groups/invites/${encodeURIComponent(token)}/accept`, { method: "POST" });
111
+ console.log(`Joined ${groupName} (${groupSlug}).`);
112
+ }
113
+ // Removes a member (owner-only for someone else; see leaveGroup below for
114
+ // "remove yourself"). No flags today, so plain positional filtering is
115
+ // unambiguous.
116
+ export async function removeMember(args) {
117
+ const positional = args.filter((a) => !a.startsWith("--"));
118
+ const [slug, username] = positional;
119
+ if (!slug || !username)
120
+ throw new Error(REMOVE_MEMBER_USAGE);
121
+ await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
122
+ console.log(`Removed ${username} from ${slug}.`);
123
+ }
124
+ // There's no cached username anywhere in credentials.ts (only the bearer
125
+ // token itself is stored) and no endpoint that echoes an identity for a
126
+ // bearer caller directly, so the caller's own username is resolved the
127
+ // same way whoami.ts's profile enrichment does: GET /api/v1/profile.
128
+ async function getOwnUsername() {
129
+ const profile = await apiJson("/api/v1/profile");
130
+ return profile.username;
131
+ }
132
+ // "Leave" is just "remove-member" targeting yourself -- the API route
133
+ // handles both cases identically (DELETE .../members/{username} with your
134
+ // own username removes you, same as an owner removing someone else).
135
+ export async function leaveGroup(args) {
136
+ const slug = args.find((a) => !a.startsWith("--"));
137
+ if (!slug)
138
+ throw new Error(LEAVE_USAGE);
139
+ const username = await getOwnUsername();
140
+ await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
141
+ console.log(`Left ${slug}.`);
142
+ }
143
+ // Owner-only, deletes the group outright -- mirrors unpublish.ts's
144
+ // confirmation-prompt pattern exactly (--yes bypasses it for scripts/CI,
145
+ // otherwise a typed "yes" is required).
146
+ export async function deleteGroup(args) {
147
+ const yes = args.includes("--yes");
148
+ const slug = args.find((a) => !a.startsWith("--"));
149
+ if (!slug)
150
+ throw new Error(DELETE_USAGE);
151
+ const confirmed = yes
152
+ ? true
153
+ : await confirm(`This will permanently delete the group "${slug}" for all members. Type "yes" to confirm: `);
154
+ if (!confirmed) {
155
+ console.log("Aborted.");
156
+ return;
157
+ }
158
+ await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}`, { method: "DELETE" });
159
+ console.log(`Deleted group ${slug}.`);
160
+ }
@@ -0,0 +1,32 @@
1
+ import { apiJson } from "../http.js";
2
+ import { parseOwnerSkill } from "../spec.js";
3
+ const USAGE = "Usage: ahood skill read <owner>/<skill> [--json]";
4
+ export async function read(args) {
5
+ const jsonOutput = args.includes("--json");
6
+ const spec = args.find((a) => !a.startsWith("--"));
7
+ if (!spec)
8
+ throw new Error(USAGE);
9
+ const { owner, skill } = parseOwnerSkill(spec, USAGE);
10
+ const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
11
+ if (!detail.skill_versions) {
12
+ throw new Error(`${owner}/${skill} has no published version.`);
13
+ }
14
+ // Mirrors view.ts's exact wording/tone for the same warning -- this
15
+ // command reads straight from the same endpoint/version, so it must not
16
+ // be silent about handing back a yanked version's content.
17
+ if (detail.skill_versions.yanked_at) {
18
+ console.warn(`WARNING: ${detail.owner}/${detail.slug}@${detail.skill_versions.version} has been yanked${detail.skill_versions.yanked_reason ? `: ${detail.skill_versions.yanked_reason}` : "."}`);
19
+ }
20
+ const content = detail.skill_versions.skill_md_content;
21
+ if (!content) {
22
+ throw new Error(`${owner}/${skill}@${detail.skill_versions.version} has no SKILL.md content available.`);
23
+ }
24
+ if (jsonOutput) {
25
+ console.log(JSON.stringify({ version: detail.skill_versions.version, content }));
26
+ return;
27
+ }
28
+ // Plain mode prints the raw content verbatim -- no formatting/labels/
29
+ // trailing decoration -- since the whole point is fast/pipeable access to
30
+ // the exact prompt text (e.g. piping into a file or another tool).
31
+ console.log(content);
32
+ }
@@ -0,0 +1,41 @@
1
+ import { apiJson } from "../http.js";
2
+ import { flagValue } from "../flags.js";
3
+ import { parseOwnerSkill } from "../spec.js";
4
+ // These are skill-entity verbs (reached as `ahood skill share`/`ahood skill
5
+ // unshare`, registered in SKILL_COMMANDS in index.ts), not group verbs --
6
+ // they mutate a skill's sharing, not a group. Grouped together in one file
7
+ // the same way star.ts groups its own paired verbs (star/unstar).
8
+ const SHARE_USAGE = "Usage: ahood skill share <owner>/<skill> --group <group>";
9
+ const UNSHARE_USAGE = "Usage: ahood skill unshare <owner>/<skill> --group <group>";
10
+ // Owner-of-the-skill-only server-side, and the caller must already be a
11
+ // member of the target group -- both enforced by the API, not re-checked
12
+ // here. Follows edit.ts's convention of requiring the owner/skill spec
13
+ // first (before any flags), since --group takes a value and a "first
14
+ // non---flag token" scan would misidentify the --group value as the spec
15
+ // if --group happened to come first.
16
+ export async function share(args) {
17
+ const spec = args[0];
18
+ if (!spec || spec.startsWith("--"))
19
+ throw new Error(SHARE_USAGE);
20
+ const { owner, skill } = parseOwnerSkill(spec, SHARE_USAGE);
21
+ const groupSlug = flagValue(args, "--group");
22
+ if (!groupSlug)
23
+ throw new Error(SHARE_USAGE);
24
+ await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/share`, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({ group_slug: groupSlug }),
28
+ });
29
+ console.log(`Shared ${owner}/${skill} with ${groupSlug}.`);
30
+ }
31
+ export async function unshare(args) {
32
+ const spec = args[0];
33
+ if (!spec || spec.startsWith("--"))
34
+ throw new Error(UNSHARE_USAGE);
35
+ const { owner, skill } = parseOwnerSkill(spec, UNSHARE_USAGE);
36
+ const groupSlug = flagValue(args, "--group");
37
+ if (!groupSlug)
38
+ throw new Error(UNSHARE_USAGE);
39
+ await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/share/${encodeURIComponent(groupSlug)}`, { method: "DELETE" });
40
+ console.log(`Unshared ${owner}/${skill} from ${groupSlug}.`);
41
+ }
package/dist/help.js CHANGED
@@ -47,6 +47,15 @@ export const SKILL_COMMANDS_HELP = [
47
47
  "--web Open the skill's page in your browser instead of printing.",
48
48
  ],
49
49
  },
50
+ {
51
+ usage: "ahood skill read <owner>/<skill> [--json]",
52
+ summary: "Print a skill's full SKILL.md content, without installing it.",
53
+ desc: "Print a skill's full SKILL.md content (the prompt itself) without installing it via `ahood skill add`. " +
54
+ "Plain mode prints the raw content verbatim to stdout -- no formatting, no labels -- so it's safe to pipe " +
55
+ "into a file or another tool.",
56
+ flags: ["--json Emit {version, content} as a single line instead of the raw content."],
57
+ examples: ["ahood skill read alice/pdf-tools", "ahood skill read alice/pdf-tools --json"],
58
+ },
50
59
  {
51
60
  usage: "ahood skill versions <owner>/<skill> [--json]",
52
61
  summary: "List a skill's published-version history -- version, changelog, size, and publish date.",
@@ -110,6 +119,22 @@ export const SKILL_COMMANDS_HELP = [
110
119
  summary: "Remove your star from a skill.",
111
120
  desc: "Remove your star from a skill.",
112
121
  },
122
+ {
123
+ usage: "ahood skill share <owner>/<skill> --group <group>",
124
+ summary: "Share a skill you own with a group, without changing its public/private visibility.",
125
+ desc: "Share a skill you own with a group. Sharing is additive -- it doesn't change the skill's own " +
126
+ "public/private visibility, it just makes it visible to everyone in the group too. You must own the " +
127
+ "skill and already be a member of the target group.",
128
+ flags: ["--group <group> The group's slug (from `ahood group list`)."],
129
+ examples: ["ahood skill share alice/pdf-tools --group design-team"],
130
+ },
131
+ {
132
+ usage: "ahood skill unshare <owner>/<skill> --group <group>",
133
+ summary: "Stop sharing a skill you own with a group.",
134
+ desc: "Stop sharing a skill you own with a group. Does not affect the skill's own visibility.",
135
+ flags: ["--group <group> The group's slug."],
136
+ examples: ["ahood skill unshare alice/pdf-tools --group design-team"],
137
+ },
113
138
  {
114
139
  usage: "ahood skill init [name]",
115
140
  summary: "Scaffold a new skill folder with a minimal, valid SKILL.md.",
@@ -141,17 +166,89 @@ export const SKILL_COMMANDS_HELP = [
141
166
  ],
142
167
  },
143
168
  ];
169
+ // Every group-entity verb, all reached as `ahood group <verb>` -- mirrors
170
+ // SKILL_COMMANDS_HELP above, just for the "Groups" feature: private groups,
171
+ // shareable invite links, and sharing your own skills with a group without
172
+ // changing their public/private visibility.
173
+ export const GROUP_COMMANDS_HELP = [
174
+ {
175
+ usage: "ahood group create <name> [--description <text>]",
176
+ summary: "Create a private group, becoming its owner.",
177
+ desc: "Create a private group. You become its owner.",
178
+ flags: ["--description <text> Optional short description."],
179
+ examples: ['ahood group create "Design Team" --description "Shared skills for the design team"'],
180
+ },
181
+ {
182
+ usage: "ahood group list [--json]",
183
+ summary: "List groups you own or belong to.",
184
+ desc: "List groups you own or belong to.",
185
+ flags: ["--json Emit the raw group objects instead of formatted lines."],
186
+ },
187
+ {
188
+ usage: "ahood group members <group> [--json]",
189
+ summary: "List a group's members and their role (owner-only visible to members).",
190
+ desc: "List a group's details and members. Member-only -- you must belong to the group. Returns not found " +
191
+ "(rather than a permission error) if you don't, so a group's existence isn't leaked to non-members.",
192
+ flags: ["--json Emit the raw group/members objects instead of formatted lines."],
193
+ },
194
+ {
195
+ usage: "ahood group invite-link <group> [--json]",
196
+ summary: "Create (or regenerate) a shareable invite link for a group you own.",
197
+ desc: "Create or regenerate a shareable invite link for a group you own. Regenerating invalidates any " +
198
+ "previously issued link. The raw token is shown only this once -- only its hash is stored server-side, " +
199
+ "so it cannot be retrieved again later; save it (or the link) somewhere safe.",
200
+ flags: ["--json Emit {token, expiresAt, url} instead of formatted lines."],
201
+ },
202
+ {
203
+ usage: "ahood group join <invite-url-or-token>",
204
+ summary: "Join a group using an invite link or its raw token.",
205
+ desc: "Join a group using an invite link (e.g. https://ahood.vercel.app/groups/join?token=...) or its bare " +
206
+ "raw token -- either form works.",
207
+ examples: [
208
+ "ahood group join https://ahood.vercel.app/groups/join?token=abc123",
209
+ "ahood group join abc123",
210
+ ],
211
+ },
212
+ {
213
+ usage: "ahood group remove-member <group> <username>",
214
+ summary: "Remove a member from a group you own.",
215
+ desc: "Remove a member from a group you own. The group's owner cannot be removed this way -- delete the " +
216
+ "group instead. Only the group's owner can remove someone other than themselves.",
217
+ },
218
+ {
219
+ usage: "ahood group leave <group>",
220
+ summary: "Leave a group you belong to.",
221
+ desc: "Leave a group you belong to. The group's owner cannot leave -- delete the group instead.",
222
+ },
223
+ {
224
+ usage: "ahood group delete <group> [--yes]",
225
+ summary: "Permanently delete a group you own (prompts for confirmation unless --yes is passed).",
226
+ desc: "Permanently delete a group you own, removing it for every member. Prompts for a typed \"yes\" unless " +
227
+ "--yes is passed.",
228
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
229
+ },
230
+ ];
144
231
  // Exported so other consumers of the command list (e.g. shell completion) can
145
232
  // surface aliases without needing their own copy of this map. Aliases apply
146
233
  // at the skill-verb level today (e.g. "show" for "view"); nothing at the
147
234
  // top level currently has one.
148
235
  export const COMMAND_ALIASES = { show: "view" };
149
- // Two-token lookup for a skill-entity verb (findCommandHelp("skill", "search"))
150
- // or a single-token lookup for a top-level command (findCommandHelp("whoami")).
236
+ // Entity-scoped command lists, keyed by the entity name as it appears right
237
+ // after "ahood" in a usage string (e.g. "ahood skill <verb> ..."). Both
238
+ // findCommandHelp and usageWithAliases key off this map so a future entity
239
+ // only has to be added here once, not re-special-cased in both places.
240
+ const ENTITY_COMMANDS_HELP = {
241
+ skill: SKILL_COMMANDS_HELP,
242
+ group: GROUP_COMMANDS_HELP,
243
+ };
244
+ // Two-token lookup for an entity verb (findCommandHelp("skill", "search"),
245
+ // findCommandHelp("group", "create")) or a single-token lookup for a
246
+ // top-level command (findCommandHelp("whoami")).
151
247
  export function findCommandHelp(command, subcommand) {
152
- if (command === "skill" && subcommand !== undefined) {
248
+ const entityList = ENTITY_COMMANDS_HELP[command];
249
+ if (entityList && subcommand !== undefined) {
153
250
  const resolved = COMMAND_ALIASES[subcommand] ?? subcommand;
154
- return SKILL_COMMANDS_HELP.find((c) => c.usage.startsWith(`ahood skill ${resolved} `) || c.usage === `ahood skill ${resolved}`);
251
+ return entityList.find((c) => c.usage.startsWith(`ahood ${command} ${resolved} `) || c.usage === `ahood ${command} ${resolved}`);
155
252
  }
156
253
  const resolved = COMMAND_ALIASES[command] ?? command;
157
254
  return TOP_LEVEL_COMMANDS_HELP.find((c) => c.usage.startsWith(`ahood ${resolved} `) || c.usage === `ahood ${resolved}`);
@@ -161,11 +258,11 @@ export function findCommandHelp(command, subcommand) {
161
258
  // -> "ahood skill view|show <owner>/<skill> ...". Used by the --help listings
162
259
  // so aliases (like "show" for "view") aren't invisible to users who only
163
260
  // skim `ahood --help` / `ahood skill --help`. The alias-carrying word is
164
- // parts[2] for a skill-entity usage string ("ahood skill <verb> ...") and
165
- // parts[1] for a top-level one ("ahood <command> ...").
261
+ // parts[2] for an entity usage string ("ahood skill <verb> ..." / "ahood
262
+ // group <verb> ...") and parts[1] for a top-level one ("ahood <command> ...").
166
263
  export function usageWithAliases(entry) {
167
264
  const parts = entry.usage.split(" ");
168
- const idx = parts[1] === "skill" ? 2 : 1;
265
+ const idx = ENTITY_COMMANDS_HELP[parts[1]] ? 2 : 1;
169
266
  const name = parts[idx];
170
267
  const aliases = Object.keys(COMMAND_ALIASES).filter((alias) => COMMAND_ALIASES[alias] === name);
171
268
  if (aliases.length === 0)
@@ -194,16 +291,32 @@ export function formatSkillHelp() {
194
291
  "Run `ahood skill <command> --help` for a single command's flags and examples.",
195
292
  ].join("\n");
196
293
  }
294
+ // `ahood group --help` -- the group-level listing for every group verb.
295
+ export function formatGroupHelp() {
296
+ const width = Math.max(...GROUP_COMMANDS_HELP.map((c) => usageWithAliases(c).length));
297
+ const lines = GROUP_COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`);
298
+ return [
299
+ "ahood group -- create private groups, invite members, and share skills with them",
300
+ "",
301
+ "Commands:",
302
+ ...lines,
303
+ "",
304
+ "Run `ahood group <command> --help` for a single command's flags and examples.",
305
+ ].join("\n");
306
+ }
197
307
  // `ahood --help` -- the top-level listing: account commands rendered in
198
- // full, plus a single summary line pointing at `ahood skill --help` for
199
- // the (much longer) skill-entity command list.
308
+ // full, plus a single summary line each pointing at `ahood skill --help`
309
+ // and `ahood group --help` for their (much longer) entity command lists.
200
310
  export function formatHelp() {
201
311
  const skillGroupUsage = "ahood skill <command>";
202
312
  const skillGroupSummary = "Search, install, and publish skills -- run `ahood skill --help` for the full list.";
203
- const width = Math.max(...TOP_LEVEL_COMMANDS_HELP.map((c) => usageWithAliases(c).length), skillGroupUsage.length);
313
+ const groupGroupUsage = "ahood group <command>";
314
+ const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
315
+ const width = Math.max(...TOP_LEVEL_COMMANDS_HELP.map((c) => usageWithAliases(c).length), skillGroupUsage.length, groupGroupUsage.length);
204
316
  const lines = [
205
317
  ...TOP_LEVEL_COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`),
206
318
  ` ${skillGroupUsage.padEnd(width + 2)}${skillGroupSummary}`,
319
+ ` ${groupGroupUsage.padEnd(width + 2)}${groupGroupSummary}`,
207
320
  ];
208
321
  return [
209
322
  "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
@@ -218,6 +331,7 @@ export function formatHelp() {
218
331
  "",
219
332
  "Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
220
333
  "Run `ahood skill --help` for the full list of skill commands.",
334
+ "Run `ahood group --help` for the full list of group commands.",
221
335
  "Run `ahood --version` to print the installed CLI version.",
222
336
  "",
223
337
  "Exit codes: 0 success, 1 general error, 2 usage/validation error,",
package/dist/index.js CHANGED
@@ -13,11 +13,14 @@ import { edit } from "./commands/edit.js";
13
13
  import { unpublish } from "./commands/unpublish.js";
14
14
  import { listSkills } from "./commands/list.js";
15
15
  import { star, unstar } from "./commands/star.js";
16
+ import { share, unshare } from "./commands/share.js";
16
17
  import { view } from "./commands/view.js";
18
+ import { read } from "./commands/read.js";
17
19
  import { versions } from "./commands/versions.js";
18
20
  import { completion } from "./commands/completion.js";
19
21
  import { init } from "./commands/init.js";
20
- import { formatHelp, formatSkillHelp, formatCommandHelp, findCommandHelp } from "./help.js";
22
+ import { createGroup, listGroups, groupMembers, inviteLink, joinGroup, removeMember, leaveGroup, deleteGroup, } from "./commands/group.js";
23
+ import { formatHelp, formatSkillHelp, formatGroupHelp, formatCommandHelp, findCommandHelp } from "./help.js";
21
24
  import { ApiError } from "./http.js";
22
25
  import { exitCodeFor } from "./exit-code.js";
23
26
  import { CLI_NAME, CLI_VERSION } from "./version.js";
@@ -27,6 +30,7 @@ const SKILL_COMMANDS = {
27
30
  search,
28
31
  view,
29
32
  show: view,
33
+ read,
30
34
  versions,
31
35
  list: listSkills,
32
36
  add,
@@ -36,13 +40,29 @@ const SKILL_COMMANDS = {
36
40
  unpublish,
37
41
  star,
38
42
  unstar,
43
+ share,
44
+ unshare,
39
45
  init,
40
46
  publish,
41
47
  };
48
+ // Every group-entity verb, reached only as `ahood group <verb>` -- see
49
+ // dispatchGroup() below. Named GROUP_VERBS (not GROUP_COMMANDS) specifically
50
+ // to avoid colliding with the pre-existing GROUP_COMMANDS Set below, which
51
+ // means something unrelated ("entities that own their own sub-dispatch").
52
+ const GROUP_VERBS = {
53
+ create: createGroup,
54
+ list: listGroups,
55
+ members: groupMembers,
56
+ "invite-link": inviteLink,
57
+ join: joinGroup,
58
+ "remove-member": removeMember,
59
+ leave: leaveGroup,
60
+ delete: deleteGroup,
61
+ };
42
62
  // Top level is now just account/auth-scoped commands (not entity-specific --
43
63
  // same reasoning `gh auth login` isn't `gh account login`) plus the `skill`
44
- // entity group. Future entities (e.g. "personality", "protocols") get their
45
- // own entry here alongside "skill".
64
+ // and `group` entity groups. Future entities (e.g. "personality",
65
+ // "protocols") get their own entry here alongside these two.
46
66
  const COMMANDS = {
47
67
  login: () => login(),
48
68
  logout: () => logout(),
@@ -50,12 +70,16 @@ const COMMANDS = {
50
70
  token,
51
71
  completion,
52
72
  skill: dispatchSkill,
73
+ group: dispatchGroup,
53
74
  };
54
75
  // Commands whose handler owns its own --help handling (at both the group
55
- // level, e.g. `ahood skill --help`, and the per-verb level, e.g.
56
- // `ahood skill add --help`) instead of the generic top-level interception
57
- // in main().
58
- const GROUP_COMMANDS = new Set(["skill"]);
76
+ // level, e.g. `ahood skill --help` / `ahood group --help`, and the per-verb
77
+ // level, e.g. `ahood skill add --help` / `ahood group create --help`)
78
+ // instead of the generic top-level interception in main(). Despite the
79
+ // name, this Set has nothing to do with the "Groups" feature -- "group"
80
+ // here just means "a command that owns its own sub-verb dispatch", the same
81
+ // sense "skill" already used before "group" (the entity) existed.
82
+ const GROUP_COMMANDS = new Set(["skill", "group"]);
59
83
  function levenshtein(a, b) {
60
84
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
61
85
  for (let i = 0; i <= a.length; i++)
@@ -87,6 +111,9 @@ function closestCommand(input) {
87
111
  function closestSkillCommand(input) {
88
112
  return closestOf(input, Object.keys(SKILL_COMMANDS));
89
113
  }
114
+ function closestGroupVerb(input) {
115
+ return closestOf(input, Object.keys(GROUP_VERBS));
116
+ }
90
117
  // `ahood skill <verb> [...]` -- owns its own help handling at both the
91
118
  // group level (`ahood skill` / `ahood skill --help`) and the per-verb level
92
119
  // (`ahood skill <verb> --help`), since neither should fall through to
@@ -113,6 +140,31 @@ async function dispatchSkill(args) {
113
140
  }
114
141
  await handler(rest);
115
142
  }
143
+ // `ahood group <verb> [...]` -- owns its own help handling at both the
144
+ // group level (`ahood group` / `ahood group --help`) and the per-verb level
145
+ // (`ahood group <verb> --help`), exactly mirroring dispatchSkill() above.
146
+ async function dispatchGroup(args) {
147
+ const [sub, ...rest] = args;
148
+ if (!sub || sub === "--help" || sub === "-h") {
149
+ console.log(formatGroupHelp());
150
+ return;
151
+ }
152
+ const handler = GROUP_VERBS[sub];
153
+ if (!handler) {
154
+ console.error(`Unknown group command: ${sub}`);
155
+ const suggestion = closestGroupVerb(sub);
156
+ if (suggestion)
157
+ console.error(`Did you mean '${suggestion}'?`);
158
+ console.error("Run `ahood group --help` for a list of commands.");
159
+ process.exit(2);
160
+ }
161
+ if (rest.includes("--help") || rest.includes("-h")) {
162
+ const entry = findCommandHelp("group", sub);
163
+ console.log(entry ? formatCommandHelp(entry) : formatGroupHelp());
164
+ return;
165
+ }
166
+ await handler(rest);
167
+ }
116
168
  async function main() {
117
169
  const [command, ...args] = process.argv.slice(2);
118
170
  if (command === "--version" || command === "-v") {
@@ -127,6 +179,12 @@ async function main() {
127
179
  console.log(entry ? formatCommandHelp(entry) : formatSkillHelp());
128
180
  return;
129
181
  }
182
+ if (sub === "group") {
183
+ const verb = args[1];
184
+ const entry = verb ? findCommandHelp("group", verb) : undefined;
185
+ console.log(entry ? formatCommandHelp(entry) : formatGroupHelp());
186
+ return;
187
+ }
130
188
  const entry = sub ? findCommandHelp(sub) : undefined;
131
189
  console.log(entry ? formatCommandHelp(entry) : formatHelp());
132
190
  return;
@@ -189,4 +247,4 @@ if (isEntrypoint) {
189
247
  main();
190
248
  }
191
249
  // Exported for tests only -- the CLI itself only ever calls main() above.
192
- export { main, dispatchSkill, COMMANDS, SKILL_COMMANDS };
250
+ export { main, dispatchSkill, dispatchGroup, COMMANDS, SKILL_COMMANDS, GROUP_VERBS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",