skillbox 0.3.2 → 0.3.4

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
@@ -67,8 +67,9 @@ skillbox update [name] # update skills
67
67
 
68
68
  | Command | Description |
69
69
  |---------|-------------|
70
- | `skillbox project add <path>` | Register a project |
70
+ | `skillbox project add <path>` | Register a project and auto-import skills from `skills/` and agent directories |
71
71
  | `skillbox project list` | List registered projects |
72
+ | `skillbox project inspect <path>` | Show project details and skills |
72
73
  | `skillbox project sync <path>` | Re-sync skills to a project |
73
74
 
74
75
  ## Supported Agents
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { registerRemove } from "./commands/remove.js";
12
12
  import { registerStatus } from "./commands/status.js";
13
13
  import { registerUpdate } from "./commands/update.js";
14
14
  const program = new Command();
15
- program.name("skillbox").description("Local-first, agent-agnostic skills manager").version("0.3.2");
15
+ program.name("skillbox").description("Local-first, agent-agnostic skills manager").version("0.3.4");
16
16
  registerAdd(program);
17
17
  registerAgent(program);
18
18
  registerConfig(program);
@@ -1,9 +1,10 @@
1
+ import path from "node:path";
1
2
  import { getErrorMessage } from "../lib/command.js";
2
3
  import { loadConfig } from "../lib/config.js";
3
4
  import { parseRepoRef } from "../lib/github.js";
4
5
  import { loadIndex, saveIndex, sortIndex, upsertSkill } from "../lib/index.js";
5
6
  import { recordInstallPaths } from "../lib/installs.js";
6
- import { printInfo, printJson } from "../lib/output.js";
7
+ import { printFailure, printInfo, printJson, printSkipped, printSuccess, startSpinner, stopSpinner, } from "../lib/output.js";
7
8
  import { buildProjectAgentPaths } from "../lib/project-paths.js";
8
9
  import { fetchRepoFile, listRepoSkills, normalizeRepoRef, writeRepoSkillDirectory, } from "../lib/repo-skills.js";
9
10
  import { ensureProjectRegistered, resolveRuntime } from "../lib/runtime.js";
@@ -38,25 +39,22 @@ async function installSkillTargets(skillName, options, installs) {
38
39
  if (!map) {
39
40
  continue;
40
41
  }
41
- const targets = buildTargets(agent, map, scope).map((target) => target.path);
42
+ const targets = buildTargets(agent, map, scope).map((target) => path.join(target.path, skillName));
42
43
  const results = await installSkillToTargets(skillName, targets, config);
43
- const written = results
44
- .filter((result) => result.mode !== "skipped")
45
- .map((result) => result.path);
46
44
  const warnings = buildSymlinkWarning(agent, results);
47
45
  for (const warning of warnings) {
48
46
  printInfo(warning);
49
47
  }
50
- const deduped = recordInstallPaths(written, recordedPaths);
51
- if (deduped.length > 0) {
52
- for (const target of deduped) {
53
- installs.push({
54
- scope,
55
- agent,
56
- path: target,
57
- projectRoot: scope === "project" ? projectRoot : undefined,
58
- });
59
- }
48
+ // Record all targets, not just successfully written ones
49
+ // The warning tells users about symlink issues, but we still track the install intent
50
+ const deduped = recordInstallPaths(targets, recordedPaths);
51
+ for (const target of deduped) {
52
+ installs.push({
53
+ scope,
54
+ agent,
55
+ path: target,
56
+ projectRoot: scope === "project" ? projectRoot : undefined,
57
+ });
60
58
  }
61
59
  }
62
60
  }
@@ -86,16 +84,27 @@ export async function handleRepoInstall(input, options) {
86
84
  }
87
85
  const summary = { installed: [], updated: [], skipped: [], failed: [] };
88
86
  const index = await loadIndex();
89
- for (const skill of skills) {
90
- if (!selected.includes(skill.name)) {
91
- continue;
92
- }
87
+ const showProgress = !options.json;
88
+ const selectedSkills = skills.filter((s) => selected.includes(s.name));
89
+ const total = selectedSkills.length;
90
+ if (showProgress && total > 0) {
91
+ printInfo(`Adding ${total} skill${total === 1 ? "" : "s"} from ${ref.owner}/${ref.repo}...\n`);
92
+ }
93
+ for (let i = 0; i < selectedSkills.length; i++) {
94
+ const skill = selectedSkills[i];
95
+ const progress = `(${i + 1}/${total})`;
93
96
  const alreadyInstalled = index.skills.some((entry) => entry.name === skill.name);
97
+ if (showProgress) {
98
+ startSpinner(`${skill.name} ${progress}`);
99
+ }
94
100
  try {
95
101
  const skillMarkdown = await fetchRepoFile(ref, ref.path ? `${ref.path}/${skill.skillFile}` : skill.skillFile);
96
102
  const parsed = parseSkillMarkdown(skillMarkdown);
97
103
  if (!parsed.description) {
98
104
  summary.skipped.push(skill.name);
105
+ if (showProgress) {
106
+ printSkipped(skill.name, "missing description");
107
+ }
99
108
  continue;
100
109
  }
101
110
  await writeRepoSkillDirectory(ref, skill.path, skill.name);
@@ -126,56 +135,49 @@ export async function handleRepoInstall(input, options) {
126
135
  index.skills = nextIndex.skills;
127
136
  if (alreadyInstalled) {
128
137
  summary.updated.push(skill.name);
138
+ if (showProgress) {
139
+ printSuccess(skill.name, "updated");
140
+ }
129
141
  }
130
142
  else {
131
143
  summary.installed.push(skill.name);
144
+ if (showProgress) {
145
+ printSuccess(skill.name);
146
+ }
132
147
  }
133
148
  }
134
149
  catch (error) {
135
150
  const message = getErrorMessage(error, "unknown");
136
151
  summary.failed.push({ name: skill.name, reason: message });
152
+ if (showProgress) {
153
+ printFailure(skill.name, message);
154
+ }
137
155
  }
138
156
  }
157
+ if (showProgress) {
158
+ stopSpinner();
159
+ }
139
160
  await saveIndex(sortIndex(index));
140
161
  if (options.json) {
141
162
  printJson({ ok: true, command: "add", data: { repo: `${ref.owner}/${ref.repo}`, ...summary } });
142
163
  return;
143
164
  }
144
- printInfo(`Skills Added from: ${ref.owner}/${ref.repo}`);
145
- printInfo("");
146
- printInfo("Source: git");
147
- printInfo(` ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""} (${ref.ref})`);
148
- if (summary.installed.length > 0) {
149
- printInfo("");
150
- printInfo(`Installed (${summary.installed.length}):`);
151
- for (const name of summary.installed) {
152
- printInfo(` ✓ ${name}`);
153
- }
165
+ // Summary line
166
+ const added = summary.installed.length + summary.updated.length;
167
+ const failed = summary.failed.length;
168
+ const skipped = summary.skipped.length;
169
+ if (added > 0 && failed === 0 && skipped === 0) {
170
+ printInfo(`\nAdded ${added} skill${added === 1 ? "" : "s"} from ${ref.owner}/${ref.repo}.`);
154
171
  }
155
- if (summary.updated.length > 0) {
156
- printInfo("");
157
- printInfo(`Updated (${summary.updated.length}):`);
158
- for (const name of summary.updated) {
159
- printInfo(` ✓ ${name}`);
160
- }
161
- }
162
- if (summary.skipped.length > 0) {
163
- printInfo("");
164
- printInfo(`Skipped (${summary.skipped.length}):`);
165
- for (const name of summary.skipped) {
166
- printInfo(` - ${name} (missing description)`);
167
- }
172
+ else if (added > 0) {
173
+ const parts = [];
174
+ if (failed > 0)
175
+ parts.push(`${failed} failed`);
176
+ if (skipped > 0)
177
+ parts.push(`${skipped} skipped`);
178
+ printInfo(`\nAdded ${added} skill${added === 1 ? "" : "s"} (${parts.join(", ")}).`);
168
179
  }
169
- if (summary.failed.length > 0) {
170
- printInfo("");
171
- printInfo(`Failed (${summary.failed.length}):`);
172
- for (const failure of summary.failed) {
173
- printInfo(` ✗ ${failure.name} (${failure.reason})`);
174
- }
175
- }
176
- const total = summary.installed.length + summary.updated.length;
177
- if (total === 0) {
178
- printInfo("");
179
- printInfo("No skills were added.");
180
+ else {
181
+ printInfo("\nNo skills were added.");
180
182
  }
181
183
  }
@@ -1,10 +1,11 @@
1
+ import path from "node:path";
1
2
  import { handleCommandError } from "../lib/command.js";
2
3
  import { loadConfig } from "../lib/config.js";
3
4
  import { fetchText } from "../lib/fetcher.js";
4
5
  import { collect } from "../lib/fs-utils.js";
5
6
  import { loadIndex, saveIndex, sortIndex, upsertSkill } from "../lib/index.js";
6
7
  import { recordInstallPaths } from "../lib/installs.js";
7
- import { isJsonEnabled, printInfo, printJson } from "../lib/output.js";
8
+ import { isJsonEnabled, printInfo, printJson, printSuccess, startSpinner, stopSpinner, } from "../lib/output.js";
8
9
  import { buildProjectAgentPaths } from "../lib/project-paths.js";
9
10
  import { ensureProjectRegistered, resolveRuntime } from "../lib/runtime.js";
10
11
  import { buildMetadata, inferNameFromUrl, parseSkillMarkdown } from "../lib/skill-parser.js";
@@ -33,17 +34,28 @@ export function registerAdd(program) {
33
34
  });
34
35
  return;
35
36
  }
37
+ const showProgress = !isJsonEnabled(options);
38
+ const inferred = inferNameFromUrl(url);
39
+ const displayName = options.name ?? inferred ?? "skill";
40
+ if (showProgress) {
41
+ startSpinner(`Adding ${displayName}`);
42
+ }
36
43
  const skillMarkdown = await fetchText(url);
37
44
  const parsed = parseSkillMarkdown(skillMarkdown);
38
- const inferred = inferNameFromUrl(url);
39
45
  const skillName = options.name ?? inferred ?? parsed.name;
40
46
  if (!skillName) {
47
+ if (showProgress)
48
+ stopSpinner();
41
49
  throw new Error("Unable to infer skill name. Use --name to specify it.");
42
50
  }
43
51
  if (!parsed.name && !options.name) {
52
+ if (showProgress)
53
+ stopSpinner();
44
54
  throw new Error("Skill frontmatter missing name. Provide --name to continue.");
45
55
  }
46
56
  if (!parsed.description) {
57
+ if (showProgress)
58
+ stopSpinner();
47
59
  throw new Error("Skill frontmatter missing description. Convert the source into a valid skill.");
48
60
  }
49
61
  const metadata = buildMetadata(parsed, { type: "url", url }, skillName);
@@ -71,16 +83,15 @@ export function registerAdd(program) {
71
83
  if (!map) {
72
84
  continue;
73
85
  }
74
- const targets = buildTargets(agent, map, scope).map((target) => target.path);
86
+ const targets = buildTargets(agent, map, scope).map((target) => path.join(target.path, skillName));
75
87
  const results = await installSkillToTargets(skillName, targets, config);
76
- const written = results
77
- .filter((result) => result.mode !== "skipped")
78
- .map((result) => result.path);
79
88
  const warnings = buildSymlinkWarning(agent, results);
80
89
  for (const warning of warnings) {
81
90
  printInfo(warning);
82
91
  }
83
- const deduped = recordInstallPaths(written, recordedPaths);
92
+ // Record all targets, not just successfully written ones
93
+ // The warning tells users about symlink issues, but we still track the install intent
94
+ const deduped = recordInstallPaths(targets, recordedPaths);
84
95
  if (deduped.length > 0) {
85
96
  installed.push({ agent, scope, targets: deduped });
86
97
  for (const target of deduped) {
@@ -102,6 +113,7 @@ export function registerAdd(program) {
102
113
  });
103
114
  await saveIndex(sortIndex(nextIndex));
104
115
  if (isJsonEnabled(options)) {
116
+ stopSpinner();
105
117
  printJson({
106
118
  ok: true,
107
119
  command: "add",
@@ -114,22 +126,8 @@ export function registerAdd(program) {
114
126
  });
115
127
  return;
116
128
  }
117
- printInfo(`Skill Added: ${skillName}`);
118
- printInfo("");
119
- printInfo("Source: url");
120
- printInfo(` ${url}`);
121
- if (installs.length > 0) {
122
- printInfo("");
123
- printInfo("Installed to:");
124
- for (const install of installs) {
125
- const scopeLabel = install.scope === "project" ? `project:${install.projectRoot}` : "user";
126
- printInfo(` ✓ ${scopeLabel}/${install.agent}`);
127
- }
128
- }
129
- else {
130
- printInfo("");
131
- printInfo("No agent targets were updated.");
132
- }
129
+ printSuccess(skillName);
130
+ printInfo(`\nAdded skill from ${url}.`);
133
131
  }
134
132
  catch (error) {
135
133
  handleCommandError(options, "add", error);
@@ -1,4 +1,3 @@
1
- import fs from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import { getUserPathsForAgents } from "../lib/agents.js";
4
3
  import { handleCommandError } from "../lib/command.js";
@@ -6,8 +5,7 @@ import { discoverSkills } from "../lib/discovery.js";
6
5
  import { loadIndex, saveIndex, sortIndex, upsertSkill } from "../lib/index.js";
7
6
  import { isJsonEnabled, printInfo, printJson } from "../lib/output.js";
8
7
  import { resolveRuntime } from "../lib/runtime.js";
9
- import { buildMetadata, parseSkillMarkdown } from "../lib/skill-parser.js";
10
- import { ensureSkillsDir, writeSkillFiles } from "../lib/skill-store.js";
8
+ import { importSkillFromDir } from "../lib/skill-store.js";
11
9
  async function importGlobalSkills(agents) {
12
10
  const projectRoot = process.cwd();
13
11
  const agentPaths = getUserPathsForAgents(projectRoot, agents);
@@ -32,24 +30,20 @@ async function importGlobalSkills(agents) {
32
30
  skipped.add(skill.name);
33
31
  continue;
34
32
  }
35
- const markdown = await fs.readFile(skill.skillFile, "utf8");
36
- const parsed = parseSkillMarkdown(markdown);
37
- if (!parsed.description) {
33
+ const data = await importSkillFromDir(skill.skillFile);
34
+ if (!data) {
38
35
  skipped.add(skill.name);
39
36
  continue;
40
37
  }
41
- const metadata = buildMetadata(parsed, { type: "local" });
42
- await ensureSkillsDir();
43
- await writeSkillFiles(metadata.name, markdown, metadata);
44
38
  const next = upsertSkill(index, {
45
- name: metadata.name,
39
+ name: data.name,
46
40
  source: { type: "local" },
47
- checksum: parsed.checksum,
48
- updatedAt: metadata.updatedAt,
41
+ checksum: data.checksum,
42
+ updatedAt: data.updatedAt,
49
43
  installs: [{ scope: "user", agent: skill.agent, path: skill.skillDir }],
50
44
  });
51
45
  index.skills = next.skills;
52
- imported.add(metadata.name);
46
+ imported.add(data.name);
53
47
  }
54
48
  await saveIndex(sortIndex(index));
55
49
  return {
@@ -81,27 +75,23 @@ export function registerImport(program) {
81
75
  }
82
76
  const resolved = path.resolve(inputPath);
83
77
  const skillPath = path.join(resolved, "SKILL.md");
84
- const markdown = await fs.readFile(skillPath, "utf8");
85
- const parsed = parseSkillMarkdown(markdown);
86
- if (!parsed.description) {
78
+ const data = await importSkillFromDir(skillPath);
79
+ if (!data) {
87
80
  throw new Error("Skill frontmatter missing description.");
88
81
  }
89
- const metadata = buildMetadata(parsed, { type: "local" });
90
- await ensureSkillsDir();
91
- await writeSkillFiles(metadata.name, markdown, metadata);
92
82
  const index = await loadIndex();
93
83
  const updated = upsertSkill(index, {
94
- name: metadata.name,
84
+ name: data.name,
95
85
  source: { type: "local" },
96
- checksum: parsed.checksum,
97
- updatedAt: metadata.updatedAt,
86
+ checksum: data.checksum,
87
+ updatedAt: data.updatedAt,
98
88
  });
99
89
  await saveIndex(sortIndex(updated));
100
90
  if (isJsonEnabled(options)) {
101
- printJson({ ok: true, command: "import", data: { name: metadata.name, path: resolved } });
91
+ printJson({ ok: true, command: "import", data: { name: data.name, path: resolved } });
102
92
  return;
103
93
  }
104
- printInfo(`Imported skill: ${metadata.name}`);
94
+ printInfo(`Imported skill: ${data.name}`);
105
95
  }
106
96
  catch (error) {
107
97
  handleCommandError(options, "import", error);
@@ -1,9 +1,34 @@
1
+ import chalk from "chalk";
1
2
  import fs from "node:fs/promises";
3
+ import terminalLink from "terminal-link";
2
4
  import { discoverGlobalSkills } from "../lib/global-skills.js";
3
5
  import { loadIndex } from "../lib/index.js";
4
6
  import { isJsonEnabled, printInfo, printJson } from "../lib/output.js";
5
7
  import { resolveRuntime } from "../lib/runtime.js";
6
8
  import { groupAndSort, sortByName } from "../lib/source-grouping.js";
9
+ function getSkillUrl(skill) {
10
+ if (skill.source.type === "url" && skill.source.url) {
11
+ return skill.source.url;
12
+ }
13
+ if (skill.source.type === "git" && skill.source.repo) {
14
+ const repo = skill.source.repo;
15
+ // If already a full URL, use it directly
16
+ if (repo.startsWith("http://") || repo.startsWith("https://")) {
17
+ return repo;
18
+ }
19
+ // Convert shorthand (user/repo) to full GitHub URL
20
+ return `https://github.com/${repo}`;
21
+ }
22
+ return undefined;
23
+ }
24
+ function linkSkillName(skill) {
25
+ const url = getSkillUrl(skill);
26
+ if (url && terminalLink.isSupported) {
27
+ const linkIcon = terminalLink("‹↗›", url);
28
+ return `${skill.name} ${chalk.dim(linkIcon)}`;
29
+ }
30
+ return skill.name;
31
+ }
7
32
  async function detectSubcommands(skillPath) {
8
33
  try {
9
34
  const entries = await fs.readdir(skillPath);
@@ -108,7 +133,7 @@ function printScopeGroup(group) {
108
133
  for (const sourceGroup of projectGroup.sourceGroups) {
109
134
  printInfo(` ${sourceGroup.source}`);
110
135
  for (const skill of sourceGroup.skills) {
111
- printInfo(` ${skill.name}`);
136
+ printInfo(` ${linkSkillName(skill)}`);
112
137
  if (skill.subcommands.length > 0) {
113
138
  printInfo(` → ${skill.subcommands.join(", ")}`);
114
139
  }
@@ -121,7 +146,7 @@ function printScopeGroup(group) {
121
146
  printInfo("");
122
147
  printInfo(`${sourceGroup.source}`);
123
148
  for (const skill of sourceGroup.skills) {
124
- printInfo(` ${skill.name}`);
149
+ printInfo(` ${linkSkillName(skill)}`);
125
150
  if (skill.subcommands.length > 0) {
126
151
  printInfo(` → ${skill.subcommands.join(", ")}`);
127
152
  }
@@ -147,6 +172,14 @@ function filterByAgents(skills, agents) {
147
172
  const agentSet = new Set(agents);
148
173
  return skills.filter((skill) => skill.installs?.some((install) => install.agent && agentSet.has(install.agent)));
149
174
  }
175
+ function filterUserScope(skills) {
176
+ return skills
177
+ .filter((skill) => skill.installs?.some((install) => install.scope === "user") ?? !skill.installs?.length)
178
+ .map((skill) => ({
179
+ ...skill,
180
+ installs: skill.installs?.filter((install) => install.scope === "user"),
181
+ }));
182
+ }
150
183
  export function registerList(program) {
151
184
  program
152
185
  .command("list")
@@ -161,8 +194,9 @@ export function registerList(program) {
161
194
  const indexedSkills = options.agents
162
195
  ? filterByAgents(index.skills, runtime.agentList)
163
196
  : index.skills;
164
- const allSkills = [...indexedSkills, ...globalSkills];
165
- const enrichedSkills = await enrichWithSubcommands(allSkills);
197
+ const scopedSkills = options.global ? filterUserScope(indexedSkills) : indexedSkills;
198
+ const mergedSkills = [...scopedSkills, ...globalSkills];
199
+ const enrichedSkills = await enrichWithSubcommands(mergedSkills);
166
200
  if (isJsonEnabled(options)) {
167
201
  printJson({
168
202
  ok: true,
@@ -1,11 +1,53 @@
1
1
  import path from "node:path";
2
+ import { allAgents, getProjectPathsForAgents } from "../lib/agents.js";
2
3
  import { handleCommandError } from "../lib/command.js";
4
+ import { discoverSkills } from "../lib/discovery.js";
3
5
  import { collect } from "../lib/fs-utils.js";
4
- import { loadIndex } from "../lib/index.js";
6
+ import { loadIndex, saveIndex, sortIndex, upsertSkill } from "../lib/index.js";
5
7
  import { collectProjectSkills, getProjectInstallPaths, getProjectSkills } from "../lib/installs.js";
6
8
  import { isJsonEnabled, printInfo, printJson } from "../lib/output.js";
7
9
  import { loadProjects, saveProjects, upsertProject } from "../lib/projects.js";
10
+ import { importSkillFromDir } from "../lib/skill-store.js";
8
11
  import { copySkillToInstallPaths } from "../lib/sync.js";
12
+ function getProjectSkillPaths(projectRoot) {
13
+ const agentPaths = getProjectPathsForAgents(projectRoot, allAgents);
14
+ const seen = new Set();
15
+ // Add generic skills/ directory
16
+ seen.add(path.join(projectRoot, "skills"));
17
+ // Add all agent-specific project paths
18
+ for (const { path: agentPath } of agentPaths) {
19
+ seen.add(agentPath);
20
+ }
21
+ return Array.from(seen);
22
+ }
23
+ async function discoverProjectSkills(projectRoot) {
24
+ const skillPaths = getProjectSkillPaths(projectRoot);
25
+ const discovered = await discoverSkills(skillPaths);
26
+ if (discovered.length === 0) {
27
+ return [];
28
+ }
29
+ const index = await loadIndex();
30
+ const imported = [];
31
+ for (const skill of discovered) {
32
+ const data = await importSkillFromDir(skill.skillFile);
33
+ if (!data) {
34
+ continue;
35
+ }
36
+ const updated = upsertSkill(index, {
37
+ name: data.name,
38
+ source: { type: "local" },
39
+ checksum: data.checksum,
40
+ updatedAt: data.updatedAt,
41
+ installs: [{ scope: "project", agent: "claude", path: skill.skillDir, projectRoot }],
42
+ });
43
+ index.skills = updated.skills;
44
+ imported.push(data.name);
45
+ }
46
+ if (imported.length > 0) {
47
+ await saveIndex(sortIndex(index));
48
+ }
49
+ return imported;
50
+ }
9
51
  function parseAgentPaths(entries) {
10
52
  const overrides = {};
11
53
  for (const entry of entries) {
@@ -22,6 +64,7 @@ export function registerProject(program) {
22
64
  const project = program.command("project").description("Manage projects");
23
65
  project
24
66
  .command("add")
67
+ .description("Register a project and import skills from its skills/ directory")
25
68
  .argument("<path>", "Project path")
26
69
  .option("--agent-path <agentPath>", "Agent path override (agent=path)", collect)
27
70
  .option("--json", "JSON output")
@@ -45,6 +88,7 @@ export function registerProject(program) {
45
88
  }),
46
89
  };
47
90
  await saveProjects(merged);
91
+ const importedSkills = await discoverProjectSkills(resolved);
48
92
  if (isJsonEnabled(options)) {
49
93
  printJson({
50
94
  ok: true,
@@ -52,11 +96,15 @@ export function registerProject(program) {
52
96
  data: {
53
97
  path: resolved,
54
98
  agentPaths: merged.projects.find((p) => p.root === resolved)?.agentPaths ?? {},
99
+ skills: importedSkills,
55
100
  },
56
101
  });
57
102
  return;
58
103
  }
59
104
  printInfo(`Project registered: ${resolved}`);
105
+ if (importedSkills.length > 0) {
106
+ printInfo(`Discovered ${importedSkills.length} skill(s): ${importedSkills.join(", ")}`);
107
+ }
60
108
  }
61
109
  catch (error) {
62
110
  handleCommandError(options, "project add", error);
@@ -4,7 +4,7 @@ import { loadConfig } from "../lib/config.js";
4
4
  import { fetchText } from "../lib/fetcher.js";
5
5
  import { loadIndex, saveIndex, sortIndex, upsertSkill } from "../lib/index.js";
6
6
  import { getInstallPaths } from "../lib/installs.js";
7
- import { isJsonEnabled, printInfo, printJson } from "../lib/output.js";
7
+ import { isJsonEnabled, printFailure, printInfo, printJson, printSkipped, printSuccess, startSpinner, stopSpinner, } from "../lib/output.js";
8
8
  import { fetchRepoFile, normalizeRepoRef, writeRepoSkillDirectory } from "../lib/repo-skills.js";
9
9
  import { buildMetadata, parseSkillMarkdown } from "../lib/skill-parser.js";
10
10
  import { ensureSkillsDir, writeSkillFiles, writeSkillMetadata } from "../lib/skill-store.js";
@@ -21,31 +21,6 @@ function groupBySource(results) {
21
21
  failedCount: items.filter((r) => r.status === "failed").length,
22
22
  }));
23
23
  }
24
- function formatSourceHeader(group) {
25
- const count = group.results.length;
26
- const skillWord = count === 1 ? "skill" : "skills";
27
- if (group.source === "local") {
28
- return `${group.source} (${count} ${skillWord} - skipped)`;
29
- }
30
- if (group.failedCount > 0) {
31
- return `${group.source} (${count} ${skillWord}, ${group.failedCount} failed)`;
32
- }
33
- return `${group.source} (${count} ${skillWord})`;
34
- }
35
- function printSourceGroup(group) {
36
- printInfo(formatSourceHeader(group));
37
- for (const result of group.results) {
38
- if (result.status === "skipped") {
39
- printInfo(` - ${result.name}`);
40
- }
41
- else if (result.status === "failed") {
42
- printInfo(` ✗ ${result.name} (${result.error ?? "failed"})`);
43
- }
44
- else {
45
- printInfo(` ✓ ${result.name}`);
46
- }
47
- }
48
- }
49
24
  async function updateUrlSkill(skill, index, projectRoot, config) {
50
25
  if (!skill.source.url) {
51
26
  return;
@@ -129,39 +104,72 @@ export function registerUpdate(program) {
129
104
  const config = await loadConfig();
130
105
  const projectRoot = options.project ? path.resolve(options.project) : null;
131
106
  const results = [];
132
- for (const skill of targets) {
107
+ const showProgress = !isJsonEnabled(options);
108
+ if (showProgress && targets.length > 0) {
109
+ printInfo(`Updating ${targets.length} skill${targets.length === 1 ? "" : "s"}...\n`);
110
+ }
111
+ const total = targets.length;
112
+ for (let i = 0; i < targets.length; i++) {
113
+ const skill = targets[i];
114
+ const progress = `(${i + 1}/${total})`;
133
115
  if (skill.source.type === "url") {
116
+ if (showProgress) {
117
+ startSpinner(`${skill.name} ${progress}`);
118
+ }
134
119
  try {
135
120
  await updateUrlSkill(skill, index, projectRoot, config);
136
121
  results.push({ name: skill.name, source: "url", status: "updated" });
122
+ if (showProgress) {
123
+ printSuccess(skill.name);
124
+ }
137
125
  }
138
126
  catch (err) {
127
+ const errorMsg = err instanceof Error ? err.message : "unknown error";
139
128
  results.push({
140
129
  name: skill.name,
141
130
  source: "url",
142
131
  status: "failed",
143
- error: err instanceof Error ? err.message : "unknown error",
132
+ error: errorMsg,
144
133
  });
134
+ if (showProgress) {
135
+ printFailure(skill.name, errorMsg);
136
+ }
145
137
  }
146
138
  }
147
139
  else if (skill.source.type === "git") {
140
+ if (showProgress) {
141
+ startSpinner(`${skill.name} ${progress}`);
142
+ }
148
143
  try {
149
144
  await updateGitSkill(skill, index, projectRoot, config);
150
145
  results.push({ name: skill.name, source: "git", status: "updated" });
146
+ if (showProgress) {
147
+ printSuccess(skill.name);
148
+ }
151
149
  }
152
150
  catch (err) {
151
+ const errorMsg = err instanceof Error ? err.message : "unknown error";
153
152
  results.push({
154
153
  name: skill.name,
155
154
  source: "git",
156
155
  status: "failed",
157
- error: err instanceof Error ? err.message : "unknown error",
156
+ error: errorMsg,
158
157
  });
158
+ if (showProgress) {
159
+ printFailure(skill.name, errorMsg);
160
+ }
159
161
  }
160
162
  }
161
163
  else {
162
164
  results.push({ name: skill.name, source: skill.source.type, status: "skipped" });
165
+ if (showProgress) {
166
+ printSkipped(skill.name, "skipped");
167
+ }
163
168
  }
164
169
  }
170
+ if (showProgress) {
171
+ stopSpinner();
172
+ }
165
173
  await saveIndex(sortIndex(index));
166
174
  const sourceGroups = groupBySource(results);
167
175
  const totalUpdated = results.filter((r) => r.status === "updated").length;
@@ -189,21 +197,15 @@ export function registerUpdate(program) {
189
197
  printInfo("No skills to update.");
190
198
  return;
191
199
  }
192
- printInfo("Skill Update");
193
- for (const group of sourceGroups) {
194
- printInfo("");
195
- printSourceGroup(group);
196
- }
197
200
  // Summary line
198
- printInfo("");
199
201
  if (totalFailed > 0) {
200
- printInfo(`Updated ${totalUpdated} of ${totalTrackable} trackable skills (${totalFailed} failed).`);
202
+ printInfo(`\nUpdated ${totalUpdated} of ${totalTrackable} trackable skill${totalTrackable === 1 ? "" : "s"} (${totalFailed} failed).`);
201
203
  }
202
204
  else if (totalUpdated > 0) {
203
- printInfo(`Updated ${totalUpdated} of ${totalTrackable} trackable skills.`);
205
+ printInfo(`\nUpdated ${totalUpdated} of ${totalTrackable} trackable skill${totalTrackable === 1 ? "" : "s"}.`);
204
206
  }
205
207
  else if (totalSkipped > 0 && totalTrackable === 0) {
206
- printInfo("No trackable skills to update.");
208
+ printInfo("\nNo trackable skills to update.");
207
209
  }
208
210
  }
209
211
  catch (error) {
@@ -48,6 +48,12 @@ export function getUserAgentPaths(projectRoot) {
48
48
  return Object.values(paths).flatMap((entry) => entry.user);
49
49
  }
50
50
  export function getUserPathsForAgents(projectRoot, agents) {
51
+ return getPathsForAgents(projectRoot, agents, "user");
52
+ }
53
+ export function getProjectPathsForAgents(projectRoot, agents) {
54
+ return getPathsForAgents(projectRoot, agents, "project");
55
+ }
56
+ function getPathsForAgents(projectRoot, agents, scope) {
51
57
  const paths = agentPaths(projectRoot);
52
58
  const seen = new Set();
53
59
  const results = [];
@@ -56,12 +62,12 @@ export function getUserPathsForAgents(projectRoot, agents) {
56
62
  if (!agentEntry) {
57
63
  continue;
58
64
  }
59
- for (const userPath of agentEntry.user) {
60
- if (seen.has(userPath)) {
65
+ for (const pathValue of agentEntry[scope]) {
66
+ if (seen.has(pathValue)) {
61
67
  continue;
62
68
  }
63
- seen.add(userPath);
64
- results.push({ agent, path: userPath });
69
+ seen.add(pathValue);
70
+ results.push({ agent, path: pathValue });
65
71
  }
66
72
  }
67
73
  return results;
@@ -1,5 +1,44 @@
1
+ import { execSync } from "node:child_process";
2
+ const GITHUB_HOSTS = ["api.github.com", "raw.githubusercontent.com"];
3
+ let cachedToken;
4
+ function getGitHubToken() {
5
+ if (cachedToken !== undefined)
6
+ return cachedToken;
7
+ // Check environment variables first, then fall back to gh CLI
8
+ const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
9
+ if (envToken) {
10
+ cachedToken = envToken;
11
+ }
12
+ else {
13
+ try {
14
+ cachedToken =
15
+ execSync("gh auth token", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim() ||
16
+ null;
17
+ }
18
+ catch {
19
+ cachedToken = null;
20
+ }
21
+ }
22
+ return cachedToken;
23
+ }
24
+ function isGitHubUrl(url) {
25
+ try {
26
+ const { host } = new URL(url);
27
+ return GITHUB_HOSTS.includes(host);
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
1
33
  export async function fetchText(url) {
2
- const response = await fetch(url);
34
+ const headers = {};
35
+ if (isGitHubUrl(url)) {
36
+ const token = getGitHubToken();
37
+ if (token) {
38
+ headers.Authorization = `Bearer ${token}`;
39
+ }
40
+ }
41
+ const response = await fetch(url, { headers });
3
42
  if (!response.ok) {
4
43
  throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
5
44
  }
package/dist/lib/index.js CHANGED
@@ -29,9 +29,30 @@ export function upsertSkill(index, skill) {
29
29
  next.skills.push(skill);
30
30
  return next;
31
31
  }
32
- next.skills[existingIndex] = { ...next.skills[existingIndex], ...skill };
32
+ const existing = next.skills[existingIndex];
33
+ const mergedInstalls = mergeInstalls(existing.installs, skill.installs);
34
+ next.skills[existingIndex] = { ...existing, ...skill, installs: mergedInstalls };
33
35
  return next;
34
36
  }
37
+ function mergeInstalls(existing, incoming) {
38
+ if (!existing && !incoming)
39
+ return undefined;
40
+ if (!existing)
41
+ return incoming;
42
+ if (!incoming)
43
+ return existing;
44
+ // Dedupe by scope + agent + projectRoot combination
45
+ const seen = new Set();
46
+ const merged = [];
47
+ for (const install of [...existing, ...incoming]) {
48
+ const key = `${install.scope}:${install.agent}:${install.projectRoot ?? ""}`;
49
+ if (seen.has(key))
50
+ continue;
51
+ seen.add(key);
52
+ merged.push(install);
53
+ }
54
+ return merged;
55
+ }
35
56
  export function sortIndex(index) {
36
57
  const skills = [...index.skills].sort((a, b) => a.name.localeCompare(b.name));
37
58
  return { ...index, skills };
@@ -2,6 +2,48 @@ import chalk from "chalk";
2
2
  export function isJsonEnabled(options) {
3
3
  return Boolean(options.json);
4
4
  }
5
+ // Progress indicator support
6
+ const isTTY = process.stdout.isTTY ?? false;
7
+ // Braille spinner frames (single character, smooth animation)
8
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+ let spinnerInterval = null;
10
+ let spinnerFrame = 0;
11
+ export function startSpinner(message) {
12
+ if (!isTTY)
13
+ return;
14
+ spinnerFrame = 0;
15
+ const render = () => {
16
+ const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
17
+ process.stdout.write(`\r\x1b[K ${frame} ${message}`);
18
+ spinnerFrame++;
19
+ };
20
+ render();
21
+ spinnerInterval = setInterval(render, 80);
22
+ }
23
+ export function stopSpinner() {
24
+ if (spinnerInterval) {
25
+ clearInterval(spinnerInterval);
26
+ spinnerInterval = null;
27
+ }
28
+ if (isTTY) {
29
+ process.stdout.write(`\r\x1b[K`);
30
+ }
31
+ }
32
+ export function printProgressResult(message) {
33
+ stopSpinner();
34
+ process.stdout.write(`${message}\n`);
35
+ }
36
+ // Common result formatters for progress output
37
+ export function printSuccess(name, suffix) {
38
+ const msg = suffix ? ` ✓ ${name} (${suffix})` : ` ✓ ${name}`;
39
+ printProgressResult(msg);
40
+ }
41
+ export function printFailure(name, error) {
42
+ printProgressResult(` ✗ ${name} (${error})`);
43
+ }
44
+ export function printSkipped(name, reason) {
45
+ printProgressResult(` - ${name} (${reason})`);
46
+ }
5
47
  export function printJson(result) {
6
48
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
7
49
  }
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { skillboxSkillsDir } from "./paths.js";
5
+ import { buildMetadata, parseSkillMarkdown } from "./skill-parser.js";
5
6
  export async function ensureSkillsDir() {
6
7
  await fs.mkdir(skillboxSkillsDir(), { recursive: true });
7
8
  }
@@ -26,3 +27,23 @@ export async function writeSkillMetadata(name, metadata) {
26
27
  export function hashContent(content) {
27
28
  return crypto.createHash("sha256").update(content).digest("hex");
28
29
  }
30
+ /**
31
+ * Import a skill from a local directory. Reads the markdown, parses it,
32
+ * and writes to the skill store. Returns the data needed for index updates,
33
+ * or null if the skill is invalid (missing description).
34
+ */
35
+ export async function importSkillFromDir(skillFile) {
36
+ const markdown = await fs.readFile(skillFile, "utf8");
37
+ const parsed = parseSkillMarkdown(markdown);
38
+ if (!parsed.description) {
39
+ return null;
40
+ }
41
+ const metadata = buildMetadata(parsed, { type: "local" });
42
+ await ensureSkillsDir();
43
+ await writeSkillFiles(metadata.name, markdown, metadata);
44
+ return {
45
+ name: metadata.name,
46
+ checksum: parsed.checksum,
47
+ updatedAt: metadata.updatedAt,
48
+ };
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillbox",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Local-first, agent-agnostic skills manager",
5
5
  "license": "MIT",
6
6
  "author": "Christian Anagnostou",
@@ -33,18 +33,24 @@
33
33
  "lint:ci": "oxlint --deny-warnings",
34
34
  "format": "oxfmt",
35
35
  "format:check": "oxfmt --check",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "test:ci": "vitest run --reporter=verbose --reporter=junit --outputFile=test-results.xml",
36
39
  "prepublishOnly": "npm run lint:ci && npm run format:check && npm run build"
37
40
  },
38
41
  "dependencies": {
39
42
  "chalk": "^5.3.0",
40
43
  "commander": "^12.0.0",
44
+ "terminal-link": "^3.0.0",
41
45
  "zod": "^3.23.8"
42
46
  },
43
47
  "devDependencies": {
44
48
  "@types/node": "^20.11.19",
49
+ "execa": "^9.6.1",
45
50
  "oxfmt": "^0.16.0",
46
51
  "oxlint": "^0.16.0",
47
52
  "ts-node": "^10.9.2",
48
- "typescript": "^5.4.2"
53
+ "typescript": "^5.4.2",
54
+ "vitest": "^4.0.18"
49
55
  }
50
56
  }