@hydranium/cli 1.0.0-next.412 → 1.0.0-next.416

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 (38) 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 +3 -18
  11. package/lib/commands/init-args.d.ts.map +1 -1
  12. package/lib/commands/init-args.js +62 -9
  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 +7 -0
  19. package/lib/commands/init-wizard.d.ts.map +1 -1
  20. package/lib/commands/init-wizard.js +32 -2
  21. package/lib/commands/init-wizard.js.map +1 -1
  22. package/lib/commands/init.d.ts +6 -5
  23. package/lib/commands/init.d.ts.map +1 -1
  24. package/lib/commands/init.js +43 -6
  25. package/lib/commands/init.js.map +1 -1
  26. package/lib/commands/skills.d.ts +22 -0
  27. package/lib/commands/skills.d.ts.map +1 -0
  28. package/lib/commands/skills.js +129 -0
  29. package/lib/commands/skills.js.map +1 -0
  30. package/package.json +26 -20
  31. package/src/cli.ts +2 -0
  32. package/src/commands/check-skills-args.ts +70 -0
  33. package/src/commands/generate-ast-builder.ts +1 -1
  34. package/src/commands/init-args.ts +64 -9
  35. package/src/commands/init-skills.ts +129 -0
  36. package/src/commands/init-wizard.ts +44 -3
  37. package/src/commands/init.ts +50 -6
  38. package/src/commands/skills.ts +146 -0
@@ -0,0 +1,70 @@
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 path from 'node:path';
11
+ import { exitWithUsage, helpRequested, printHelp } from './harness-args.js';
12
+ import { checkSkills } from './skills.js';
13
+
14
+ export const CHECK_SKILLS_HELP = [
15
+ 'Usage: hydranium-cli check-skills [project-directory] [--from <directory>] [--skill <names>]',
16
+ '',
17
+ 'Check declared compatibility of installed Hydranium and Langium skills.',
18
+ 'Reads .agents/skills and .claude/skills in the project directory (default: cwd).',
19
+ 'Checks every copy. Does not download, update, or execute skills.',
20
+ 'Exits non-zero for incompatible, missing, malformed, or unverified skills.',
21
+ '',
22
+ ' --from <directory> Resolve installed packages from this directory, relative',
23
+ ' to the project. Default: the project directory.',
24
+ ' Use the server package for workspace-local dependencies.',
25
+ ' --skill <names> Check only these comma-separated skill names.',
26
+ ' Default: hydranium-*. Use --skill langium to check upstream.',
27
+ '',
28
+ 'A compatible result checks declared skill contracts,',
29
+ 'not the accuracy of prose.',
30
+ 'Upstream skills without Hydranium metadata are unverified.'
31
+ ];
32
+
33
+ export function runCheckSkillsCommand(args: string[]): void {
34
+ if (helpRequested(args, ['--from', '--skill'])) {
35
+ printHelp(CHECK_SKILLS_HELP);
36
+ return;
37
+ }
38
+ let project: string | undefined;
39
+ let from: string | undefined;
40
+ let names: string[] | undefined;
41
+ for (let index = 0; index < args.length; index++) {
42
+ const argument = args[index];
43
+ if (argument === '--from' || argument === '--skill') {
44
+ const value = args[++index];
45
+ if (!value || value.startsWith('--')) {
46
+ exitWithUsage(`Missing value for ${argument}`);
47
+ }
48
+ if (argument === '--from') {
49
+ from = value;
50
+ } else {
51
+ names = value.split(',');
52
+ if (names.some(name => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))) {
53
+ exitWithUsage('Invalid skill name.');
54
+ }
55
+ }
56
+ } else if (argument.startsWith('-') || project !== undefined) {
57
+ exitWithUsage(`Unexpected argument: ${argument} (hydranium-cli check-skills --help)`);
58
+ } else {
59
+ project = argument;
60
+ }
61
+ }
62
+ const root = path.resolve(project ?? '.');
63
+ const results = checkSkills(root, path.resolve(root, from ?? '.'), names);
64
+ for (const result of results) {
65
+ console.log(`${result.status}: ${result.file}: ${result.detail}`);
66
+ }
67
+ if (results.some(result => result.status !== 'compatible')) {
68
+ process.exitCode = 1;
69
+ }
70
+ }
@@ -48,7 +48,7 @@ const NAMESPACED_AST_TYPE_NAME = 'AstType';
48
48
  const MERGED_EXPORT_NAME = 'astNode';
49
49
 
50
50
  /**
51
- * Emit a module binding {@link makeAstNodeBuilder} to a project's reflection,
51
+ * Emit a module binding `makeAstNodeBuilder` to a project's reflection,
52
52
  * once per grammar plus once across all of them.
53
53
  *
54
54
  * **The binding is boilerplate with exactly one degree of freedom** — the type
@@ -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
- import { isNonEmptyDir, runInit, type InitHead } from './init.js';
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 {
@@ -46,6 +48,9 @@ export interface ParsedInitArgs {
46
48
  targetDir?: string;
47
49
  name?: string;
48
50
  force: boolean;
51
+ install: boolean;
52
+ skills?: string;
53
+ agents?: string;
49
54
  heads?: InitHead[];
50
55
  hosts?: InitHost[];
51
56
  monorepo: boolean;
@@ -66,6 +71,9 @@ export const INIT_FLAGS: readonly string[] = [
66
71
  '--heads',
67
72
  '--hosts',
68
73
  '--force',
74
+ '--install',
75
+ '--skills',
76
+ '--agents',
69
77
  '--monorepo',
70
78
  '--scope',
71
79
  '--public',
@@ -80,6 +88,8 @@ export const INIT_FLAGS: readonly string[] = [
80
88
  * The rest are presence-only.
81
89
  */
82
90
  export const INIT_VALUE_FLAGS: readonly string[] = [
91
+ '--skills',
92
+ '--agents',
83
93
  '--name',
84
94
  '--heads',
85
95
  '--hosts',
@@ -104,6 +114,9 @@ export function parseInitArgs(args: string[], onError: UsageError = exitWithUsag
104
114
  let targetDir: string | undefined;
105
115
  let name: string | undefined;
106
116
  let force = false;
117
+ let install = false;
118
+ let skills: string | undefined;
119
+ let agents: string | undefined;
107
120
  let heads: InitHead[] | undefined;
108
121
  let hosts: InitHost[] | undefined;
109
122
  let monorepo = false;
@@ -131,6 +144,12 @@ export function parseInitArgs(args: string[], onError: UsageError = exitWithUsag
131
144
  return current;
132
145
  };
133
146
  switch (flag) {
147
+ case '--skills':
148
+ skills = next();
149
+ break;
150
+ case '--agents':
151
+ agents = next();
152
+ break;
134
153
  case '--name':
135
154
  name = next();
136
155
  break;
@@ -160,6 +179,9 @@ export function parseInitArgs(args: string[], onError: UsageError = exitWithUsag
160
179
  case '--force':
161
180
  force = true;
162
181
  break;
182
+ case '--install':
183
+ install = true;
184
+ break;
163
185
  case '--monorepo':
164
186
  monorepo = true;
165
187
  break;
@@ -184,7 +206,7 @@ export function parseInitArgs(args: string[], onError: UsageError = exitWithUsag
184
206
  }
185
207
  }
186
208
  }
187
- return { targetDir, name, force, heads, hosts, monorepo, scope, public: isPublic, grammars };
209
+ return { targetDir, name, force, install, skills, agents, heads, hosts, monorepo, scope, public: isPublic, grammars };
188
210
  }
189
211
 
190
212
  /**
@@ -197,7 +219,7 @@ export function parseInitArgs(args: string[], onError: UsageError = exitWithUsag
197
219
  export const INIT_HELP: readonly string[] = [
198
220
  'Usage: hydranium-cli init <target-dir> --name <Name> [--heads <list>]',
199
221
  ' [--hosts <list>] [--force] [--monorepo]',
200
- ' [--scope <@scope>] [--public]',
222
+ ' [--scope <@scope>] [--public] [--install]',
201
223
  ' ( --grammar <Name> [--extensions <list>]',
202
224
  ' [--language-id <id>] [--diagram] )...',
203
225
  '',
@@ -210,12 +232,18 @@ export const INIT_HELP: readonly string[] = [
210
232
  'launch of the VS Code and Theia ones, and theia/ the Theia extension;',
211
233
  'workspace/ holds two starter model files per grammar. A standalone project',
212
234
  'also gets a root manifest naming the packages as npm workspaces.',
213
- 'Does NOT run `npm install` or `langium generate` — those are printed as next',
214
- 'steps. Refuses a non-empty directory without --force.',
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',
238
+ 'without --force.',
215
239
  '',
216
240
  'Run it with no --name on a terminal to be prompted instead. The wizard echoes',
217
241
  'the command it composed before running it, so every answer is reproducible as',
218
242
  'flags. Without a terminal a missing --name stays an error, so CI never hangs.',
243
+ 'For a standalone project, the wizard ends by offering npm install unless',
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.',
219
247
  '',
220
248
  'Project options (position-free):',
221
249
  ' <target-dir> The project directory to scaffold into (required).',
@@ -240,12 +268,22 @@ export const INIT_HELP: readonly string[] = [
240
268
  ' --hosts, the server with its Node entry points, and',
241
269
  ' with glsp the client/ package a host mounts.',
242
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.',
277
+ ' --install Run npm install in the scaffold, with lifecycle scripts',
278
+ ' and network access. Cannot be combined with --monorepo:',
279
+ ' its parent manifest needs manual edits first.',
243
280
  ' --monorepo Scaffold the packages as members of the surrounding npm',
244
281
  ' workspace, with no root of their own: each tsconfig',
245
282
  ' extends the root config that actually carries',
246
283
  ' compilerOptions, and the scripts address the server by',
247
284
  ' --prefix. Errors when no ancestor declares `workspaces`.',
248
- ' 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`',
249
287
  ' entries and dependency pins are printed, not added.',
250
288
  ' --scope <@scope> npm scope for the package names, e.g. @acme. Not inferred:',
251
289
  ' a root manifest is usually named for the repo, not the',
@@ -291,18 +329,24 @@ export async function runInitCommand(args: string[]): Promise<void> {
291
329
  printHelp(INIT_HELP);
292
330
  return;
293
331
  }
294
- const parsed = parseInitArgs(args);
332
+ let parsed = parseInitArgs(args);
333
+ validateInitInstallation(parsed);
334
+ let selection = parseSkillInstallation(parsed.skills, parsed.agents);
335
+ let prompt: Awaited<ReturnType<typeof createClackPrompt>> | undefined;
295
336
  // The wizard's entry point: no `--name` on an interactive terminal. Safe
296
337
  // because that combination has no non-wizard meaning, and gated on a TTY
297
338
  // because a non-interactive caller must keep failing fast — a prompt in CI
298
339
  // hangs the job instead of reporting the missing flag.
299
340
  if (parsed.name === undefined && process.stdin.isTTY && process.stdout.isTTY) {
300
- const prompt = await createClackPrompt();
341
+ prompt = await createClackPrompt();
301
342
  const argv = await runInitWizard(prompt, targetDir => detectWorkspace(targetDir, createNodeWorkspaceProbe()), {
302
343
  targetDir: parsed.targetDir,
344
+ install: parsed.install,
345
+ skills: parsed.skills,
346
+ agents: parsed.agents,
303
347
  isOccupied: isNonEmptyDir
304
348
  });
305
- return runInitCommand(argv);
349
+ parsed = parseInitArgs(argv);
306
350
  }
307
351
  runInit({
308
352
  targetDir: assertRequired(parsed.targetDir, '<target-dir>', 'init'),
@@ -311,8 +355,19 @@ export async function runInitCommand(args: string[]): Promise<void> {
311
355
  heads: parsed.heads,
312
356
  hosts: parsed.hosts,
313
357
  force: parsed.force,
358
+ install: parsed.install,
314
359
  monorepo: parsed.monorepo,
315
360
  scope: parsed.scope,
316
361
  public: parsed.public
317
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
+ }
318
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
+ }
@@ -39,6 +39,9 @@
39
39
  * posture rather than a packaging detail: the emission withholds publication
40
40
  * AND declares itself UNLICENSED, so a default nobody was shown would settle
41
41
  * the licence question by silence.
42
+ * - **installation**, which opts into network access and lifecycle scripts.
43
+ * Workspace members need parent-manifest edits first, so only standalone
44
+ * projects offer it, unless the caller already supplied `--install`.
42
45
  *
43
46
  * The **language id is deliberately not asked**, though it is the third of the
44
47
  * three independent names: its derivation was settled and built separately
@@ -91,6 +94,10 @@ export interface InitAnswers {
91
94
  readonly scope?: string;
92
95
  /** Leave the package publishable, which the scaffold does not do by default. */
93
96
  readonly public: boolean;
97
+ /** Run npm install after writing the scaffold. */
98
+ readonly install: boolean;
99
+ readonly skills?: string;
100
+ readonly agents?: string;
94
101
  }
95
102
 
96
103
  /** PascalCase a directory name, splitting on any non-alphanumeric run. */
@@ -153,6 +160,12 @@ export function composeInitArgv(answers: InitAnswers): string[] {
153
160
  if (answers.public) {
154
161
  argv.push('--public');
155
162
  }
163
+ if (answers.install) {
164
+ argv.push('--install');
165
+ }
166
+ if (answers.skills !== undefined && answers.agents !== undefined) {
167
+ argv.push('--skills', answers.skills, '--agents', answers.agents);
168
+ }
156
169
  return argv;
157
170
  }
158
171
 
@@ -350,7 +363,13 @@ async function askPackaging(
350
363
  export async function runInitWizard(
351
364
  prompt: PromptPort,
352
365
  detect: (targetDir: string) => WorkspaceDetection | undefined,
353
- options: { readonly targetDir?: string; readonly isOccupied?: (targetDir: string) => 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
+ } = {}
354
373
  ): Promise<string[]> {
355
374
  prompt.intro('Scaffold a Hydranium language project');
356
375
 
@@ -392,9 +411,31 @@ export async function runInitWizard(
392
411
  } while (await prompt.confirm({ message: 'Add another grammar?', initialValue: false }));
393
412
 
394
413
  const withDiagram = await askDiagramGrammar(prompt, heads, grammars);
395
- const packaging = await askPackaging(prompt, detect(targetDir), heads, hosts);
414
+ const detection = detect(targetDir);
415
+ if (options.install && detection !== undefined) {
416
+ prompt.note('--install requires a standalone project; workspace membership is skipped.', 'Installation');
417
+ }
418
+ const packaging = await askPackaging(prompt, options.install ? undefined : detection, heads, hosts);
419
+ const install =
420
+ (options.install ?? false) ||
421
+ (!packaging.monorepo &&
422
+ (await prompt.confirm({
423
+ message: 'Install dependencies now (npm install)?',
424
+ initialValue: false
425
+ })));
396
426
 
397
- const argv = composeInitArgv({ targetDir, name, heads, hosts, grammars: withDiagram, force, ...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
+ });
398
439
  // Echoed as the LAST thing before the scaffold runs, because it is the
399
440
  // artefact the session produces: everything above is how it was reached.
400
441
  prompt.outro(formatCommand(argv));
@@ -284,6 +284,8 @@ export interface InitCommandOptions {
284
284
  readonly hosts?: readonly InitHost[];
285
285
  /** Scaffold into a non-empty directory anyway. */
286
286
  readonly force?: boolean;
287
+ /** Install dependencies after scaffolding. Only supported for standalone projects. */
288
+ readonly install?: boolean;
287
289
  /**
288
290
  * Scaffold a member of the surrounding npm workspace: extend its base
289
291
  * tsconfig and address the package by `--prefix` in the scripts it prints
@@ -597,16 +599,21 @@ function formatScaffold(targetDir: string, files: readonly InitFile[], write: (l
597
599
  }
598
600
  }
599
601
 
602
+ export function validateInitInstallation(options: Pick<InitCommandOptions, 'install' | 'monorepo'>): void {
603
+ if (options.install && options.monorepo) {
604
+ throw new Error('--install cannot be combined with --monorepo: update the parent workspace manifest before running npm install.');
605
+ }
606
+ }
607
+
600
608
  /**
601
609
  * Scaffold a new Hydranium language project end-to-end: one starter grammar per
602
610
  * `--grammar`, `create<Name>Services` DI wiring, a launcher for each head in
603
- * `--heads`, `langium-config.json`, `package.json`, and scripts. Does NOT run `npm install`
604
- * or `langium generate` — those are printed as next steps, so the command stays
605
- * offline and changes nothing beyond the files it writes, which a standalone
606
- * scaffold's oxfmt then formats. Refuses a non-empty target directory unless
607
- * `force`.
611
+ * `--heads`, `langium-config.json`, `package.json`, and scripts. Stays offline
612
+ * unless `install` opts into npm and its lifecycle scripts. Building remains a
613
+ * next step. Refuses a non-empty target directory unless `force`.
608
614
  */
609
615
  export function runInit(options: InitCommandOptions): void {
616
+ validateInitInstallation(options);
610
617
  const write = options.write ?? ((line: string) => process.stdout.write(line));
611
618
  const targetDir = path.resolve(options.targetDir);
612
619
  const detection = options.monorepo ? detectWorkspace(targetDir, options.probe ?? createNodeWorkspaceProbe()) : undefined;
@@ -691,6 +698,41 @@ export function runInit(options: InitCommandOptions): void {
691
698
  write(` Sibling packages all use the "${detection.scope}" scope; pass --scope ${detection.scope} to match them.\n`);
692
699
  }
693
700
  }
701
+ if (options.install) {
702
+ write('\nRunning npm install...\n');
703
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
704
+ // --prefix also changes npm's default globalconfig. Resolve it first without walking into an enclosing workspace.
705
+ const config = spawnSync(npm, ['config', 'get', 'globalconfig', '--workspaces=false'], {
706
+ cwd: targetDir,
707
+ encoding: 'utf8',
708
+ stdio: ['ignore', 'pipe', 'inherit'],
709
+ shell: process.platform === 'win32'
710
+ });
711
+ const globalconfig = config.stdout?.trim();
712
+ if (config.error || config.signal || config.status !== 0 || !globalconfig) {
713
+ const reason = config.error?.message ?? (config.signal ? `signal ${config.signal}` : `exit code ${config.status}`);
714
+ throw new Error(
715
+ `Could not read npm global configuration (${reason}). Scaffold kept at ${targetDir}; installation was not started.`
716
+ );
717
+ }
718
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'npm_config_globalconfig'));
719
+ env.npm_config_globalconfig = globalconfig;
720
+ // An explicit prefix keeps npm from selecting an enclosing workspace instead of this standalone root.
721
+ // Windows resolves npm through a command shim; only fixed arguments reach the shell.
722
+ const result = spawnSync(npm, ['--prefix', '.', 'install'], {
723
+ cwd: targetDir,
724
+ stdio: 'inherit',
725
+ shell: process.platform === 'win32',
726
+ env
727
+ });
728
+ if (result.error || result.signal || result.status !== 0) {
729
+ const reason = result.error?.message ?? (result.signal ? `signal ${result.signal}` : `exit code ${result.status}`);
730
+ throw new Error(
731
+ `npm install failed (${reason}). Scaffold kept at ${targetDir}. ` +
732
+ `Set npm_config_globalconfig to ${globalconfig} to preserve npm's global configuration. Run npm --prefix . install there to retry.`
733
+ );
734
+ }
735
+ }
694
736
  write('\n');
695
737
  write('Next steps:\n');
696
738
  // A workspace member's project has no manifest of its own, so its steps run
@@ -703,7 +745,9 @@ export function runInit(options: InitCommandOptions): void {
703
745
  if (spawnSync('git', ['check-ignore', '-q', targetDir], { cwd: targetDir }).status === 0) {
704
746
  write(' git init # an enclosing repository ignores this folder, so lint and format find no file until then\n');
705
747
  }
706
- write(' npm install\n');
748
+ if (!options.install) {
749
+ write(' npm install\n');
750
+ }
707
751
  write(' npm run build # langium generate + tsc, every package\n');
708
752
  write(' npm test # the server tests\n');
709
753
  write(' npm run watch # rebuild the members on every change; watch:<host> adds a host\n');