@nowline/cli 0.8.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +42 -12
  2. package/dist/cli/args.d.ts +21 -5
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +26 -1
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/formats.d.ts.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +35 -2
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/options.d.ts +74 -0
  11. package/dist/cli/options.d.ts.map +1 -0
  12. package/dist/cli/options.js +209 -0
  13. package/dist/cli/options.js.map +1 -0
  14. package/dist/commands/init.d.ts.map +1 -1
  15. package/dist/commands/init.js +2 -3
  16. package/dist/commands/init.js.map +1 -1
  17. package/dist/commands/mcp-install.d.ts +18 -0
  18. package/dist/commands/mcp-install.d.ts.map +1 -0
  19. package/dist/commands/mcp-install.js +151 -0
  20. package/dist/commands/mcp-install.js.map +1 -0
  21. package/dist/commands/mcp.d.ts.map +1 -1
  22. package/dist/commands/mcp.js +6 -12
  23. package/dist/commands/mcp.js.map +1 -1
  24. package/dist/commands/render.d.ts +7 -0
  25. package/dist/commands/render.d.ts.map +1 -1
  26. package/dist/commands/render.js +176 -152
  27. package/dist/commands/render.js.map +1 -1
  28. package/dist/commands/serve.d.ts.map +1 -1
  29. package/dist/commands/serve.js +87 -60
  30. package/dist/commands/serve.js.map +1 -1
  31. package/dist/core/parse.js.map +1 -1
  32. package/dist/diagnostics/adapt.d.ts +12 -1
  33. package/dist/diagnostics/adapt.d.ts.map +1 -1
  34. package/dist/diagnostics/adapt.js +53 -1
  35. package/dist/diagnostics/adapt.js.map +1 -1
  36. package/dist/diagnostics/format.js.map +1 -1
  37. package/dist/diagnostics/model.d.ts +1 -1
  38. package/dist/diagnostics/text.js.map +1 -1
  39. package/dist/generated/version.d.ts +1 -1
  40. package/dist/generated/version.js +2 -2
  41. package/dist/i18n/locale.d.ts +1 -1
  42. package/dist/i18n/locale.d.ts.map +1 -1
  43. package/dist/i18n/locale.js +2 -5
  44. package/dist/i18n/locale.js.map +1 -1
  45. package/dist/index.js +4 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/io/config.d.ts +1 -1
  48. package/dist/io/config.d.ts.map +1 -1
  49. package/dist/io/config.js +1 -1
  50. package/dist/io/config.js.map +1 -1
  51. package/dist/io/exit-codes.d.ts.map +1 -1
  52. package/dist/io/exit-codes.js.map +1 -1
  53. package/dist/io/log.d.ts +17 -0
  54. package/dist/io/log.d.ts.map +1 -0
  55. package/dist/io/log.js +16 -0
  56. package/dist/io/log.js.map +1 -0
  57. package/dist/io/read.d.ts +3 -0
  58. package/dist/io/read.d.ts.map +1 -1
  59. package/dist/io/read.js +32 -5
  60. package/dist/io/read.js.map +1 -1
  61. package/dist/io/write.js.map +1 -1
  62. package/dist/mcp-install/clients.d.ts +58 -0
  63. package/dist/mcp-install/clients.d.ts.map +1 -0
  64. package/dist/mcp-install/clients.js +304 -0
  65. package/dist/mcp-install/clients.js.map +1 -0
  66. package/dist/mcp-install/host.d.ts +29 -0
  67. package/dist/mcp-install/host.d.ts.map +1 -0
  68. package/dist/mcp-install/host.js +72 -0
  69. package/dist/mcp-install/host.js.map +1 -0
  70. package/dist/mcp-install/json-config.d.ts +29 -0
  71. package/dist/mcp-install/json-config.d.ts.map +1 -0
  72. package/dist/mcp-install/json-config.js +139 -0
  73. package/dist/mcp-install/json-config.js.map +1 -0
  74. package/dist/mcp-install/server-command.d.ts +26 -0
  75. package/dist/mcp-install/server-command.d.ts.map +1 -0
  76. package/dist/mcp-install/server-command.js +52 -0
  77. package/dist/mcp-install/server-command.js.map +1 -0
  78. package/man/fr/nowline.1 +246 -19
  79. package/man/fr/nowline.5 +819 -45
  80. package/man/nowline.1 +191 -18
  81. package/man/nowline.5 +764 -36
  82. package/package.json +24 -24
  83. package/src/cli/args.ts +69 -8
  84. package/src/cli/help.ts +35 -2
  85. package/src/cli/options.ts +286 -0
  86. package/src/commands/init.ts +2 -3
  87. package/src/commands/mcp-install.ts +211 -0
  88. package/src/commands/mcp.ts +9 -14
  89. package/src/commands/render.ts +219 -186
  90. package/src/commands/serve.ts +102 -74
  91. package/src/diagnostics/adapt.ts +62 -0
  92. package/src/diagnostics/model.ts +1 -1
  93. package/src/generated/version.ts +2 -2
  94. package/src/i18n/locale.ts +2 -6
  95. package/src/index.ts +3 -0
  96. package/src/io/config.ts +0 -1
  97. package/src/io/log.ts +33 -0
  98. package/src/io/read.ts +39 -5
  99. package/src/mcp-install/clients.ts +443 -0
  100. package/src/mcp-install/host.ts +106 -0
  101. package/src/mcp-install/json-config.ts +179 -0
  102. package/src/mcp-install/server-command.ts +75 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowline/cli",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "description": "Command-line tool to check, convert, and create Nowline roadmaps.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -30,35 +30,35 @@
30
30
  "scripts/"
31
31
  ],
32
32
  "dependencies": {
33
- "@babel/code-frame": "^8.0.0",
34
- "@clack/prompts": "^1.5.1",
35
- "@modelcontextprotocol/sdk": "^1.29.0",
36
- "chalk": "^5.6.2",
33
+ "@babel/code-frame": "^8.0.6",
34
+ "@clack/prompts": "^1.8.1",
35
+ "@modelcontextprotocol/server": "^2.3.1",
36
+ "@nowline/config": "0.9.0",
37
+ "@nowline/core": "0.9.0",
38
+ "@nowline/export": "0.9.0",
39
+ "@nowline/export-core": "0.9.0",
40
+ "@nowline/export-html": "0.9.0",
41
+ "@nowline/export-mermaid": "0.9.0",
42
+ "@nowline/export-msproj": "0.9.0",
43
+ "@nowline/export-pdf": "0.9.0",
44
+ "@nowline/export-png": "0.9.0",
45
+ "@nowline/export-xlsx": "0.9.0",
46
+ "@nowline/layout": "0.9.0",
47
+ "@nowline/mcp": "0.9.0",
48
+ "@nowline/renderer": "0.9.0",
49
+ "chalk": "^6.0.1",
37
50
  "citty": "^0.2.2",
38
51
  "consola": "^3.4.2",
39
- "js-yaml": "^4.2.0",
40
- "langium": "~4.3.0",
41
- "@nowline/core": "0.8.5",
42
- "@nowline/export": "0.8.5",
43
- "@nowline/export-core": "0.8.5",
44
- "@nowline/export-html": "0.8.5",
45
- "@nowline/export-mermaid": "0.8.5",
46
- "@nowline/config": "0.8.5",
47
- "@nowline/export-msproj": "0.8.5",
48
- "@nowline/export-pdf": "0.8.5",
49
- "@nowline/export-png": "0.8.5",
50
- "@nowline/export-xlsx": "0.8.5",
51
- "@nowline/layout": "0.8.5",
52
- "@nowline/mcp": "0.8.5",
53
- "@nowline/renderer": "0.8.5"
52
+ "js-yaml": "^5.5.0",
53
+ "jsonc-parser": "^3.3.1",
54
+ "langium": "~4.4.0"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@resvg/resvg-wasm": "^2.6.2",
57
58
  "@types/babel__code-frame": "^7.27.0",
58
- "@types/js-yaml": "^4.0.9",
59
- "@types/node": "^25.9.3",
60
- "typescript": "^6.0.3",
61
- "vitest": "^4.1.9"
59
+ "@types/node": "^26.6.5",
60
+ "typescript": "^7.0.2",
61
+ "vitest": "^5.0.5"
62
62
  },
63
63
  "scripts": {
64
64
  "bundle-templates": "node scripts/bundle-templates.mjs",
package/src/cli/args.ts CHANGED
@@ -1,17 +1,33 @@
1
1
  import { type ParseArgsConfig, parseArgs } from 'node:util';
2
+ import { isDiagnosticFormat } from '../diagnostics/format.js';
2
3
  import { CliError, ExitCode } from '../io/exit-codes.js';
4
+ import type { LogLevel } from '../io/log.js';
3
5
 
4
- export type ModeKind = 'render' | 'serve' | 'init' | 'mcp' | 'help' | 'version';
6
+ export type ModeKind =
7
+ | 'render'
8
+ | 'serve'
9
+ | 'init'
10
+ | 'mcp'
11
+ | 'mcp-install'
12
+ | 'mcp-uninstall'
13
+ | 'help'
14
+ | 'version';
15
+
16
+ export type McpScope = 'user' | 'project';
17
+ export type McpServerKind = 'self' | 'npx';
5
18
 
6
19
  export interface ParsedArgs {
7
20
  /** Resolved mode after dispatch (mutual-exclusivity already checked). */
8
21
  mode: ModeKind;
9
- /** Positional argument, or undefined. Render = input path. Init = project name. Serve = input path. */
22
+ /**
23
+ * Positional argument, or undefined. Render = input path. Init = project name. Serve = input path.
24
+ * MCP install / uninstall = client id.
25
+ */
10
26
  positional?: string;
11
- /** True if `--dry-run` / `-n` was passed (only valid for render mode). */
27
+ /** True if `--dry-run` / `-n` was passed (render and MCP install / uninstall). */
12
28
  dryRun: boolean;
13
29
  /** Logging level. Verbose and quiet are mutually exclusive. */
14
- logLevel: 'verbose' | 'quiet' | 'normal';
30
+ logLevel: LogLevel;
15
31
 
16
32
  // I/O
17
33
  output?: string;
@@ -37,6 +53,12 @@ export interface ParsedArgs {
37
53
  scale?: string;
38
54
  strict: boolean;
39
55
  width?: string;
56
+ /**
57
+ * Raw `--non-working` value (`hide` or `show`). Undefined means unset, so
58
+ * the file's `default roadmap non-working:` key still applies. Validated
59
+ * by the render and serve commands, like `--width`.
60
+ */
61
+ nonWorking?: string;
40
62
  assetRoot?: string;
41
63
 
42
64
  // Format-specific options (m2c)
@@ -67,14 +89,20 @@ export interface ParsedArgs {
67
89
  host?: string;
68
90
  open: boolean;
69
91
 
70
- // Validate / dry-run formatting
71
- diagnosticFormat?: string;
92
+ // Validate / dry-run formatting. `text` or `json`; validated in parseArgv.
93
+ // Undefined means "not given": the render handler defaults to `json` for
94
+ // `--dry-run --format=json` and `text` otherwise.
95
+ diagnosticFormat?: 'text' | 'json';
72
96
 
73
97
  // Init
74
98
  template?: string;
75
99
 
76
100
  // MCP
77
101
  root?: string;
102
+ /** `--scope` for --mcp-install / --mcp-uninstall; validated in parseArgv. */
103
+ scope?: McpScope;
104
+ /** `--server` for --mcp-install; validated in parseArgv. */
105
+ server?: McpServerKind;
78
106
  }
79
107
 
80
108
  /**
@@ -115,6 +143,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
115
143
  serve: { type: 'boolean' },
116
144
  init: { type: 'boolean' },
117
145
  mcp: { type: 'boolean' },
146
+ 'mcp-install': { type: 'boolean' },
147
+ 'mcp-uninstall': { type: 'boolean' },
118
148
  'dry-run': { type: 'boolean', short: 'n' },
119
149
 
120
150
  theme: { type: 'string', short: 't' },
@@ -130,6 +160,7 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
130
160
  scale: { type: 'string', short: 's' },
131
161
  strict: { type: 'boolean' },
132
162
  width: { type: 'string', short: 'w' },
163
+ 'non-working': { type: 'string' },
133
164
  'asset-root': { type: 'string' },
134
165
 
135
166
  port: { type: 'string', short: 'p' },
@@ -142,6 +173,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
142
173
 
143
174
  // MCP
144
175
  root: { type: 'string' },
176
+ scope: { type: 'string' },
177
+ server: { type: 'string' },
145
178
 
146
179
  // Format-specific (m2c)
147
180
  'page-size': { type: 'string' },
@@ -205,6 +238,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
205
238
  if (values.serve === true) modes.push('serve');
206
239
  if (values.init === true) modes.push('init');
207
240
  if (values.mcp === true) modes.push('mcp');
241
+ if (values['mcp-install'] === true) modes.push('mcp-install');
242
+ if (values['mcp-uninstall'] === true) modes.push('mcp-uninstall');
208
243
  if (modes.length > 1) {
209
244
  throw new CliError(
210
245
  ExitCode.InputError,
@@ -230,7 +265,30 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
230
265
  throw new CliError(ExitCode.InputError, `nowline: unexpected extra arguments: ${extras}.`);
231
266
  }
232
267
 
233
- const logLevel: ParsedArgs['logLevel'] =
268
+ const scope = stringOrUndefined(values.scope);
269
+ if (scope !== undefined && scope !== 'user' && scope !== 'project') {
270
+ throw new CliError(
271
+ ExitCode.InputError,
272
+ `nowline: invalid --scope "${scope}". Expected user or project.`,
273
+ );
274
+ }
275
+ const server = stringOrUndefined(values.server);
276
+ if (server !== undefined && server !== 'self' && server !== 'npx') {
277
+ throw new CliError(
278
+ ExitCode.InputError,
279
+ `nowline: invalid --server "${server}". Expected self or npx.`,
280
+ );
281
+ }
282
+
283
+ const rawDiagnosticFormat = stringOrUndefined(values['diagnostic-format']);
284
+ if (rawDiagnosticFormat !== undefined && !isDiagnosticFormat(rawDiagnosticFormat)) {
285
+ throw new CliError(
286
+ ExitCode.InputError,
287
+ `nowline: invalid --diagnostic-format "${rawDiagnosticFormat}". Expected text or json. Try --help for usage.`,
288
+ );
289
+ }
290
+
291
+ const logLevel: LogLevel =
234
292
  values.verbose === true ? 'verbose' : values.quiet === true ? 'quiet' : 'normal';
235
293
 
236
294
  return {
@@ -248,11 +306,12 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
248
306
  scale: stringOrUndefined(values.scale),
249
307
  strict: values.strict === true,
250
308
  width: stringOrUndefined(values.width),
309
+ nonWorking: stringOrUndefined(values['non-working']),
251
310
  assetRoot: stringOrUndefined(values['asset-root']),
252
311
  port: stringOrUndefined(values.port),
253
312
  host: stringOrUndefined(values.host),
254
313
  open: values.open === true,
255
- diagnosticFormat: stringOrUndefined(values['diagnostic-format']),
314
+ diagnosticFormat: rawDiagnosticFormat,
256
315
  template: stringOrUndefined(values.template),
257
316
  pageSize: stringOrUndefined(values['page-size']),
258
317
  orientation: stringOrUndefined(values.orientation),
@@ -264,6 +323,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
264
323
  start: stringOrUndefined(values.start),
265
324
  locale: stringOrUndefined(values.locale),
266
325
  root: stringOrUndefined(values.root),
326
+ scope,
327
+ server,
267
328
  };
268
329
  }
269
330
 
package/src/cli/help.ts CHANGED
@@ -9,6 +9,7 @@ USAGE
9
9
  nowline <input> [options]
10
10
  nowline --serve <input> [options]
11
11
  nowline --init [<name>]
12
+ nowline --mcp-install [<client>] [options]
12
13
  nowline --version
13
14
  nowline --help
14
15
 
@@ -35,9 +36,22 @@ MODE FLAGS (mutually exclusive)
35
36
  exposing validate, render, export, and file tools.
36
37
  Shares the same @nowline/mcp server code as
37
38
  \`npx @nowline/mcp\`.
39
+ --mcp-install [<client>]
40
+ Register the MCP server with an agent: claude-code,
41
+ claude-desktop, cursor, vscode, codex, gemini,
42
+ antigravity, muse, opencode, copilot, windsurf, zed,
43
+ amp, goose, or all. Omitted: every agent found on this machine.
44
+ Re-running is safe; -n previews the change.
45
+ --mcp-uninstall [<client>]
46
+ Remove the server from the same configs.
38
47
  -n, --dry-run Run the full pipeline (parse + validate + layout +
39
48
  format) but skip the write step. Subsumes the old
40
49
  'validate' verb. Exit 0 on success, 1 on errors.
50
+ --diagnostic-format <f>
51
+ Diagnostics on stderr: text | json. json prints one
52
+ { "$nowlineDiagnostics": "1", ... } document per run
53
+ (warnings included). Default: json with --dry-run
54
+ --format=json, else text.
41
55
 
42
56
  RENDER OPTIONS
43
57
  -t, --theme <name> light | dark | grayscale (greyscale alias)
@@ -61,8 +75,18 @@ RENDER OPTIONS
61
75
  --no-links Omit link icons from rendered items.
62
76
  -s, --scale <n> Raster scale factor (PNG only; default 1).
63
77
  --strict Promote asset / sanitizer warnings to errors.
64
- -w, --width <px> Canvas width in pixels (default: 1280).
65
- --asset-root <dir> Root for logo / image assets (default: input dir).
78
+ -w, --width <px> Maximum canvas width in pixels. Default: fit the
79
+ content. A wider roadmap shrinks its timeline to
80
+ fit; long captions can still overflow it.
81
+ --non-working <hide|show>
82
+ How a business calendar's non-working days appear:
83
+ hide collapses them (the default), show draws them
84
+ as shaded bands. Overrides the file's
85
+ 'default roadmap non-working:' key; omit it to let
86
+ the key apply. Mermaid, MS Project, and XLSX output
87
+ is the same in both views.
88
+ --asset-root <dir> Root for logo / image assets (default: the
89
+ .nowlinerc assetRoot key, then the input dir).
66
90
  --locale <bcp47> BCP-47 locale (e.g. fr, fr-CA) for CLI message
67
91
  output (validator diagnostics, --help, errors).
68
92
  Used as a fallback for the rendered drawing only
@@ -94,6 +118,13 @@ SERVE OPTIONS
94
118
  MCP OPTIONS
95
119
  --root <dir> Allowed-root directory for file tools (default: cwd).
96
120
  File paths are resolved and restricted to this root.
121
+ With --mcp-install, written into the registered
122
+ command.
123
+ --scope <s> --mcp-install target: user (default, every
124
+ project) or project (config files in cwd).
125
+ --server <s> What --mcp-install registers: self (default, this
126
+ nowline binary; needs no Node) or npx
127
+ (npx -y @nowline/mcp, tracks npm).
97
128
 
98
129
  LOGGING (mutually exclusive)
99
130
  -v, --verbose Print extra diagnostics to stderr.
@@ -115,6 +146,8 @@ EXAMPLES
115
146
  nowline --init my-project # scaffold ./my-project.nowline
116
147
  nowline --mcp # start MCP stdio server in cwd
117
148
  nowline --mcp --root ./roadmaps # MCP server with custom root
149
+ nowline --mcp-install # register with every agent found
150
+ nowline --mcp-install cursor --scope project -n # preview .cursor/mcp.json
118
151
 
119
152
  EXIT CODES
120
153
  0 Success
@@ -0,0 +1,286 @@
1
+ // How the CLI turns flags and `.nowlinerc` keys into render options. The five
2
+ // shared options (theme, width, now, page size, non-working display) come from
3
+ // `@nowline/config`'s schema; the CLI-only keys are registered here as option
4
+ // specs over the same two layers, so adding an rc key is one entry in
5
+ // `CLI_OPTION_SPECS` rather than a hand-written merge in each command
6
+ // (specs/surface-options.md §3.1).
7
+
8
+ import * as path from 'node:path';
9
+ import {
10
+ ASSET_ROOT_SPEC,
11
+ type NowlineRc,
12
+ type OptionError,
13
+ type OptionLayer,
14
+ type OptionParse,
15
+ type OptionSpec,
16
+ rcLayer,
17
+ resolveOptions,
18
+ resolveSurfaceOptions,
19
+ SURFACE_OPTION_SPECS,
20
+ type SurfaceOptions,
21
+ } from '@nowline/config';
22
+ import { lengthToPoints, parseLength } from '@nowline/export-core';
23
+ import { type NormalizedZone, normalizeZone, TimezoneError } from '@nowline/layout';
24
+ import { CliError, ExitCode } from '../io/exit-codes.js';
25
+ import type { ParsedArgs } from './args.js';
26
+
27
+ // ---- CLI-only option specs --------------------------------------------------
28
+
29
+ export type PdfOrientation = 'portrait' | 'landscape' | 'auto';
30
+
31
+ const ORIENTATIONS: readonly PdfOrientation[] = ['portrait', 'landscape', 'auto'];
32
+
33
+ /** An rc key the CLI reads as a string; any other type is ignored, as it always was. */
34
+ function lenientString(raw: unknown): OptionParse<string> {
35
+ return { ok: true, value: typeof raw === 'string' ? raw : undefined };
36
+ }
37
+
38
+ /** An rc or flag switch: only a literal `true` turns it on. */
39
+ function lenientSwitch(raw: unknown): OptionParse<boolean> {
40
+ return { ok: true, value: raw === true ? true : undefined };
41
+ }
42
+
43
+ function parseOrientation(raw: unknown): OptionParse<PdfOrientation> {
44
+ if (raw === '') return { ok: true, value: undefined };
45
+ const lower = typeof raw === 'string' ? raw.toLowerCase() : undefined;
46
+ const found = ORIENTATIONS.find((orientation) => orientation === lower);
47
+ if (found) return { ok: true, value: found };
48
+ return { ok: false, detail: 'must be an orientation', expected: ORIENTATIONS };
49
+ }
50
+
51
+ function parseMargin(raw: unknown): OptionParse<number> {
52
+ if (raw === '') return { ok: true, value: undefined };
53
+ // A bare number is points; YAML and JSON hand the rc one over as a number.
54
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
55
+ return { ok: true, value: raw };
56
+ }
57
+ const text = String(raw);
58
+ if (/^\d+(?:\.\d+)?$/.test(text)) return { ok: true, value: Number(text) };
59
+ try {
60
+ return { ok: true, value: lengthToPoints(parseLength(text)) };
61
+ } catch (err) {
62
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
63
+ }
64
+ }
65
+
66
+ /** Every option the CLI resolves over its flag and rc layers: the shared five, then its own. */
67
+ export const CLI_OPTION_SPECS = Object.freeze({
68
+ ...SURFACE_OPTION_SPECS,
69
+ orientation: { rcKey: 'pdfOrientation', parse: parseOrientation } as OptionSpec<PdfOrientation>,
70
+ margin: { rcKey: 'pdfMargin', parse: parseMargin } as OptionSpec<number>,
71
+ defaultFormat: { rcKey: 'defaultFormat', parse: lenientString } as OptionSpec<string>,
72
+ fontSans: { rcKey: 'fontSans', parse: lenientString } as OptionSpec<string>,
73
+ fontMono: { rcKey: 'fontMono', parse: lenientString } as OptionSpec<string>,
74
+ headless: { rcKey: 'headlessFonts', parse: lenientSwitch } as OptionSpec<boolean>,
75
+ useSystemFonts: { rcKey: 'useSystemFonts', parse: lenientSwitch } as OptionSpec<boolean>,
76
+ locale: { rcKey: 'locale', parse: lenientString } as OptionSpec<string>,
77
+ assetRoot: ASSET_ROOT_SPEC as OptionSpec<string>,
78
+ });
79
+
80
+ type CliSpecs = typeof CLI_OPTION_SPECS;
81
+ export type CliOptionKey = Extract<keyof CliSpecs, string>;
82
+
83
+ /** The flag each option is passed as, for an error that names its source. */
84
+ const FLAG_NAMES: Readonly<Partial<Record<CliOptionKey, string>>> = {
85
+ theme: '--theme',
86
+ width: '--width',
87
+ now: '--now',
88
+ pageSize: '--page-size',
89
+ nonWorking: '--non-working',
90
+ orientation: '--orientation',
91
+ margin: '--margin',
92
+ fontSans: '--font-sans',
93
+ fontMono: '--font-mono',
94
+ headless: '--headless',
95
+ useSystemFonts: '--use-system-fonts',
96
+ assetRoot: '--asset-root',
97
+ };
98
+
99
+ // ---- Resolution -------------------------------------------------------------
100
+
101
+ export interface CliOptions {
102
+ /** Theme, width, now-line date and non-working display; page size when set. */
103
+ surface: SurfaceOptions;
104
+ orientation?: PdfOrientation;
105
+ /** Page margin in points. */
106
+ margin?: number;
107
+ /** The rc `defaultFormat`; the `--format` flag is resolved by `resolveFormat`. */
108
+ defaultFormat?: string;
109
+ fontSans?: string;
110
+ fontMono?: string;
111
+ headless: boolean;
112
+ useSystemFonts: boolean;
113
+ /**
114
+ * Where `logo:` assets are read from, as an absolute path: `--asset-root`,
115
+ * else the rc `assetRoot`. Unset means the input file's directory.
116
+ */
117
+ assetRoot?: string;
118
+ /** The rc `locale`; the flag and `NOWLINE_LOCALE` rank above it (`resolveLocaleOverride`). */
119
+ rcLocale?: string;
120
+ /**
121
+ * Throw the first invalid option as a usage error, in the order the CLI
122
+ * has always reported them: theme, timezone, now, width, non-working.
123
+ */
124
+ assertValid(): void;
125
+ /**
126
+ * Throw the first invalid PDF setting (page size, orientation, margin).
127
+ * Run after validation, so these errors follow any diagnostics.
128
+ */
129
+ assertPdfSettingsValid(): void;
130
+ }
131
+
132
+ /**
133
+ * Resolve the CLI's options over the flag layer, then the `.nowlinerc` layer.
134
+ * `render` and `--serve` both call this, so they read the same keys the same
135
+ * way. `cwd` is what a relative `--asset-root` resolves against (default: the
136
+ * process's). Nothing throws here: a caller reads `defaultFormat` early and reports bad
137
+ * values at the point it always did, through `assertValid`.
138
+ */
139
+ export function resolveCliOptions(
140
+ args: ParsedArgs,
141
+ config: NowlineRc | null,
142
+ context: { rcPath?: string; cwd?: string; clock?: () => Date } = {},
143
+ ): CliOptions {
144
+ const { rcPath, cwd, clock } = context;
145
+ const layers = [flagLayer(args), rcLayer(config ?? {}, CLI_OPTION_SPECS)];
146
+
147
+ // `--timezone` is validated even when `--now` is explicit, as it always was.
148
+ const zone = readZone(args.timezone);
149
+ const { options, errors: surfaceErrors } = resolveSurfaceOptions(
150
+ layers,
151
+ { theme: 'light' },
152
+ { zone: zone.zone, clock },
153
+ );
154
+ const { values, errors: cliErrors } = resolveOptions(CLI_OPTION_SPECS, layers);
155
+ const failures = new Map<string, OptionError<CliOptionKey>>(
156
+ [...surfaceErrors, ...cliErrors].map((error) => [error.key, error]),
157
+ );
158
+ const failure = (key: CliOptionKey): CliError | undefined => {
159
+ const error = failures.get(key);
160
+ return error && new CliError(ExitCode.InputError, formatOptionError(error));
161
+ };
162
+
163
+ return {
164
+ surface: options,
165
+ orientation: values.orientation,
166
+ margin: values.margin,
167
+ defaultFormat: values.defaultFormat,
168
+ fontSans: values.fontSans,
169
+ fontMono: values.fontMono,
170
+ headless: values.headless ?? false,
171
+ useSystemFonts: values.useSystemFonts ?? false,
172
+ assetRoot: absoluteAssetRoot(values.assetRoot, args.assetRoot !== undefined, rcPath, cwd),
173
+ rcLocale: values.locale,
174
+ assertValid: () =>
175
+ throwFirst([
176
+ failure('theme'),
177
+ zone.error,
178
+ failure('now'),
179
+ failure('width'),
180
+ failure('nonWorking'),
181
+ failure('assetRoot'),
182
+ ]),
183
+ assertPdfSettingsValid: () =>
184
+ throwFirst([failure('pageSize'), failure('orientation'), failure('margin')]),
185
+ };
186
+ }
187
+
188
+ function throwFirst(candidates: readonly (CliError | undefined)[]): void {
189
+ for (const candidate of candidates) {
190
+ if (candidate) throw candidate;
191
+ }
192
+ }
193
+
194
+ function flagLayer(args: ParsedArgs): OptionLayer<CliOptionKey> {
195
+ return {
196
+ source: (key) => ({ kind: 'flag', name: FLAG_NAMES[key] ?? `--${key}` }),
197
+ values: {
198
+ // An empty `--theme ""` has always meant "not passed", so the rc theme applies.
199
+ theme: args.theme || undefined,
200
+ width: args.width,
201
+ now: args.now,
202
+ pageSize: args.pageSize,
203
+ nonWorking: args.nonWorking,
204
+ orientation: args.orientation,
205
+ margin: args.margin,
206
+ fontSans: args.fontSans,
207
+ fontMono: args.fontMono,
208
+ assetRoot: args.assetRoot,
209
+ // A switch flag is absent or on; absent leaves the rc to decide.
210
+ headless: args.headless || undefined,
211
+ useSystemFonts: args.useSystemFonts || undefined,
212
+ },
213
+ };
214
+ }
215
+
216
+ function readZone(raw: string | undefined): { zone?: NormalizedZone; error?: CliError } {
217
+ if (!raw) return {};
218
+ try {
219
+ return { zone: normalizeZone(raw) };
220
+ } catch (err) {
221
+ if (err instanceof TimezoneError) {
222
+ return { error: new CliError(ExitCode.InputError, err.message) };
223
+ }
224
+ throw err;
225
+ }
226
+ }
227
+
228
+ // ---- Asset root -------------------------------------------------------------
229
+
230
+ /**
231
+ * The asset root as an absolute path. A flag resolves against `cwd` (the
232
+ * process's, unless a caller passes its own); an rc value against the
233
+ * directory of the `.nowlinerc` it came from, so a checked-in `assetRoot:
234
+ * assets` means the same directory wherever `nowline` is run from.
235
+ */
236
+ function absoluteAssetRoot(
237
+ value: string | undefined,
238
+ fromFlag: boolean,
239
+ rcPath: string | undefined,
240
+ cwd?: string,
241
+ ): string | undefined {
242
+ if (value === undefined) return undefined;
243
+ if (fromFlag || rcPath === undefined) return path.resolve(cwd ?? '.', value);
244
+ return path.resolve(path.dirname(rcPath), value);
245
+ }
246
+
247
+ // ---- Error text -------------------------------------------------------------
248
+
249
+ /**
250
+ * An {@link OptionError} in the CLI's wording, naming the flag or `.nowlinerc`
251
+ * key the value came from: `nowline: invalid --width "abc". Must be an integer
252
+ * ≥ 320.` The templates are the ones the CLI used before the schema, so
253
+ * scripts that match them keep working.
254
+ */
255
+ export function formatOptionError(error: OptionError<string>): string {
256
+ const lead = `nowline: invalid ${describeSource(error.source)} "${String(error.raw)}"`;
257
+ if (error.expected) return `${lead}. Expected ${formatChoices(error.expected)}.`;
258
+ switch (error.key) {
259
+ // Detail reads "must be YYYY-MM-DD, ...": the CLI says "Expected".
260
+ case 'now':
261
+ return `${lead}. Expected ${error.detail.replace(/^must be /, '')}.`;
262
+ // These two carry the parser's own message after a colon.
263
+ case 'pageSize':
264
+ case 'margin':
265
+ return `${lead}: ${error.detail}`;
266
+ default:
267
+ return `${lead}. ${error.detail.charAt(0).toUpperCase()}${error.detail.slice(1)}.`;
268
+ }
269
+ }
270
+
271
+ function describeSource(source: OptionError['source']): string {
272
+ switch (source.kind) {
273
+ case 'flag':
274
+ case 'argument':
275
+ case 'setting':
276
+ return source.name;
277
+ case 'rc':
278
+ return `.nowlinerc ${source.key}`;
279
+ }
280
+ }
281
+
282
+ // `a`, `a or b`, `a, b, or c`.
283
+ export function formatChoices(names: readonly string[]): string {
284
+ if (names.length <= 2) return names.join(' or ');
285
+ return `${names.slice(0, -1).join(', ')}, or ${names[names.length - 1]}`;
286
+ }
@@ -2,6 +2,7 @@ import type { ParsedArgs } from '../cli/args.js';
2
2
  import { defaultInitOutputPath, initNameHasIncompatibleExtension } from '../cli/output-path.js';
3
3
  import { TEMPLATE_NAMES, TEMPLATES, type TemplateName } from '../generated/templates.js';
4
4
  import { CliError, ExitCode } from '../io/exit-codes.js';
5
+ import { createLog } from '../io/log.js';
5
6
  import { writeOutput } from '../io/write.js';
6
7
 
7
8
  export interface InitHandlerOptions {
@@ -47,9 +48,7 @@ export async function initHandler(options: InitHandlerOptions): Promise<void> {
47
48
 
48
49
  await writeOutput(outputPath, rendered, 'text', { cwd });
49
50
 
50
- if (args.logLevel === 'verbose') {
51
- process.stderr.write(`nowline: wrote ${outputPath}\n`);
52
- }
51
+ createLog(args.logLevel).verbose(`nowline: wrote ${outputPath}`);
53
52
  }
54
53
 
55
54
  function parseTemplateName(raw: string | undefined): TemplateName {