skills-script 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 youzi
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,52 @@
1
+ # skills-script
2
+
3
+ Interactive skills toolkit CLI.
4
+
5
+ ## Run
6
+
7
+ ```bash
8
+ npx skills-script
9
+ ```
10
+
11
+ Flow:
12
+
13
+ 1. Select tool (currently: `skills-sync`).
14
+ 2. Select platforms with Space (multi-select).
15
+ 3. Press Enter to confirm.
16
+ 4. Optional: input custom platform path tokens (for example: `.aaa`).
17
+
18
+ ## Platform Selection
19
+
20
+ Built-in options:
21
+
22
+ - `all`
23
+ - `claude`
24
+ - `codex`
25
+ - `gemini`
26
+
27
+ Custom token rules:
28
+
29
+ - `.aaa` => `~/.aaa/skills`
30
+ - absolute/relative path => used directly as target directory
31
+
32
+ ## Source Skills Directory
33
+
34
+ The source is fixed to:
35
+
36
+ ```bash
37
+ <current_working_directory>/.agents/skills
38
+ ```
39
+
40
+ ## Non-Interactive Mode
41
+
42
+ ```bash
43
+ npx skills-script sync claude,codex,.aaa
44
+ ```
45
+
46
+ ## Publish
47
+
48
+ ```bash
49
+ cd /Users/youzi/Desktop/__yz/skills-script
50
+ npm login
51
+ npm publish --access public
52
+ ```
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../src/cli.js';
3
+
4
+ runCli(process.argv.slice(2))
5
+ .then((code) => {
6
+ process.exit(code ?? 0);
7
+ })
8
+ .catch((error) => {
9
+ console.error('[ERROR]', error?.message || error);
10
+ process.exit(1);
11
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "skills-script",
3
+ "version": "1.0.0",
4
+ "description": "Interactive skills toolkit CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "skill-script": "./bin/skill-script.js",
8
+ "skills-script": "./bin/skill-script.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "keywords": [
17
+ "skills",
18
+ "sync",
19
+ "cli",
20
+ "interactive",
21
+ "claude",
22
+ "codex",
23
+ "gemini"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "license": "MIT",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }
package/src/cli.js ADDED
@@ -0,0 +1,45 @@
1
+ import { createRequire } from 'node:module';
2
+ import { runSyncCommand } from './commands/sync.js';
3
+ import { runInteractive } from './commands/interactive.js';
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const { version } = require('../package.json');
7
+
8
+ function printUsage() {
9
+ console.log('skill-script: skills toolkit CLI');
10
+ console.log('');
11
+ console.log('Interactive:');
12
+ console.log(' skill-script');
13
+ console.log('');
14
+ console.log('Non-interactive:');
15
+ console.log(' skill-script sync <platforms>');
16
+ console.log('');
17
+ console.log('platforms: all | claude | codex | gemini | custom_path');
18
+ console.log('example : skill-script sync claude,codex,.aaa');
19
+ }
20
+
21
+ export async function runCli(argv, { cwd = process.cwd() } = {}) {
22
+ if (!argv.length) {
23
+ return runInteractive(cwd);
24
+ }
25
+
26
+ const first = argv[0].toLowerCase();
27
+
28
+ if (first === '-h' || first === '--help' || first === 'help') {
29
+ printUsage();
30
+ return 0;
31
+ }
32
+
33
+ if (first === '-v' || first === '--version' || first === 'version') {
34
+ console.log(version);
35
+ return 0;
36
+ }
37
+
38
+ if (first === 'sync') {
39
+ return runSyncCommand(argv.slice(1), { cwd });
40
+ }
41
+
42
+ console.error(`[ERROR] Unknown command: ${argv[0]}`);
43
+ printUsage();
44
+ return 1;
45
+ }
@@ -0,0 +1,59 @@
1
+ import path from 'node:path';
2
+ import { multiSelect, promptInput, selectOne } from '../lib/interactive.js';
3
+ import { resolveTargetDirs, runSyncByTargets } from './sync.js';
4
+
5
+ function parseCustomInput(input) {
6
+ if (!input) return [];
7
+ return input
8
+ .split(/[\s,]+/g)
9
+ .map((s) => s.trim())
10
+ .filter(Boolean);
11
+ }
12
+
13
+ export async function runInteractive(cwd = process.cwd()) {
14
+ const tool = await selectOne({
15
+ title: 'Select a tool',
16
+ options: [{ label: 'skills-sync', value: 'skills-sync' }]
17
+ });
18
+
19
+ if (tool !== 'skills-sync') {
20
+ console.error('[ERROR] Unsupported tool selected.');
21
+ return 1;
22
+ }
23
+
24
+ const selected = await multiSelect({
25
+ title: 'Select target platforms (multi-select)',
26
+ options: [
27
+ { label: 'all', value: 'all' },
28
+ { label: 'claude', value: 'claude' },
29
+ { label: 'codex', value: 'codex' },
30
+ { label: 'gemini', value: 'gemini' },
31
+ { label: 'custom path', value: 'custom' }
32
+ ]
33
+ });
34
+
35
+ if (!selected.length) {
36
+ console.error('[ERROR] No platform selected.');
37
+ return 1;
38
+ }
39
+
40
+ const tokens = selected.filter((item) => item !== 'custom');
41
+
42
+ if (selected.includes('custom')) {
43
+ // First read may consume trailing Enter from the multi-select confirm key.
44
+ let customRaw = await promptInput(
45
+ 'Input custom platform path tokens (space/comma separated). Example: .aaa ~/.bbb/skills'
46
+ );
47
+
48
+ if (!customRaw) {
49
+ customRaw = await promptInput('Input custom platform path (press Enter to skip)');
50
+ }
51
+
52
+ tokens.push(...parseCustomInput(customRaw));
53
+ }
54
+
55
+ const sourceDir = path.join(cwd, '.agents', 'skills');
56
+ const targetDirs = resolveTargetDirs({ platformTokens: tokens, cwd });
57
+
58
+ return runSyncByTargets({ sourceDir, targetDirs });
59
+ }
@@ -0,0 +1,187 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { expandHome, resolveCustomTarget, sameOrSubPath } from '../lib/path-utils.js';
4
+
5
+ export const PLATFORM_TARGETS = {
6
+ codex: '~/.codex/skills',
7
+ claude: '~/.claude/skills',
8
+ gemini: '~/.gemini/skills'
9
+ };
10
+
11
+ function splitTokens(input) {
12
+ return input
13
+ .split(/[\s,]+/g)
14
+ .map((s) => s.trim())
15
+ .filter(Boolean);
16
+ }
17
+
18
+ function dedupePaths(paths) {
19
+ const seen = new Set();
20
+ const result = [];
21
+
22
+ for (const item of paths) {
23
+ const abs = path.resolve(item);
24
+ if (seen.has(abs)) continue;
25
+ seen.add(abs);
26
+ result.push(abs);
27
+ }
28
+
29
+ return result;
30
+ }
31
+
32
+ export function resolveTargetDirs({ platformTokens, cwd }) {
33
+ const targets = [];
34
+
35
+ const addNamed = (name) => {
36
+ targets.push(expandHome(PLATFORM_TARGETS[name]));
37
+ };
38
+
39
+ for (const tokenRaw of platformTokens) {
40
+ if (typeof tokenRaw !== 'string') {
41
+ continue;
42
+ }
43
+
44
+ const token = tokenRaw.trim();
45
+ if (!token) continue;
46
+
47
+ const lower = token.toLowerCase();
48
+ if (lower === 'all') {
49
+ addNamed('claude');
50
+ addNamed('codex');
51
+ addNamed('gemini');
52
+ continue;
53
+ }
54
+
55
+ if (PLATFORM_TARGETS[lower]) {
56
+ addNamed(lower);
57
+ continue;
58
+ }
59
+
60
+ const custom = resolveCustomTarget(token, cwd);
61
+ if (custom) targets.push(custom);
62
+ }
63
+
64
+ return dedupePaths(targets);
65
+ }
66
+
67
+ async function listSkillDirs(sourceDir) {
68
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
69
+ return entries
70
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
71
+ .map((entry) => ({
72
+ name: entry.name,
73
+ fullPath: path.join(sourceDir, entry.name)
74
+ }))
75
+ .sort((a, b) => a.name.localeCompare(b.name));
76
+ }
77
+
78
+ async function syncTarget(sourceDir, sourceReal, targetDir) {
79
+ await fs.mkdir(targetDir, { recursive: true });
80
+
81
+ const skills = await listSkillDirs(sourceDir);
82
+ const skillNames = new Set(skills.map((s) => s.name));
83
+
84
+ let linked = 0;
85
+ let skipped = 0;
86
+ let removed = 0;
87
+
88
+ for (const skill of skills) {
89
+ const linkPath = path.join(targetDir, skill.name);
90
+
91
+ try {
92
+ const stat = await fs.lstat(linkPath);
93
+ if (!stat.isSymbolicLink()) {
94
+ console.warn(`[WARN] Skip existing non-symlink: ${linkPath}`);
95
+ skipped += 1;
96
+ continue;
97
+ }
98
+ await fs.unlink(linkPath);
99
+ } catch (error) {
100
+ if (error && error.code !== 'ENOENT') throw error;
101
+ }
102
+
103
+ await fs.symlink(skill.fullPath, linkPath);
104
+ linked += 1;
105
+ }
106
+
107
+ const sourceResolved = path.resolve(sourceDir);
108
+ const targetEntries = await fs.readdir(targetDir, { withFileTypes: true });
109
+
110
+ for (const entry of targetEntries) {
111
+ if (!entry.isSymbolicLink()) continue;
112
+ if (skillNames.has(entry.name)) continue;
113
+
114
+ const fullPath = path.join(targetDir, entry.name);
115
+
116
+ let rawLinkTarget;
117
+ try {
118
+ rawLinkTarget = await fs.readlink(fullPath);
119
+ } catch {
120
+ continue;
121
+ }
122
+
123
+ const linkTargetAbsolute = path.isAbsolute(rawLinkTarget)
124
+ ? rawLinkTarget
125
+ : path.resolve(targetDir, rawLinkTarget);
126
+
127
+ if (
128
+ sameOrSubPath(linkTargetAbsolute, sourceResolved) ||
129
+ sameOrSubPath(linkTargetAbsolute, sourceReal)
130
+ ) {
131
+ await fs.unlink(fullPath);
132
+ removed += 1;
133
+ }
134
+ }
135
+
136
+ console.log(`[OK] ${targetDir} -> linked:${linked} skipped:${skipped} removed-stale:${removed}`);
137
+ }
138
+
139
+ export async function runSyncByTargets({ sourceDir, targetDirs }) {
140
+ if (!targetDirs.length) {
141
+ console.error('[ERROR] No target platform/path selected.');
142
+ return 1;
143
+ }
144
+
145
+ let sourceStat;
146
+ try {
147
+ sourceStat = await fs.stat(sourceDir);
148
+ } catch {
149
+ console.error(`[ERROR] Source skill directory not found: ${sourceDir}`);
150
+ return 1;
151
+ }
152
+
153
+ if (!sourceStat.isDirectory()) {
154
+ console.error(`[ERROR] Source is not a directory: ${sourceDir}`);
155
+ return 1;
156
+ }
157
+
158
+ const sourceReal = await fs.realpath(sourceDir);
159
+
160
+ console.log(`[INFO] Source: ${sourceDir}`);
161
+ for (const targetDir of targetDirs) {
162
+ await syncTarget(sourceDir, sourceReal, targetDir);
163
+ }
164
+
165
+ console.log('[DONE] Skill symlink sync complete');
166
+ return 0;
167
+ }
168
+
169
+ export async function runSyncCommand(args, { cwd = process.cwd() } = {}) {
170
+ // Non-interactive mode: sync <platforms>
171
+ // Examples: sync claude
172
+ // sync all
173
+ // sync claude,codex,.aaa
174
+ const joined = args.join(' ');
175
+ const tokens = splitTokens(joined);
176
+
177
+ if (!tokens.length) {
178
+ console.log('Usage: skill-script sync <platforms>');
179
+ console.log('platforms: all | claude | codex | gemini | custom_path');
180
+ console.log('example: skill-script sync claude,codex,.aaa');
181
+ return 1;
182
+ }
183
+
184
+ const sourceDir = path.join(cwd, '.agents', 'skills');
185
+ const targetDirs = resolveTargetDirs({ platformTokens: tokens, cwd });
186
+ return runSyncByTargets({ sourceDir, targetDirs });
187
+ }
@@ -0,0 +1,161 @@
1
+ import readline from 'node:readline';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import process from 'node:process';
4
+
5
+ function ensureTty() {
6
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
7
+ throw new Error('Interactive mode requires a TTY terminal.');
8
+ }
9
+ }
10
+
11
+ function clearScreen() {
12
+ process.stdout.write('\x1Bc');
13
+ }
14
+
15
+ function normalizeEnter(key) {
16
+ return key?.name === 'return' || key?.name === 'enter';
17
+ }
18
+
19
+ export async function selectOne({ title, options }) {
20
+ ensureTty();
21
+
22
+ let cursor = 0;
23
+
24
+ return new Promise((resolve, reject) => {
25
+ const cleanup = () => {
26
+ process.stdin.off('keypress', onKeyPress);
27
+ process.stdin.setRawMode(false);
28
+ process.stdin.pause();
29
+ };
30
+
31
+ const render = () => {
32
+ clearScreen();
33
+ console.log(title);
34
+ console.log('Use Up/Down to move, Enter to confirm.');
35
+ console.log('');
36
+ options.forEach((item, index) => {
37
+ const pointer = index === cursor ? '>' : ' ';
38
+ console.log(`${pointer} ${item.label}`);
39
+ });
40
+ };
41
+
42
+ const onKeyPress = (_str, key = {}) => {
43
+ if (key.ctrl && key.name === 'c') {
44
+ cleanup();
45
+ process.stdout.write('\n');
46
+ reject(new Error('Canceled by user.'));
47
+ return;
48
+ }
49
+
50
+ if (key.name === 'up') {
51
+ cursor = (cursor - 1 + options.length) % options.length;
52
+ render();
53
+ return;
54
+ }
55
+
56
+ if (key.name === 'down') {
57
+ cursor = (cursor + 1) % options.length;
58
+ render();
59
+ return;
60
+ }
61
+
62
+ if (normalizeEnter(key)) {
63
+ const selected = options[cursor];
64
+ cleanup();
65
+ clearScreen();
66
+ resolve(selected.value);
67
+ }
68
+ };
69
+
70
+ readline.emitKeypressEvents(process.stdin);
71
+ process.stdin.setRawMode(true);
72
+ process.stdin.resume();
73
+ process.stdin.on('keypress', onKeyPress);
74
+ render();
75
+ });
76
+ }
77
+
78
+ export async function multiSelect({ title, options }) {
79
+ ensureTty();
80
+
81
+ let cursor = 0;
82
+ const selected = new Set();
83
+
84
+ return new Promise((resolve, reject) => {
85
+ const cleanup = () => {
86
+ process.stdin.off('keypress', onKeyPress);
87
+ process.stdin.setRawMode(false);
88
+ process.stdin.pause();
89
+ };
90
+
91
+ const render = () => {
92
+ clearScreen();
93
+ console.log(title);
94
+ console.log('Use Up/Down to move, Space to select, Enter to confirm.');
95
+ console.log('');
96
+ options.forEach((item, index) => {
97
+ const pointer = index === cursor ? '>' : ' ';
98
+ const mark = selected.has(item.value) ? '[x]' : '[ ]';
99
+ console.log(`${pointer} ${mark} ${item.label}`);
100
+ });
101
+ };
102
+
103
+ const onKeyPress = (_str, key = {}) => {
104
+ if (key.ctrl && key.name === 'c') {
105
+ cleanup();
106
+ process.stdout.write('\n');
107
+ reject(new Error('Canceled by user.'));
108
+ return;
109
+ }
110
+
111
+ if (key.name === 'up') {
112
+ cursor = (cursor - 1 + options.length) % options.length;
113
+ render();
114
+ return;
115
+ }
116
+
117
+ if (key.name === 'down') {
118
+ cursor = (cursor + 1) % options.length;
119
+ render();
120
+ return;
121
+ }
122
+
123
+ if (key.name === 'space') {
124
+ const current = options[cursor].value;
125
+ if (selected.has(current)) {
126
+ selected.delete(current);
127
+ } else {
128
+ selected.add(current);
129
+ }
130
+ render();
131
+ return;
132
+ }
133
+
134
+ if (normalizeEnter(key)) {
135
+ cleanup();
136
+ clearScreen();
137
+ resolve(Array.from(selected));
138
+ }
139
+ };
140
+
141
+ readline.emitKeypressEvents(process.stdin);
142
+ process.stdin.setRawMode(true);
143
+ process.stdin.resume();
144
+ process.stdin.on('keypress', onKeyPress);
145
+ render();
146
+ });
147
+ }
148
+
149
+ export async function promptInput(message) {
150
+ const rl = createInterface({
151
+ input: process.stdin,
152
+ output: process.stdout
153
+ });
154
+
155
+ try {
156
+ const answer = await rl.question(`${message}\n> `);
157
+ return typeof answer === 'string' ? answer.trim() : '';
158
+ } finally {
159
+ rl.close();
160
+ }
161
+ }
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+
4
+ export function expandHome(inputPath) {
5
+ if (!inputPath) return inputPath;
6
+ if (inputPath === '~') return os.homedir();
7
+ if (inputPath.startsWith('~/')) return path.join(os.homedir(), inputPath.slice(2));
8
+ return inputPath;
9
+ }
10
+
11
+ export function sameOrSubPath(childPath, parentPath) {
12
+ const child = path.resolve(childPath);
13
+ const parent = path.resolve(parentPath);
14
+ if (child === parent) return true;
15
+ const relative = path.relative(parent, child);
16
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
17
+ }
18
+
19
+ export function resolveCustomTarget(token, cwd) {
20
+ const raw = token.trim();
21
+ if (!raw) return null;
22
+
23
+ // .aaa -> ~/.aaa/skills
24
+ if (raw.startsWith('.') && !raw.startsWith('./') && !raw.startsWith('../')) {
25
+ return path.join(os.homedir(), raw, 'skills');
26
+ }
27
+
28
+ const expanded = expandHome(raw);
29
+
30
+ if (path.isAbsolute(expanded)) {
31
+ return expanded;
32
+ }
33
+
34
+ return path.resolve(cwd, expanded);
35
+ }