hubskillz 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -12
  3. package/dist/index.js +639 -135
  4. package/package.json +28 -13
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jérôme Desmares
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,41 +1,69 @@
1
1
  # hubskillz
2
2
 
3
- Keep the Claude Code skills on your machine in sync with your organization's approved catalog.
3
+ [![npm](https://img.shields.io/npm/v/hubskillz)](https://www.npmjs.com/package/hubskillz)
4
+ [![CI](https://github.com/JejeDurden2/hubskillz-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/JejeDurden2/hubskillz-cli/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/hubskillz)](https://github.com/JejeDurden2/hubskillz-cli/blob/main/LICENSE)
6
+
7
+ Keep the Claude Code skills on your machine in sync with your organization's approved directory.
8
+
9
+ `hubskillz` inventories `~/.claude/skills` and `<repo>/.claude/skills`, reports the state of every skill against the directory, and installs the approved versions after showing you the plan. Skills you customized are never overwritten unless you ask.
4
10
 
5
11
  ## Install
6
12
 
7
13
  ```sh
8
14
  npm i -g hubskillz
9
- # or run without installing
10
- npx hubskillz --help
15
+ # or without installing
16
+ npx hubskillz help
11
17
  ```
12
18
 
13
19
  Requires Node 22 or newer.
14
20
 
21
+ ## Quickstart
22
+
23
+ ```sh
24
+ hubskillz login # paste a device token from Settings, Device tokens
25
+ hubskillz status # what is installed, and how it compares
26
+ hubskillz sync --all # install the approved set, adopt what the directory lacks
27
+ ```
28
+
29
+ The bare `hubskillz` command prints this quickstart, with your progress, until the first sync completes.
30
+
15
31
  ## Commands
16
32
 
17
33
  ```
18
- hubskillz login [--base-url URL]
19
- hubskillz status [--path DIR]
20
- hubskillz sync [--path DIR] [--all] [--yes] [--dry-run] [--force]
21
- hubskillz push <skill-dir> [-m MESSAGE]
34
+ hubskillz login [--token TOKEN] [--base-url URL]
35
+ hubskillz logout
36
+ hubskillz status [--path DIR] [--yes]
37
+ hubskillz sync [--path DIR] [--all] [--adopt] [--yes] [--dry-run] [--force]
38
+ hubskillz push <skill-dir> [-m MESSAGE]
39
+ hubskillz projects [add|remove [DIR] | discover [--yes] | list]
40
+ hubskillz help [command]
22
41
  hubskillz --version
23
42
  ```
24
43
 
44
+ Every command accepts `--help`.
45
+
25
46
  ### login
26
47
 
27
- Prompts for a device token (create one in the web app at `/app/settings/tokens`), verifies it against the server and writes it to the config file.
48
+ Prompts for a device token (create one in the web app at `/app/settings/tokens`), verifies it against the server and writes it to the config file. `--token` skips the prompt. When stdin is a pipe, the token is read from it.
49
+
50
+ ### logout
51
+
52
+ Removes the config file, so the token and the registered projects.
28
53
 
29
54
  ### status
30
55
 
31
- Scans the local skills root, sends the inventory (names and content hashes only, never file contents) and prints the state of every skill: `synced`, `drifted`, `customized`, `missing` or `unmanaged`.
56
+ Scans the global root and every registered project (running `projects discover` first when none is registered), sends each inventory and prints the state of every skill: `synced`, `drifted`, `customized`, `missing` or `unmanaged`. Reads only.
57
+
58
+ What the inventory contains: for every skill, its name, content hash and per-file hashes and sizes. Skills installed from skills.sh (found in the lock file) travel as hashes only, the server fetches their content upstream. Private skills (no upstream) also ship the content of their files, up to 200,000 characters per skill, so the web app can adopt them into the directory. The server keeps that snapshot with the installation and replaces it on every new inventory.
32
59
 
33
60
  ### sync
34
61
 
35
62
  Installs missing skills and updates drifted ones to the approved version. Shows the plan first and asks for confirmation. Skills you changed locally are left untouched unless you pass `--force`.
36
63
 
37
64
  - `--path DIR`: project directory to act on. Default: the current directory.
38
- - `--all`: act on the global root and the project root. Default: the project root when `DIR/.claude/skills` exists, else the global root.
65
+ - `--all`: act on the global root, the project root and every registered project. With nothing registered yet, it first scans your home directory for repos with `.claude/skills` and asks which ones to register (see `projects discover`). Default: the project root when `DIR/.claude/skills` exists, else the global root.
66
+ - `--adopt`: add every importable skill found on this surface to the directory as an approved version, without asking. Importable means installed here and absent from the directory: an upstream skill is pinned at its installed hash, a private skill is created from its snapshot. Maintainer only, other roles get a notice and keep syncing. Without the flag, an interactive run asks the same question once; `--yes` alone answers no and `--dry-run` skips it.
39
67
  - `--yes`, `-y`: apply without asking.
40
68
  - `--dry-run`: print the plan and stop.
41
69
  - `--force`: overwrite locally customized skills.
@@ -44,13 +72,45 @@ Installs missing skills and updates drifted ones to the approved version. Shows
44
72
 
45
73
  Uploads a skill directory as a draft version for review. `-m`, `--message` attaches a message to the draft.
46
74
 
75
+ ### projects
76
+
77
+ Registers repos so `sync --all` and `status` cover them from anywhere, in one run. `add` records the current directory (or `DIR`), which must contain `.claude/skills`; `remove` forgets it; `list` prints them; `discover` scans your home directory (three levels deep, skipping `node_modules`, `.git`, `Library` and hidden folders) for repos with `.claude/skills` and lets you pick which ones to register, `--yes` registers them all. Without a TTY the prompt selects everything. A registered path that no longer has a skills root is skipped with a note.
78
+
47
79
  ## Surfaces
48
80
 
49
81
  - Global root: `~/.claude/skills`
50
82
  - Project root: `<project>/.claude/skills`
51
83
 
84
+ A symlinked skill directory (the skills.sh canonical copy under `~/.agents/skills`) is written through, so every agent on the machine sees the approved version. Links that leave your home directory are refused.
85
+
86
+ ## CI and containers
87
+
88
+ Set `HUBSKILLZ_TOKEN` and no config file is needed:
89
+
90
+ ```sh
91
+ HUBSKILLZ_TOKEN=... npx hubskillz sync --all --yes
92
+ ```
93
+
94
+ Prompts are skipped when stdin is not a TTY: `sync` needs `--yes` to apply, `projects discover` registers everything found.
95
+
52
96
  ## Configuration
53
97
 
54
- `~/.hubskillz/config.json` stores `{ "baseUrl", "token", "machineId" }`. It is written by `login`.
98
+ | Source | What |
99
+ | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | `~/.hubskillz/config.json` | `{ "baseUrl", "token", "machineId", "projects", "firstSyncAt" }`, mode `0600`. `login` writes it, `hubskillz projects` edits `projects`, the first completed `sync` sets `firstSyncAt`. |
101
+ | `HUBSKILLZ_TOKEN` | Device token. Takes precedence over the file. |
102
+ | `HUBSKILLZ_BASE_URL` | Server URL for self-hosted instances. |
103
+ | `NO_COLOR` | Disables colours. Colours are also off when stdout is not a TTY. |
104
+
105
+ The server URL resolves in this order: `--base-url` flag, `HUBSKILLZ_BASE_URL`, the config file, then `https://hubskillz.com`.
106
+
107
+ ## Exit codes
108
+
109
+ `0` on success, `1` on any failure. The failure is one line on stderr, prefixed with `hubskillz:`.
110
+
111
+ ## Links
55
112
 
56
- The server URL resolves in this order: `--base-url` flag, `HUBSKILLZ_BASE_URL` environment variable, the config file, then `https://hubskillz.com`.
113
+ - Docs: https://hubskillz.com/docs/cli
114
+ - Changelog: https://github.com/JejeDurden2/hubskillz-cli/blob/main/CHANGELOG.md
115
+ - Issues: https://github.com/JejeDurden2/hubskillz-cli/issues
116
+ - License: MIT
package/dist/index.js CHANGED
@@ -19,6 +19,14 @@ function contentHash(files) {
19
19
  return hash2.digest("hex");
20
20
  }
21
21
 
22
+ // ../shared/src/cli/skill-file.ts
23
+ var BINARY = /\.(png|jpe?g|gif|webp|svg|ico|pdf|zip|gz|tar|woff2?|ttf|otf|mp[34]|wav|bin|exe|dylib|so)$/i;
24
+ var SKIPPED = /* @__PURE__ */ new Set(["node_modules", ".git"]);
25
+ function isSkillFile(path) {
26
+ const segments = path.split("/");
27
+ return !segments.some((s) => s.startsWith(".") || SKIPPED.has(s)) && !BINARY.test(path);
28
+ }
29
+
22
30
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
23
31
  var external_exports = {};
24
32
  __export(external_exports, {
@@ -14550,10 +14558,12 @@ var skillFilePathSchema = external_exports.string().min(1).max(200).refine(
14550
14558
  var MAX_FILES_PER_SKILL = 100;
14551
14559
  var MAX_FILE_CONTENT_CHARS = 2e5;
14552
14560
  var MAX_SKILLS_PER_REQUEST = 500;
14561
+ var MAX_SNAPSHOT_CHARS = MAX_FILE_CONTENT_CHARS;
14553
14562
  var inventoryFileSchema = external_exports.object({
14554
14563
  path: skillFilePathSchema,
14555
14564
  hash: external_exports.string().min(1),
14556
- size: external_exports.number().int().nonnegative()
14565
+ size: external_exports.number().int().nonnegative(),
14566
+ content: external_exports.string().max(MAX_FILE_CONTENT_CHARS).optional()
14557
14567
  });
14558
14568
  var skillFileSchema = external_exports.object({
14559
14569
  path: skillFilePathSchema,
@@ -14631,6 +14641,11 @@ var approvedSkillSchema = external_exports.object({
14631
14641
  var approvedResponseSchema = external_exports.object({
14632
14642
  skills: external_exports.array(approvedSkillSchema)
14633
14643
  });
14644
+ var adoptRequestSchema = external_exports.object({ surfaceId: external_exports.string().min(1) });
14645
+ var adoptResponseSchema = external_exports.object({
14646
+ adopted: external_exports.array(external_exports.string()),
14647
+ skipped: external_exports.array(external_exports.object({ name: external_exports.string(), code: external_exports.string() }))
14648
+ });
14634
14649
  var draftRequestSchema = external_exports.object({
14635
14650
  name: skillNameSchema,
14636
14651
  files: external_exports.array(skillFileSchema).min(1).max(MAX_FILES_PER_SKILL),
@@ -14744,7 +14759,7 @@ async function apiRequest(request) {
14744
14759
  const message = decodeBody(text, apiErrorSchema)?.message ?? `${response.status} ${response.statusText}`;
14745
14760
  return Result.fail(
14746
14761
  new CliError(
14747
- response.status === 401 ? "UNAUTHORIZED" : "HTTP",
14762
+ response.status === 401 ? "UNAUTHORIZED" : response.status === 403 ? "FORBIDDEN" : "HTTP",
14748
14763
  `${request.method} ${request.path} failed: ${message}`
14749
14764
  )
14750
14765
  );
@@ -14767,14 +14782,18 @@ function decodeBody(text, schema) {
14767
14782
 
14768
14783
  // src/config.ts
14769
14784
  import { randomUUID } from "node:crypto";
14770
- import { mkdir, readFile, writeFile } from "node:fs/promises";
14771
- import { homedir } from "node:os";
14785
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
14786
+ import { homedir, hostname as hostname3 } from "node:os";
14772
14787
  import { join } from "node:path";
14773
14788
  var DEFAULT_BASE_URL = "https://hubskillz.com";
14774
14789
  var configSchema = external_exports.object({
14775
14790
  baseUrl: external_exports.string().min(1),
14776
14791
  token: external_exports.string().min(1),
14777
- machineId: external_exports.string().min(1)
14792
+ machineId: external_exports.string().min(1),
14793
+ /** Absolute repo paths that `--all` includes next to the global root. */
14794
+ projects: external_exports.array(external_exports.string()).default([]),
14795
+ /** ISO date of the first completed `sync`. Absent until then: the quickstart shows. */
14796
+ firstSyncAt: external_exports.string().optional()
14778
14797
  });
14779
14798
  function configDir() {
14780
14799
  return join(homedir(), ".hubskillz");
@@ -14783,16 +14802,30 @@ function configPath() {
14783
14802
  return join(configDir(), "config.json");
14784
14803
  }
14785
14804
  async function readConfig() {
14805
+ const envToken = process.env["HUBSKILLZ_TOKEN"];
14786
14806
  let raw;
14787
14807
  try {
14788
14808
  raw = await readFile(configPath(), "utf8");
14789
14809
  } catch {
14810
+ if (envToken !== void 0 && envToken !== "") {
14811
+ return Result.ok({
14812
+ baseUrl: resolveBaseUrl(void 0),
14813
+ token: envToken,
14814
+ machineId: hostname3(),
14815
+ projects: []
14816
+ });
14817
+ }
14790
14818
  return Result.fail(
14791
14819
  new CliError("NOT_SIGNED_IN", "Not signed in. Run `hubskillz login`.")
14792
14820
  );
14793
14821
  }
14794
- const parsed = configSchema.safeParse(JSON.parse(raw));
14795
- if (!parsed.success) {
14822
+ let parsed = null;
14823
+ try {
14824
+ parsed = configSchema.safeParse(JSON.parse(raw));
14825
+ } catch {
14826
+ parsed = null;
14827
+ }
14828
+ if (parsed === null || !parsed.success) {
14796
14829
  return Result.fail(
14797
14830
  new CliError(
14798
14831
  "BAD_CONFIG",
@@ -14800,7 +14833,17 @@ async function readConfig() {
14800
14833
  )
14801
14834
  );
14802
14835
  }
14803
- return Result.ok(parsed.data);
14836
+ return Result.ok(
14837
+ envToken === void 0 || envToken === "" ? parsed.data : { ...parsed.data, token: envToken }
14838
+ );
14839
+ }
14840
+ async function deleteConfig() {
14841
+ try {
14842
+ await rm(configPath());
14843
+ return true;
14844
+ } catch {
14845
+ return false;
14846
+ }
14804
14847
  }
14805
14848
  async function writeConfig(config2) {
14806
14849
  await mkdir(configDir(), { recursive: true, mode: 448 });
@@ -14913,6 +14956,66 @@ async function readAll(stream) {
14913
14956
  }
14914
14957
  return Buffer.concat(chunks).toString("utf8");
14915
14958
  }
14959
+ function parseSelection(answer, count) {
14960
+ const text = answer.trim().toLowerCase();
14961
+ if (text === "" || text === "all" || text === "a") {
14962
+ return Array.from({ length: count }, (_, index) => index);
14963
+ }
14964
+ if (text === "none" || text === "n") return [];
14965
+ const picked = /* @__PURE__ */ new Set();
14966
+ for (const token of text.split(/[\s,]+/u)) {
14967
+ const index = Number(token) - 1;
14968
+ if (Number.isInteger(index) && index >= 0 && index < count) {
14969
+ picked.add(index);
14970
+ }
14971
+ }
14972
+ return [...picked].sort((left, right) => left - right);
14973
+ }
14974
+ async function selectMany(question, choices) {
14975
+ if (process.stdin.isTTY !== true) return choices;
14976
+ process.stdout.write(`${question}
14977
+ `);
14978
+ choices.forEach((choice, index) => {
14979
+ process.stdout.write(` ${index + 1}. ${choice}
14980
+ `);
14981
+ });
14982
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
14983
+ try {
14984
+ const answer = await rl.question("Select [all]: ");
14985
+ const picked = new Set(parseSelection(answer, choices.length));
14986
+ return choices.filter((_, index) => picked.has(index));
14987
+ } finally {
14988
+ rl.close();
14989
+ }
14990
+ }
14991
+
14992
+ // src/quickstart.ts
14993
+ function quickstart(config2) {
14994
+ const signedIn = config2.isSuccess;
14995
+ const synced = signedIn && config2.value.firstSyncAt !== void 0;
14996
+ const step = (done, text, command) => ` ${done ? dim("[x]") : "[ ]"} ${done ? dim(text) : text}
14997
+ ${dim("$")} ${done ? dim(command) : bold(command)}
14998
+ `;
14999
+ return `${bold("Quickstart")}
15000
+ ` + step(
15001
+ signedIn,
15002
+ "Sign in with a device token (create one under Settings, Device tokens)",
15003
+ "hubskillz login"
15004
+ ) + step(
15005
+ synced,
15006
+ "See what is installed on this machine and how it compares to the directory",
15007
+ "hubskillz status"
15008
+ ) + step(
15009
+ synced,
15010
+ "Install the approved set everywhere, adopt what the directory lacks",
15011
+ "hubskillz sync --all"
15012
+ ) + `
15013
+ ${dim("Run `hubskillz help` for every command, `hubskillz help <command>` for its flags.")}
15014
+ `;
15015
+ }
15016
+ function quickstartPending(config2) {
15017
+ return config2.isFailure || config2.value.firstSyncAt === void 0;
15018
+ }
14916
15019
 
14917
15020
  // src/commands/login.ts
14918
15021
  async function login(options) {
@@ -14921,9 +15024,12 @@ async function login(options) {
14921
15024
  options.baseUrl,
14922
15025
  existing.isSuccess ? existing.value.baseUrl : void 0
14923
15026
  );
14924
- process.stdout.write(`${dim(`Signing in to ${baseUrl}`)}
14925
- `);
14926
- const token = await promptSecret("Device token: ");
15027
+ process.stdout.write(
15028
+ `${dim(`Signing in to ${baseUrl}`)}
15029
+ ${dim(`Create a device token at ${baseUrl}/app/settings/tokens`)}
15030
+ `
15031
+ );
15032
+ const token = options.token ?? await promptSecret("Device token: ");
14927
15033
  if (token === "") {
14928
15034
  return Result.fail(new CliError("EMPTY_TOKEN", "No device token given."));
14929
15035
  }
@@ -14934,19 +15040,51 @@ async function login(options) {
14934
15040
  schema: meResponseSchema
14935
15041
  });
14936
15042
  if (me.isFailure) return Result.fail(me.error);
14937
- await writeConfig({ baseUrl, token, machineId: await machineId() });
15043
+ const config2 = {
15044
+ baseUrl,
15045
+ token,
15046
+ machineId: await machineId(),
15047
+ projects: existing.isSuccess ? existing.value.projects : []
15048
+ };
15049
+ if (existing.isSuccess && existing.value.firstSyncAt !== void 0) {
15050
+ config2.firstSyncAt = existing.value.firstSyncAt;
15051
+ }
15052
+ await writeConfig(config2);
14938
15053
  process.stdout.write(
14939
15054
  `Signed in as ${me.value.user.email} on ${me.value.org.name}.
14940
15055
  ${dim(`Token written to ${configPath()}`)}
15056
+
14941
15057
  `
14942
15058
  );
15059
+ if (config2.firstSyncAt === void 0) {
15060
+ process.stdout.write(quickstart(Result.ok(config2)));
15061
+ }
14943
15062
  return Result.ok(void 0);
14944
15063
  }
14945
15064
 
15065
+ // src/commands/logout.ts
15066
+ async function logout() {
15067
+ const removed = await deleteConfig();
15068
+ process.stdout.write(
15069
+ removed ? `Signed out. ${dim(`Removed ${configPath()}`)}
15070
+ ` : `${dim("Not signed in.")}
15071
+ `
15072
+ );
15073
+ return Result.ok(void 0);
15074
+ }
15075
+
15076
+ // src/commands/projects.ts
15077
+ import { resolve as resolve4 } from "node:path";
15078
+
15079
+ // src/discover.ts
15080
+ import { readdir as readdir2 } from "node:fs/promises";
15081
+ import { homedir as homedir4 } from "node:os";
15082
+ import { join as join4, resolve as resolve3 } from "node:path";
15083
+
14946
15084
  // src/scan.ts
14947
15085
  import { createHash as createHash2 } from "node:crypto";
14948
15086
  import { readdir, readFile as readFile3, realpath, stat } from "node:fs/promises";
14949
- import { homedir as homedir3, hostname as hostname3 } from "node:os";
15087
+ import { homedir as homedir3, hostname as hostname4 } from "node:os";
14950
15088
  import { basename, dirname, join as join3, resolve as resolve2 } from "node:path";
14951
15089
 
14952
15090
  // src/lock.ts
@@ -14992,7 +15130,6 @@ async function readLock(path) {
14992
15130
  }
14993
15131
 
14994
15132
  // src/scan.ts
14995
- var SKIPPED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
14996
15133
  function globalSkillsRoot() {
14997
15134
  return join3(homedir3(), ".claude", "skills");
14998
15135
  }
@@ -15000,10 +15137,10 @@ function projectSkillsRoot(dir) {
15000
15137
  return join3(resolve2(dir), ".claude", "skills");
15001
15138
  }
15002
15139
  function globalSurfaceLabel() {
15003
- return hostname3();
15140
+ return hostname4();
15004
15141
  }
15005
15142
  function projectSurfaceLabel(dir) {
15006
- return `${hostname3()}:${basename(resolve2(dir))}`;
15143
+ return `${hostname4()}:${basename(resolve2(dir))}`;
15007
15144
  }
15008
15145
  async function exists(path) {
15009
15146
  try {
@@ -15050,7 +15187,7 @@ async function scanSkills(root) {
15050
15187
  }
15051
15188
  const skills = [];
15052
15189
  for (const entry of entries) {
15053
- if (entry.name.startsWith(".") || SKIPPED_DIRS.has(entry.name)) continue;
15190
+ if (!isSkillFile(entry.name)) continue;
15054
15191
  const dir = join3(root, entry.name);
15055
15192
  const symlink = entry.isSymbolicLink();
15056
15193
  if (!entry.isDirectory() && !(symlink && await isDir(dir))) continue;
@@ -15080,9 +15217,9 @@ async function walk(dir, prefix, visited) {
15080
15217
  let link = false;
15081
15218
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
15082
15219
  for (const entry of entries) {
15083
- if (entry.name.startsWith(".") || SKIPPED_DIRS.has(entry.name)) continue;
15084
15220
  const full = join3(dir, entry.name);
15085
15221
  const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
15222
+ if (!isSkillFile(relative)) continue;
15086
15223
  const symlink = entry.isSymbolicLink();
15087
15224
  if (entry.isDirectory() || symlink && await isDir(full)) {
15088
15225
  const nested = await walk(full, relative, visited);
@@ -15092,7 +15229,9 @@ async function walk(dir, prefix, visited) {
15092
15229
  }
15093
15230
  if (!entry.isFile() && !symlink) continue;
15094
15231
  const content = await readFile3(full, "utf8").catch(() => null);
15095
- if (content === null) continue;
15232
+ if (content === null || content.includes("\0") || content.length > MAX_FILE_CONTENT_CHARS) {
15233
+ continue;
15234
+ }
15096
15235
  files.push({
15097
15236
  path: relative,
15098
15237
  content,
@@ -15109,14 +15248,19 @@ function inventoryRequestOf(surface) {
15109
15248
  return {
15110
15249
  surface: surface.descriptor,
15111
15250
  skills: surface.skills.map((skill) => {
15251
+ const snapshot = skill.upstream === void 0 && skill.files.reduce((sum, file2) => sum + file2.content.length, 0) <= MAX_SNAPSHOT_CHARS;
15112
15252
  const item = {
15113
15253
  name: skill.name,
15114
15254
  contentHash: skill.contentHash,
15115
- files: skill.files.map((file2) => ({
15116
- path: file2.path,
15117
- hash: file2.hash,
15118
- size: file2.size
15119
- }))
15255
+ files: skill.files.map((file2) => {
15256
+ const entry = {
15257
+ path: file2.path,
15258
+ hash: file2.hash,
15259
+ size: file2.size
15260
+ };
15261
+ if (snapshot) entry.content = file2.content;
15262
+ return entry;
15263
+ })
15120
15264
  };
15121
15265
  if (skill.upstream !== void 0) item.upstream = skill.upstream;
15122
15266
  return item;
@@ -15124,6 +15268,120 @@ function inventoryRequestOf(surface) {
15124
15268
  };
15125
15269
  }
15126
15270
 
15271
+ // src/discover.ts
15272
+ var SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "Library", ".Trash", ".cache"]);
15273
+ async function discoverProjects(roots = [homedir4()], maxDepth = 3) {
15274
+ const found = /* @__PURE__ */ new Set();
15275
+ const walk2 = async (dir, depth) => {
15276
+ if (depth > maxDepth) return;
15277
+ let entries;
15278
+ try {
15279
+ entries = await readdir2(dir, { withFileTypes: true });
15280
+ } catch {
15281
+ return;
15282
+ }
15283
+ for (const entry of entries) {
15284
+ const name = entry.name;
15285
+ if (!entry.isDirectory() || SKIP.has(name)) continue;
15286
+ if (name.startsWith(".") && name !== ".claude") continue;
15287
+ const child = join4(dir, name);
15288
+ if (name === ".claude" && depth > 0) {
15289
+ if (await exists(projectSkillsRoot(dir))) found.add(dir);
15290
+ continue;
15291
+ }
15292
+ await walk2(child, depth + 1);
15293
+ }
15294
+ };
15295
+ for (const root of roots) await walk2(resolve3(root), 0);
15296
+ return [...found].sort();
15297
+ }
15298
+
15299
+ // src/commands/projects.ts
15300
+ async function discoverAndRegister(config2, yes) {
15301
+ const known = new Set(config2.projects);
15302
+ const found = (await discoverProjects()).filter((dir) => !known.has(dir));
15303
+ if (found.length === 0) {
15304
+ process.stdout.write(dim("No new project with .claude/skills found.\n"));
15305
+ return config2.projects;
15306
+ }
15307
+ const chosen = yes ? found : await selectMany("Projects with skills found. Register:", found);
15308
+ if (chosen.length === 0) return config2.projects;
15309
+ const projects2 = [...config2.projects, ...chosen];
15310
+ await writeConfig({ ...config2, projects: projects2 });
15311
+ process.stdout.write(
15312
+ `Registered ${chosen.length} project${chosen.length === 1 ? "" : "s"}. They are now part of \`hubskillz sync --all\`.
15313
+ `
15314
+ );
15315
+ return projects2;
15316
+ }
15317
+ async function projects(options) {
15318
+ const config2 = await readConfig();
15319
+ if (config2.isFailure) return Result.fail(config2.error);
15320
+ const current = config2.value.projects;
15321
+ const dir = resolve4(options.dir ?? process.cwd());
15322
+ switch (options.action ?? "list") {
15323
+ case "list": {
15324
+ if (current.length === 0) {
15325
+ process.stdout.write(
15326
+ dim(
15327
+ "No registered projects. Run `hubskillz projects add` inside a repo.\n"
15328
+ )
15329
+ );
15330
+ return Result.ok(void 0);
15331
+ }
15332
+ for (const project of current) process.stdout.write(`${project}
15333
+ `);
15334
+ return Result.ok(void 0);
15335
+ }
15336
+ case "add": {
15337
+ if (!await exists(projectSkillsRoot(dir))) {
15338
+ return Result.fail(
15339
+ new CliError(
15340
+ "NO_SKILLS_ROOT",
15341
+ `${dir} has no .claude/skills directory.`
15342
+ )
15343
+ );
15344
+ }
15345
+ if (current.includes(dir)) {
15346
+ process.stdout.write(dim(`${dir} is already registered.
15347
+ `));
15348
+ return Result.ok(void 0);
15349
+ }
15350
+ await writeConfig({ ...config2.value, projects: [...current, dir] });
15351
+ process.stdout.write(
15352
+ `Registered ${dir}. It is now part of \`hubskillz sync --all\`.
15353
+ `
15354
+ );
15355
+ return Result.ok(void 0);
15356
+ }
15357
+ case "discover": {
15358
+ await discoverAndRegister(config2.value, options.yes);
15359
+ return Result.ok(void 0);
15360
+ }
15361
+ case "remove": {
15362
+ if (!current.includes(dir)) {
15363
+ return Result.fail(
15364
+ new CliError("NOT_REGISTERED", `${dir} is not registered.`)
15365
+ );
15366
+ }
15367
+ await writeConfig({
15368
+ ...config2.value,
15369
+ projects: current.filter((project) => project !== dir)
15370
+ });
15371
+ process.stdout.write(`Forgot ${dir}.
15372
+ `);
15373
+ return Result.ok(void 0);
15374
+ }
15375
+ default:
15376
+ return Result.fail(
15377
+ new CliError(
15378
+ "USAGE",
15379
+ `hubskillz projects: unknown action "${options.action}". Use add, remove, discover or list.`
15380
+ )
15381
+ );
15382
+ }
15383
+ }
15384
+
15127
15385
  // src/commands/push.ts
15128
15386
  async function push(options) {
15129
15387
  const config2 = await readConfig();
@@ -15163,18 +15421,44 @@ ${dim(`skill ${draft.value.skillId} version ${draft.value.versionId}`)}
15163
15421
  }
15164
15422
 
15165
15423
  // src/surfaces.ts
15166
- async function localSurfaces(path, both, machineId2) {
15167
- const projectDir = path ?? process.cwd();
15424
+ import { resolve as resolve5 } from "node:path";
15425
+ async function localSurfaces(path, both, machineId2, registered = []) {
15426
+ const projectDir = resolve5(path ?? process.cwd());
15168
15427
  const projectRoot = projectSkillsRoot(projectDir);
15169
15428
  const hasProject = await exists(projectRoot);
15170
- const project = async () => scanSurface(projectRoot, projectSurfaceLabel(projectDir), machineId2);
15429
+ const project = async (dir) => scanSurface(projectSkillsRoot(dir), projectSurfaceLabel(dir), machineId2);
15171
15430
  const global = async () => scanSurface(globalSkillsRoot(), globalSurfaceLabel(), machineId2);
15172
- if (both) {
15173
- const surfaces = [await global()];
15174
- if (hasProject || path !== void 0) surfaces.push(await project());
15175
- return surfaces;
15431
+ if (!both) return [hasProject ? await project(projectDir) : await global()];
15432
+ const surfaces = [await global()];
15433
+ const dirs = projectDirs(
15434
+ hasProject || path !== void 0 ? projectDir : void 0,
15435
+ registered
15436
+ );
15437
+ for (const dir of dirs) {
15438
+ if (await exists(projectSkillsRoot(dir))) {
15439
+ surfaces.push(await project(dir));
15440
+ continue;
15441
+ }
15442
+ process.stdout.write(
15443
+ dim(
15444
+ `skipping ${dir}: no .claude/skills here, run \`hubskillz projects remove ${dir}\` to forget it
15445
+ `
15446
+ )
15447
+ );
15176
15448
  }
15177
- return [hasProject ? await project() : await global()];
15449
+ return surfaces;
15450
+ }
15451
+ function projectDirs(current, registered) {
15452
+ const seen = /* @__PURE__ */ new Set();
15453
+ const out = [];
15454
+ for (const dir of [current, ...registered]) {
15455
+ if (dir === void 0) continue;
15456
+ const abs = resolve5(dir);
15457
+ if (seen.has(abs)) continue;
15458
+ seen.add(abs);
15459
+ out.push(abs);
15460
+ }
15461
+ return out;
15178
15462
  }
15179
15463
 
15180
15464
  // src/commands/status.ts
@@ -15185,16 +15469,22 @@ async function status(options) {
15185
15469
  baseUrl: resolveBaseUrl(options.baseUrl, config2.value.baseUrl),
15186
15470
  token: config2.value.token
15187
15471
  };
15472
+ const projects2 = config2.value.projects.length === 0 ? await discoverAndRegister(config2.value, options.yes) : config2.value.projects;
15188
15473
  const surfaces = await localSurfaces(
15189
15474
  options.path,
15190
15475
  true,
15191
- config2.value.machineId
15476
+ config2.value.machineId,
15477
+ projects2
15192
15478
  );
15193
15479
  for (const surface of surfaces) {
15194
15480
  const inventory = await postInventory(session, surface);
15195
15481
  if (inventory.isFailure) return Result.fail(inventory.error);
15196
15482
  printSurface(surface, inventory.value);
15197
15483
  }
15484
+ if (quickstartPending(config2)) {
15485
+ process.stdout.write(`
15486
+ ${quickstart(config2)}`);
15487
+ }
15198
15488
  return Result.ok(void 0);
15199
15489
  }
15200
15490
  async function postInventory(session, surface) {
@@ -15212,57 +15502,58 @@ function originOf(surface, name) {
15212
15502
  return skill.upstream === void 0 ? "private" : `skills.sh ${skill.upstream.source}`;
15213
15503
  }
15214
15504
  function printNotes(surface, items) {
15215
- for (const item of items) {
15216
- if (item.importable) {
15217
- process.stdout.write(
15218
- `${item.name}: not in directory yet (${originOf(surface, item.name)}), add it from the web app
15505
+ const importable = items.filter((item) => item.importable).map((item) => item.name);
15506
+ if (importable.length > 0) {
15507
+ process.stdout.write(
15508
+ `${dim(`${importable.length} not in the directory yet, run npx hubskillz sync to adopt them:`)} ${importable.join(", ")}
15219
15509
  `
15220
- );
15221
- }
15222
- if (item.upstreamAhead === true) {
15223
- process.stdout.write(
15224
- `${item.name}: upstream ahead of approved version, waiting for review
15510
+ );
15511
+ }
15512
+ const ahead = items.filter((item) => item.upstreamAhead === true).map((item) => `${item.name} (${originOf(surface, item.name)})`);
15513
+ if (ahead.length > 0) {
15514
+ process.stdout.write(
15515
+ `${dim("upstream ahead of approved version, waiting for review:")} ${ahead.join(", ")}
15225
15516
  `
15226
- );
15227
- }
15517
+ );
15228
15518
  }
15229
15519
  }
15230
- function printSurface(surface, inventory) {
15231
- const links = new Set(
15232
- surface.skills.filter((skill) => skill.link).map((skill) => skill.name)
15520
+ function printHeader(surface) {
15521
+ process.stdout.write(
15522
+ `
15523
+ ${bold(surface.descriptor.label)} ${dim(surface.descriptor.path)}
15524
+ `
15233
15525
  );
15526
+ }
15527
+ function printSurface(surface, inventory) {
15234
15528
  const rows = inventory.items.map((item) => [
15235
- links.has(item.name) ? `${item.name} ${dim("(link)")}` : item.name,
15529
+ item.name,
15236
15530
  originOf(surface, item.name),
15237
15531
  item.required ? `${item.state} ${dim("(required)")}` : item.state,
15238
15532
  shortHash(item.installedHash),
15239
15533
  item.approvedVersion === void 0 ? "-" : `v${item.approvedVersion}`
15240
15534
  ]);
15241
- process.stdout.write(
15242
- `
15243
- ${bold(surface.descriptor.label)} ${dim(surface.descriptor.path)}
15244
- `
15245
- );
15535
+ printHeader(surface);
15246
15536
  if (rows.length === 0) {
15247
15537
  process.stdout.write(`${dim("no skills")}
15248
15538
  `);
15249
15539
  return;
15250
15540
  }
15251
15541
  process.stdout.write(
15252
- `${table(["SKILL", "ORIGIN", "STATE", "INSTALLED", "APPROVED"], rows)}
15542
+ `${table(["SKILL", "ORIGIN", "STATE", "LOCAL", "APPROVED"], rows)}
15543
+
15253
15544
  `
15254
15545
  );
15255
15546
  printNotes(surface, inventory.items);
15256
15547
  }
15257
15548
 
15258
15549
  // src/commands/sync.ts
15259
- import { lstat, readdir as readdir2, realpath as realpath2 } from "node:fs/promises";
15260
- import { homedir as homedir4 } from "node:os";
15261
- import { join as join5, resolve as resolve3, sep as sep2 } from "node:path";
15550
+ import { lstat, readdir as readdir3, realpath as realpath2 } from "node:fs/promises";
15551
+ import { homedir as homedir5 } from "node:os";
15552
+ import { join as join6, resolve as resolve6, sep as sep2 } from "node:path";
15262
15553
 
15263
15554
  // src/apply.ts
15264
- import { mkdir as mkdir2, mkdtemp, rename, rm, rmdir, writeFile as writeFile2 } from "node:fs/promises";
15265
- import { dirname as dirname2, join as join4, sep } from "node:path";
15555
+ import { mkdir as mkdir2, mkdtemp, rename, rm as rm2, rmdir, writeFile as writeFile2 } from "node:fs/promises";
15556
+ import { dirname as dirname2, join as join5, sep } from "node:path";
15266
15557
  async function applySkill(input) {
15267
15558
  for (const path of [...input.files.map((f) => f.path), ...input.remove]) {
15268
15559
  if (!isSafeRelativePath(path)) {
@@ -15275,25 +15566,25 @@ async function applySkill(input) {
15275
15566
  }
15276
15567
  }
15277
15568
  await mkdir2(input.dir, { recursive: true });
15278
- const staging = await mkdtemp(join4(dirname2(input.dir), ".hubskillz-"));
15569
+ const staging = await mkdtemp(join5(dirname2(input.dir), ".hubskillz-"));
15279
15570
  try {
15280
15571
  for (const file2 of input.files) {
15281
- const staged = join4(staging, ...file2.path.split("/"));
15572
+ const staged = join5(staging, ...file2.path.split("/"));
15282
15573
  await mkdir2(dirname2(staged), { recursive: true });
15283
15574
  await writeFile2(staged, file2.content, "utf8");
15284
15575
  }
15285
15576
  for (const file2 of input.files) {
15286
- const target = join4(input.dir, ...file2.path.split("/"));
15577
+ const target = join5(input.dir, ...file2.path.split("/"));
15287
15578
  await mkdir2(dirname2(target), { recursive: true });
15288
- await rename(join4(staging, ...file2.path.split("/")), target);
15579
+ await rename(join5(staging, ...file2.path.split("/")), target);
15289
15580
  }
15290
15581
  for (const path of input.remove) {
15291
- const target = join4(input.dir, ...path.split("/"));
15292
- await rm(target, { force: true });
15582
+ const target = join5(input.dir, ...path.split("/"));
15583
+ await rm2(target, { force: true });
15293
15584
  await pruneEmptyDirs(dirname2(target), input.dir);
15294
15585
  }
15295
15586
  } finally {
15296
- await rm(staging, { recursive: true, force: true });
15587
+ await rm2(staging, { recursive: true, force: true });
15297
15588
  }
15298
15589
  return Result.ok(void 0);
15299
15590
  }
@@ -15372,21 +15663,35 @@ async function sync(options) {
15372
15663
  baseUrl: resolveBaseUrl(options.baseUrl, config2.value.baseUrl),
15373
15664
  token: config2.value.token
15374
15665
  };
15666
+ const projects2 = options.all && config2.value.projects.length === 0 ? await discoverAndRegister(config2.value, options.yes) : config2.value.projects;
15375
15667
  const surfaces = await localSurfaces(
15376
15668
  options.path,
15377
15669
  options.all,
15378
- config2.value.machineId
15670
+ config2.value.machineId,
15671
+ projects2
15379
15672
  );
15380
15673
  for (const surface of surfaces) {
15381
15674
  const result = await syncSurface(session, surface, options);
15382
15675
  if (result.isFailure) return result;
15383
15676
  }
15677
+ if (!options.dryRun && config2.value.firstSyncAt === void 0 && process.env["HUBSKILLZ_TOKEN"] === void 0) {
15678
+ const stored = await readConfig();
15679
+ if (stored.isSuccess && stored.value.firstSyncAt === void 0) {
15680
+ await writeConfig({
15681
+ ...stored.value,
15682
+ projects: [...projects2],
15683
+ firstSyncAt: (/* @__PURE__ */ new Date()).toISOString()
15684
+ });
15685
+ }
15686
+ }
15384
15687
  return Result.ok(void 0);
15385
15688
  }
15386
15689
  async function syncSurface(session, surface, options) {
15387
- const inventory = await postInventory(session, surface);
15690
+ const first = await postInventory(session, surface);
15691
+ if (first.isFailure) return Result.fail(first.error);
15692
+ const surfaceId = first.value.surfaceId;
15693
+ const inventory = await maybeAdopt(session, surface, first.value, options);
15388
15694
  if (inventory.isFailure) return Result.fail(inventory.error);
15389
- const surfaceId = inventory.value.surfaceId;
15390
15695
  const approved = await apiRequest({
15391
15696
  session,
15392
15697
  method: "GET",
@@ -15401,18 +15706,8 @@ async function syncSurface(session, surface, options) {
15401
15706
  local: surface.skills,
15402
15707
  force: options.force
15403
15708
  });
15404
- process.stdout.write(
15405
- `
15406
- ${bold(surface.descriptor.label)} ${dim(surface.descriptor.path)}
15407
- `
15408
- );
15409
- printPlan(plans, surface);
15410
- for (const skill of blocked) {
15411
- process.stdout.write(
15412
- `${skill.name}: blocked by org policy (${skill.blockedReason ?? "no reason given"})
15413
- `
15414
- );
15415
- }
15709
+ printHeader(surface);
15710
+ printPlan(plans, blocked, surface);
15416
15711
  printNotes(surface, inventory.value.items);
15417
15712
  if (options.dryRun || !planHasWrites(plans)) return Result.ok(void 0);
15418
15713
  if (!options.yes && !await confirm("Apply?")) {
@@ -15430,6 +15725,44 @@ ${bold(surface.descriptor.label)} ${dim(surface.descriptor.path)}
15430
15725
  if (reposted.isFailure) return Result.fail(reposted.error);
15431
15726
  return clearPending(session, surfaceId);
15432
15727
  }
15728
+ async function maybeAdopt(session, surface, inventory, options) {
15729
+ const importable = inventory.items.filter((item) => item.importable);
15730
+ if (importable.length === 0 || options.dryRun) return Result.ok(inventory);
15731
+ const wanted = options.adopt || !options.yes && await confirm(
15732
+ `Adopt ${plural(importable.length)} found here as approved in your directory?`
15733
+ );
15734
+ if (!wanted) return Result.ok(inventory);
15735
+ const adopted = await apiRequest({
15736
+ session,
15737
+ method: "POST",
15738
+ path: "/api/cli/adopt",
15739
+ schema: adoptResponseSchema,
15740
+ body: { surfaceId: inventory.surfaceId }
15741
+ });
15742
+ if (adopted.isFailure && adopted.error.code === "FORBIDDEN") {
15743
+ process.stdout.write(
15744
+ `${dim("not adopted: ask a maintainer to adopt these skills")}
15745
+ `
15746
+ );
15747
+ return Result.ok(inventory);
15748
+ }
15749
+ if (adopted.isFailure) return Result.fail(adopted.error);
15750
+ const names = adopted.value.adopted;
15751
+ process.stdout.write(
15752
+ names.length === 0 ? `${dim("nothing adopted")}
15753
+ ` : `adopted ${plural(names.length)} as approved: ${names.join(", ")}
15754
+ `
15755
+ );
15756
+ for (const skip of adopted.value.skipped) {
15757
+ process.stdout.write(dim(`skipped ${skip.name} (${skip.code})
15758
+ `));
15759
+ }
15760
+ if (adopted.value.adopted.length === 0) return Result.ok(inventory);
15761
+ return postInventory(session, surface);
15762
+ }
15763
+ function plural(n) {
15764
+ return `${n} skill${n === 1 ? "" : "s"}`;
15765
+ }
15433
15766
  async function applyPlan(surface, plans, approved) {
15434
15767
  for (const plan of plans) {
15435
15768
  if (plan.action !== "install" && plan.action !== "update") continue;
@@ -15458,12 +15791,12 @@ async function applyPlan(surface, plans, approved) {
15458
15791
  return Result.ok(void 0);
15459
15792
  }
15460
15793
  async function writeTarget(root, name) {
15461
- const dir = join5(root, name);
15794
+ const dir = join6(root, name);
15462
15795
  const stats = await lstat(dir).catch(() => null);
15463
15796
  if (stats === null || !stats.isSymbolicLink()) return Result.ok(dir);
15464
15797
  const target = await realpath2(dir).catch(() => null);
15465
- const home = await realpath2(homedir4()).catch(() => homedir4());
15466
- if (target === null || !isInside(target, home) || isInside(target, await realpath2(root).catch(() => resolve3(root)))) {
15798
+ const home2 = await realpath2(homedir5()).catch(() => homedir5());
15799
+ if (target === null || !isInside(target, home2) || isInside(target, await realpath2(root).catch(() => resolve6(root)))) {
15467
15800
  return Result.fail(
15468
15801
  new CliError(
15469
15802
  "UNSAFE_LINK",
@@ -15474,7 +15807,7 @@ async function writeTarget(root, name) {
15474
15807
  return Result.ok(target);
15475
15808
  }
15476
15809
  async function containsSymlink(dir) {
15477
- const entries = await readdir2(dir, {
15810
+ const entries = await readdir3(dir, {
15478
15811
  recursive: true,
15479
15812
  withFileTypes: true
15480
15813
  }).catch(() => []);
@@ -15502,104 +15835,273 @@ async function clearPending(session, surfaceId) {
15502
15835
  }
15503
15836
  return Result.ok(void 0);
15504
15837
  }
15505
- function printPlan(plans, surface) {
15506
- if (plans.length === 0) {
15838
+ function printPlan(plans, blocked, surface) {
15839
+ const rows = [
15840
+ ...plans.map((plan) => [
15841
+ plan.action,
15842
+ plan.name,
15843
+ originOf(surface, plan.name),
15844
+ `v${plan.version}`,
15845
+ detailOf(plan)
15846
+ ]),
15847
+ ...blocked.map((skill) => [
15848
+ accent("blocked"),
15849
+ skill.name,
15850
+ originOf(surface, skill.name),
15851
+ `v${skill.version}`,
15852
+ `org policy: ${skill.blockedReason ?? "no reason given"}`
15853
+ ])
15854
+ ];
15855
+ if (rows.length === 0) {
15507
15856
  process.stdout.write(`${dim("nothing to sync")}
15857
+
15508
15858
  `);
15509
15859
  return;
15510
15860
  }
15511
- const rows = plans.map((plan) => [
15512
- plan.action,
15513
- plan.name,
15514
- originOf(surface, plan.name),
15515
- `v${plan.version}`,
15516
- diffSummary(plan)
15517
- ]);
15518
15861
  process.stdout.write(
15519
15862
  `${table(["ACTION", "SKILL", "ORIGIN", "VERSION", "FILES"], rows)}
15520
15863
  `
15521
15864
  );
15522
15865
  for (const plan of plans) {
15866
+ if (plan.action !== "update") continue;
15523
15867
  for (const [marker, paths] of [
15524
15868
  ["+", plan.added],
15525
15869
  ["~", plan.changed],
15526
15870
  ["-", plan.removed]
15527
15871
  ]) {
15528
- if (plan.action === "keep" || plan.action === "skip") continue;
15529
15872
  for (const path of paths) {
15530
- process.stdout.write(` ${marker} ${plan.name}/${path}
15531
- `);
15873
+ process.stdout.write(dim(` ${marker} ${plan.name}/${path}
15874
+ `));
15532
15875
  }
15533
15876
  }
15534
15877
  }
15535
- const skipped = plans.filter((plan) => plan.action === "skip");
15536
- if (skipped.length > 0) {
15537
- process.stdout.write(
15538
- `${accent("warning")}: customized locally, left untouched: ${skipped.map((plan) => plan.name).join(", ")}. Use --force to overwrite.
15878
+ const counts = [
15879
+ [plans.filter((plan) => plan.action === "install").length, "to install"],
15880
+ [plans.filter((plan) => plan.action === "update").length, "to update"],
15881
+ [plans.filter((plan) => plan.action === "keep").length, "up to date"],
15882
+ [plans.filter((plan) => plan.action === "skip").length, "skipped"],
15883
+ [blocked.length, "blocked"]
15884
+ ];
15885
+ process.stdout.write(
15886
+ `${counts.filter(([n]) => n > 0).map(([n, label]) => `${n} ${label}`).join(", ")}
15887
+
15539
15888
  `
15540
- );
15541
- }
15889
+ );
15542
15890
  }
15543
- function diffSummary(plan) {
15891
+ function detailOf(plan) {
15544
15892
  if (plan.action === "keep") return dim("up to date");
15545
- if (plan.action === "skip") return dim("customized");
15893
+ if (plan.action === "skip") {
15894
+ return `${accent("customized locally")}, use --force to overwrite`;
15895
+ }
15896
+ if (plan.action === "install") return `+${plan.added.length}`;
15546
15897
  return `+${plan.added.length} ~${plan.changed.length} -${plan.removed.length}`;
15547
15898
  }
15548
15899
 
15549
15900
  // src/index.ts
15550
- var USAGE = `hubskillz - keep your agent skills in sync
15901
+ var GLOBAL_FLAGS = [
15902
+ {
15903
+ spec: "--base-url URL",
15904
+ help: "Server to talk to (default: config file, then https://hubskillz.com)"
15905
+ },
15906
+ { spec: "-h, --help", help: "Show help" },
15907
+ { spec: "-v, --version", help: "Show the version" }
15908
+ ];
15909
+ var COMMANDS = [
15910
+ {
15911
+ name: "login",
15912
+ usage: "hubskillz login [--token TOKEN] [--base-url URL]",
15913
+ summary: "Sign in with a device token",
15914
+ flags: [
15915
+ { spec: "--token TOKEN", help: "Device token, instead of the prompt" }
15916
+ ]
15917
+ },
15918
+ {
15919
+ name: "logout",
15920
+ usage: "hubskillz logout",
15921
+ summary: "Forget the device token on this machine",
15922
+ flags: []
15923
+ },
15924
+ {
15925
+ name: "status",
15926
+ usage: "hubskillz status [--path DIR] [--yes]",
15927
+ summary: "Report the state of every installed skill",
15928
+ flags: [
15929
+ {
15930
+ spec: "--path DIR",
15931
+ help: "Project directory (default: current directory)"
15932
+ },
15933
+ {
15934
+ spec: "-y, --yes",
15935
+ help: "Register every discovered project without asking"
15936
+ }
15937
+ ]
15938
+ },
15939
+ {
15940
+ name: "sync",
15941
+ usage: "hubskillz sync [--path DIR] [--all] [--adopt] [--yes] [--dry-run] [--force]",
15942
+ summary: "Install and update skills to the approved versions",
15943
+ flags: [
15944
+ {
15945
+ spec: "--path DIR",
15946
+ help: "Project directory (default: current directory)"
15947
+ },
15948
+ {
15949
+ spec: "--all",
15950
+ help: "Global root, this project and every registered project"
15951
+ },
15952
+ {
15953
+ spec: "--adopt",
15954
+ help: "Add importable skills to the directory as approved (maintainers)"
15955
+ },
15956
+ { spec: "-y, --yes", help: "Apply without asking" },
15957
+ { spec: "--dry-run", help: "Print the plan and stop" },
15958
+ { spec: "--force", help: "Overwrite skills you customized locally" }
15959
+ ]
15960
+ },
15961
+ {
15962
+ name: "push",
15963
+ usage: "hubskillz push <skill-dir> [-m MESSAGE]",
15964
+ summary: "Upload a skill directory as a draft for review",
15965
+ flags: [{ spec: "-m, --message TEXT", help: "Note attached to the draft" }]
15966
+ },
15967
+ {
15968
+ name: "projects",
15969
+ usage: "hubskillz projects [add|remove [DIR] | discover [--yes] | list]",
15970
+ summary: "Repos that `sync --all` and `status` cover",
15971
+ flags: [{ spec: "-y, --yes", help: "discover: register everything found" }]
15972
+ }
15973
+ ];
15974
+ function flagLines(flags) {
15975
+ const width = Math.max(...flags.map((flag) => flag.spec.length));
15976
+ return flags.map((flag) => ` ${flag.spec.padEnd(width)} ${dim(flag.help)}
15977
+ `).join("");
15978
+ }
15979
+ function usage() {
15980
+ const width = Math.max(...COMMANDS.map((command) => command.name.length));
15981
+ return `${bold("hubskillz")} ${dim(`v${"0.2.1"}`)} keep your agent skills in sync
15982
+
15983
+ ${bold("Usage")}
15984
+ hubskillz <command> [flags]
15551
15985
 
15552
- hubskillz login [--base-url URL]
15553
- hubskillz status [--path DIR]
15554
- hubskillz sync [--path DIR] [--all] [--yes] [--dry-run] [--force]
15555
- hubskillz push <skill-dir> [-m MESSAGE]
15556
- hubskillz --version
15986
+ ${bold("Commands")}
15987
+ ` + COMMANDS.map(
15988
+ (command) => ` ${command.name.padEnd(width)} ${dim(command.summary)}
15989
+ `
15990
+ ).join("") + `
15991
+ ${bold("Flags")}
15992
+ ${flagLines(GLOBAL_FLAGS)}
15993
+ ${dim("Environment: HUBSKILLZ_TOKEN, HUBSKILLZ_BASE_URL, NO_COLOR. Docs: https://hubskillz.com/docs/cli")}
15557
15994
  `;
15995
+ }
15996
+ function commandHelp(command) {
15997
+ const flags = [...command.flags, ...GLOBAL_FLAGS];
15998
+ return `${bold("Usage")}
15999
+ ${command.usage}
16000
+
16001
+ ${command.summary}.
16002
+
16003
+ ${bold("Flags")}
16004
+ ${flagLines(flags)}`;
16005
+ }
16006
+ function help(name) {
16007
+ if (name === void 0) {
16008
+ process.stdout.write(usage());
16009
+ return Result.ok(void 0);
16010
+ }
16011
+ const command = COMMANDS.find((entry) => entry.name === name);
16012
+ if (command === void 0) {
16013
+ return Result.fail(
16014
+ new CliError(
16015
+ "USAGE",
16016
+ `Unknown command "${name}". Run \`hubskillz help\`.`
16017
+ )
16018
+ );
16019
+ }
16020
+ process.stdout.write(commandHelp(command));
16021
+ return Result.ok(void 0);
16022
+ }
16023
+ async function home() {
16024
+ const config2 = await readConfig();
16025
+ process.stdout.write(
16026
+ quickstartPending(config2) ? `${usage()}
16027
+ ${quickstart(config2)}` : usage()
16028
+ );
16029
+ return Result.ok(void 0);
16030
+ }
15558
16031
  async function run() {
15559
- const { values, positionals } = parseArgs({
15560
- args: process.argv.slice(2),
15561
- allowPositionals: true,
15562
- options: {
15563
- "base-url": { type: "string" },
15564
- path: { type: "string" },
15565
- all: { type: "boolean", default: false },
15566
- yes: { type: "boolean", short: "y", default: false },
15567
- "dry-run": { type: "boolean", default: false },
15568
- force: { type: "boolean", default: false },
15569
- message: { type: "string", short: "m" },
15570
- help: { type: "boolean", short: "h", default: false },
15571
- version: { type: "boolean", short: "v", default: false }
15572
- }
15573
- });
16032
+ let values;
16033
+ let positionals;
16034
+ try {
16035
+ ({ values, positionals } = parseArgs({
16036
+ args: process.argv.slice(2),
16037
+ allowPositionals: true,
16038
+ options: {
16039
+ "base-url": { type: "string" },
16040
+ token: { type: "string" },
16041
+ path: { type: "string" },
16042
+ all: { type: "boolean", default: false },
16043
+ adopt: { type: "boolean", default: false },
16044
+ yes: { type: "boolean", short: "y", default: false },
16045
+ "dry-run": { type: "boolean", default: false },
16046
+ force: { type: "boolean", default: false },
16047
+ message: { type: "string", short: "m" },
16048
+ help: { type: "boolean", short: "h", default: false },
16049
+ version: { type: "boolean", short: "v", default: false }
16050
+ }
16051
+ }));
16052
+ } catch (cause) {
16053
+ const detail = cause instanceof Error ? cause.message : String(cause);
16054
+ return Result.fail(
16055
+ new CliError("USAGE", `${detail}
16056
+ Run \`hubskillz help\` for usage.`)
16057
+ );
16058
+ }
15574
16059
  if (values.version === true) {
15575
- process.stdout.write(`${"0.1.0"}
16060
+ process.stdout.write(`${"0.2.1"}
15576
16061
  `);
15577
16062
  return Result.ok(void 0);
15578
16063
  }
15579
16064
  const command = positionals[0];
15580
- if (values.help === true || command === void 0) {
15581
- process.stdout.write(USAGE);
15582
- return Result.ok(void 0);
16065
+ if (command === void 0)
16066
+ return values.help === true ? help(void 0) : home();
16067
+ if (values.help === true || command === "help") {
16068
+ return help(command === "help" ? positionals[1] : command);
15583
16069
  }
15584
16070
  switch (command) {
15585
16071
  case "login":
15586
- return login({ baseUrl: values["base-url"] });
16072
+ return login({ baseUrl: values["base-url"], token: values.token });
16073
+ case "logout":
16074
+ return logout();
15587
16075
  case "status":
15588
- return status({ baseUrl: values["base-url"], path: values.path });
16076
+ return status({
16077
+ baseUrl: values["base-url"],
16078
+ path: values.path,
16079
+ yes: values.yes === true
16080
+ });
15589
16081
  case "sync":
15590
16082
  return sync({
15591
16083
  baseUrl: values["base-url"],
15592
16084
  path: values.path,
15593
16085
  all: values.all === true,
16086
+ adopt: values.adopt === true,
15594
16087
  yes: values.yes === true,
15595
16088
  dryRun: values["dry-run"] === true,
15596
16089
  force: values.force === true
15597
16090
  });
16091
+ case "projects":
16092
+ return projects({
16093
+ action: positionals[1],
16094
+ dir: positionals[2],
16095
+ yes: values.yes === true
16096
+ });
15598
16097
  case "push": {
15599
16098
  const dir = positionals[1];
15600
16099
  if (dir === void 0) {
15601
16100
  return Result.fail(
15602
- new CliError("USAGE", "hubskillz push needs a skill directory.")
16101
+ new CliError(
16102
+ "USAGE",
16103
+ "hubskillz push needs a skill directory. Run `hubskillz help push`."
16104
+ )
15603
16105
  );
15604
16106
  }
15605
16107
  return push({
@@ -15610,8 +16112,10 @@ async function run() {
15610
16112
  }
15611
16113
  default:
15612
16114
  return Result.fail(
15613
- new CliError("USAGE", `Unknown command "${command}".
15614
- ${USAGE}`)
16115
+ new CliError(
16116
+ "USAGE",
16117
+ `Unknown command "${command}". Run \`hubskillz help\`.`
16118
+ )
15615
16119
  );
15616
16120
  }
15617
16121
  }
package/package.json CHANGED
@@ -1,21 +1,36 @@
1
1
  {
2
2
  "name": "hubskillz",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Keep your Claude Code skills in sync with your organization's approved catalog.",
5
5
  "license": "MIT",
6
+ "author": "Jérôme Desmares",
7
+ "homepage": "https://hubskillz.com/docs/cli",
6
8
  "repository": {
7
9
  "type": "git",
8
- "url": "https://github.com/JejeDurden2/hubskillz",
10
+ "url": "https://github.com/JejeDurden2/hubskillz-cli",
9
11
  "directory": "packages/cli"
10
12
  },
11
- "keywords": ["claude-code", "skills", "cli", "agent", "sync"],
13
+ "bugs": {
14
+ "url": "https://github.com/JejeDurden2/hubskillz-cli/issues"
15
+ },
16
+ "keywords": [
17
+ "claude-code",
18
+ "claude",
19
+ "skills",
20
+ "agent-skills",
21
+ "cli",
22
+ "sync",
23
+ "skills.sh"
24
+ ],
12
25
  "type": "module",
13
26
  "bin": {
14
27
  "hubskillz": "./dist/index.js"
15
28
  },
16
29
  "main": "./dist/index.js",
17
30
  "files": [
18
- "dist"
31
+ "dist",
32
+ "README.md",
33
+ "LICENSE"
19
34
  ],
20
35
  "engines": {
21
36
  "node": ">=22"
@@ -23,19 +38,19 @@
23
38
  "publishConfig": {
24
39
  "access": "public"
25
40
  },
41
+ "devDependencies": {
42
+ "@types/node": "^26.3.0",
43
+ "esbuild": "0.28.2",
44
+ "typescript": "^7.0.2",
45
+ "vitest": "^4.1.11",
46
+ "zod": "^4.4.3",
47
+ "@hubskillz/shared": "0.0.1"
48
+ },
26
49
  "scripts": {
27
50
  "dev": "pnpm build --watch",
28
51
  "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --alias:@hubskillz/shared=../shared/src/index.ts --outfile=dist/index.js --define:__VERSION__=\"'$npm_package_version'\" && chmod +x dist/index.js",
29
52
  "lint": "oxlint src/",
30
53
  "type-check": "tsc --noEmit",
31
54
  "test": "vitest run"
32
- },
33
- "devDependencies": {
34
- "@hubskillz/shared": "workspace:*",
35
- "@types/node": "^26.3.0",
36
- "esbuild": "0.28.2",
37
- "typescript": "^7.0.2",
38
- "vitest": "^4.1.11",
39
- "zod": "^4.4.3"
40
55
  }
41
- }
56
+ }