@hydranium/cli 1.0.0-next.413 → 1.0.0-next.418

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.
Files changed (37) hide show
  1. package/README.md +5 -0
  2. package/lib/cli.js +2 -0
  3. package/lib/cli.js.map +1 -1
  4. package/lib/commands/check-skills-args.d.ts +11 -0
  5. package/lib/commands/check-skills-args.d.ts.map +1 -0
  6. package/lib/commands/check-skills-args.js +71 -0
  7. package/lib/commands/check-skills-args.js.map +1 -0
  8. package/lib/commands/generate-ast-builder.d.ts +1 -1
  9. package/lib/commands/generate-ast-builder.js +1 -1
  10. package/lib/commands/init-args.d.ts +2 -18
  11. package/lib/commands/init-args.d.ts.map +1 -1
  12. package/lib/commands/init-args.js +46 -7
  13. package/lib/commands/init-args.js.map +1 -1
  14. package/lib/commands/init-skills.d.ts +41 -0
  15. package/lib/commands/init-skills.d.ts.map +1 -0
  16. package/lib/commands/init-skills.js +110 -0
  17. package/lib/commands/init-skills.js.map +1 -0
  18. package/lib/commands/init-wizard.d.ts +4 -0
  19. package/lib/commands/init-wizard.d.ts.map +1 -1
  20. package/lib/commands/init-wizard.js +15 -1
  21. package/lib/commands/init-wizard.js.map +1 -1
  22. package/lib/commands/init.d.ts.map +1 -1
  23. package/lib/commands/init.js +3 -1
  24. package/lib/commands/init.js.map +1 -1
  25. package/lib/commands/skills.d.ts +22 -0
  26. package/lib/commands/skills.d.ts.map +1 -0
  27. package/lib/commands/skills.js +129 -0
  28. package/lib/commands/skills.js.map +1 -0
  29. package/package.json +26 -20
  30. package/src/cli.ts +2 -0
  31. package/src/commands/check-skills-args.ts +70 -0
  32. package/src/commands/generate-ast-builder.ts +1 -1
  33. package/src/commands/init-args.ts +47 -7
  34. package/src/commands/init-skills.ts +129 -0
  35. package/src/commands/init-wizard.ts +24 -2
  36. package/src/commands/init.ts +3 -1
  37. package/src/commands/skills.ts +146 -0
@@ -26,12 +26,14 @@
26
26
  * association is gone.
27
27
  */
28
28
 
29
+ import * as path from 'node:path';
29
30
  import { assertRequired, exitWithUsage, helpRequested, printHelp, type UsageError } from './harness-args.js';
30
31
  import { isNonEmptyDir, runInit, validateInitInstallation, type InitHead } from './init.js';
31
32
  import type { InitHost } from './init-hosts.js';
32
33
  import { createClackPrompt } from './init-prompt.js';
33
34
  import { runInitWizard } from './init-wizard.js';
34
35
  import { createNodeWorkspaceProbe, detectWorkspace } from './init-workspace.js';
36
+ import { installSkills, offerSkills, parseSkillInstallation, SKILLS_INSTALLER } from './init-skills.js';
35
37
 
36
38
  /** One grammar as the command line collected it, before any derivation. */
37
39
  export interface ParsedInitGrammar {
@@ -47,6 +49,8 @@ export interface ParsedInitArgs {
47
49
  name?: string;
48
50
  force: boolean;
49
51
  install: boolean;
52
+ skills?: string;
53
+ agents?: string;
50
54
  heads?: InitHead[];
51
55
  hosts?: InitHost[];
52
56
  monorepo: boolean;
@@ -68,6 +72,8 @@ export const INIT_FLAGS: readonly string[] = [
68
72
  '--hosts',
69
73
  '--force',
70
74
  '--install',
75
+ '--skills',
76
+ '--agents',
71
77
  '--monorepo',
72
78
  '--scope',
73
79
  '--public',
@@ -82,6 +88,8 @@ export const INIT_FLAGS: readonly string[] = [
82
88
  * The rest are presence-only.
83
89
  */
84
90
  export const INIT_VALUE_FLAGS: readonly string[] = [
91
+ '--skills',
92
+ '--agents',
85
93
  '--name',
86
94
  '--heads',
87
95
  '--hosts',
@@ -107,6 +115,8 @@ export function parseInitArgs(args: string[], onError: UsageError = exitWithUsag
107
115
  let name: string | undefined;
108
116
  let force = false;
109
117
  let install = false;
118
+ let skills: string | undefined;
119
+ let agents: string | undefined;
110
120
  let heads: InitHead[] | undefined;
111
121
  let hosts: InitHost[] | undefined;
112
122
  let monorepo = false;
@@ -134,6 +144,12 @@ export function parseInitArgs(args: string[], onError: UsageError = exitWithUsag
134
144
  return current;
135
145
  };
136
146
  switch (flag) {
147
+ case '--skills':
148
+ skills = next();
149
+ break;
150
+ case '--agents':
151
+ agents = next();
152
+ break;
137
153
  case '--name':
138
154
  name = next();
139
155
  break;
@@ -190,7 +206,7 @@ export function parseInitArgs(args: string[], onError: UsageError = exitWithUsag
190
206
  }
191
207
  }
192
208
  }
193
- return { targetDir, name, force, install, heads, hosts, monorepo, scope, public: isPublic, grammars };
209
+ return { targetDir, name, force, install, skills, agents, heads, hosts, monorepo, scope, public: isPublic, grammars };
194
210
  }
195
211
 
196
212
  /**
@@ -216,8 +232,9 @@ export const INIT_HELP: readonly string[] = [
216
232
  'launch of the VS Code and Theia ones, and theia/ the Theia extension;',
217
233
  'workspace/ holds two starter model files per grammar. A standalone project',
218
234
  'also gets a root manifest naming the packages as npm workspaces.',
219
- 'Stays offline by default. With --install, runs `npm install` after writing',
220
- 'the scaffold. Building remains a next step. Refuses a non-empty directory',
235
+ 'Stays offline unless --install or --skills is requested. With --install,',
236
+ 'runs `npm install` after writing the scaffold. Building remains a next step.',
237
+ 'Refuses a non-empty directory',
221
238
  'without --force.',
222
239
  '',
223
240
  'Run it with no --name on a terminal to be prompted instead. The wizard echoes',
@@ -225,6 +242,8 @@ export const INIT_HELP: readonly string[] = [
225
242
  'flags. Without a terminal a missing --name stays an error, so CI never hangs.',
226
243
  'For a standalone project, the wizard ends by offering npm install unless',
227
244
  '--install already requested it.',
245
+ 'After a successful scaffold, the wizard offers coding-agent skills separately.',
246
+ 'Skipping or cancelling that offer leaves the scaffold intact.',
228
247
  '',
229
248
  'Project options (position-free):',
230
249
  ' <target-dir> The project directory to scaffold into (required).',
@@ -249,6 +268,12 @@ export const INIT_HELP: readonly string[] = [
249
268
  ' --hosts, the server with its Node entry points, and',
250
269
  ' with glsp the client/ package a host mounts.',
251
270
  ' --force Scaffold into a non-empty directory anyway.',
271
+ ' --skills <names> After scaffolding, install named skills: hydranium-profiling,',
272
+ ` langium. Requires --agents. Downloads with npx ${SKILLS_INSTALLER} add`,
273
+ ' --copy, preserving unrelated skills and lock entries.',
274
+ ' With --monorepo, skills are written at the workspace root.',
275
+ ' --agents <names> Comma-separated agents for --skills: codex, claude-code,',
276
+ ' cursor, github-copilot, opencode.',
252
277
  ' --install Run npm install in the scaffold, with lifecycle scripts',
253
278
  ' and network access. Cannot be combined with --monorepo:',
254
279
  ' its parent manifest needs manual edits first.',
@@ -257,7 +282,8 @@ export const INIT_HELP: readonly string[] = [
257
282
  ' extends the root config that actually carries',
258
283
  ' compilerOptions, and the scripts address the server by',
259
284
  ' --prefix. Errors when no ancestor declares `workspaces`.',
260
- ' Never writes outside the target — the root `workspaces`',
285
+ ' Scaffold files stay inside the target; optional skills',
286
+ ' go at the workspace root. The root `workspaces`',
261
287
  ' entries and dependency pins are printed, not added.',
262
288
  ' --scope <@scope> npm scope for the package names, e.g. @acme. Not inferred:',
263
289
  ' a root manifest is usually named for the repo, not the',
@@ -303,20 +329,24 @@ export async function runInitCommand(args: string[]): Promise<void> {
303
329
  printHelp(INIT_HELP);
304
330
  return;
305
331
  }
306
- const parsed = parseInitArgs(args);
332
+ let parsed = parseInitArgs(args);
307
333
  validateInitInstallation(parsed);
334
+ let selection = parseSkillInstallation(parsed.skills, parsed.agents);
335
+ let prompt: Awaited<ReturnType<typeof createClackPrompt>> | undefined;
308
336
  // The wizard's entry point: no `--name` on an interactive terminal. Safe
309
337
  // because that combination has no non-wizard meaning, and gated on a TTY
310
338
  // because a non-interactive caller must keep failing fast — a prompt in CI
311
339
  // hangs the job instead of reporting the missing flag.
312
340
  if (parsed.name === undefined && process.stdin.isTTY && process.stdout.isTTY) {
313
- const prompt = await createClackPrompt();
341
+ prompt = await createClackPrompt();
314
342
  const argv = await runInitWizard(prompt, targetDir => detectWorkspace(targetDir, createNodeWorkspaceProbe()), {
315
343
  targetDir: parsed.targetDir,
316
344
  install: parsed.install,
345
+ skills: parsed.skills,
346
+ agents: parsed.agents,
317
347
  isOccupied: isNonEmptyDir
318
348
  });
319
- return runInitCommand(argv);
349
+ parsed = parseInitArgs(argv);
320
350
  }
321
351
  runInit({
322
352
  targetDir: assertRequired(parsed.targetDir, '<target-dir>', 'init'),
@@ -330,4 +360,14 @@ export async function runInitCommand(args: string[]): Promise<void> {
330
360
  scope: parsed.scope,
331
361
  public: parsed.public
332
362
  });
363
+ const target = path.resolve(parsed.targetDir!);
364
+ const project = parsed.monorepo ? detectWorkspace(target, createNodeWorkspaceProbe())!.rootDir : target;
365
+ if (selection === undefined && prompt !== undefined) {
366
+ selection = await offerSkills(prompt, project);
367
+ }
368
+ if (selection !== undefined) {
369
+ installSkills(project, path.join(target, 'server'), selection);
370
+ } else {
371
+ process.stdout.write('\nOptional agent skills: see the Work with coding-agent skills guide.\n');
372
+ }
333
373
  }
@@ -0,0 +1,129 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import * as path from 'node:path';
12
+ import { InitWizardCancelled, type PromptPort } from './init-prompt.js';
13
+ import { checkSkills } from './skills.js';
14
+
15
+ export const SKILLS_INSTALLER = 'skills@1.7.6';
16
+
17
+ export const ADOPTER_SKILLS = {
18
+ 'hydranium-profiling': { source: 'eclipse-emfcloud/hydranium', label: 'Hydranium profiling' },
19
+ langium: { source: 'eclipse-langium/langium-ai', label: 'Langium grammar and runtime' }
20
+ } as const;
21
+
22
+ export type AdopterSkill = keyof typeof ADOPTER_SKILLS;
23
+
24
+ export const SKILL_AGENTS = {
25
+ codex: 'Codex',
26
+ 'claude-code': 'Claude Code',
27
+ cursor: 'Cursor',
28
+ 'github-copilot': 'GitHub Copilot',
29
+ opencode: 'OpenCode'
30
+ } as const;
31
+
32
+ export type SkillAgent = keyof typeof SKILL_AGENTS;
33
+
34
+ export interface SkillInstallation {
35
+ readonly skills: readonly AdopterSkill[];
36
+ readonly agents: readonly SkillAgent[];
37
+ }
38
+
39
+ /** Validate before scaffolding so a misspelling cannot leave a half-completed command. */
40
+ export function parseSkillInstallation(skills?: string, agents?: string): SkillInstallation | undefined {
41
+ if (skills === undefined && agents === undefined) {
42
+ return undefined;
43
+ }
44
+ if (!skills || !agents) {
45
+ throw new Error('--skills and --agents must be supplied together.');
46
+ }
47
+ const selectedSkills = skills.split(',');
48
+ const selectedAgents = agents.split(',');
49
+ for (const name of selectedSkills) {
50
+ if (!Object.hasOwn(ADOPTER_SKILLS, name)) {
51
+ throw new Error(`Unknown skill '${name}'. Choose ${Object.keys(ADOPTER_SKILLS).join(', ')}.`);
52
+ }
53
+ }
54
+ for (const name of selectedAgents) {
55
+ if (!Object.hasOwn(SKILL_AGENTS, name)) {
56
+ throw new Error(`Unknown agent '${name}'. Choose ${Object.keys(SKILL_AGENTS).join(', ')}.`);
57
+ }
58
+ }
59
+ return { skills: [...new Set(selectedSkills)] as AdopterSkill[], agents: [...new Set(selectedAgents)] as SkillAgent[] };
60
+ }
61
+
62
+ export function skillInstallArgs(skill: AdopterSkill, agents: readonly SkillAgent[]): string[] {
63
+ return ['--yes', SKILLS_INSTALLER, 'add', ADOPTER_SKILLS[skill].source, '--skill', skill, '--agent', ...agents, '--copy', '--yes'];
64
+ }
65
+
66
+ /** A failed optional download leaves the scaffold usable and gives an independently runnable retry. */
67
+ export function installSkills(
68
+ project: string,
69
+ from: string,
70
+ selection: SkillInstallation,
71
+ write: (message: string) => void = message => process.stdout.write(message)
72
+ ): void {
73
+ for (const skill of selection.skills) {
74
+ const args = skillInstallArgs(skill, selection.agents);
75
+ write(`\nIn ${project}: npx ${args.join(' ')}\n`);
76
+ try {
77
+ // Only catalogued names reach the Windows shim's shell; paths travel as cwd, never shell text.
78
+ const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', args, {
79
+ cwd: project,
80
+ stdio: 'inherit',
81
+ shell: process.platform === 'win32'
82
+ });
83
+ if (result.signal === 'SIGINT' || result.status === 130) {
84
+ write('Skill installation cancelled. Scaffold kept.\n');
85
+ return;
86
+ }
87
+ if (result.error || result.signal || result.status !== 0) {
88
+ throw result.error ?? new Error(result.signal ?? `exit code ${result.status}`);
89
+ }
90
+ for (const result of checkSkills(project, from, [skill])) {
91
+ write(`${result.status}: ${result.file}: ${result.detail}\n`);
92
+ }
93
+ } catch (error) {
94
+ write(`Skill installation failed (${error instanceof Error ? error.message : String(error)}). Scaffold kept.\n`);
95
+ write(`Retry in ${project}: npx ${args.join(' ')}\n`);
96
+ }
97
+ }
98
+ const relative = path.relative(project, from) || '.';
99
+ const quoted = `'${relative.replace(/'/g, "'\\''")}'`;
100
+ write(`Check again after installing or updating dependencies: npx hydranium-cli check-skills --from ${quoted}\n`);
101
+ }
102
+
103
+ /** This prompt runs only after the scaffold succeeds; cancelling it cannot undo the scaffold. */
104
+ export async function offerSkills(prompt: PromptPort, project: string): Promise<SkillInstallation | undefined> {
105
+ try {
106
+ prompt.intro('Optional coding-agent skills');
107
+ if (!(await prompt.confirm({ message: `Install coding-agent skills in ${project}?`, initialValue: false }))) {
108
+ prompt.outro('Skills skipped.');
109
+ return undefined;
110
+ }
111
+ const skills = await prompt.multiselect<AdopterSkill>({
112
+ message: 'Skills to install',
113
+ choices: Object.entries(ADOPTER_SKILLS).map(([value, skill]) => ({ value: value as AdopterSkill, label: skill.label })),
114
+ initialValues: ['hydranium-profiling', 'langium']
115
+ });
116
+ const agents = await prompt.multiselect<SkillAgent>({
117
+ message: 'Coding agents',
118
+ choices: Object.entries(SKILL_AGENTS).map(([value, label]) => ({ value: value as SkillAgent, label }))
119
+ });
120
+ prompt.outro('Installing selected skills as project files. Commit the files and skills-lock.json.');
121
+ return { skills, agents };
122
+ } catch (error) {
123
+ if (error instanceof InitWizardCancelled) {
124
+ prompt.outro('Skills skipped. Scaffold kept.');
125
+ return undefined;
126
+ }
127
+ throw error;
128
+ }
129
+ }
@@ -96,6 +96,8 @@ export interface InitAnswers {
96
96
  readonly public: boolean;
97
97
  /** Run npm install after writing the scaffold. */
98
98
  readonly install: boolean;
99
+ readonly skills?: string;
100
+ readonly agents?: string;
99
101
  }
100
102
 
101
103
  /** PascalCase a directory name, splitting on any non-alphanumeric run. */
@@ -161,6 +163,9 @@ export function composeInitArgv(answers: InitAnswers): string[] {
161
163
  if (answers.install) {
162
164
  argv.push('--install');
163
165
  }
166
+ if (answers.skills !== undefined && answers.agents !== undefined) {
167
+ argv.push('--skills', answers.skills, '--agents', answers.agents);
168
+ }
164
169
  return argv;
165
170
  }
166
171
 
@@ -358,7 +363,13 @@ async function askPackaging(
358
363
  export async function runInitWizard(
359
364
  prompt: PromptPort,
360
365
  detect: (targetDir: string) => WorkspaceDetection | undefined,
361
- options: { readonly targetDir?: string; readonly isOccupied?: (targetDir: string) => boolean; readonly install?: boolean } = {}
366
+ options: {
367
+ readonly targetDir?: string;
368
+ readonly isOccupied?: (targetDir: string) => boolean;
369
+ readonly install?: boolean;
370
+ readonly skills?: string;
371
+ readonly agents?: string;
372
+ } = {}
362
373
  ): Promise<string[]> {
363
374
  prompt.intro('Scaffold a Hydranium language project');
364
375
 
@@ -413,7 +424,18 @@ export async function runInitWizard(
413
424
  initialValue: false
414
425
  })));
415
426
 
416
- const argv = composeInitArgv({ targetDir, name, heads, hosts, grammars: withDiagram, force, install, ...packaging });
427
+ const argv = composeInitArgv({
428
+ targetDir,
429
+ name,
430
+ heads,
431
+ hosts,
432
+ grammars: withDiagram,
433
+ force,
434
+ install,
435
+ skills: options.skills,
436
+ agents: options.agents,
437
+ ...packaging
438
+ });
417
439
  // Echoed as the LAST thing before the scaffold runs, because it is the
418
440
  // artefact the session produces: everything above is how it was reached.
419
441
  prompt.outro(formatCommand(argv));
@@ -715,13 +715,15 @@ export function runInit(options: InitCommandOptions): void {
715
715
  `Could not read npm global configuration (${reason}). Scaffold kept at ${targetDir}; installation was not started.`
716
716
  );
717
717
  }
718
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'npm_config_globalconfig'));
719
+ env.npm_config_globalconfig = globalconfig;
718
720
  // An explicit prefix keeps npm from selecting an enclosing workspace instead of this standalone root.
719
721
  // Windows resolves npm through a command shim; only fixed arguments reach the shell.
720
722
  const result = spawnSync(npm, ['--prefix', '.', 'install'], {
721
723
  cwd: targetDir,
722
724
  stdio: 'inherit',
723
725
  shell: process.platform === 'win32',
724
- env: { ...process.env, npm_config_globalconfig: globalconfig }
726
+ env
725
727
  });
726
728
  if (result.error || result.signal || result.status !== 0) {
727
729
  const reason = result.error?.message ?? (result.signal ? `signal ${result.signal}` : `exit code ${result.status}`);
@@ -0,0 +1,146 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { resolve } from 'import-meta-resolve';
15
+ import { parse } from 'yaml';
16
+
17
+ /** A compatibility result applies to one installed copy, not just its skill name. */
18
+ export interface SkillCompatibility {
19
+ readonly file: string;
20
+ readonly status: 'compatible' | 'incompatible' | 'unverified';
21
+ readonly detail: string;
22
+ }
23
+
24
+ export const SKILL_DIRECTORIES = ['.agents/skills', '.claude/skills'] as const;
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ /** Resolve from the adopter, never from a globally installed CLI's dependencies. */
31
+ export function installedPackageManifest(name: string, from: string): Record<string, unknown> | undefined {
32
+ const parent = path.join(path.resolve(from), 'package.json');
33
+ let manifest: string;
34
+ try {
35
+ manifest = createRequire(parent).resolve(`${name}/package.json`);
36
+ } catch {
37
+ try {
38
+ let directory = path.dirname(fileURLToPath(resolve(name, pathToFileURL(parent).href)));
39
+ while (true) {
40
+ const candidate = path.join(directory, 'package.json');
41
+ if (fs.existsSync(candidate)) {
42
+ const value: unknown = JSON.parse(fs.readFileSync(candidate, 'utf8'));
43
+ if (isRecord(value) && value.name === name) {
44
+ return value;
45
+ }
46
+ }
47
+ const next = path.dirname(directory);
48
+ if (next === directory) {
49
+ return undefined;
50
+ }
51
+ directory = next;
52
+ }
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ const value: unknown = JSON.parse(fs.readFileSync(manifest, 'utf8'));
58
+ return isRecord(value) && value.name === name ? value : undefined;
59
+ }
60
+
61
+ /** Missing or malformed requirements never constitute evidence of compatibility. */
62
+ export function checkSkill(file: string, from: string): SkillCompatibility {
63
+ const result = (status: SkillCompatibility['status'], detail: string): SkillCompatibility => ({ file, status, detail });
64
+ try {
65
+ const text = fs.readFileSync(file, 'utf8');
66
+ const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
67
+ const skill: unknown = frontmatter === undefined ? undefined : parse(frontmatter);
68
+ const metadata = isRecord(skill) ? skill.metadata : undefined;
69
+ if (!isRecord(metadata) || metadata['hydranium-compatibility-schema'] !== '1') {
70
+ return result('unverified', 'No supported Hydranium compatibility metadata.');
71
+ }
72
+ const encoded = metadata['hydranium-contracts'];
73
+ const contracts: unknown = typeof encoded === 'string' ? JSON.parse(encoded) : undefined;
74
+ if (
75
+ !isRecord(contracts) ||
76
+ Object.keys(contracts).length === 0 ||
77
+ Object.entries(contracts).some(
78
+ ([name, declarations]) =>
79
+ !/^(@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(name) ||
80
+ !isRecord(declarations) ||
81
+ Object.keys(declarations).length === 0 ||
82
+ Object.entries(declarations).some(
83
+ ([contract, revision]) => !contract.trim() || typeof revision !== 'string' || !revision.trim()
84
+ )
85
+ )
86
+ ) {
87
+ return result('unverified', 'Invalid package contract requirements.');
88
+ }
89
+ const problems: string[] = [];
90
+ const versions: string[] = [];
91
+ let mismatch = false;
92
+ for (const [name, requiredContracts] of Object.entries(contracts)) {
93
+ const manifest = installedPackageManifest(name, from);
94
+ const version = manifest?.version;
95
+ const configuration = manifest?.hydranium;
96
+ const suppliedContracts = isRecord(configuration) ? configuration.skillContracts : undefined;
97
+ const contractMatches =
98
+ isRecord(requiredContracts) &&
99
+ isRecord(suppliedContracts) &&
100
+ Object.entries(requiredContracts).every(
101
+ ([contract, revision]) => Object.hasOwn(suppliedContracts, contract) && suppliedContracts[contract] === revision
102
+ );
103
+ if (manifest === undefined) {
104
+ problems.push(`${name} is not installed; install dependencies and retry.`);
105
+ } else if (!contractMatches) {
106
+ mismatch = true;
107
+ problems.push(`${name} does not declare the required skill contracts.`);
108
+ } else {
109
+ versions.push(`${name}${typeof version === 'string' ? `@${version}` : ''} (declared skill contract)`);
110
+ }
111
+ }
112
+ return problems.length > 0
113
+ ? result(mismatch ? 'incompatible' : 'unverified', problems.join(' '))
114
+ : result('compatible', `Declared requirements satisfied by ${versions.join(', ')}.`);
115
+ } catch (error) {
116
+ return result('unverified', error instanceof Error ? error.message : String(error));
117
+ }
118
+ }
119
+
120
+ /** Check every selected copy so an outdated agent-specific copy cannot hide behind a shared one. */
121
+ export function checkSkills(project: string, from = project, names?: readonly string[]): SkillCompatibility[] {
122
+ const results: SkillCompatibility[] = [];
123
+ const found = new Set<string>();
124
+ for (const directory of SKILL_DIRECTORIES) {
125
+ const root = path.join(project, directory);
126
+ if (!fs.existsSync(root)) {
127
+ continue;
128
+ }
129
+ for (const name of fs.readdirSync(root)) {
130
+ if (!(names ? names.includes(name) : name.startsWith('hydranium-'))) {
131
+ continue;
132
+ }
133
+ found.add(name);
134
+ results.push(checkSkill(path.join(root, name, 'SKILL.md'), from));
135
+ }
136
+ }
137
+ for (const name of names ?? []) {
138
+ if (!found.has(name)) {
139
+ results.push({ file: name, status: 'unverified', detail: 'Skill is not installed in a checked project directory.' });
140
+ }
141
+ }
142
+ if (results.length === 0) {
143
+ results.push({ file: project, status: 'unverified', detail: 'No Hydranium project skills found.' });
144
+ }
145
+ return results;
146
+ }