@tomaskral/skillmanager 0.1.0

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 ADDED
@@ -0,0 +1,106 @@
1
+ # skillmanager
2
+
3
+ A CLI tool for installing and updating [Claude Code](https://docs.anthropic.com/en/docs/claude-code) skills from local paths or GitHub URLs.
4
+
5
+ ## Why
6
+
7
+ The `~/.claude/skills/` directory is becoming a standard path for AI coding assistants to discover reusable skills. Claude Code uses it natively, and other tools like Cursor are adopting the same convention. However, there is no built-in way to install skills into this directory from external sources, and more importantly, no way to keep them up to date as upstream skill repositories evolve.
8
+
9
+ skillmanager fills that gap. It handles installation from local paths or GitHub URLs, tracks provenance via metadata, and provides an update mechanism that detects upstream changes by comparing git commit SHAs.
10
+
11
+ ## How it works
12
+
13
+ Skills are installed into `~/.claude/skills/` where Claude Code can discover and use them.
14
+
15
+ ## Prerequisites
16
+
17
+ - [Node.js](https://nodejs.org/) >= 18
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install -g skillmanager
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Install a skill
28
+
29
+ From a local path:
30
+
31
+ ```bash
32
+ npx skillmanager install ~/Code/my-plugins/skills/my-skill
33
+ npx skillmanager install ./plugins/jira-utils/skills/use-jira-cli
34
+ ```
35
+
36
+ From a GitHub URL (use the `tree` URL for the skill directory):
37
+
38
+ ```bash
39
+ npx skillmanager install https://github.com/owner/repo/tree/main/path/to/skill
40
+ ```
41
+
42
+ If the skill already exists, use `--force` to overwrite:
43
+
44
+ ```bash
45
+ npx skillmanager install ./path/to/skill --force
46
+ ```
47
+
48
+ Preview what would happen without making changes:
49
+
50
+ ```bash
51
+ npx skillmanager install ./path/to/skill --dry-run
52
+ ```
53
+
54
+ ### Update installed skills
55
+
56
+ Update a single skill:
57
+
58
+ ```bash
59
+ npx skillmanager update my-skill
60
+ ```
61
+
62
+ Update all installed skills:
63
+
64
+ ```bash
65
+ npx skillmanager update --all
66
+ ```
67
+
68
+ Force re-install even if already up to date:
69
+
70
+ ```bash
71
+ npx skillmanager update my-skill --force
72
+ ```
73
+
74
+ ## Details
75
+
76
+ 1. **Install** copies a skill directory into `~/.claude/skills/<skill-name>/`. The source directory must contain a `SKILL.md` file. A `.metadata.json` file is written alongside the skill to track where it came from and what commit it was installed from.
77
+
78
+ 2. **Update** reads `.metadata.json` from installed skills and compares the stored git commit against the current commit at the source (local git HEAD or GitHub API). If a newer commit is found, the skill is re-copied.
79
+
80
+ ### GitHub installs
81
+
82
+ GitHub sources are specified as `https://github.com/owner/repo/tree/branch/path/to/skill`. The tool downloads the branch tarball and extracts only the target subdirectory.
83
+
84
+ Set the `GITHUB_TOKEN` environment variable to authenticate API requests and avoid rate limits:
85
+
86
+ ```bash
87
+ export GITHUB_TOKEN=ghp_...
88
+ ```
89
+
90
+ ### Metadata tracking
91
+
92
+ Each installed skill gets a `.metadata.json` file containing:
93
+
94
+ - **Source type** — `github` or `local`
95
+ - **Source location** — URL or filesystem path
96
+ - **Git commit SHA** — used for change detection during updates
97
+ - **Timestamps** — `installed_at` and `updated_at`
98
+
99
+ ## Options reference
100
+
101
+ | Flag | Description |
102
+ |------|-------------|
103
+ | `--force` | Overwrite an existing skill or force re-install even if up to date |
104
+ | `--dry-run` | Show what would happen without making changes |
105
+ | `--all` | Update all installed skills (update command only) |
106
+ | `--help` | Show help message |
@@ -0,0 +1,7 @@
1
+ interface InstallOptions {
2
+ source: string;
3
+ force: boolean;
4
+ dryRun: boolean;
5
+ }
6
+ export declare function install(options: InstallOptions): Promise<void>;
7
+ export {};
@@ -0,0 +1,129 @@
1
+ import { existsSync, mkdirSync, rmSync, cpSync, mkdtempSync } from "fs";
2
+ import { join, basename } from "path";
3
+ import { tmpdir } from "os";
4
+ import { SKILLS_DIR, validateSkillDir, log, info, error, success } from "../utils.js";
5
+ import { writeMetadata } from "../metadata.js";
6
+ import { parseGitHubUrl, downloadSkill, fetchLatestCommit } from "../sources/github.js";
7
+ import { resolveLocalPath, getGitCommit } from "../sources/local.js";
8
+ export async function install(options) {
9
+ const { source, force, dryRun } = options;
10
+ const isGitHub = source.startsWith("https://github.com/");
11
+ if (isGitHub) {
12
+ await installFromGitHub(source, force, dryRun);
13
+ }
14
+ else {
15
+ await installFromLocal(source, force, dryRun);
16
+ }
17
+ }
18
+ async function installFromGitHub(url, force, dryRun) {
19
+ const parsed = parseGitHubUrl(url);
20
+ const skillName = basename(parsed.path);
21
+ const dest = join(SKILLS_DIR, skillName);
22
+ info(`Fetching skill '${skillName}' from GitHub...`);
23
+ log(` Repo: ${parsed.owner}/${parsed.repo}`);
24
+ log(` Branch: ${parsed.branch}`);
25
+ log(` Path: ${parsed.path}`);
26
+ log("");
27
+ if (existsSync(dest)) {
28
+ if (!force) {
29
+ error(`Skill '${skillName}' already exists at ${dest}`);
30
+ log("Use --force to overwrite");
31
+ process.exit(1);
32
+ }
33
+ if (dryRun) {
34
+ log(`Would replace existing skill: ${skillName}`);
35
+ }
36
+ }
37
+ if (dryRun) {
38
+ log(`Would install skill '${skillName}' to ${dest}`);
39
+ log("");
40
+ log("Dry run complete. No changes made.");
41
+ return;
42
+ }
43
+ // Download to a temp directory first, then move
44
+ const tmpDir = mkdtempSync(join(tmpdir(), "skillmanager-install-"));
45
+ try {
46
+ await downloadSkill(parsed, tmpDir);
47
+ validateSkillDir(tmpDir);
48
+ // Fetch commit SHA
49
+ let commitSha;
50
+ try {
51
+ commitSha = await fetchLatestCommit(parsed);
52
+ }
53
+ catch {
54
+ commitSha = "unknown";
55
+ }
56
+ // Ensure skills directory exists
57
+ if (!existsSync(SKILLS_DIR)) {
58
+ mkdirSync(SKILLS_DIR, { recursive: true });
59
+ }
60
+ // Remove existing if force
61
+ if (existsSync(dest)) {
62
+ rmSync(dest, { recursive: true });
63
+ }
64
+ // Copy to destination
65
+ cpSync(tmpDir, dest, { recursive: true });
66
+ // Write metadata
67
+ const now = new Date().toISOString();
68
+ const metadata = {
69
+ source_type: "github",
70
+ source_url: url,
71
+ github_commit: commitSha,
72
+ installed_at: now,
73
+ updated_at: now,
74
+ };
75
+ writeMetadata(dest, metadata);
76
+ success(`Installed skill '${skillName}' to ${dest}`);
77
+ }
78
+ finally {
79
+ rmSync(tmpDir, { recursive: true, force: true });
80
+ }
81
+ }
82
+ async function installFromLocal(source, force, dryRun) {
83
+ const resolvedPath = resolveLocalPath(source);
84
+ const skillName = basename(resolvedPath);
85
+ const dest = join(SKILLS_DIR, skillName);
86
+ validateSkillDir(resolvedPath);
87
+ info(`Installing skill '${skillName}' from local path...`);
88
+ log(` Source: ${resolvedPath}`);
89
+ log("");
90
+ if (existsSync(dest)) {
91
+ if (!force) {
92
+ error(`Skill '${skillName}' already exists at ${dest}`);
93
+ log("Use --force to overwrite");
94
+ process.exit(1);
95
+ }
96
+ if (dryRun) {
97
+ log(`Would replace existing skill: ${skillName}`);
98
+ }
99
+ }
100
+ if (dryRun) {
101
+ log(`Would install skill '${skillName}' to ${dest}`);
102
+ log("");
103
+ log("Dry run complete. No changes made.");
104
+ return;
105
+ }
106
+ // Detect git commit
107
+ const gitCommit = await getGitCommit(resolvedPath);
108
+ // Ensure skills directory exists
109
+ if (!existsSync(SKILLS_DIR)) {
110
+ mkdirSync(SKILLS_DIR, { recursive: true });
111
+ }
112
+ // Remove existing if force
113
+ if (existsSync(dest)) {
114
+ rmSync(dest, { recursive: true });
115
+ }
116
+ // Copy to destination
117
+ cpSync(resolvedPath, dest, { recursive: true });
118
+ // Write metadata
119
+ const now = new Date().toISOString();
120
+ const metadata = {
121
+ source_type: "local",
122
+ source_path: resolvedPath,
123
+ installed_at: now,
124
+ updated_at: now,
125
+ ...(gitCommit ? { local_git_commit: gitCommit } : {}),
126
+ };
127
+ writeMetadata(dest, metadata);
128
+ success(`Installed skill '${skillName}' to ${dest}`);
129
+ }
@@ -0,0 +1 @@
1
+ export declare function list(): void;
@@ -0,0 +1,35 @@
1
+ import { existsSync, readdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { SKILLS_DIR, log, info } from "../utils.js";
4
+ import { readMetadata } from "../metadata.js";
5
+ export function list() {
6
+ if (!existsSync(SKILLS_DIR)) {
7
+ log("No skills installed.");
8
+ return;
9
+ }
10
+ const entries = readdirSync(SKILLS_DIR, { withFileTypes: true })
11
+ .filter((entry) => entry.isDirectory())
12
+ .map((entry) => ({
13
+ name: entry.name,
14
+ metadata: readMetadata(join(SKILLS_DIR, entry.name)),
15
+ }))
16
+ .filter((entry) => entry.metadata !== null);
17
+ if (entries.length === 0) {
18
+ log("No skills installed.");
19
+ return;
20
+ }
21
+ info(`Installed skills (${entries.length}):\n`);
22
+ for (const { name, metadata } of entries) {
23
+ const source = metadata.source_type === "github"
24
+ ? metadata.source_url
25
+ : metadata.source_path;
26
+ const commit = metadata.source_type === "github"
27
+ ? metadata.github_commit.slice(0, 12)
28
+ : metadata.local_git_commit?.slice(0, 12) ?? "unknown";
29
+ log(` ${name}`);
30
+ log(` source: ${source}`);
31
+ log(` commit: ${commit}`);
32
+ log(` updated: ${metadata.updated_at}`);
33
+ log("");
34
+ }
35
+ }
@@ -0,0 +1,8 @@
1
+ interface UpdateOptions {
2
+ skillName?: string;
3
+ all: boolean;
4
+ force: boolean;
5
+ dryRun: boolean;
6
+ }
7
+ export declare function update(options: UpdateOptions): Promise<void>;
8
+ export {};
@@ -0,0 +1,153 @@
1
+ import { existsSync, readdirSync, rmSync, cpSync, mkdtempSync } from "fs";
2
+ import { join } from "path";
3
+ import { tmpdir } from "os";
4
+ import { SKILLS_DIR, validateSkillDir, log, info, error, success } from "../utils.js";
5
+ import { readMetadata, writeMetadata, } from "../metadata.js";
6
+ import { parseGitHubUrl, downloadSkill, fetchLatestCommit } from "../sources/github.js";
7
+ import { getGitCommit } from "../sources/local.js";
8
+ export async function update(options) {
9
+ const { skillName, all, force, dryRun } = options;
10
+ if (!skillName && !all) {
11
+ error("Specify a skill name or use --all");
12
+ process.exit(1);
13
+ }
14
+ if (!existsSync(SKILLS_DIR)) {
15
+ error(`Skills directory not found: ${SKILLS_DIR}`);
16
+ process.exit(1);
17
+ }
18
+ const skillNames = all ? getInstalledSkills() : [skillName];
19
+ if (skillNames.length === 0) {
20
+ log("No installed skills with metadata found.");
21
+ return;
22
+ }
23
+ for (const name of skillNames) {
24
+ await updateSkill(name, force, dryRun);
25
+ }
26
+ }
27
+ function getInstalledSkills() {
28
+ if (!existsSync(SKILLS_DIR))
29
+ return [];
30
+ return readdirSync(SKILLS_DIR, { withFileTypes: true })
31
+ .filter((entry) => entry.isDirectory())
32
+ .filter((entry) => {
33
+ const metadata = readMetadata(join(SKILLS_DIR, entry.name));
34
+ return metadata !== null;
35
+ })
36
+ .map((entry) => entry.name);
37
+ }
38
+ async function updateSkill(skillName, force, dryRun) {
39
+ const skillDir = join(SKILLS_DIR, skillName);
40
+ if (!existsSync(skillDir)) {
41
+ error(`Skill '${skillName}' is not installed`);
42
+ return;
43
+ }
44
+ const metadata = readMetadata(skillDir);
45
+ if (!metadata) {
46
+ error(`Skill '${skillName}' has no metadata — cannot update. Reinstall it.`);
47
+ return;
48
+ }
49
+ info(`Checking '${skillName}' for updates...`);
50
+ if (metadata.source_type === "github") {
51
+ await updateGitHubSkill(skillName, skillDir, metadata, force, dryRun);
52
+ }
53
+ else {
54
+ await updateLocalSkill(skillName, skillDir, metadata, force, dryRun);
55
+ }
56
+ }
57
+ async function updateGitHubSkill(skillName, skillDir, metadata, force, dryRun) {
58
+ const parsed = parseGitHubUrl(metadata.source_url);
59
+ let latestCommit;
60
+ try {
61
+ latestCommit = await fetchLatestCommit(parsed);
62
+ }
63
+ catch (err) {
64
+ error(`Failed to check for updates: ${err instanceof Error ? err.message : err}`);
65
+ return;
66
+ }
67
+ if (latestCommit === metadata.github_commit && !force) {
68
+ log(` Up to date (commit: ${latestCommit.slice(0, 12)})`);
69
+ return;
70
+ }
71
+ if (latestCommit === metadata.github_commit && force) {
72
+ log(` Already at latest commit, but --force specified`);
73
+ }
74
+ else {
75
+ log(` Update available: ${metadata.github_commit.slice(0, 12)} -> ${latestCommit.slice(0, 12)}`);
76
+ }
77
+ if (dryRun) {
78
+ log(` Would update skill '${skillName}'`);
79
+ return;
80
+ }
81
+ const tmpDir = mkdtempSync(join(tmpdir(), "skillmanager-update-"));
82
+ try {
83
+ await downloadSkill(parsed, tmpDir);
84
+ validateSkillDir(tmpDir);
85
+ // Replace skill contents
86
+ rmSync(skillDir, { recursive: true });
87
+ cpSync(tmpDir, skillDir, { recursive: true });
88
+ // Update metadata
89
+ const now = new Date().toISOString();
90
+ const updatedMetadata = {
91
+ ...metadata,
92
+ github_commit: latestCommit,
93
+ updated_at: now,
94
+ };
95
+ writeMetadata(skillDir, updatedMetadata);
96
+ success(` Updated '${skillName}'`);
97
+ }
98
+ finally {
99
+ rmSync(tmpDir, { recursive: true, force: true });
100
+ }
101
+ }
102
+ async function updateLocalSkill(skillName, skillDir, metadata, force, dryRun) {
103
+ const sourcePath = metadata.source_path;
104
+ if (!existsSync(sourcePath)) {
105
+ error(`Source path no longer exists: ${sourcePath}`);
106
+ return;
107
+ }
108
+ const currentCommit = await getGitCommit(sourcePath);
109
+ const storedCommit = metadata.local_git_commit;
110
+ // Determine if update is needed
111
+ let needsUpdate = force;
112
+ if (storedCommit && currentCommit) {
113
+ if (storedCommit !== currentCommit) {
114
+ log(` Update available: ${storedCommit.slice(0, 12)} -> ${currentCommit.slice(0, 12)}`);
115
+ needsUpdate = true;
116
+ }
117
+ else if (!force) {
118
+ log(` Up to date (commit: ${currentCommit.slice(0, 12)})`);
119
+ return;
120
+ }
121
+ else {
122
+ log(` Already at latest commit, but --force specified`);
123
+ }
124
+ }
125
+ else {
126
+ // No git commits to compare — always update if force, otherwise note it
127
+ if (!force) {
128
+ log(` No git commit info available. Use --force to re-copy.`);
129
+ return;
130
+ }
131
+ log(` --force specified, re-copying from source`);
132
+ needsUpdate = true;
133
+ }
134
+ if (!needsUpdate)
135
+ return;
136
+ if (dryRun) {
137
+ log(` Would update skill '${skillName}'`);
138
+ return;
139
+ }
140
+ validateSkillDir(sourcePath);
141
+ // Replace skill contents
142
+ rmSync(skillDir, { recursive: true });
143
+ cpSync(sourcePath, skillDir, { recursive: true });
144
+ // Update metadata
145
+ const now = new Date().toISOString();
146
+ const updatedMetadata = {
147
+ ...metadata,
148
+ updated_at: now,
149
+ ...(currentCommit ? { local_git_commit: currentCommit } : {}),
150
+ };
151
+ writeMetadata(skillDir, updatedMetadata);
152
+ success(` Updated '${skillName}'`);
153
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ import { install } from "./commands/install.js";
3
+ import { list } from "./commands/list.js";
4
+ import { update } from "./commands/update.js";
5
+ import { error, log } from "./utils.js";
6
+ const HELP = `
7
+ skillmanager - Install and update Claude Code skills
8
+
9
+ Usage:
10
+ skillmanager install <local-path-or-github-url> [--force] [--dry-run]
11
+ skillmanager list
12
+ skillmanager update [<skill-name>] [--all] [--force] [--dry-run]
13
+
14
+ Commands:
15
+ install Install a skill from a local path or GitHub URL
16
+ list List all installed skills
17
+ update Check for updates and re-install changed skills
18
+
19
+ Options:
20
+ --force Overwrite existing skill / force re-install
21
+ --dry-run Show what would be done without making changes
22
+ --all Update all installed skills (update command only)
23
+ --help Show this help message
24
+
25
+ Examples:
26
+ skillmanager install ~/Code/my-plugins/skills/my-skill
27
+ skillmanager install https://github.com/owner/repo/tree/branch/path/to/skill
28
+ skillmanager install ./plugins/jira-utils/skills/use-jira-cli --force
29
+ skillmanager update my-skill --dry-run
30
+ skillmanager update --all
31
+ `.trim();
32
+ function parseArgs(argv) {
33
+ // argv[0] = bun, argv[1] = script path
34
+ const args = argv.slice(2);
35
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
36
+ log(HELP);
37
+ process.exit(0);
38
+ }
39
+ const subcommand = args[0];
40
+ const rest = args.slice(1);
41
+ const flags = {
42
+ force: false,
43
+ dryRun: false,
44
+ all: false,
45
+ };
46
+ const positional = [];
47
+ for (const arg of rest) {
48
+ switch (arg) {
49
+ case "--force":
50
+ flags.force = true;
51
+ break;
52
+ case "--dry-run":
53
+ flags.dryRun = true;
54
+ break;
55
+ case "--all":
56
+ flags.all = true;
57
+ break;
58
+ default:
59
+ if (arg.startsWith("-")) {
60
+ error(`Unknown option: ${arg}`);
61
+ process.exit(1);
62
+ }
63
+ positional.push(arg);
64
+ break;
65
+ }
66
+ }
67
+ return { subcommand, positional, flags };
68
+ }
69
+ async function main() {
70
+ const { subcommand, positional, flags } = parseArgs(process.argv);
71
+ switch (subcommand) {
72
+ case "install": {
73
+ if (positional.length === 0) {
74
+ error("Missing source argument for install");
75
+ log("Usage: skillmanager install <local-path-or-github-url> [--force] [--dry-run]");
76
+ process.exit(1);
77
+ }
78
+ if (positional.length > 1) {
79
+ error("Only one source argument is allowed");
80
+ process.exit(1);
81
+ }
82
+ await install({
83
+ source: positional[0],
84
+ force: flags.force,
85
+ dryRun: flags.dryRun,
86
+ });
87
+ break;
88
+ }
89
+ case "list": {
90
+ list();
91
+ break;
92
+ }
93
+ case "update": {
94
+ await update({
95
+ skillName: positional[0],
96
+ all: flags.all,
97
+ force: flags.force,
98
+ dryRun: flags.dryRun,
99
+ });
100
+ break;
101
+ }
102
+ default:
103
+ error(`Unknown command: ${subcommand}`);
104
+ log(HELP);
105
+ process.exit(1);
106
+ }
107
+ }
108
+ main().catch((err) => {
109
+ error(err instanceof Error ? err.message : String(err));
110
+ process.exit(1);
111
+ });
@@ -0,0 +1,17 @@
1
+ export interface GitHubMetadata {
2
+ source_type: "github";
3
+ source_url: string;
4
+ github_commit: string;
5
+ installed_at: string;
6
+ updated_at: string;
7
+ }
8
+ export interface LocalMetadata {
9
+ source_type: "local";
10
+ source_path: string;
11
+ local_git_commit?: string;
12
+ installed_at: string;
13
+ updated_at: string;
14
+ }
15
+ export type SkillMetadata = GitHubMetadata | LocalMetadata;
16
+ export declare function writeMetadata(skillDir: string, metadata: SkillMetadata): void;
17
+ export declare function readMetadata(skillDir: string): SkillMetadata | null;
@@ -0,0 +1,15 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "fs";
2
+ import { join } from "path";
3
+ const METADATA_FILE = ".metadata.json";
4
+ export function writeMetadata(skillDir, metadata) {
5
+ const filePath = join(skillDir, METADATA_FILE);
6
+ writeFileSync(filePath, JSON.stringify(metadata, null, 2) + "\n");
7
+ }
8
+ export function readMetadata(skillDir) {
9
+ const filePath = join(skillDir, METADATA_FILE);
10
+ if (!existsSync(filePath)) {
11
+ return null;
12
+ }
13
+ const content = readFileSync(filePath, "utf-8");
14
+ return JSON.parse(content);
15
+ }
@@ -0,0 +1,9 @@
1
+ export interface GitHubParsed {
2
+ owner: string;
3
+ repo: string;
4
+ branch: string;
5
+ path: string;
6
+ }
7
+ export declare function parseGitHubUrl(url: string): GitHubParsed;
8
+ export declare function downloadSkill(parsed: GitHubParsed, destDir: string): Promise<void>;
9
+ export declare function fetchLatestCommit(parsed: GitHubParsed): Promise<string>;
@@ -0,0 +1,74 @@
1
+ import { mkdtempSync, existsSync, readdirSync, writeFileSync, rmSync } from "fs";
2
+ import { join } from "path";
3
+ import { tmpdir } from "os";
4
+ import { execFileSync } from "child_process";
5
+ export function parseGitHubUrl(url) {
6
+ const cleaned = url.replace(/\/+$/, "");
7
+ const match = cleaned.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/);
8
+ if (!match) {
9
+ throw new Error(`Invalid GitHub URL format. Expected: https://github.com/owner/repo/tree/branch/path/to/skill`);
10
+ }
11
+ return {
12
+ owner: match[1],
13
+ repo: match[2],
14
+ branch: match[3],
15
+ path: match[4],
16
+ };
17
+ }
18
+ export async function downloadSkill(parsed, destDir) {
19
+ const tarballUrl = `https://github.com/${parsed.owner}/${parsed.repo}/archive/refs/heads/${parsed.branch}.tar.gz`;
20
+ const tarballPrefix = `${parsed.repo}-${parsed.branch}`;
21
+ const stripComponents = parsed.path.split("/").length + 1;
22
+ const tmpDir = mkdtempSync(join(tmpdir(), "skillmanager-"));
23
+ try {
24
+ // Download tarball
25
+ const response = await fetch(tarballUrl);
26
+ if (!response.ok) {
27
+ throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
28
+ }
29
+ const tarballPath = join(tmpDir, "archive.tar.gz");
30
+ writeFileSync(tarballPath, Buffer.from(await response.arrayBuffer()));
31
+ // Extract the specific subdirectory
32
+ try {
33
+ execFileSync("tar", [
34
+ "xzf",
35
+ tarballPath,
36
+ "-C",
37
+ destDir,
38
+ `--strip-components=${stripComponents}`,
39
+ `${tarballPrefix}/${parsed.path}`,
40
+ ]);
41
+ }
42
+ catch (err) {
43
+ throw new Error(`Failed to extract tarball: ${err.stderr?.toString() ?? err.message}`);
44
+ }
45
+ // Verify something was extracted
46
+ if (!existsSync(destDir) || readdirSync(destDir).length === 0) {
47
+ throw new Error("Extraction produced no files. Check that the URL, branch, and path are correct.");
48
+ }
49
+ }
50
+ finally {
51
+ // Clean up temp tarball
52
+ rmSync(tmpDir, { recursive: true, force: true });
53
+ }
54
+ }
55
+ export async function fetchLatestCommit(parsed) {
56
+ const apiUrl = `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/commits?sha=${parsed.branch}&path=${parsed.path}&per_page=1`;
57
+ const headers = {
58
+ Accept: "application/vnd.github.v3+json",
59
+ "User-Agent": "skillmanager",
60
+ };
61
+ const token = process.env.GITHUB_TOKEN;
62
+ if (token) {
63
+ headers["Authorization"] = `token ${token}`;
64
+ }
65
+ const response = await fetch(apiUrl, { headers });
66
+ if (!response.ok) {
67
+ throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}`);
68
+ }
69
+ const commits = (await response.json());
70
+ if (!commits.length) {
71
+ throw new Error(`No commits found for path '${parsed.path}'`);
72
+ }
73
+ return commits[0].sha;
74
+ }
@@ -0,0 +1,2 @@
1
+ export declare function resolveLocalPath(source: string): string;
2
+ export declare function getGitCommit(dirPath: string): string | null;
@@ -0,0 +1,23 @@
1
+ import { existsSync, realpathSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { execFileSync } from "child_process";
4
+ export function resolveLocalPath(source) {
5
+ // Expand ~ to home directory
6
+ const expanded = source.replace(/^~/, process.env.HOME || "");
7
+ const resolved = resolve(expanded);
8
+ if (!existsSync(resolved)) {
9
+ throw new Error(`Local path does not exist: ${source}`);
10
+ }
11
+ return realpathSync(resolved);
12
+ }
13
+ export function getGitCommit(dirPath) {
14
+ try {
15
+ const output = execFileSync("git", ["-C", dirPath, "rev-parse", "HEAD"], {
16
+ encoding: "utf-8",
17
+ });
18
+ return output.trim() || null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
@@ -0,0 +1,6 @@
1
+ export declare const SKILLS_DIR: string;
2
+ export declare function validateSkillDir(dir: string): void;
3
+ export declare function log(message: string): void;
4
+ export declare function info(message: string): void;
5
+ export declare function error(message: string): void;
6
+ export declare function success(message: string): void;
package/dist/utils.js ADDED
@@ -0,0 +1,22 @@
1
+ import { existsSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ export const SKILLS_DIR = join(homedir(), ".claude", "skills");
5
+ export function validateSkillDir(dir) {
6
+ const skillMd = join(dir, "SKILL.md");
7
+ if (!existsSync(skillMd)) {
8
+ throw new Error(`No SKILL.md found in '${dir}'. This does not appear to be a valid skill directory.`);
9
+ }
10
+ }
11
+ export function log(message) {
12
+ console.log(message);
13
+ }
14
+ export function info(message) {
15
+ console.log(`\x1b[36m${message}\x1b[0m`);
16
+ }
17
+ export function error(message) {
18
+ console.error(`\x1b[31mError: ${message}\x1b[0m`);
19
+ }
20
+ export function success(message) {
21
+ console.log(`\x1b[32m${message}\x1b[0m`);
22
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@tomaskral/skillmanager",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/kadel/skillmanager.git"
11
+ },
12
+ "bin": {
13
+ "skillmanager": "dist/index.js"
14
+ },
15
+ "files": ["dist"],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "prepublishOnly": "npm run build",
22
+ "start": "node dist/index.js"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "semantic-release": "^24.0.0",
27
+ "typescript": "^5.9.3"
28
+ }
29
+ }