viepilot 2.23.0 → 2.41.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/CHANGELOG.md +195 -0
- package/README.md +6 -6
- package/bin/viepilot.cjs +108 -1
- package/bin/vp-tools.cjs +109 -0
- package/docs/brainstorm/session-2026-04-20.md +261 -0
- package/docs/skills-reference.md +22 -0
- package/docs/user/features/adapters.md +2 -2
- package/docs/user/features/scaffold-first.md +62 -0
- package/docs/user/features/skill-registry.md +125 -0
- package/lib/adapters/antigravity.cjs +5 -4
- package/lib/skill-installer.cjs +274 -0
- package/lib/skill-registry.cjs +212 -0
- package/lib/viepilot-update.cjs +113 -0
- package/package.json +1 -1
- package/skills/vp-audit/SKILL.md +57 -9
- package/skills/vp-auto/SKILL.md +44 -0
- package/skills/vp-brainstorm/SKILL.md +108 -1
- package/skills/vp-crystallize/SKILL.md +72 -0
- package/skills/vp-debug/SKILL.md +27 -0
- package/skills/vp-docs/SKILL.md +27 -0
- package/skills/vp-evolve/SKILL.md +59 -6
- package/skills/vp-info/SKILL.md +27 -0
- package/skills/vp-pause/SKILL.md +27 -0
- package/skills/vp-proposal/SKILL.md +27 -0
- package/skills/vp-request/SKILL.md +52 -6
- package/skills/vp-resume/SKILL.md +27 -0
- package/skills/vp-rollback/SKILL.md +27 -0
- package/skills/vp-skills/SKILL.md +301 -0
- package/skills/vp-status/SKILL.md +27 -0
- package/skills/vp-task/SKILL.md +27 -0
- package/skills/vp-ui-components/SKILL.md +27 -0
- package/skills/vp-update/SKILL.md +27 -0
- package/templates/phase/TASK.md +7 -0
- package/templates/project/PROJECT-CONTEXT.md +76 -0
- package/workflows/audit.md +131 -0
- package/workflows/autonomous.md +140 -0
- package/workflows/brainstorm.md +1025 -9
- package/workflows/crystallize.md +528 -3
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ViePilot Skill Installer — multi-channel skill installation (FEAT-020 Phase 91).
|
|
3
|
+
*
|
|
4
|
+
* Channels supported:
|
|
5
|
+
* npm — "npm:@scope/pkg" or bare "@scope/pkg" / "pkg"
|
|
6
|
+
* github — "github:org/repo"
|
|
7
|
+
* local — "./path" or "/absolute/path"
|
|
8
|
+
*
|
|
9
|
+
* After any install/uninstall/update: calls scanSkills() to refresh registry.
|
|
10
|
+
* Records skill-meta.json in skill dir for updateSkill() re-install support.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const { execSync } = require('child_process');
|
|
19
|
+
const https = require('https');
|
|
20
|
+
const http = require('http');
|
|
21
|
+
|
|
22
|
+
const { scanSkills } = require('./skill-registry.cjs');
|
|
23
|
+
const { listAdapters } = require('./adapters/index.cjs');
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Channel detection
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function detectChannel(source) {
|
|
30
|
+
if (source.startsWith('github:')) return 'github';
|
|
31
|
+
if (source.startsWith('./') || source.startsWith('/') || source.startsWith('../')) return 'local';
|
|
32
|
+
return 'npm';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Adapter skill dirs
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function getAdapterSkillDirs(homeDir) {
|
|
40
|
+
const adapters = listAdapters(homeDir);
|
|
41
|
+
return adapters
|
|
42
|
+
.map(a => (typeof a.skillsDir === 'function' ? a.skillsDir(homeDir) : null))
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// npm channel
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function installFromNpm(pkgName, skillId, skillDirs, tempBase) {
|
|
51
|
+
const tempDir = fs.mkdtempSync(path.join(tempBase, 'vp-npm-'));
|
|
52
|
+
try {
|
|
53
|
+
// Pack the package into tempDir
|
|
54
|
+
execSync(`npm pack ${pkgName} --pack-destination "${tempDir}"`, {
|
|
55
|
+
stdio: 'pipe',
|
|
56
|
+
timeout: 60000,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const tarballs = fs.readdirSync(tempDir).filter(f => f.endsWith('.tgz'));
|
|
60
|
+
if (tarballs.length === 0) throw new Error(`npm pack produced no tarball for ${pkgName}`);
|
|
61
|
+
|
|
62
|
+
const tarball = path.join(tempDir, tarballs[0]);
|
|
63
|
+
const extractDir = path.join(tempDir, 'extracted');
|
|
64
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
// Extract synchronously
|
|
67
|
+
execSync(`tar -xzf "${tarball}" -C "${extractDir}"`, { stdio: 'pipe' });
|
|
68
|
+
|
|
69
|
+
// npm pack wraps in package/
|
|
70
|
+
const packageDir = path.join(extractDir, 'package');
|
|
71
|
+
const skillMdPath = path.join(packageDir, 'SKILL.md');
|
|
72
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
73
|
+
throw new Error(`SKILL.md not found in npm package ${pkgName}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const installedPaths = [];
|
|
77
|
+
for (const skillsDir of skillDirs) {
|
|
78
|
+
const targetDir = path.join(skillsDir, skillId);
|
|
79
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
80
|
+
fs.cpSync(packageDir, targetDir, { recursive: true });
|
|
81
|
+
installedPaths.push(targetDir);
|
|
82
|
+
}
|
|
83
|
+
return installedPaths;
|
|
84
|
+
} finally {
|
|
85
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// github channel
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function downloadFile(url, destPath) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const proto = url.startsWith('https:') ? https : http;
|
|
96
|
+
const file = fs.createWriteStream(destPath);
|
|
97
|
+
proto.get(url, res => {
|
|
98
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
99
|
+
file.close();
|
|
100
|
+
fs.unlinkSync(destPath);
|
|
101
|
+
return downloadFile(res.headers.location, destPath).then(resolve).catch(reject);
|
|
102
|
+
}
|
|
103
|
+
if (res.statusCode !== 200) {
|
|
104
|
+
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
res.pipe(file);
|
|
108
|
+
file.on('finish', () => file.close(resolve));
|
|
109
|
+
}).on('error', reject);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function installFromGithub(orgRepo, skillId, skillDirs, tempBase) {
|
|
114
|
+
const [org, repo] = orgRepo.split('/');
|
|
115
|
+
if (!org || !repo) throw new Error(`Invalid github source "github:${orgRepo}" — expected "github:org/repo"`);
|
|
116
|
+
|
|
117
|
+
const tarballUrl = `https://github.com/${org}/${repo}/archive/refs/heads/main.tar.gz`;
|
|
118
|
+
const tempDir = fs.mkdtempSync(path.join(tempBase, 'vp-gh-'));
|
|
119
|
+
try {
|
|
120
|
+
const tarball = path.join(tempDir, 'repo.tar.gz');
|
|
121
|
+
await downloadFile(tarballUrl, tarball);
|
|
122
|
+
|
|
123
|
+
const extractDir = path.join(tempDir, 'extracted');
|
|
124
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
125
|
+
execSync(`tar -xzf "${tarball}" -C "${extractDir}"`, { stdio: 'pipe' });
|
|
126
|
+
|
|
127
|
+
// GitHub archives create org-repo-main/ directory
|
|
128
|
+
const entries = fs.readdirSync(extractDir);
|
|
129
|
+
const repoDir = path.join(extractDir, entries[0]);
|
|
130
|
+
|
|
131
|
+
const skillMdPath = path.join(repoDir, 'SKILL.md');
|
|
132
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
133
|
+
throw new Error(`SKILL.md not found in GitHub repo ${org}/${repo}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const installedPaths = [];
|
|
137
|
+
for (const skillsDir of skillDirs) {
|
|
138
|
+
const targetDir = path.join(skillsDir, skillId);
|
|
139
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
140
|
+
fs.cpSync(repoDir, targetDir, { recursive: true });
|
|
141
|
+
installedPaths.push(targetDir);
|
|
142
|
+
}
|
|
143
|
+
return installedPaths;
|
|
144
|
+
} finally {
|
|
145
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// local channel
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
function installFromLocal(srcPath, skillId, skillDirs) {
|
|
154
|
+
const resolved = path.resolve(srcPath);
|
|
155
|
+
if (!fs.existsSync(resolved)) {
|
|
156
|
+
throw new Error(`Local path not found: ${resolved}`);
|
|
157
|
+
}
|
|
158
|
+
const skillMdPath = path.join(resolved, 'SKILL.md');
|
|
159
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
160
|
+
throw new Error(`SKILL.md not found at ${resolved}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const installedPaths = [];
|
|
164
|
+
for (const skillsDir of skillDirs) {
|
|
165
|
+
const targetDir = path.join(skillsDir, skillId);
|
|
166
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
167
|
+
fs.cpSync(resolved, targetDir, { recursive: true });
|
|
168
|
+
installedPaths.push(targetDir);
|
|
169
|
+
}
|
|
170
|
+
return installedPaths;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// skill-meta.json
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
function writeSkillMeta(installedPaths, id, source) {
|
|
178
|
+
const meta = { id, source, installed_at: new Date().toISOString() };
|
|
179
|
+
for (const p of installedPaths) {
|
|
180
|
+
fs.writeFileSync(path.join(p, 'skill-meta.json'), JSON.stringify(meta, null, 2));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readSkillMeta(skillId, homeDir) {
|
|
185
|
+
const skillDirs = getAdapterSkillDirs(homeDir);
|
|
186
|
+
for (const skillsDir of skillDirs) {
|
|
187
|
+
const metaPath = path.join(skillsDir, skillId, 'skill-meta.json');
|
|
188
|
+
if (fs.existsSync(metaPath)) {
|
|
189
|
+
try { return JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { /* skip */ }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Public API
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Install a skill from source.
|
|
201
|
+
* Returns { ok: true, id, installedPaths[] } or { ok: false, error }
|
|
202
|
+
*/
|
|
203
|
+
async function installSkill(source, home = os.homedir()) {
|
|
204
|
+
try {
|
|
205
|
+
const channel = detectChannel(source);
|
|
206
|
+
const skillDirs = getAdapterSkillDirs(home);
|
|
207
|
+
if (skillDirs.length === 0) {
|
|
208
|
+
return { ok: false, error: 'No adapter skill directories found' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const tempBase = os.tmpdir();
|
|
212
|
+
let skillId;
|
|
213
|
+
let installedPaths;
|
|
214
|
+
|
|
215
|
+
if (channel === 'npm') {
|
|
216
|
+
const pkgName = source.replace(/^npm:/, '');
|
|
217
|
+
skillId = pkgName.split('/').pop().replace(/^vp-skills-/, '');
|
|
218
|
+
installedPaths = installFromNpm(pkgName, skillId, skillDirs, tempBase);
|
|
219
|
+
} else if (channel === 'github') {
|
|
220
|
+
const orgRepo = source.replace('github:', '');
|
|
221
|
+
skillId = orgRepo.split('/').pop();
|
|
222
|
+
installedPaths = await installFromGithub(orgRepo, skillId, skillDirs, tempBase);
|
|
223
|
+
} else {
|
|
224
|
+
// local
|
|
225
|
+
skillId = path.basename(source.replace(/[/\\]+$/, ''));
|
|
226
|
+
installedPaths = installFromLocal(source, skillId, skillDirs);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
writeSkillMeta(installedPaths, skillId, source);
|
|
230
|
+
scanSkills(home);
|
|
231
|
+
|
|
232
|
+
return { ok: true, id: skillId, installedPaths };
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return { ok: false, error: err.message };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Uninstall a skill by id from all adapter dirs.
|
|
240
|
+
* Returns { ok: true, removedPaths[] } or { ok: false, error }
|
|
241
|
+
*/
|
|
242
|
+
function uninstallSkill(id, home = os.homedir()) {
|
|
243
|
+
try {
|
|
244
|
+
const skillDirs = getAdapterSkillDirs(home);
|
|
245
|
+
const removedPaths = [];
|
|
246
|
+
|
|
247
|
+
for (const skillsDir of skillDirs) {
|
|
248
|
+
const targetDir = path.join(skillsDir, id);
|
|
249
|
+
if (fs.existsSync(targetDir)) {
|
|
250
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
251
|
+
removedPaths.push(targetDir);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
scanSkills(home);
|
|
256
|
+
return { ok: true, removedPaths };
|
|
257
|
+
} catch (err) {
|
|
258
|
+
return { ok: false, error: err.message };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Update a skill by re-installing from its recorded source.
|
|
264
|
+
* Returns installSkill result, or { ok: false, error } if source not found.
|
|
265
|
+
*/
|
|
266
|
+
async function updateSkill(id, home = os.homedir()) {
|
|
267
|
+
const meta = readSkillMeta(id, home);
|
|
268
|
+
if (!meta || !meta.source) {
|
|
269
|
+
return { ok: false, error: `No skill-meta.json found for skill "${id}" — cannot update` };
|
|
270
|
+
}
|
|
271
|
+
return installSkill(meta.source, home);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = { installSkill, uninstallSkill, updateSkill, detectChannel };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ViePilot Skill Registry — scanner + registry builder (FEAT-020 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Registry file: ~/.viepilot/skill-registry.json
|
|
5
|
+
* Schema:
|
|
6
|
+
* version — "1.0"
|
|
7
|
+
* last_scan — ISO datetime
|
|
8
|
+
* scan_paths — adapter skillsDir paths scanned
|
|
9
|
+
* skills[] — indexed skill entries (deduplicated by id)
|
|
10
|
+
*
|
|
11
|
+
* Extended SKILL.md format (optional sections):
|
|
12
|
+
* ## Capabilities — one capability per line (- prefix)
|
|
13
|
+
* ## Tags — comma-separated or one per line
|
|
14
|
+
* ## Best Practices — one practice per line (- prefix)
|
|
15
|
+
*
|
|
16
|
+
* Legacy SKILL.md (no extended sections) → fields default to []
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
|
|
25
|
+
const REGISTRY_REL = path.join('.viepilot', 'skill-registry.json');
|
|
26
|
+
const REGISTRY_VERSION = '1.0';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Section parsers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a SKILL.md string and extract extended metadata.
|
|
34
|
+
* Falls back gracefully when sections are absent.
|
|
35
|
+
* @param {string} content
|
|
36
|
+
* @returns {{ description: string, capabilities: string[], tags: string[], best_practices: string[] }}
|
|
37
|
+
*/
|
|
38
|
+
function parseSkillMd(content) {
|
|
39
|
+
const lines = content.split('\n');
|
|
40
|
+
|
|
41
|
+
// Extract first non-empty non-heading paragraph as description
|
|
42
|
+
let description = '';
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
const t = line.trim();
|
|
45
|
+
if (t && !t.startsWith('#')) { description = t; break; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
description,
|
|
50
|
+
capabilities: extractListSection(content, 'Capabilities'),
|
|
51
|
+
tags: extractTagsSection(content, 'Tags'),
|
|
52
|
+
best_practices: extractListSection(content, 'Best Practices'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract a `- item` list under `## {sectionName}`.
|
|
58
|
+
* @param {string} content
|
|
59
|
+
* @param {string} sectionName
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
function extractListSection(content, sectionName) {
|
|
63
|
+
const re = new RegExp(`^##\\s+${sectionName}\\s*$`, 'im');
|
|
64
|
+
const match = re.exec(content);
|
|
65
|
+
if (!match) return [];
|
|
66
|
+
|
|
67
|
+
const afterHeader = content.slice(match.index + match[0].length);
|
|
68
|
+
const items = [];
|
|
69
|
+
for (const line of afterHeader.split('\n')) {
|
|
70
|
+
const t = line.trim();
|
|
71
|
+
if (t.startsWith('## ')) break; // next section — stop
|
|
72
|
+
const itemMatch = t.match(/^-\s+(.+)/);
|
|
73
|
+
if (itemMatch) items.push(itemMatch[1].trim());
|
|
74
|
+
}
|
|
75
|
+
return items;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract comma-separated or line-per-item tags under `## Tags`.
|
|
80
|
+
* @param {string} content
|
|
81
|
+
* @param {string} sectionName
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
function extractTagsSection(content, sectionName) {
|
|
85
|
+
const re = new RegExp(`^##\\s+${sectionName}\\s*$`, 'im');
|
|
86
|
+
const match = re.exec(content);
|
|
87
|
+
if (!match) return [];
|
|
88
|
+
|
|
89
|
+
const afterHeader = content.slice(match.index + match[0].length);
|
|
90
|
+
const raw = [];
|
|
91
|
+
for (const line of afterHeader.split('\n')) {
|
|
92
|
+
const t = line.trim();
|
|
93
|
+
if (t.startsWith('## ')) break;
|
|
94
|
+
if (t.startsWith('- ')) { raw.push(t.slice(2).trim()); continue; }
|
|
95
|
+
if (t) {
|
|
96
|
+
// Comma-separated on one line
|
|
97
|
+
raw.push(...t.split(',').map(s => s.trim()).filter(Boolean));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return raw.filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Scanner
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Scan all adapter skillsDirs, parse SKILL.md files, build registry.
|
|
109
|
+
* Writes ~/.viepilot/skill-registry.json and returns the registry object.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} [home] Override home directory (defaults to os.homedir())
|
|
112
|
+
* @returns {{ version: string, last_scan: string, scan_paths: string[], skills: object[] }}
|
|
113
|
+
*/
|
|
114
|
+
function scanSkills(home) {
|
|
115
|
+
const homeDir = home || os.homedir();
|
|
116
|
+
const { listAdapters } = require('./adapters/index.cjs');
|
|
117
|
+
const adapters = listAdapters();
|
|
118
|
+
|
|
119
|
+
/** @type {Map<string, object>} */
|
|
120
|
+
const skillMap = new Map();
|
|
121
|
+
const scanPaths = [];
|
|
122
|
+
|
|
123
|
+
for (const adapter of adapters) {
|
|
124
|
+
const skillsDir = adapter.skillsDir(homeDir);
|
|
125
|
+
scanPaths.push(skillsDir);
|
|
126
|
+
|
|
127
|
+
if (!fs.existsSync(skillsDir)) continue;
|
|
128
|
+
|
|
129
|
+
let entries;
|
|
130
|
+
try { entries = fs.readdirSync(skillsDir, { withFileTypes: true }); }
|
|
131
|
+
catch (_) { continue; }
|
|
132
|
+
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (!entry.isDirectory()) continue;
|
|
135
|
+
const skillDir = path.join(skillsDir, entry.name);
|
|
136
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
137
|
+
|
|
138
|
+
if (!fs.existsSync(skillMdPath)) continue;
|
|
139
|
+
|
|
140
|
+
let mdContent = '';
|
|
141
|
+
try { mdContent = fs.readFileSync(skillMdPath, 'utf8'); }
|
|
142
|
+
catch (_) { /* skip unreadable */ }
|
|
143
|
+
|
|
144
|
+
const parsed = parseSkillMd(mdContent);
|
|
145
|
+
const skillId = entry.name;
|
|
146
|
+
|
|
147
|
+
if (skillMap.has(skillId)) {
|
|
148
|
+
// Merge — same skill found in multiple adapter dirs
|
|
149
|
+
const existing = skillMap.get(skillId);
|
|
150
|
+
if (!existing.adapters.includes(adapter.id)) {
|
|
151
|
+
existing.adapters.push(adapter.id);
|
|
152
|
+
}
|
|
153
|
+
existing.installed_paths[adapter.id] = skillDir;
|
|
154
|
+
} else {
|
|
155
|
+
skillMap.set(skillId, {
|
|
156
|
+
id: skillId,
|
|
157
|
+
name: skillId, // display name = dir name by default
|
|
158
|
+
source: null,
|
|
159
|
+
version: null,
|
|
160
|
+
description: parsed.description,
|
|
161
|
+
capabilities: parsed.capabilities,
|
|
162
|
+
tags: parsed.tags,
|
|
163
|
+
best_practices: parsed.best_practices,
|
|
164
|
+
adapters: [adapter.id],
|
|
165
|
+
installed_paths: { [adapter.id]: skillDir },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const registry = {
|
|
172
|
+
version: REGISTRY_VERSION,
|
|
173
|
+
last_scan: new Date().toISOString(),
|
|
174
|
+
scan_paths: scanPaths,
|
|
175
|
+
skills: [...skillMap.values()],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Write registry to ~/.viepilot/skill-registry.json
|
|
179
|
+
const registryDir = path.join(homeDir, '.viepilot');
|
|
180
|
+
if (!fs.existsSync(registryDir)) fs.mkdirSync(registryDir, { recursive: true });
|
|
181
|
+
fs.writeFileSync(
|
|
182
|
+
path.join(registryDir, 'skill-registry.json'),
|
|
183
|
+
JSON.stringify(registry, null, 2),
|
|
184
|
+
'utf8'
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return registry;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Registry reader
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Load the current skill registry from disk.
|
|
196
|
+
* Returns null if the registry has not been written yet.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} [home] Override home directory (defaults to os.homedir())
|
|
199
|
+
* @returns {object|null}
|
|
200
|
+
*/
|
|
201
|
+
function loadRegistry(home) {
|
|
202
|
+
const homeDir = home || os.homedir();
|
|
203
|
+
const registryPath = path.join(homeDir, '.viepilot', 'skill-registry.json');
|
|
204
|
+
if (!fs.existsSync(registryPath)) return null;
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
207
|
+
} catch (_) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { scanSkills, loadRegistry, parseSkillMd };
|
package/lib/viepilot-update.cjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plan and run `npm` upgrade for the viepilot package (FEAT-008 / vp-tools update).
|
|
3
|
+
* ENH-072: checkLatestVersion() — non-blocking npm registry check with 24h cache.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const https = require('https');
|
|
6
10
|
const { spawnSync, execFileSync } = require('child_process');
|
|
7
11
|
const viepilotInfo = require('./viepilot-info.cjs');
|
|
8
12
|
|
|
@@ -147,10 +151,119 @@ function runNpmUpdate(plan) {
|
|
|
147
151
|
return { ok: false, code: r.status == null ? 1 : r.status };
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
/**
|
|
155
|
+
* ENH-072: Check npm registry for a newer ViePilot version, with 24h cache.
|
|
156
|
+
*
|
|
157
|
+
* Silent on all errors — never throws, never crashes a skill invocation.
|
|
158
|
+
*
|
|
159
|
+
* @param {{ force?: boolean, cacheFile?: string, _fetchFn?: function }} opts
|
|
160
|
+
* @returns {Promise<{ upToDate: boolean, installed: string, latest: string }>}
|
|
161
|
+
*/
|
|
162
|
+
async function checkLatestVersion(opts = {}) {
|
|
163
|
+
const SILENT_RESULT = { upToDate: true, installed: '', latest: '' };
|
|
164
|
+
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const cacheFile =
|
|
168
|
+
opts.cacheFile || path.join(os.homedir(), '.viepilot', 'update-cache.json');
|
|
169
|
+
|
|
170
|
+
// Read installed version from package.json at viepilot package root
|
|
171
|
+
const pkgRoot = viepilotInfo.resolveViepilotPackageRoot(path.join(__dirname, '..'));
|
|
172
|
+
const installed = pkgRoot ? viepilotInfo.readInstalledVersion(pkgRoot) : null;
|
|
173
|
+
if (!installed) return SILENT_RESULT;
|
|
174
|
+
|
|
175
|
+
// Check cache unless force
|
|
176
|
+
if (!opts.force) {
|
|
177
|
+
try {
|
|
178
|
+
const raw = fs.readFileSync(cacheFile, 'utf8');
|
|
179
|
+
const cache = JSON.parse(raw);
|
|
180
|
+
if (
|
|
181
|
+
cache &&
|
|
182
|
+
typeof cache.checked_at === 'string' &&
|
|
183
|
+
typeof cache.latest === 'string' &&
|
|
184
|
+
Date.now() - new Date(cache.checked_at).getTime() < TTL_MS
|
|
185
|
+
) {
|
|
186
|
+
const upToDate = compareSemver(installed, cache.latest) >= 0;
|
|
187
|
+
return { upToDate, installed, latest: cache.latest };
|
|
188
|
+
}
|
|
189
|
+
} catch (_e) {
|
|
190
|
+
// cache missing or unreadable — proceed to network
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Fetch latest version from npm registry with 3s timeout
|
|
195
|
+
const fetchFn = opts._fetchFn || _fetchNpmLatest;
|
|
196
|
+
let latest;
|
|
197
|
+
try {
|
|
198
|
+
latest = await fetchFn('viepilot');
|
|
199
|
+
} catch (_e) {
|
|
200
|
+
return { upToDate: true, installed, latest: installed };
|
|
201
|
+
}
|
|
202
|
+
if (!latest || typeof latest !== 'string') {
|
|
203
|
+
return { upToDate: true, installed, latest: installed };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Write cache
|
|
207
|
+
try {
|
|
208
|
+
const cacheDir = path.dirname(cacheFile);
|
|
209
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
210
|
+
const has_update = compareSemver(installed, latest) < 0;
|
|
211
|
+
fs.writeFileSync(
|
|
212
|
+
cacheFile,
|
|
213
|
+
JSON.stringify({ checked_at: new Date().toISOString(), installed, latest, has_update }),
|
|
214
|
+
'utf8'
|
|
215
|
+
);
|
|
216
|
+
} catch (_e) {
|
|
217
|
+
// cache write failure is non-fatal
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const upToDate = compareSemver(installed, latest) >= 0;
|
|
221
|
+
return { upToDate, installed, latest };
|
|
222
|
+
} catch (_e) {
|
|
223
|
+
return SILENT_RESULT;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Fetch latest version of a package from npm registry using https (3s timeout).
|
|
229
|
+
* @param {string} pkgName
|
|
230
|
+
* @returns {Promise<string>}
|
|
231
|
+
*/
|
|
232
|
+
function _fetchNpmLatest(pkgName) {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const req = https.get(
|
|
235
|
+
`https://registry.npmjs.org/${pkgName}/latest`,
|
|
236
|
+
{ headers: { 'Accept': 'application/json' } },
|
|
237
|
+
(res) => {
|
|
238
|
+
let body = '';
|
|
239
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
240
|
+
res.on('end', () => {
|
|
241
|
+
try {
|
|
242
|
+
const data = JSON.parse(body);
|
|
243
|
+
if (data && typeof data.version === 'string') {
|
|
244
|
+
resolve(data.version);
|
|
245
|
+
} else {
|
|
246
|
+
reject(new Error('no version in response'));
|
|
247
|
+
}
|
|
248
|
+
} catch (e) {
|
|
249
|
+
reject(e);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
req.on('error', reject);
|
|
255
|
+
req.setTimeout(3000, () => {
|
|
256
|
+
req.destroy(new Error('timeout'));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
150
261
|
module.exports = {
|
|
151
262
|
tryGetNpmGlobalViepilotPath,
|
|
152
263
|
classifyInstall,
|
|
153
264
|
compareSemver,
|
|
154
265
|
buildUpdatePlan,
|
|
155
266
|
runNpmUpdate,
|
|
267
|
+
checkLatestVersion,
|
|
268
|
+
_fetchNpmLatest,
|
|
156
269
|
};
|