@buaa_smat/hometrans 0.1.10 → 0.1.12

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/dist/cli/init.js CHANGED
@@ -7,13 +7,14 @@
7
7
  */
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
- import os from 'node:os';
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
+ import { parseTree, modify, applyEdits } from 'jsonc-parser';
15
16
  import { setupMcpForAllEditors } from './mcp-setup.js';
16
- import { expandHome, getConfigPath, getToolsDir, loadHomeTransConfig, saveHomeTransConfig, } from './config-store.js';
17
+ import { deriveSdkPaths, expandHome, getConfigDir, getConfigPath, getToolsDir, loadHomeTransConfig, saveHomeTransConfig, } from './config-store.js';
17
18
  function ensureChalkColor() {
18
19
  if (process.stdout.isTTY && chalk.level === 0) {
19
20
  chalk.level = 1;
@@ -89,6 +90,55 @@ async function installTools(toolsRoot, config) {
89
90
  function resolveAutotestDir(toolPath) {
90
91
  return path.join(toolPath, 'test-tools', 'autotest');
91
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
+ }
92
142
  /**
93
143
  * Initialize / refresh `agents/test-tools/autotest/config.yaml`:
94
144
  * - If config.yaml does not exist, seed it from config.yaml.example.
@@ -97,7 +147,7 @@ function resolveAutotestDir(toolPath) {
97
147
  * Returns a status string for the result summary, or null if nothing was done
98
148
  * (e.g., the autotest folder isn't present in this package).
99
149
  */
100
- async function refreshAutotestConfig(autotestDir, apiKey) {
150
+ async function refreshAutotestConfig(autotestDir, apiKey, prevExampleContent = null) {
101
151
  const examplePath = path.join(autotestDir, 'config.yaml.example');
102
152
  const configPath = path.join(autotestDir, 'config.yaml');
103
153
  const hasExample = await fs
@@ -107,6 +157,8 @@ async function refreshAutotestConfig(autotestDir, apiKey) {
107
157
  if (!hasExample)
108
158
  return null;
109
159
  let seeded = false;
160
+ let backupName = null;
161
+ let templateBackupName = null;
110
162
  const hasConfig = await fs
111
163
  .access(configPath)
112
164
  .then(() => true)
@@ -115,7 +167,33 @@ async function refreshAutotestConfig(autotestDir, apiKey) {
115
167
  await fs.copyFile(examplePath, configPath);
116
168
  seeded = true;
117
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
+ }
118
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
+ }
119
197
  return seeded
120
198
  ? `autotest config.yaml seeded (api_key left as placeholder)`
121
199
  : null;
@@ -126,11 +204,115 @@ async function refreshAutotestConfig(autotestDir, apiKey) {
126
204
  if (updated !== original) {
127
205
  await fs.writeFile(configPath, updated, 'utf-8');
128
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
+ }
129
213
  return seeded
130
214
  ? `autotest config.yaml seeded + api_key filled`
131
215
  : `autotest config.yaml api_key refreshed`;
132
216
  }
133
- async function installSkillsTo(skillsRoot, targetDir) {
217
+ const UI_ALIGN_SKILL = 'hmos-incremental-ui-align';
218
+ /**
219
+ * Initialize / refresh `<skillsDir>/hmos-incremental-ui-align/config.json`
220
+ * in an editor's installed skills dir — same semantics as the autotest
221
+ * config.yaml handling:
222
+ * - If config.json does not exist, seed it from config-example.json.
223
+ * - If it exists, surgically overwrite ONLY the `glm_api_key` and
224
+ * `hmos_sdk_dir` values (via jsonc edits), preserving every other
225
+ * field the user has set. `hmos_sdk_dir` = `<DEVECO_SDK_HOME>/default`.
226
+ *
227
+ * Returns a status string for the result summary, or null if nothing was
228
+ * done (skill not installed there, or no values to write into an existing file).
229
+ */
230
+ export async function refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir, prevExampleContent = null) {
231
+ const skillDir = path.join(skillsDir, UI_ALIGN_SKILL);
232
+ const examplePath = path.join(skillDir, 'config-example.json');
233
+ const configPath = path.join(skillDir, 'config.json');
234
+ const hasExample = await fs
235
+ .access(examplePath)
236
+ .then(() => true)
237
+ .catch(() => false);
238
+ if (!hasExample)
239
+ return null;
240
+ let seeded = false;
241
+ let backupName = null;
242
+ let templateBackupName = null;
243
+ const hasConfig = await fs
244
+ .access(configPath)
245
+ .then(() => true)
246
+ .catch(() => false);
247
+ if (!hasConfig) {
248
+ await fs.copyFile(examplePath, configPath);
249
+ seeded = true;
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
+ }
271
+ const updates = [];
272
+ if (glmApiKey)
273
+ updates.push(['glm_api_key', glmApiKey]);
274
+ if (hmosSdkDir)
275
+ updates.push(['hmos_sdk_dir', hmosSdkDir]);
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
+ }
283
+ return seeded
284
+ ? `${UI_ALIGN_SKILL}/config.json seeded (glm_api_key / hmos_sdk_dir left empty)`
285
+ : null;
286
+ }
287
+ let raw = await fs.readFile(configPath, 'utf-8');
288
+ const parseErrors = [];
289
+ const tree = parseTree(raw, parseErrors);
290
+ if (!tree || tree.type !== 'object' || parseErrors.length > 0) {
291
+ // Corrupt config — never regenerate over user content; just report.
292
+ return `${UI_ALIGN_SKILL}/config.json NOT updated (file is not valid JSON — fix it manually)`;
293
+ }
294
+ const original = raw;
295
+ for (const [key, value] of updates) {
296
+ const edits = modify(raw, [key], value, {
297
+ formattingOptions: { tabSize: 2, insertSpaces: true },
298
+ });
299
+ raw = applyEdits(raw, edits);
300
+ }
301
+ if (raw !== original) {
302
+ await fs.writeFile(configPath, raw, 'utf-8');
303
+ }
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
+ }
311
+ return seeded
312
+ ? `${UI_ALIGN_SKILL}/config.json seeded + ${what} filled`
313
+ : `${UI_ALIGN_SKILL}/config.json ${what} refreshed`;
314
+ }
315
+ async function installSkillsTo(skillsRoot, targetDir, skipNames = new Set()) {
134
316
  let entries;
135
317
  try {
136
318
  entries = await fs.readdir(skillsRoot, { withFileTypes: true });
@@ -142,6 +324,8 @@ async function installSkillsTo(skillsRoot, targetDir) {
142
324
  for (const entry of entries) {
143
325
  if (!entry.isDirectory())
144
326
  continue;
327
+ if (skipNames.has(entry.name))
328
+ continue;
145
329
  const skillSrc = path.join(skillsRoot, entry.name);
146
330
  const hasSkillFile = await fs
147
331
  .access(path.join(skillSrc, 'SKILL.md'))
@@ -155,7 +339,7 @@ async function installSkillsTo(skillsRoot, targetDir) {
155
339
  }
156
340
  return installed;
157
341
  }
158
- async function installAgentsTo(agentsRoot, targetDir) {
342
+ async function installAgentsTo(agentsRoot, targetDir, skipNames = new Set()) {
159
343
  let entries;
160
344
  try {
161
345
  entries = await fs.readdir(agentsRoot, { withFileTypes: true });
@@ -166,6 +350,8 @@ async function installAgentsTo(agentsRoot, targetDir) {
166
350
  await fs.mkdir(targetDir, { recursive: true });
167
351
  const installed = [];
168
352
  for (const entry of entries) {
353
+ if (skipNames.has(entry.name))
354
+ continue;
169
355
  const srcPath = path.join(agentsRoot, entry.name);
170
356
  const destPath = path.join(targetDir, entry.name);
171
357
  if (entry.isDirectory()) {
@@ -180,7 +366,79 @@ async function installAgentsTo(agentsRoot, targetDir) {
180
366
  }
181
367
  return installed;
182
368
  }
183
- async function installForEditor(editor, skillsRoot, agentsRoot, result) {
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
+ }
441
+ async function installForEditor(editor, skillsRoot, agentsRoot, glmApiKey, hmosSdkDir, result) {
184
442
  const marker = expandHome(editor.markerDir);
185
443
  if (marker && !(await dirExists(marker))) {
186
444
  result.skipped.push(`${editor.name} (not installed)`);
@@ -188,8 +446,30 @@ async function installForEditor(editor, skillsRoot, agentsRoot, result) {
188
446
  }
189
447
  const skillsDir = expandHome(editor.skillsDir);
190
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);
191
471
  try {
192
- const skills = await installSkillsTo(skillsRoot, skillsDir);
472
+ const skills = await installSkillsTo(skillsRoot, skillsDir, skipSkills);
193
473
  if (skills.length > 0) {
194
474
  result.configured.push(`${editor.name} skills (${skills.length} -> ${prettyHome(skillsDir)})`);
195
475
  }
@@ -197,8 +477,23 @@ async function installForEditor(editor, skillsRoot, agentsRoot, result) {
197
477
  catch (err) {
198
478
  result.errors.push(`${editor.name} skills: ${err.message}`);
199
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
+ }
483
+ // Seed / refresh the incremental-ui-align per-skill config.json with the
484
+ // GLM key + SDK dir from ~/.hometrans/config.json (existing files: key-only
485
+ // update). Runs regardless of the overwrite decision above.
200
486
  try {
201
- const agents = await installAgentsTo(agentsRoot, agentsDir);
487
+ const status = await refreshUiAlignConfig(skillsDir, glmApiKey, hmosSdkDir, prevUiAlignExample);
488
+ if (status) {
489
+ result.configured.push(`${editor.name} ${status}`);
490
+ }
491
+ }
492
+ catch (err) {
493
+ result.errors.push(`${editor.name} ${UI_ALIGN_SKILL}/config.json: ${err.message}`);
494
+ }
495
+ try {
496
+ const agents = await installAgentsTo(agentsRoot, agentsDir, skipAgents);
202
497
  if (agents.length > 0) {
203
498
  result.configured.push(`${editor.name} agents (${agents.length} -> ${prettyHome(agentsDir)})`);
204
499
  }
@@ -207,12 +502,305 @@ async function installForEditor(editor, skillsRoot, agentsRoot, result) {
207
502
  result.errors.push(`${editor.name} agents: ${err.message}`);
208
503
  }
209
504
  }
505
+ /**
506
+ * Display paths as full absolute OS-native paths (e.g. on Windows:
507
+ * `C:\Users\<you>\.claude\skills`) — no `~` abbreviation, no separator
508
+ * rewriting, so output matches what the user can copy into their shell.
509
+ */
210
510
  export function prettyHome(p) {
211
- const home = os.homedir();
212
- if (p.startsWith(home)) {
213
- return '~' + p.slice(home.length).replace(/\\/g, '/');
511
+ return p;
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
+ }
558
+ /** Locate a command on PATH (`where` on Windows, `which` elsewhere). */
559
+ function whichSync(cmd) {
560
+ const isWin = process.platform === 'win32';
561
+ try {
562
+ const out = execFileSync(isWin ? 'where' : 'which', [cmd], {
563
+ encoding: 'utf-8',
564
+ timeout: 5000,
565
+ stdio: ['ignore', 'pipe', 'ignore'],
566
+ });
567
+ const lines = out.split('\n').map((l) => l.trim()).filter(Boolean);
568
+ return lines[0] || null;
569
+ }
570
+ catch {
571
+ return null;
572
+ }
573
+ }
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)
582
+ return null;
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;
587
+ }
588
+ async function fileExists(p) {
589
+ try {
590
+ await fs.access(p);
591
+ return true;
592
+ }
593
+ catch {
594
+ return false;
595
+ }
596
+ }
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
+ }
646
+ /**
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.
651
+ */
652
+ async function detectEnvironment(spec, env) {
653
+ const status = new Map();
654
+ const isWin = process.platform === 'win32';
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;
689
+ }
690
+ }
691
+ }
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`;
709
+ }
710
+ status.set(id, st);
711
+ }
712
+ return status;
713
+ }
714
+ /**
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.
718
+ */
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
+ }
733
+ /** Print detected tools + the per-skill/agent impact table. */
734
+ export async function runEnvironmentCheck(env) {
735
+ console.log('');
736
+ console.log(chalk.blue(' Environment Check'));
737
+ const spec = await loadEnvRequirements();
738
+ if (!spec)
739
+ return;
740
+ const tools = await detectEnvironment(spec, env);
741
+ const order = Object.keys(spec.tools);
742
+ for (const name of order) {
743
+ const st = tools.get(name);
744
+ const tool = spec.tools[name];
745
+ if (st.found) {
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}`);
752
+ }
753
+ else {
754
+ console.log(` ${chalk.yellow('!')} ${name.padEnd(9)} ${chalk.yellow('not found')} ${chalk.gray(tool.hint ?? '')}`);
755
+ }
756
+ }
757
+ console.log('');
758
+ console.log(chalk.blue(' Impact on skills/agents:'));
759
+ const nameW = 28;
760
+ console.log(chalk.gray(` ${'Name'.padEnd(nameW)} ${'Kind'.padEnd(6)} ${'Status'.padEnd(8)} Missing`));
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
+ }
774
+ let plain;
775
+ let color;
776
+ let parts;
777
+ if (unmetReq.length > 0) {
778
+ plain = 'BLOCKED';
779
+ color = chalk.red;
780
+ parts = [...unmetReq, ...unmetOpt];
781
+ allOk = false;
782
+ }
783
+ else if (unmetOpt.length > 0) {
784
+ plain = 'LIMITED';
785
+ color = chalk.yellow;
786
+ parts = unmetOpt;
787
+ allOk = false;
788
+ }
789
+ else {
790
+ plain = 'OK';
791
+ color = chalk.green;
792
+ parts = [];
793
+ }
794
+ const noteStr = parts.length > 0 && row.note ? chalk.gray(` — ${row.note}`) : '';
795
+ console.log(` ${row.name.padEnd(nameW)} ${row.kind.padEnd(6)} ${color(plain.padEnd(8))} ${parts.join(', ')}${noteStr}`);
796
+ }
797
+ console.log('');
798
+ if (allOk) {
799
+ console.log(chalk.green(' All environment dependencies found — every skill/agent is fully usable.'));
800
+ }
801
+ else {
802
+ console.log(chalk.gray(' Install the missing tools above and re-run `ht init` to clear the impact list.'));
214
803
  }
215
- return p.replace(/\\/g, '/');
216
804
  }
217
805
  async function detectInstalledEditors(editors) {
218
806
  const status = new Map();
@@ -309,33 +897,76 @@ export async function initCommand(options = {}) {
309
897
  const answers = await inquirer.prompt([
310
898
  {
311
899
  type: 'input',
312
- name: 'OHOS_SDK_PATH',
313
- message: 'OHOS_SDK_PATH:',
314
- default: config.env.OHOS_SDK_PATH || undefined,
900
+ name: 'DEVECO_SDK_HOME',
901
+ message: 'DEVECO_SDK_HOME (DevEco Studio SDK dir):',
902
+ default: config.env.DEVECO_SDK_HOME || undefined,
315
903
  },
316
904
  {
317
905
  type: 'input',
318
- name: 'HMS_SDK_PATH',
319
- message: 'HMS_SDK_PATH:',
320
- default: config.env.HMS_SDK_PATH || undefined,
906
+ name: 'TEST_API_KEY',
907
+ message: 'TEST_API_KEY (LLM API key used by the integration-test agent to run test cases):',
908
+ default: config.env.TEST_API_KEY || undefined,
321
909
  },
322
910
  {
323
911
  type: 'input',
324
- name: 'TEST_API_KEY',
325
- message: 'TEST_API_KEY (for autotest config.yaml):',
326
- default: config.env.TEST_API_KEY || undefined,
912
+ name: 'GLM_API_KEY',
913
+ message: 'GLM_API_KEY (LLM API key for the GLM phone-agent used in UI alignment):',
914
+ default: config.env.GLM_API_KEY || undefined,
327
915
  },
328
916
  ]);
329
- config.env.OHOS_SDK_PATH = answers.OHOS_SDK_PATH.trim();
330
- config.env.HMS_SDK_PATH = answers.HMS_SDK_PATH.trim();
917
+ const sdk = deriveSdkPaths(answers.DEVECO_SDK_HOME);
918
+ config.env.DEVECO_SDK_HOME = sdk.DEVECO_SDK_HOME;
919
+ config.env.DEVECO_PATH = sdk.DEVECO_PATH;
920
+ config.env.OHOS_SDK_PATH = sdk.OHOS_SDK_PATH;
921
+ config.env.HMS_SDK_PATH = sdk.HMS_SDK_PATH;
331
922
  config.env.TEST_API_KEY = answers.TEST_API_KEY.trim();
923
+ config.env.GLM_API_KEY = answers.GLM_API_KEY.trim();
332
924
  await saveHomeTransConfig(config);
925
+ // Echo the derived paths and validate each exists on disk; missing ones are
926
+ // a strong signal of a typo'd DEVECO_SDK_HOME or a broken DevEco install.
927
+ if (sdk.DEVECO_SDK_HOME) {
928
+ const checks = [
929
+ ['DEVECO_SDK_HOME', sdk.DEVECO_SDK_HOME],
930
+ ['DEVECO_PATH', sdk.DEVECO_PATH],
931
+ ['OHOS_SDK_PATH', sdk.OHOS_SDK_PATH],
932
+ ['HMS_SDK_PATH', sdk.HMS_SDK_PATH],
933
+ ];
934
+ console.log('');
935
+ console.log(chalk.blue(' Derived from DEVECO_SDK_HOME:'));
936
+ let missingCount = 0;
937
+ for (const [name, p] of checks) {
938
+ const exists = await dirExists(p);
939
+ if (!exists)
940
+ missingCount++;
941
+ const mark = exists ? chalk.green('+') : chalk.yellow('!');
942
+ const note = exists ? '' : chalk.yellow(' (not found on disk)');
943
+ console.log(` ${mark} ${name.padEnd(15)} : ${p}${note}`);
944
+ }
945
+ if (missingCount > 0) {
946
+ console.log('');
947
+ console.log(chalk.yellow(' ! Check DEVECO_SDK_HOME (it should be the "sdk" folder inside your DevEco Studio install)'));
948
+ console.log(chalk.yellow(' and re-run `ht init` after fixing it. Skills that build or'));
949
+ console.log(chalk.yellow(' review code will not work until these paths resolve.'));
950
+ }
951
+ }
333
952
  }
334
953
  catch (err) {
335
954
  if (isPromptAbort(err))
336
955
  abortInit();
337
956
  console.log(chalk.yellow(' Parameter prompts skipped (non-interactive mode).'));
338
957
  }
958
+ // Detect external tools (adb / hdc / python / uv / java / gitnexus + DevEco)
959
+ // and report which skills/agents are impacted by anything missing.
960
+ try {
961
+ await runEnvironmentCheck(config.env);
962
+ }
963
+ catch (err) {
964
+ console.log(chalk.yellow(` ! environment check skipped: ${err.message}`));
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'));
339
970
  // Copy bundled tools/ into ~/.hometrans/tools and record env.TOOL_PATH.
340
971
  // Must run before the autotest config step below, which seeds config.yaml
341
972
  // into the installed tools dir (the location agents read via env.TOOL_PATH).
@@ -350,13 +981,24 @@ export async function initCommand(options = {}) {
350
981
  catch (err) {
351
982
  console.log(chalk.red(` ! tools copy: ${err.message}`));
352
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
+ }
353
995
  // Initialize / refresh autotest config.yaml from the example template,
354
996
  // inside the installed tools dir (<TOOL_PATH>/test-tools/autotest).
355
997
  try {
356
998
  const toolPath = installedToolPath ?? config.env.TOOL_PATH;
357
999
  if (toolPath) {
358
1000
  const autotestDir = resolveAutotestDir(toolPath);
359
- const status = await refreshAutotestConfig(autotestDir, config.env.TEST_API_KEY);
1001
+ const status = await refreshAutotestConfig(autotestDir, config.env.TEST_API_KEY, prevAutotestExample);
360
1002
  if (status) {
361
1003
  console.log(chalk.green(` + ${status}`));
362
1004
  console.log(chalk.gray(` ${path.join(autotestDir, 'config.yaml')}`));
@@ -369,9 +1011,13 @@ export async function initCommand(options = {}) {
369
1011
  console.log('');
370
1012
  const editorsToSetup = editors.filter((e) => selectedEditors.includes(e.name));
371
1013
  const result = { configured: [], skipped: [], errors: [] };
1014
+ // hmos_sdk_dir consumed by incremental-ui-align = <DEVECO_SDK_HOME>/default.
1015
+ const hmosSdkDir = config.env.DEVECO_SDK_HOME
1016
+ ? path.join(config.env.DEVECO_SDK_HOME, 'default')
1017
+ : '';
372
1018
  for (const editor of editorsToSetup) {
373
1019
  console.log(chalk.blue(` Configuring ${editor.name}...`));
374
- await installForEditor(editor, skillsRoot, agentsRoot, result);
1020
+ await installForEditor(editor, skillsRoot, agentsRoot, config.env.GLM_API_KEY, hmosSdkDir, result);
375
1021
  }
376
1022
  await setupMcpForAllEditors(editorsToSetup, result);
377
1023
  console.log('');