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 +21 -0
- package/README.md +52 -0
- package/bin/skill-script.js +11 -0
- package/package.json +32 -0
- package/src/cli.js +45 -0
- package/src/commands/interactive.js +59 -0
- package/src/commands/sync.js +187 -0
- package/src/lib/interactive.js +161 -0
- package/src/lib/path-utils.js +35 -0
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
|
+
```
|
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
|
+
}
|