@buaa_smat/hometrans 0.1.11 → 0.1.13
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 +1 -1
- package/README.en.md +0 -0
- package/README.md +296 -229
- package/dist/cli/init.js +458 -104
- package/env-requirements.json +181 -0
- package/package.json +3 -2
- package/skills/hmos-incremental-ui-align/config-example.json +8 -8
package/dist/cli/init.js
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import chalk from 'chalk';
|
|
13
13
|
import figlet from 'figlet';
|
|
14
14
|
import inquirer from 'inquirer';
|
|
15
15
|
import { parseTree, modify, applyEdits } from 'jsonc-parser';
|
|
16
16
|
import { setupMcpForAllEditors } from './mcp-setup.js';
|
|
17
|
-
import { deriveSdkPaths, expandHome, getConfigPath, getToolsDir, loadHomeTransConfig, saveHomeTransConfig, } from './config-store.js';
|
|
17
|
+
import { deriveSdkPaths, expandHome, getConfigDir, getConfigPath, getToolsDir, loadHomeTransConfig, saveHomeTransConfig, } from './config-store.js';
|
|
18
18
|
function ensureChalkColor() {
|
|
19
19
|
if (process.stdout.isTTY && chalk.level === 0) {
|
|
20
20
|
chalk.level = 1;
|
|
@@ -90,6 +90,55 @@ async function installTools(toolsRoot, config) {
|
|
|
90
90
|
function resolveAutotestDir(toolPath) {
|
|
91
91
|
return path.join(toolPath, 'test-tools', 'autotest');
|
|
92
92
|
}
|
|
93
|
+
async function readFileIfExists(p) {
|
|
94
|
+
try {
|
|
95
|
+
return await fs.readFile(p, 'utf-8');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Template drift check; line-ending differences alone don't count. */
|
|
102
|
+
export function templatesDiffer(a, b) {
|
|
103
|
+
if (a === null || b === null)
|
|
104
|
+
return false;
|
|
105
|
+
const norm = (s) => s.replace(/\r\n/g, '\n');
|
|
106
|
+
return norm(a) !== norm(b);
|
|
107
|
+
}
|
|
108
|
+
/** e.g. 20260610-153012 — used to name config backups. */
|
|
109
|
+
function timestampSuffix() {
|
|
110
|
+
const d = new Date();
|
|
111
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
112
|
+
return (`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
|
113
|
+
`-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The bundled template changed since the local config was created — ask
|
|
117
|
+
* whether to regenerate. Y = back up the current file with a timestamp and
|
|
118
|
+
* recreate from the new template; N (default, and the non-interactive
|
|
119
|
+
* fallback) = keep the current file and only refresh the keys in place.
|
|
120
|
+
* Ctrl-C aborts init.
|
|
121
|
+
*/
|
|
122
|
+
async function confirmRegenerateFromTemplate(label) {
|
|
123
|
+
console.log(chalk.yellow(` ! ${label}: the bundled template changed since this file was created.`));
|
|
124
|
+
try {
|
|
125
|
+
const answers = await inquirer.prompt([
|
|
126
|
+
{
|
|
127
|
+
type: 'confirm',
|
|
128
|
+
name: 'regenerate',
|
|
129
|
+
message: `Regenerate ${label} from the new template? (Y = back up current file with a timestamp, then recreate; N = keep current file, only refresh keys)`,
|
|
130
|
+
default: false,
|
|
131
|
+
},
|
|
132
|
+
]);
|
|
133
|
+
return answers.regenerate;
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (isPromptAbort(err))
|
|
137
|
+
abortInit();
|
|
138
|
+
console.log(chalk.yellow(' Non-interactive: keeping current file (keys refreshed only).'));
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
93
142
|
/**
|
|
94
143
|
* Initialize / refresh `agents/test-tools/autotest/config.yaml`:
|
|
95
144
|
* - If config.yaml does not exist, seed it from config.yaml.example.
|
|
@@ -98,7 +147,7 @@ function resolveAutotestDir(toolPath) {
|
|
|
98
147
|
* Returns a status string for the result summary, or null if nothing was done
|
|
99
148
|
* (e.g., the autotest folder isn't present in this package).
|
|
100
149
|
*/
|
|
101
|
-
async function refreshAutotestConfig(autotestDir, apiKey) {
|
|
150
|
+
async function refreshAutotestConfig(autotestDir, apiKey, prevExampleContent = null) {
|
|
102
151
|
const examplePath = path.join(autotestDir, 'config.yaml.example');
|
|
103
152
|
const configPath = path.join(autotestDir, 'config.yaml');
|
|
104
153
|
const hasExample = await fs
|
|
@@ -108,6 +157,8 @@ async function refreshAutotestConfig(autotestDir, apiKey) {
|
|
|
108
157
|
if (!hasExample)
|
|
109
158
|
return null;
|
|
110
159
|
let seeded = false;
|
|
160
|
+
let backupName = null;
|
|
161
|
+
let templateBackupName = null;
|
|
111
162
|
const hasConfig = await fs
|
|
112
163
|
.access(configPath)
|
|
113
164
|
.then(() => true)
|
|
@@ -116,7 +167,33 @@ async function refreshAutotestConfig(autotestDir, apiKey) {
|
|
|
116
167
|
await fs.copyFile(examplePath, configPath);
|
|
117
168
|
seeded = true;
|
|
118
169
|
}
|
|
170
|
+
else {
|
|
171
|
+
// Template drift: the bundled example changed since the local config was
|
|
172
|
+
// created (prevExampleContent = local example snapshot taken before the
|
|
173
|
+
// tools copy). Y regenerates from the new template after a timestamped
|
|
174
|
+
// backup; N keeps the local file (key-only refresh below) — but saves the
|
|
175
|
+
// old template alongside, since the new one just overwrote it on disk
|
|
176
|
+
// and the local config is still based on the old one.
|
|
177
|
+
const newExample = await fs.readFile(examplePath, 'utf-8');
|
|
178
|
+
if (templatesDiffer(prevExampleContent, newExample)) {
|
|
179
|
+
if (await confirmRegenerateFromTemplate('autotest config.yaml')) {
|
|
180
|
+
backupName = `config.yaml.${timestampSuffix()}.bak`;
|
|
181
|
+
await fs.copyFile(configPath, path.join(autotestDir, backupName));
|
|
182
|
+
await fs.copyFile(examplePath, configPath);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
templateBackupName = `config.yaml.example.${timestampSuffix()}.bak`;
|
|
186
|
+
await fs.writeFile(path.join(autotestDir, templateBackupName), prevExampleContent, 'utf-8');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
119
190
|
if (!apiKey) {
|
|
191
|
+
if (backupName) {
|
|
192
|
+
return `autotest config.yaml regenerated from new template (backup: ${backupName}; api_key left as placeholder)`;
|
|
193
|
+
}
|
|
194
|
+
if (templateBackupName) {
|
|
195
|
+
return `autotest config.yaml kept (old template backed up: ${templateBackupName})`;
|
|
196
|
+
}
|
|
120
197
|
return seeded
|
|
121
198
|
? `autotest config.yaml seeded (api_key left as placeholder)`
|
|
122
199
|
: null;
|
|
@@ -127,6 +204,12 @@ async function refreshAutotestConfig(autotestDir, apiKey) {
|
|
|
127
204
|
if (updated !== original) {
|
|
128
205
|
await fs.writeFile(configPath, updated, 'utf-8');
|
|
129
206
|
}
|
|
207
|
+
if (backupName) {
|
|
208
|
+
return `autotest config.yaml regenerated from new template + api_key filled (backup: ${backupName})`;
|
|
209
|
+
}
|
|
210
|
+
if (templateBackupName) {
|
|
211
|
+
return `autotest config.yaml api_key refreshed (kept local file; old template backed up: ${templateBackupName})`;
|
|
212
|
+
}
|
|
130
213
|
return seeded
|
|
131
214
|
? `autotest config.yaml seeded + api_key filled`
|
|
132
215
|
: `autotest config.yaml api_key refreshed`;
|
|
@@ -144,7 +227,7 @@ const UI_ALIGN_SKILL = 'hmos-incremental-ui-align';
|
|
|
144
227
|
* Returns a status string for the result summary, or null if nothing was
|
|
145
228
|
* done (skill not installed there, or no values to write into an existing file).
|
|
146
229
|
*/
|
|
147
|
-
export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir) {
|
|
230
|
+
export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir, prevExampleContent = null) {
|
|
148
231
|
const skillDir = path.join(skillsDir, UI_ALIGN_SKILL);
|
|
149
232
|
const examplePath = path.join(skillDir, 'config-example.json');
|
|
150
233
|
const configPath = path.join(skillDir, 'config.json');
|
|
@@ -155,6 +238,8 @@ export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir) {
|
|
|
155
238
|
if (!hasExample)
|
|
156
239
|
return null;
|
|
157
240
|
let seeded = false;
|
|
241
|
+
let backupName = null;
|
|
242
|
+
let templateBackupName = null;
|
|
158
243
|
const hasConfig = await fs
|
|
159
244
|
.access(configPath)
|
|
160
245
|
.then(() => true)
|
|
@@ -163,12 +248,38 @@ export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir) {
|
|
|
163
248
|
await fs.copyFile(examplePath, configPath);
|
|
164
249
|
seeded = true;
|
|
165
250
|
}
|
|
251
|
+
else {
|
|
252
|
+
// Template drift: bundled config-example.json changed since the local
|
|
253
|
+
// config.json was created (prevExampleContent = snapshot taken before the
|
|
254
|
+
// skills copy). Y regenerates from the new template after a timestamped
|
|
255
|
+
// backup; N keeps the local file (key-only update below) — but saves the
|
|
256
|
+
// old template alongside, since the new one just overwrote it on disk
|
|
257
|
+
// and the local config is still based on the old one.
|
|
258
|
+
const newExample = await fs.readFile(examplePath, 'utf-8');
|
|
259
|
+
if (templatesDiffer(prevExampleContent, newExample)) {
|
|
260
|
+
if (await confirmRegenerateFromTemplate(`${UI_ALIGN_SKILL}/config.json`)) {
|
|
261
|
+
backupName = `config.json.${timestampSuffix()}.bak`;
|
|
262
|
+
await fs.copyFile(configPath, path.join(skillDir, backupName));
|
|
263
|
+
await fs.copyFile(examplePath, configPath);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
templateBackupName = `config-example.json.${timestampSuffix()}.bak`;
|
|
267
|
+
await fs.writeFile(path.join(skillDir, templateBackupName), prevExampleContent, 'utf-8');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
166
271
|
const updates = [];
|
|
167
272
|
if (glmApiKey)
|
|
168
273
|
updates.push(['glm_api_key', glmApiKey]);
|
|
169
274
|
if (hmosSdkDir)
|
|
170
275
|
updates.push(['hmos_sdk_dir', hmosSdkDir]);
|
|
171
276
|
if (updates.length === 0) {
|
|
277
|
+
if (backupName) {
|
|
278
|
+
return `${UI_ALIGN_SKILL}/config.json regenerated from new template (backup: ${backupName}; glm_api_key / hmos_sdk_dir left empty)`;
|
|
279
|
+
}
|
|
280
|
+
if (templateBackupName) {
|
|
281
|
+
return `${UI_ALIGN_SKILL}/config.json kept (old template backed up: ${templateBackupName})`;
|
|
282
|
+
}
|
|
172
283
|
return seeded
|
|
173
284
|
? `${UI_ALIGN_SKILL}/config.json seeded (glm_api_key / hmos_sdk_dir left empty)`
|
|
174
285
|
: null;
|
|
@@ -191,11 +302,17 @@ export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir) {
|
|
|
191
302
|
await fs.writeFile(configPath, raw, 'utf-8');
|
|
192
303
|
}
|
|
193
304
|
const what = updates.map(([k]) => k).join(' + ');
|
|
305
|
+
if (backupName) {
|
|
306
|
+
return `${UI_ALIGN_SKILL}/config.json regenerated from new template + ${what} filled (backup: ${backupName})`;
|
|
307
|
+
}
|
|
308
|
+
if (templateBackupName) {
|
|
309
|
+
return `${UI_ALIGN_SKILL}/config.json ${what} refreshed (kept local file; old template backed up: ${templateBackupName})`;
|
|
310
|
+
}
|
|
194
311
|
return seeded
|
|
195
312
|
? `${UI_ALIGN_SKILL}/config.json seeded + ${what} filled`
|
|
196
313
|
: `${UI_ALIGN_SKILL}/config.json ${what} refreshed`;
|
|
197
314
|
}
|
|
198
|
-
async function installSkillsTo(skillsRoot, targetDir) {
|
|
315
|
+
async function installSkillsTo(skillsRoot, targetDir, skipNames = new Set()) {
|
|
199
316
|
let entries;
|
|
200
317
|
try {
|
|
201
318
|
entries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
|
@@ -207,6 +324,8 @@ async function installSkillsTo(skillsRoot, targetDir) {
|
|
|
207
324
|
for (const entry of entries) {
|
|
208
325
|
if (!entry.isDirectory())
|
|
209
326
|
continue;
|
|
327
|
+
if (skipNames.has(entry.name))
|
|
328
|
+
continue;
|
|
210
329
|
const skillSrc = path.join(skillsRoot, entry.name);
|
|
211
330
|
const hasSkillFile = await fs
|
|
212
331
|
.access(path.join(skillSrc, 'SKILL.md'))
|
|
@@ -220,7 +339,7 @@ async function installSkillsTo(skillsRoot, targetDir) {
|
|
|
220
339
|
}
|
|
221
340
|
return installed;
|
|
222
341
|
}
|
|
223
|
-
async function installAgentsTo(agentsRoot, targetDir) {
|
|
342
|
+
async function installAgentsTo(agentsRoot, targetDir, skipNames = new Set()) {
|
|
224
343
|
let entries;
|
|
225
344
|
try {
|
|
226
345
|
entries = await fs.readdir(agentsRoot, { withFileTypes: true });
|
|
@@ -231,6 +350,8 @@ async function installAgentsTo(agentsRoot, targetDir) {
|
|
|
231
350
|
await fs.mkdir(targetDir, { recursive: true });
|
|
232
351
|
const installed = [];
|
|
233
352
|
for (const entry of entries) {
|
|
353
|
+
if (skipNames.has(entry.name))
|
|
354
|
+
continue;
|
|
234
355
|
const srcPath = path.join(agentsRoot, entry.name);
|
|
235
356
|
const destPath = path.join(targetDir, entry.name);
|
|
236
357
|
if (entry.isDirectory()) {
|
|
@@ -245,6 +366,78 @@ async function installAgentsTo(agentsRoot, targetDir) {
|
|
|
245
366
|
}
|
|
246
367
|
return installed;
|
|
247
368
|
}
|
|
369
|
+
/** Names of bundled skill folders (those containing a SKILL.md). */
|
|
370
|
+
async function listBundledSkillNames(skillsRoot) {
|
|
371
|
+
try {
|
|
372
|
+
const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
|
373
|
+
const names = [];
|
|
374
|
+
for (const entry of entries) {
|
|
375
|
+
if (!entry.isDirectory())
|
|
376
|
+
continue;
|
|
377
|
+
const hasSkillFile = await fs
|
|
378
|
+
.access(path.join(skillsRoot, entry.name, 'SKILL.md'))
|
|
379
|
+
.then(() => true)
|
|
380
|
+
.catch(() => false);
|
|
381
|
+
if (hasSkillFile)
|
|
382
|
+
names.push(entry.name);
|
|
383
|
+
}
|
|
384
|
+
return names;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return [];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/** Top-level names (files and folders) under the bundled agents dir. */
|
|
391
|
+
async function listBundledAgentNames(agentsRoot) {
|
|
392
|
+
try {
|
|
393
|
+
const entries = await fs.readdir(agentsRoot, { withFileTypes: true });
|
|
394
|
+
return entries.map((entry) => entry.name);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function pathExists(p) {
|
|
401
|
+
try {
|
|
402
|
+
await fs.access(p);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Ask whether same-name skills/agents already present in the editor's target
|
|
411
|
+
* dirs may be overwritten. Returns true (overwrite) when the user confirms or
|
|
412
|
+
* the shell is non-interactive (legacy behavior); false keeps existing files —
|
|
413
|
+
* the caller then only refreshes the API-key config files. Ctrl-C aborts init.
|
|
414
|
+
*/
|
|
415
|
+
async function confirmOverwrite(editorName, skillConflicts, agentConflicts) {
|
|
416
|
+
console.log(chalk.yellow(` ! ${editorName}: the following already exist and would be overwritten:`));
|
|
417
|
+
if (skillConflicts.length > 0) {
|
|
418
|
+
console.log(chalk.yellow(` skills: ${skillConflicts.join(', ')}`));
|
|
419
|
+
}
|
|
420
|
+
if (agentConflicts.length > 0) {
|
|
421
|
+
console.log(chalk.yellow(` agents: ${agentConflicts.join(', ')}`));
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
const answers = await inquirer.prompt([
|
|
425
|
+
{
|
|
426
|
+
type: 'confirm',
|
|
427
|
+
name: 'overwrite',
|
|
428
|
+
message: `Overwrite them in ${editorName}? (N = keep existing files; new items are still installed and API-key configs refreshed)`,
|
|
429
|
+
default: true,
|
|
430
|
+
},
|
|
431
|
+
]);
|
|
432
|
+
return answers.overwrite;
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
if (isPromptAbort(err))
|
|
436
|
+
abortInit();
|
|
437
|
+
console.log(chalk.yellow(' Non-interactive: overwriting by default.'));
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
248
441
|
async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosSdkDir, result) {
|
|
249
442
|
const marker = expandHome(editor.markerDir);
|
|
250
443
|
if (marker && !(await dirExists(marker))) {
|
|
@@ -253,8 +446,30 @@ async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosS
|
|
|
253
446
|
}
|
|
254
447
|
const skillsDir = expandHome(editor.skillsDir);
|
|
255
448
|
const agentsDir = expandHome(editor.agentsDir);
|
|
449
|
+
// Same-name items already in the editor's dirs → ask before overwriting.
|
|
450
|
+
const skillConflicts = [];
|
|
451
|
+
for (const name of await listBundledSkillNames(skillsRoot)) {
|
|
452
|
+
if (await pathExists(path.join(skillsDir, name)))
|
|
453
|
+
skillConflicts.push(name);
|
|
454
|
+
}
|
|
455
|
+
const agentConflicts = [];
|
|
456
|
+
for (const name of await listBundledAgentNames(agentsRoot)) {
|
|
457
|
+
if (await pathExists(path.join(agentsDir, name)))
|
|
458
|
+
agentConflicts.push(name);
|
|
459
|
+
}
|
|
460
|
+
let overwrite = true;
|
|
461
|
+
if (skillConflicts.length > 0 || agentConflicts.length > 0) {
|
|
462
|
+
overwrite = await confirmOverwrite(editor.name, skillConflicts, agentConflicts);
|
|
463
|
+
}
|
|
464
|
+
// Snapshot the installed ui-align template BEFORE the skills copy updates
|
|
465
|
+
// it — refreshUiAlignConfig uses it to detect template drift.
|
|
466
|
+
const prevUiAlignExample = await readFileIfExists(path.join(skillsDir, UI_ALIGN_SKILL, 'config-example.json'));
|
|
467
|
+
// N = incremental install: keep the conflicting items untouched, still copy
|
|
468
|
+
// everything that doesn't exist in the target dirs yet.
|
|
469
|
+
const skipSkills = overwrite ? new Set() : new Set(skillConflicts);
|
|
470
|
+
const skipAgents = overwrite ? new Set() : new Set(agentConflicts);
|
|
256
471
|
try {
|
|
257
|
-
const skills = await installSkillsTo(skillsRoot, skillsDir);
|
|
472
|
+
const skills = await installSkillsTo(skillsRoot, skillsDir, skipSkills);
|
|
258
473
|
if (skills.length > 0) {
|
|
259
474
|
result.configured.push(`${editor.name} skills (${skills.length} -> ${prettyHome(skillsDir)})`);
|
|
260
475
|
}
|
|
@@ -262,10 +477,14 @@ async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosS
|
|
|
262
477
|
catch (err) {
|
|
263
478
|
result.errors.push(`${editor.name} skills: ${err.message}`);
|
|
264
479
|
}
|
|
480
|
+
if (!overwrite) {
|
|
481
|
+
result.skipped.push(`${editor.name} existing kept: ${skipSkills.size} skills, ${skipAgents.size} agents (new items installed, API-key configs refreshed)`);
|
|
482
|
+
}
|
|
265
483
|
// Seed / refresh the incremental-ui-align per-skill config.json with the
|
|
266
|
-
// GLM key + SDK dir from ~/.hometrans/config.json (existing files: key-only
|
|
484
|
+
// GLM key + SDK dir from ~/.hometrans/config.json (existing files: key-only
|
|
485
|
+
// update). Runs regardless of the overwrite decision above.
|
|
267
486
|
try {
|
|
268
|
-
const status = await refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir);
|
|
487
|
+
const status = await refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir, prevUiAlignExample);
|
|
269
488
|
if (status) {
|
|
270
489
|
result.configured.push(`${editor.name} ${status}`);
|
|
271
490
|
}
|
|
@@ -274,7 +493,7 @@ async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosS
|
|
|
274
493
|
result.errors.push(`${editor.name} ${UI_ALIGN_SKILL}/config.json: ${err.message}`);
|
|
275
494
|
}
|
|
276
495
|
try {
|
|
277
|
-
const agents = await installAgentsTo(agentsRoot, agentsDir);
|
|
496
|
+
const agents = await installAgentsTo(agentsRoot, agentsDir, skipAgents);
|
|
278
497
|
if (agents.length > 0) {
|
|
279
498
|
result.configured.push(`${editor.name} agents (${agents.length} -> ${prettyHome(agentsDir)})`);
|
|
280
499
|
}
|
|
@@ -291,6 +510,51 @@ async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosS
|
|
|
291
510
|
export function prettyHome(p) {
|
|
292
511
|
return p;
|
|
293
512
|
}
|
|
513
|
+
/** Bundled at the package root, alongside skills/ and agents/. */
|
|
514
|
+
function resolveEnvRequirementsPath() {
|
|
515
|
+
return path.resolve(__dirname, '..', '..', 'env-requirements.json');
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Copy the bundled env-requirements.json into ~/.hometrans, alongside
|
|
519
|
+
* config.json, so it sits next to the user's config. Returns the destination
|
|
520
|
+
* path, or null if the package ships no env-requirements.json.
|
|
521
|
+
*/
|
|
522
|
+
async function installEnvRequirements() {
|
|
523
|
+
const src = resolveEnvRequirementsPath();
|
|
524
|
+
if (!(await fileExists(src)))
|
|
525
|
+
return null;
|
|
526
|
+
const dest = path.join(getConfigDir(), 'env-requirements.json');
|
|
527
|
+
await fs.mkdir(getConfigDir(), { recursive: true });
|
|
528
|
+
await fs.copyFile(src, dest);
|
|
529
|
+
return dest;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Load env-requirements.json. The leading `$`-prefixed keys are documentation
|
|
533
|
+
* and ignored. Returns null (with a warning) if the file is missing or invalid
|
|
534
|
+
* so `ht init` degrades gracefully instead of crashing.
|
|
535
|
+
*/
|
|
536
|
+
export async function loadEnvRequirements() {
|
|
537
|
+
const p = resolveEnvRequirementsPath();
|
|
538
|
+
let raw;
|
|
539
|
+
try {
|
|
540
|
+
raw = await fs.readFile(p, 'utf-8');
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
console.log(chalk.yellow(` ! env-requirements.json not found at ${p} — skipping impact check.`));
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const parsed = JSON.parse(raw);
|
|
548
|
+
if (!parsed.tools || !Array.isArray(parsed.requirements)) {
|
|
549
|
+
throw new Error('expected { tools: {...}, requirements: [...] }');
|
|
550
|
+
}
|
|
551
|
+
return parsed;
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
console.log(chalk.yellow(` ! env-requirements.json is invalid (${err.message}) — skipping impact check.`));
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
294
558
|
/** Locate a command on PATH (`where` on Windows, `which` elsewhere). */
|
|
295
559
|
function whichSync(cmd) {
|
|
296
560
|
const isWin = process.platform === 'win32';
|
|
@@ -307,18 +571,19 @@ function whichSync(cmd) {
|
|
|
307
571
|
return null;
|
|
308
572
|
}
|
|
309
573
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
catch {
|
|
574
|
+
/**
|
|
575
|
+
* Run a tool's verify command and return its first non-empty output line.
|
|
576
|
+
* Reads the configured stream first (stdout by default; stderr for `java
|
|
577
|
+
* -version`), falling back to the combined output if that stream is empty.
|
|
578
|
+
*/
|
|
579
|
+
function captureVerify(cmd, args, stream) {
|
|
580
|
+
const r = spawnSync(cmd, args, { encoding: 'utf-8', timeout: 5000 });
|
|
581
|
+
if (r.error)
|
|
320
582
|
return null;
|
|
321
|
-
|
|
583
|
+
const primary = stream === 'stderr' ? r.stderr : r.stdout;
|
|
584
|
+
const text = primary && primary.trim() ? primary : `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
|
|
585
|
+
const line = (text ?? '').split('\n').map((l) => l.trim()).filter(Boolean)[0];
|
|
586
|
+
return line || null;
|
|
322
587
|
}
|
|
323
588
|
async function fileExists(p) {
|
|
324
589
|
try {
|
|
@@ -329,123 +594,197 @@ async function fileExists(p) {
|
|
|
329
594
|
return false;
|
|
330
595
|
}
|
|
331
596
|
}
|
|
332
|
-
/**
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
597
|
+
/** Parse the first dotted-number sequence into [major, minor, patch]. */
|
|
598
|
+
export function parseVersion(s) {
|
|
599
|
+
const m = s.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
600
|
+
if (!m)
|
|
601
|
+
return null;
|
|
602
|
+
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
|
603
|
+
}
|
|
604
|
+
function cmpVersion(a, b) {
|
|
605
|
+
for (let i = 0; i < 3; i++) {
|
|
606
|
+
const d = a[i] - b[i];
|
|
607
|
+
if (d !== 0)
|
|
608
|
+
return d < 0 ? -1 : 1;
|
|
609
|
+
}
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Is `version` inside the [min, max) window? Unparseable versions or an absent
|
|
614
|
+
* range are treated as satisfied (we never block on what we can't measure).
|
|
615
|
+
*/
|
|
616
|
+
export function versionInRange(version, range) {
|
|
617
|
+
if (!range || (!range.min && !range.max))
|
|
618
|
+
return true;
|
|
619
|
+
const pv = parseVersion(version);
|
|
620
|
+
if (!pv)
|
|
621
|
+
return true;
|
|
622
|
+
if (range.min) {
|
|
623
|
+
const pm = parseVersion(range.min);
|
|
624
|
+
if (pm && cmpVersion(pv, pm) < 0)
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
if (range.max) {
|
|
628
|
+
const px = parseVersion(range.max);
|
|
629
|
+
if (px && cmpVersion(pv, px) >= 0)
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
/** Human-readable form of a range, e.g. ">= 3.10", "< 2.0", "[1.0, 2.0)". */
|
|
635
|
+
function describeRange(range) {
|
|
636
|
+
if (!range)
|
|
637
|
+
return '';
|
|
638
|
+
if (range.min && range.max)
|
|
639
|
+
return `[${range.min}, ${range.max})`;
|
|
640
|
+
if (range.min)
|
|
641
|
+
return `>= ${range.min}`;
|
|
642
|
+
if (range.max)
|
|
643
|
+
return `< ${range.max}`;
|
|
644
|
+
return '';
|
|
645
|
+
}
|
|
342
646
|
/**
|
|
343
|
-
* Detect
|
|
344
|
-
*
|
|
345
|
-
*
|
|
647
|
+
* Detect every tool declared in env-requirements.json. `source: 'env-dir'`
|
|
648
|
+
* tools are a directory check; `source: 'path'` tools are located by trying
|
|
649
|
+
* each alias on PATH, then the optional DevEco fallback path. When a `verify`
|
|
650
|
+
* command is present we also capture and parse the tool's version.
|
|
346
651
|
*/
|
|
347
|
-
async function detectEnvironment(env) {
|
|
652
|
+
async function detectEnvironment(spec, env) {
|
|
348
653
|
const status = new Map();
|
|
349
654
|
const isWin = process.platform === 'win32';
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
for (const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
655
|
+
const baseDir = (key) => key === 'DEVECO_SDK_HOME'
|
|
656
|
+
? env.DEVECO_SDK_HOME
|
|
657
|
+
: key === 'DEVECO_PATH'
|
|
658
|
+
? env.DEVECO_PATH
|
|
659
|
+
: '';
|
|
660
|
+
for (const [id, tool] of Object.entries(spec.tools)) {
|
|
661
|
+
if (tool.source === 'env-dir') {
|
|
662
|
+
const dir = tool.envVar ? baseDir(tool.envVar) : '';
|
|
663
|
+
const ok = dir ? await dirExists(dir) : false;
|
|
664
|
+
status.set(id, { found: ok, path: ok ? dir : undefined });
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
// source 'path': try each alias on PATH, then the fallback path.
|
|
668
|
+
const aliases = tool.aliases?.length ? tool.aliases : [id];
|
|
669
|
+
let resolvedPath = null;
|
|
670
|
+
let cmdToRun = null;
|
|
671
|
+
for (const alias of aliases) {
|
|
672
|
+
const p = whichSync(alias);
|
|
673
|
+
if (p) {
|
|
674
|
+
resolvedPath = p;
|
|
675
|
+
cmdToRun = alias;
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
let viaFallback = false;
|
|
680
|
+
if (!resolvedPath && tool.fallback) {
|
|
681
|
+
const base = baseDir(tool.fallback.base);
|
|
682
|
+
if (base) {
|
|
683
|
+
const exe = isWin ? `${tool.fallback.exe}.exe` : tool.fallback.exe;
|
|
684
|
+
const cand = path.join(base, ...tool.fallback.segments, exe);
|
|
685
|
+
if (await fileExists(cand)) {
|
|
686
|
+
resolvedPath = cand;
|
|
687
|
+
cmdToRun = cand;
|
|
688
|
+
viaFallback = true;
|
|
365
689
|
}
|
|
366
690
|
}
|
|
367
691
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
javaNote = 'via DevEco jbr';
|
|
692
|
+
const st = { found: !!resolvedPath, path: resolvedPath ?? undefined };
|
|
693
|
+
if (resolvedPath && cmdToRun && tool.verify) {
|
|
694
|
+
const raw = captureVerify(cmdToRun, tool.verify, tool.versionStream === 'stderr' ? 'stderr' : 'stdout');
|
|
695
|
+
if (raw) {
|
|
696
|
+
st.note = viaFallback ? `${raw} (via ${tool.fallback.base} fallback)` : raw;
|
|
697
|
+
if (tool.versionRegex) {
|
|
698
|
+
const m = raw.match(new RegExp(tool.versionRegex));
|
|
699
|
+
if (m && m[1])
|
|
700
|
+
st.version = m[1];
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
else if (viaFallback) {
|
|
704
|
+
st.note = `via ${tool.fallback.base} fallback`;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
else if (viaFallback) {
|
|
708
|
+
st.note = `via ${tool.fallback.base} fallback`;
|
|
386
709
|
}
|
|
710
|
+
status.set(id, st);
|
|
387
711
|
}
|
|
388
|
-
status.set('java', { found: !!javaPath, path: javaPath ?? undefined, note: javaNote });
|
|
389
712
|
return status;
|
|
390
713
|
}
|
|
391
714
|
/**
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
715
|
+
* A dependency is satisfied when its tool is present AND its detected version
|
|
716
|
+
* (if known) falls in the required range (dependency range overrides the tool
|
|
717
|
+
* default). Returns the reason token shown in the impact table when not.
|
|
395
718
|
*/
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
{
|
|
409
|
-
|
|
410
|
-
{ name: 'self-tester', kind: 'agent', required: ['uv', 'hdc'], optional: [] },
|
|
411
|
-
{ name: 'self-test-fixer', kind: 'agent', required: [], optional: [] },
|
|
412
|
-
];
|
|
719
|
+
function evaluateDependency(dep, spec, tools) {
|
|
720
|
+
const st = tools.get(dep.tool);
|
|
721
|
+
const range = dep.version ?? spec.tools[dep.tool]?.version ?? null;
|
|
722
|
+
if (!st || !st.found) {
|
|
723
|
+
return { satisfied: false, reason: dep.tool };
|
|
724
|
+
}
|
|
725
|
+
if (st.version && !versionInRange(st.version, range)) {
|
|
726
|
+
return {
|
|
727
|
+
satisfied: false,
|
|
728
|
+
reason: `${dep.tool} ${st.version} (need ${describeRange(range)})`,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
return { satisfied: true, reason: '' };
|
|
732
|
+
}
|
|
413
733
|
/** Print detected tools + the per-skill/agent impact table. */
|
|
414
734
|
export async function runEnvironmentCheck(env) {
|
|
415
735
|
console.log('');
|
|
416
736
|
console.log(chalk.blue(' Environment Check'));
|
|
417
|
-
const
|
|
418
|
-
|
|
737
|
+
const spec = await loadEnvRequirements();
|
|
738
|
+
if (!spec)
|
|
739
|
+
return;
|
|
740
|
+
const tools = await detectEnvironment(spec, env);
|
|
741
|
+
const order = Object.keys(spec.tools);
|
|
419
742
|
for (const name of order) {
|
|
420
743
|
const st = tools.get(name);
|
|
744
|
+
const tool = spec.tools[name];
|
|
421
745
|
if (st.found) {
|
|
422
|
-
|
|
423
|
-
|
|
746
|
+
let note = st.note ?? '';
|
|
747
|
+
if (st.version && tool.version && !versionInRange(st.version, tool.version)) {
|
|
748
|
+
note += ` (requires ${describeRange(tool.version)})`;
|
|
749
|
+
}
|
|
750
|
+
const noteStr = note ? chalk.gray(` (${note})`) : '';
|
|
751
|
+
console.log(` ${chalk.green('+')} ${name.padEnd(9)} ${st.path ?? ''}${noteStr}`);
|
|
424
752
|
}
|
|
425
753
|
else {
|
|
426
|
-
console.log(` ${chalk.yellow('!')} ${name.padEnd(9)} ${chalk.yellow('not found')} ${chalk.gray(
|
|
754
|
+
console.log(` ${chalk.yellow('!')} ${name.padEnd(9)} ${chalk.yellow('not found')} ${chalk.gray(tool.hint ?? '')}`);
|
|
427
755
|
}
|
|
428
756
|
}
|
|
429
|
-
const missing = order.filter((n) => !tools.get(n).found);
|
|
430
757
|
console.log('');
|
|
431
758
|
console.log(chalk.blue(' Impact on skills/agents:'));
|
|
432
759
|
const nameW = 28;
|
|
433
760
|
console.log(chalk.gray(` ${'Name'.padEnd(nameW)} ${'Kind'.padEnd(6)} ${'Status'.padEnd(8)} Missing`));
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
761
|
+
let allOk = true;
|
|
762
|
+
for (const row of spec.requirements) {
|
|
763
|
+
const unmetReq = [];
|
|
764
|
+
const unmetOpt = [];
|
|
765
|
+
for (const dep of row.dependencies) {
|
|
766
|
+
const { satisfied, reason } = evaluateDependency(dep, spec, tools);
|
|
767
|
+
if (satisfied)
|
|
768
|
+
continue;
|
|
769
|
+
if (dep.level === 'required')
|
|
770
|
+
unmetReq.push(reason);
|
|
771
|
+
else
|
|
772
|
+
unmetOpt.push(`${reason}(optional)`);
|
|
773
|
+
}
|
|
437
774
|
let plain;
|
|
438
775
|
let color;
|
|
439
776
|
let parts;
|
|
440
|
-
if (
|
|
777
|
+
if (unmetReq.length > 0) {
|
|
441
778
|
plain = 'BLOCKED';
|
|
442
779
|
color = chalk.red;
|
|
443
|
-
parts = [...
|
|
780
|
+
parts = [...unmetReq, ...unmetOpt];
|
|
781
|
+
allOk = false;
|
|
444
782
|
}
|
|
445
|
-
else if (
|
|
783
|
+
else if (unmetOpt.length > 0) {
|
|
446
784
|
plain = 'LIMITED';
|
|
447
785
|
color = chalk.yellow;
|
|
448
|
-
parts =
|
|
786
|
+
parts = unmetOpt;
|
|
787
|
+
allOk = false;
|
|
449
788
|
}
|
|
450
789
|
else {
|
|
451
790
|
plain = 'OK';
|
|
@@ -456,7 +795,7 @@ export async function runEnvironmentCheck(env) {
|
|
|
456
795
|
console.log(` ${row.name.padEnd(nameW)} ${row.kind.padEnd(6)} ${color(plain.padEnd(8))} ${parts.join(', ')}${noteStr}`);
|
|
457
796
|
}
|
|
458
797
|
console.log('');
|
|
459
|
-
if (
|
|
798
|
+
if (allOk) {
|
|
460
799
|
console.log(chalk.green(' All environment dependencies found — every skill/agent is fully usable.'));
|
|
461
800
|
}
|
|
462
801
|
else {
|
|
@@ -624,6 +963,10 @@ export async function initCommand(options = {}) {
|
|
|
624
963
|
catch (err) {
|
|
625
964
|
console.log(chalk.yellow(` ! environment check skipped: ${err.message}`));
|
|
626
965
|
}
|
|
966
|
+
// Snapshot the currently installed autotest template BEFORE the tools copy
|
|
967
|
+
// overwrites it — refreshAutotestConfig compares it with the new template
|
|
968
|
+
// to detect drift and offer a config.yaml regeneration.
|
|
969
|
+
const prevAutotestExample = await readFileIfExists(path.join(resolveAutotestDir(config.env.TOOL_PATH || getToolsDir()), 'config.yaml.example'));
|
|
627
970
|
// Copy bundled tools/ into ~/.hometrans/tools and record env.TOOL_PATH.
|
|
628
971
|
// Must run before the autotest config step below, which seeds config.yaml
|
|
629
972
|
// into the installed tools dir (the location agents read via env.TOOL_PATH).
|
|
@@ -638,13 +981,24 @@ export async function initCommand(options = {}) {
|
|
|
638
981
|
catch (err) {
|
|
639
982
|
console.log(chalk.red(` ! tools copy: ${err.message}`));
|
|
640
983
|
}
|
|
984
|
+
// Copy env-requirements.json into ~/.hometrans, next to config.json, so the
|
|
985
|
+
// declared environment requirements live alongside the user's config.
|
|
986
|
+
try {
|
|
987
|
+
const envReqDest = await installEnvRequirements();
|
|
988
|
+
if (envReqDest) {
|
|
989
|
+
console.log(chalk.green(` + env-requirements.json copied -> ${prettyHome(envReqDest)}`));
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
catch (err) {
|
|
993
|
+
console.log(chalk.red(` ! env-requirements.json copy: ${err.message}`));
|
|
994
|
+
}
|
|
641
995
|
// Initialize / refresh autotest config.yaml from the example template,
|
|
642
996
|
// inside the installed tools dir (<TOOL_PATH>/test-tools/autotest).
|
|
643
997
|
try {
|
|
644
998
|
const toolPath = installedToolPath ?? config.env.TOOL_PATH;
|
|
645
999
|
if (toolPath) {
|
|
646
1000
|
const autotestDir = resolveAutotestDir(toolPath);
|
|
647
|
-
const status = await refreshAutotestConfig(autotestDir, config.env.TEST_API_KEY);
|
|
1001
|
+
const status = await refreshAutotestConfig(autotestDir, config.env.TEST_API_KEY, prevAutotestExample);
|
|
648
1002
|
if (status) {
|
|
649
1003
|
console.log(chalk.green(` + ${status}`));
|
|
650
1004
|
console.log(chalk.gray(` ${path.join(autotestDir, 'config.yaml')}`));
|