skls-mgr 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/LICENSE +21 -0
- package/README.md +151 -0
- package/bin/cli.mjs +3 -0
- package/dist/add.js +146 -0
- package/dist/base-dir.js +60 -0
- package/dist/cli.js +59 -0
- package/dist/constants.js +2 -0
- package/dist/filesystem.js +109 -0
- package/dist/git.js +38 -0
- package/dist/i18n.js +168 -0
- package/dist/install.js +117 -0
- package/dist/list.js +36 -0
- package/dist/paths.js +15 -0
- package/dist/remove.js +49 -0
- package/dist/skill-lock.js +113 -0
- package/dist/skills.js +101 -0
- package/dist/source-parser.js +82 -0
- package/dist/test-utils.js +27 -0
- package/dist/types.js +1 -0
- package/dist/update.js +187 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xavi <https://github.com/Xaviw>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# skls-mgr
|
|
2
|
+
|
|
3
|
+
[简体中文](./README-CN.md)
|
|
4
|
+
|
|
5
|
+
Maintain Agent Skills in a centralized local directory; Install on-demand across projects; Edit once, sync everywhere.
|
|
6
|
+
|
|
7
|
+
## Why not [vercel-labs/skills](https://github.com/vercel-labs/skills)?
|
|
8
|
+
|
|
9
|
+
This project is inspired by [`vercel-labs/skills`](<https://www.google.com/search?q=%5Bhttps://github.com/vercel-labs/skills%5D(https://github.com/vercel-labs/skills)>). We are grateful to the Vercel Labs team for their contribution to the Agent ecosystem.
|
|
10
|
+
|
|
11
|
+
`vercel-labs/skills` only supports installing skills directly to a project or globally, which often leads to skill files being scattered across multiple locations. Furthermore, when you need to install skills into different projects, you frequently have to look up the original installation commands.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
Find your target skills on [skills.sh](https://skills.sh/), and simply replace `skills` with `skls-mgr` in the installation command:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx skls-mgr add https://github.com/vercel-labs/skills --skill find-skills
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Once installed, the skills will be copied to the `~/.config/skls-mgr` directory. You can then run the following in any project:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx skls-mgr install
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Select the skills required for your project through the interactive interface. The installation is complete once confirmed.
|
|
30
|
+
|
|
31
|
+
## Adding Skills
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx skls-mgr add <source>
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
By default, this opens an interactive interface listing all available skills from the source.
|
|
39
|
+
|
|
40
|
+
### Options
|
|
41
|
+
|
|
42
|
+
| Option | Description |
|
|
43
|
+
| ------------------------ | -------------------------------------------------------------- |
|
|
44
|
+
| `-s, --skill <names...>` | Specify skill names directly to skip the interactive selection |
|
|
45
|
+
|
|
46
|
+
### Examples
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# GitHub shorthand (owner/repo)
|
|
50
|
+
npx skls-mgr add vercel-labs/skills
|
|
51
|
+
|
|
52
|
+
# GitHub repository URL
|
|
53
|
+
npx skls-mgr add https://github.com/vercel-labs/skills
|
|
54
|
+
|
|
55
|
+
# Sub-path within a GitHub repository
|
|
56
|
+
npx skls-mgr add https://github.com/vercel-labs/skills/tree/main/skills/find-skills
|
|
57
|
+
|
|
58
|
+
# Any Git URL
|
|
59
|
+
npx skls-mgr add https://github.com/vercel-labs/skills.git
|
|
60
|
+
npx skls-mgr add git@github.com:vercel-labs/skills.git
|
|
61
|
+
|
|
62
|
+
# Local path (copy)
|
|
63
|
+
npx skls-mgr add ./my-local-skills
|
|
64
|
+
|
|
65
|
+
# Install specific skills (repeated flags)
|
|
66
|
+
npx skls-mgr add vercel-labs/agent-skills --skill frontend-design --skill skill-creator
|
|
67
|
+
|
|
68
|
+
# Install specific skills (multiple names after a single flag)
|
|
69
|
+
npx skls-mgr add vercel-labs/agent-skills --skill frontend-design skill-creator
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Conflict Resolution
|
|
74
|
+
|
|
75
|
+
If the directory name of the skill being installed conflicts with an existing top-level directory in `~/.config/skls-mgr`:
|
|
76
|
+
|
|
77
|
+
- **Interactive Mode**: You will be prompted to enter a new directory name.
|
|
78
|
+
- **Non-interactive Mode**: If `--skill` is used, the command will terminate immediately without automatic renaming.
|
|
79
|
+
|
|
80
|
+
## Installing to Projects
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx skls-mgr install
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
By default, this opens an interactive interface listing all top-level directories in `~/.config/skls-mgr` (including manually created skills).
|
|
88
|
+
|
|
89
|
+
### Options
|
|
90
|
+
|
|
91
|
+
| Option | Description |
|
|
92
|
+
| ------------------ | --------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| `-a, --all` | Install all skills. If not specified, allows interactive selection. |
|
|
94
|
+
| `-d, --dir <path>` | Install to the target directory (absolute or relative). If not specified, allows interactive input. |
|
|
95
|
+
| `-l, --link` | Use symbolic links. |
|
|
96
|
+
| `-c, --copy` | Use direct copy. |
|
|
97
|
+
|
|
98
|
+
### Examples
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# Interactive installation to a project
|
|
102
|
+
npx skls-mgr install
|
|
103
|
+
|
|
104
|
+
# Copy all skills to the Claude Code skills directory
|
|
105
|
+
npx skls-mgr install --all --dir ./.claude/skills --copy
|
|
106
|
+
|
|
107
|
+
# Interactively select skills and link them to the .agents/skills directory
|
|
108
|
+
# Flags can be combined; missing arguments will be prompted interactively
|
|
109
|
+
npx skls-mgr install --dir ./.agents/skills --link
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Overwrite Policy
|
|
114
|
+
|
|
115
|
+
If a skill directory with the same name already exists in the target project, `skls-mgr install` will overwrite it directly without further confirmation.
|
|
116
|
+
|
|
117
|
+
When `--link` is selected, if the current environment does not support creating symbolic links, it will automatically fall back to a direct copy.
|
|
118
|
+
|
|
119
|
+
## Other Commands
|
|
120
|
+
|
|
121
|
+
| Command | Description |
|
|
122
|
+
| ---------------------------- | -------------------------------------------------------------------------------------- |
|
|
123
|
+
| `npx skls-mgr list` | List all skills in `~/.config/skls-mgr`, distinguishing between managed and manual skills. |
|
|
124
|
+
| `npx skls-mgr update [names...]` | Update one or more skills. Opens interactive mode if no names are provided. |
|
|
125
|
+
| `npx skls-mgr remove [names...]` | Remove one or more skills. Opens interactive mode if no names are provided. |
|
|
126
|
+
|
|
127
|
+
### Examples
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# Display all skills (including manually added ones)
|
|
131
|
+
npx skls-mgr list
|
|
132
|
+
|
|
133
|
+
# Interactive update
|
|
134
|
+
npx skls-mgr update
|
|
135
|
+
|
|
136
|
+
# Force update specific skills by name
|
|
137
|
+
npx skls-mgr update skill1 skill2
|
|
138
|
+
|
|
139
|
+
# Interactive removal
|
|
140
|
+
npx skls-mgr remove
|
|
141
|
+
|
|
142
|
+
# Remove specific skills by name
|
|
143
|
+
npx skls-mgr remove skill1 skill2
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
> `skls-mgr update` relies on the GitHub API. To avoid rate limits for anonymous requests (60 per hour), it is recommended to configure `GITHUB_TOKEN` or `GH_TOKEN` in your environment variables to increase the quota (5000 per hour).
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT
|
package/bin/cli.mjs
ADDED
package/dist/add.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { relative } from 'path';
|
|
4
|
+
import { hasBaseSkillDirectory, installSkillToBaseDir } from './base-dir.js';
|
|
5
|
+
import { sanitizeName } from './filesystem.js';
|
|
6
|
+
import { cloneRepo, cleanupTempDir } from './git.js';
|
|
7
|
+
import { t } from './i18n.js';
|
|
8
|
+
import { ensureBaseDir, getBaseDir } from './paths.js';
|
|
9
|
+
import { getOwnerRepo, parseSource } from './source-parser.js';
|
|
10
|
+
import { fetchSkillFolderHash, getGitHubToken } from './skill-lock.js';
|
|
11
|
+
import { discoverSkills, filterSkills } from './skills.js';
|
|
12
|
+
async function promptForDirectoryName(defaultName) {
|
|
13
|
+
return p.text({
|
|
14
|
+
message: t('directoryExistsPrompt', { defaultName }),
|
|
15
|
+
defaultValue: `${defaultName}-copy`,
|
|
16
|
+
validate(value) {
|
|
17
|
+
if (!value.trim()) {
|
|
18
|
+
return t('directoryNameRequired');
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function resolveDirectoryName(skill, options, promptImpl = promptForDirectoryName, reservedDirectoryNames = new Set()) {
|
|
24
|
+
const defaultName = sanitizeName(skill.name);
|
|
25
|
+
const hasConflict = async (directoryName) => {
|
|
26
|
+
return reservedDirectoryNames.has(directoryName) || (await hasBaseSkillDirectory(directoryName));
|
|
27
|
+
};
|
|
28
|
+
if (!(await hasConflict(defaultName))) {
|
|
29
|
+
reservedDirectoryNames.add(defaultName);
|
|
30
|
+
return defaultName;
|
|
31
|
+
}
|
|
32
|
+
if (options.skill?.length) {
|
|
33
|
+
throw new Error(t('skillDirectoryConflict', { directoryName: defaultName }));
|
|
34
|
+
}
|
|
35
|
+
const renamed = await promptImpl(defaultName);
|
|
36
|
+
if (p.isCancel(renamed)) {
|
|
37
|
+
throw new Error(t('installationCancelled'));
|
|
38
|
+
}
|
|
39
|
+
const nextName = sanitizeName(renamed);
|
|
40
|
+
if (await hasConflict(nextName)) {
|
|
41
|
+
throw new Error(t('skillDirectoryConflict', { directoryName: nextName }));
|
|
42
|
+
}
|
|
43
|
+
reservedDirectoryNames.add(nextName);
|
|
44
|
+
return nextName;
|
|
45
|
+
}
|
|
46
|
+
export function parseAddOptions(args) {
|
|
47
|
+
const options = {};
|
|
48
|
+
let source;
|
|
49
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
50
|
+
const arg = args[index];
|
|
51
|
+
if (arg === '-s' || arg === '--skill') {
|
|
52
|
+
options.skill = options.skill || [];
|
|
53
|
+
index += 1;
|
|
54
|
+
while (index < args.length && args[index] && !args[index].startsWith('-')) {
|
|
55
|
+
options.skill.push(args[index]);
|
|
56
|
+
index += 1;
|
|
57
|
+
}
|
|
58
|
+
index -= 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!arg?.startsWith('-') && !source) {
|
|
62
|
+
source = arg;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { source, options };
|
|
66
|
+
}
|
|
67
|
+
export async function runAdd(sourceInput, options = {}) {
|
|
68
|
+
if (!sourceInput) {
|
|
69
|
+
p.log.error(t('missingSource'));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
await ensureBaseDir();
|
|
73
|
+
const parsed = parseSource(sourceInput);
|
|
74
|
+
let tempDir = null;
|
|
75
|
+
try {
|
|
76
|
+
const sourceDir = parsed.type === 'local'
|
|
77
|
+
? parsed.localPath
|
|
78
|
+
: ((tempDir = await cloneRepo(parsed.url, parsed.ref)), tempDir);
|
|
79
|
+
const discoveredSkills = await discoverSkills(sourceDir, parsed.subpath);
|
|
80
|
+
if (discoveredSkills.length === 0) {
|
|
81
|
+
p.log.error(t('noSkillsFoundInSource'));
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
let selectedSkills = discoveredSkills;
|
|
85
|
+
if (options.skill?.length) {
|
|
86
|
+
selectedSkills = filterSkills(discoveredSkills, options.skill);
|
|
87
|
+
if (selectedSkills.length === 0) {
|
|
88
|
+
p.log.error(t('noMatchingSkillsFound', { names: options.skill.join(', ') }));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const picked = await p.multiselect({
|
|
94
|
+
message: `${t('selectSkillsToInstall')} ${pc.dim(t('multiselectPromptHelp'))}`,
|
|
95
|
+
options: discoveredSkills.map((skill) => ({
|
|
96
|
+
value: skill.name,
|
|
97
|
+
label: skill.name,
|
|
98
|
+
hint: skill.description,
|
|
99
|
+
})),
|
|
100
|
+
initialValues: discoveredSkills.map((skill) => skill.name),
|
|
101
|
+
required: true,
|
|
102
|
+
});
|
|
103
|
+
if (p.isCancel(picked)) {
|
|
104
|
+
p.cancel(t('installationCancelled'));
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
selectedSkills = discoveredSkills.filter((skill) => picked.includes(skill.name));
|
|
108
|
+
}
|
|
109
|
+
const reservedDirectoryNames = new Set();
|
|
110
|
+
const resolvedInstalls = [];
|
|
111
|
+
for (const skill of selectedSkills) {
|
|
112
|
+
resolvedInstalls.push({
|
|
113
|
+
skill,
|
|
114
|
+
directoryName: await resolveDirectoryName(skill, options, promptForDirectoryName, reservedDirectoryNames),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const trackableSource = getOwnerRepo(parsed);
|
|
118
|
+
const normalizedSource = trackableSource ?? parsed.url;
|
|
119
|
+
const token = getGitHubToken();
|
|
120
|
+
for (const item of resolvedInstalls) {
|
|
121
|
+
const skillPath = relative(sourceDir, item.skill.path).split('\\').join('/');
|
|
122
|
+
const skillMdRelativePath = skillPath ? `${skillPath}/SKILL.md` : 'SKILL.md';
|
|
123
|
+
const skillFolderHash = trackableSource
|
|
124
|
+
? ((await fetchSkillFolderHash(trackableSource, skillMdRelativePath, token)) ?? '')
|
|
125
|
+
: '';
|
|
126
|
+
await installSkillToBaseDir(item.skill.path, item.directoryName, {
|
|
127
|
+
displayName: item.skill.name,
|
|
128
|
+
source: normalizedSource,
|
|
129
|
+
sourceType: parsed.type,
|
|
130
|
+
sourceUrl: parsed.url,
|
|
131
|
+
skillPath: skillMdRelativePath,
|
|
132
|
+
skillFolderHash,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
p.log.success(t('installedSkillsIntoBaseDir', { count: resolvedInstalls.length, baseDir: getBaseDir() }));
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
p.log.error(error instanceof Error ? error.message : t('unknownError'));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
if (tempDir) {
|
|
143
|
+
await cleanupTempDir(tempDir).catch(() => { });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/base-dir.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { ensureBaseDir, getBaseDir } from './paths.js';
|
|
4
|
+
import { createDirectorySymlink, removeIfExists, sanitizeName } from './filesystem.js';
|
|
5
|
+
import { addSkillToLock, readSkillLock, removeSkillFromLock } from './skill-lock.js';
|
|
6
|
+
export async function listBaseSkills() {
|
|
7
|
+
const baseDir = await ensureBaseDir();
|
|
8
|
+
const lock = await readSkillLock();
|
|
9
|
+
try {
|
|
10
|
+
const entries = await readdir(baseDir, { encoding: 'utf8', withFileTypes: true });
|
|
11
|
+
return entries
|
|
12
|
+
.filter((entry) => entry.isDirectory())
|
|
13
|
+
.map((entry) => ({
|
|
14
|
+
directoryName: entry.name,
|
|
15
|
+
managed: Boolean(lock.skills[entry.name]),
|
|
16
|
+
lockEntry: lock.skills[entry.name],
|
|
17
|
+
path: join(baseDir, entry.name),
|
|
18
|
+
}))
|
|
19
|
+
.sort((a, b) => a.directoryName.localeCompare(b.directoryName));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function hasBaseSkillDirectory(directoryName) {
|
|
26
|
+
const skills = await listBaseSkills();
|
|
27
|
+
return skills.some((skill) => skill.directoryName === sanitizeName(directoryName));
|
|
28
|
+
}
|
|
29
|
+
export async function installSkillToBaseDir(sourceDir, directoryName, lockEntry) {
|
|
30
|
+
const baseDir = await ensureBaseDir();
|
|
31
|
+
const sanitizedDirectoryName = sanitizeName(directoryName);
|
|
32
|
+
const targetDir = join(baseDir, sanitizedDirectoryName);
|
|
33
|
+
const { replaceDirectoryWithCopy } = await import('./filesystem.js');
|
|
34
|
+
await replaceDirectoryWithCopy(sourceDir, targetDir);
|
|
35
|
+
if (lockEntry) {
|
|
36
|
+
await addSkillToLock(sanitizedDirectoryName, lockEntry);
|
|
37
|
+
}
|
|
38
|
+
return targetDir;
|
|
39
|
+
}
|
|
40
|
+
export async function installBaseSkillToProject(directoryName, targetRootDir, mode) {
|
|
41
|
+
const sourceDir = join(getBaseDir(), directoryName);
|
|
42
|
+
const targetDir = join(targetRootDir, directoryName);
|
|
43
|
+
if (mode === 'copy') {
|
|
44
|
+
const { replaceDirectoryWithCopy } = await import('./filesystem.js');
|
|
45
|
+
await replaceDirectoryWithCopy(sourceDir, targetDir);
|
|
46
|
+
return { path: targetDir, linked: false };
|
|
47
|
+
}
|
|
48
|
+
await removeIfExists(targetDir);
|
|
49
|
+
const linked = await createDirectorySymlink(sourceDir, targetDir);
|
|
50
|
+
if (!linked) {
|
|
51
|
+
const { replaceDirectoryWithCopy } = await import('./filesystem.js');
|
|
52
|
+
await replaceDirectoryWithCopy(sourceDir, targetDir);
|
|
53
|
+
}
|
|
54
|
+
return { path: targetDir, linked };
|
|
55
|
+
}
|
|
56
|
+
export async function removeBaseSkill(directoryName) {
|
|
57
|
+
const skillPath = join(getBaseDir(), directoryName);
|
|
58
|
+
await removeIfExists(skillPath);
|
|
59
|
+
await removeSkillFromLock(directoryName);
|
|
60
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { parseAddOptions, runAdd } from './add.js';
|
|
6
|
+
import { t } from './i18n.js';
|
|
7
|
+
import { parseInstallOptions, runInstall } from './install.js';
|
|
8
|
+
import { runList } from './list.js';
|
|
9
|
+
import { runRemove } from './remove.js';
|
|
10
|
+
import { runUpdate } from './update.js';
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
function getVersion() {
|
|
13
|
+
const pkgPath = join(__dirname, '..', 'package.json');
|
|
14
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
|
|
15
|
+
}
|
|
16
|
+
function showHelp() {
|
|
17
|
+
console.log(t('helpText'));
|
|
18
|
+
}
|
|
19
|
+
async function main() {
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const command = args[0];
|
|
22
|
+
const rest = args.slice(1);
|
|
23
|
+
if (!command) {
|
|
24
|
+
showHelp();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
switch (command) {
|
|
28
|
+
case 'add': {
|
|
29
|
+
const { source, options } = parseAddOptions(rest);
|
|
30
|
+
await runAdd(source, options);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
case 'list':
|
|
34
|
+
await runList();
|
|
35
|
+
return;
|
|
36
|
+
case 'install':
|
|
37
|
+
await runInstall(parseInstallOptions(rest));
|
|
38
|
+
return;
|
|
39
|
+
case 'remove':
|
|
40
|
+
await runRemove(rest);
|
|
41
|
+
return;
|
|
42
|
+
case 'update':
|
|
43
|
+
await runUpdate({ skillNames: rest });
|
|
44
|
+
return;
|
|
45
|
+
case '--help':
|
|
46
|
+
case '-h':
|
|
47
|
+
showHelp();
|
|
48
|
+
return;
|
|
49
|
+
case '--version':
|
|
50
|
+
case '-v':
|
|
51
|
+
console.log(getVersion());
|
|
52
|
+
return;
|
|
53
|
+
default:
|
|
54
|
+
console.log(t('unknownCommand', { command: command ?? '' }));
|
|
55
|
+
console.log(t('runHelpForUsage'));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
main();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { cp, lstat, mkdir, readlink, readdir, rm } from 'fs/promises';
|
|
2
|
+
import { join, relative, resolve, dirname } from 'path';
|
|
3
|
+
async function pathExists(path) {
|
|
4
|
+
try {
|
|
5
|
+
await lstat(path);
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function replaceDirectoryWithCopy(sourceDir, targetDir) {
|
|
13
|
+
const parentDir = dirname(targetDir);
|
|
14
|
+
const targetName = targetDir.split(/[\\/]/).pop() ?? 'skill';
|
|
15
|
+
const uniqueSuffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
16
|
+
const tempDir = join(parentDir, `${targetName}.tmp-${uniqueSuffix}`);
|
|
17
|
+
const backupDir = join(parentDir, `${targetName}.bak-${uniqueSuffix}`);
|
|
18
|
+
let hasBackup = false;
|
|
19
|
+
await mkdir(parentDir, { recursive: true });
|
|
20
|
+
try {
|
|
21
|
+
await copyDirectory(sourceDir, tempDir);
|
|
22
|
+
if (await pathExists(targetDir)) {
|
|
23
|
+
await import('fs/promises').then((fs) => fs.rename(targetDir, backupDir));
|
|
24
|
+
hasBackup = true;
|
|
25
|
+
}
|
|
26
|
+
await import('fs/promises').then((fs) => fs.rename(tempDir, targetDir));
|
|
27
|
+
if (hasBackup) {
|
|
28
|
+
await rm(backupDir, { recursive: true, force: true }).catch(() => { });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
33
|
+
if (hasBackup) {
|
|
34
|
+
if (!(await pathExists(targetDir))) {
|
|
35
|
+
await import('fs/promises').then((fs) => fs.rename(backupDir, targetDir)).catch(() => { });
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
await rm(backupDir, { recursive: true, force: true }).catch(() => { });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function sanitizeName(name) {
|
|
45
|
+
const sanitized = name
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
48
|
+
.replace(/^[.-]+|[.-]+$/g, '');
|
|
49
|
+
return sanitized || 'unnamed-skill';
|
|
50
|
+
}
|
|
51
|
+
export async function removeIfExists(path) {
|
|
52
|
+
await rm(path, { recursive: true, force: true }).catch(() => { });
|
|
53
|
+
}
|
|
54
|
+
export async function ensureDir(path) {
|
|
55
|
+
await mkdir(path, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
const EXCLUDE_FILES = new Set(['metadata.json']);
|
|
58
|
+
const EXCLUDE_DIRS = new Set(['.git', '__pycache__', 'node_modules']);
|
|
59
|
+
export async function copyDirectory(sourceDir, targetDir) {
|
|
60
|
+
await mkdir(targetDir, { recursive: true });
|
|
61
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
62
|
+
await Promise.all(entries
|
|
63
|
+
.filter((entry) => {
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
return !EXCLUDE_DIRS.has(entry.name);
|
|
66
|
+
}
|
|
67
|
+
return !EXCLUDE_FILES.has(entry.name) && !entry.name.startsWith('.');
|
|
68
|
+
})
|
|
69
|
+
.map(async (entry) => {
|
|
70
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
71
|
+
const targetPath = join(targetDir, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
await copyDirectory(sourcePath, targetPath);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await cp(sourcePath, targetPath, {
|
|
77
|
+
recursive: true,
|
|
78
|
+
dereference: true,
|
|
79
|
+
});
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
export async function createDirectorySymlink(targetDir, linkPath) {
|
|
83
|
+
try {
|
|
84
|
+
const resolvedTarget = resolve(targetDir);
|
|
85
|
+
const resolvedLinkPath = resolve(linkPath);
|
|
86
|
+
try {
|
|
87
|
+
const existing = await lstat(linkPath);
|
|
88
|
+
if (existing.isSymbolicLink()) {
|
|
89
|
+
const currentTarget = await readlink(linkPath);
|
|
90
|
+
const absoluteTarget = resolve(dirname(linkPath), currentTarget);
|
|
91
|
+
if (absoluteTarget === resolvedTarget) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await rm(linkPath, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// ignore
|
|
99
|
+
}
|
|
100
|
+
await mkdir(dirname(linkPath), { recursive: true });
|
|
101
|
+
const relativeTarget = relative(dirname(linkPath), resolvedTarget);
|
|
102
|
+
const symlinkType = process.platform === 'win32' ? 'junction' : 'dir';
|
|
103
|
+
await import('fs/promises').then((fs) => fs.symlink(relativeTarget, linkPath, symlinkType));
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'fs/promises';
|
|
2
|
+
import { tmpdir } from 'os';
|
|
3
|
+
import { join, normalize, resolve, sep } from 'path';
|
|
4
|
+
import { simpleGit } from 'simple-git';
|
|
5
|
+
import { t } from './i18n.js';
|
|
6
|
+
const CLONE_TIMEOUT_MS = 60_000;
|
|
7
|
+
export class GitCloneError extends Error {
|
|
8
|
+
url;
|
|
9
|
+
constructor(message, url) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'GitCloneError';
|
|
12
|
+
this.url = url;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function cloneRepo(url, ref) {
|
|
16
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'skls-mgr-'));
|
|
17
|
+
const git = simpleGit({
|
|
18
|
+
timeout: { block: CLONE_TIMEOUT_MS },
|
|
19
|
+
});
|
|
20
|
+
const cloneOptions = ref ? ['--depth', '1', '--branch', ref] : ['--depth', '1'];
|
|
21
|
+
try {
|
|
22
|
+
await git.clone(url, tempDir, cloneOptions);
|
|
23
|
+
return tempDir;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
27
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
28
|
+
throw new GitCloneError(t('failedToClone', { url, message }), url);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function cleanupTempDir(dir) {
|
|
32
|
+
const normalizedDir = normalize(resolve(dir));
|
|
33
|
+
const normalizedTmpDir = normalize(resolve(tmpdir()));
|
|
34
|
+
if (!normalizedDir.startsWith(normalizedTmpDir + sep) && normalizedDir !== normalizedTmpDir) {
|
|
35
|
+
throw new Error(t('attemptedTempDirCleanupOutsideTemp'));
|
|
36
|
+
}
|
|
37
|
+
await rm(dir, { recursive: true, force: true });
|
|
38
|
+
}
|