@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 +106 -0
- package/dist/commands/install.d.ts +7 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +35 -0
- package/dist/commands/update.d.ts +8 -0
- package/dist/commands/update.js +153 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +111 -0
- package/dist/metadata.d.ts +17 -0
- package/dist/metadata.js +15 -0
- package/dist/sources/github.d.ts +9 -0
- package/dist/sources/github.js +74 -0
- package/dist/sources/local.d.ts +2 -0
- package/dist/sources/local.js +23 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +22 -0
- package/package.json +29 -0
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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
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;
|
package/dist/metadata.js
ADDED
|
@@ -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,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
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -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
|
+
}
|