@gmickel/gno 0.3.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 (131) hide show
  1. package/README.md +256 -0
  2. package/assets/skill/SKILL.md +112 -0
  3. package/assets/skill/cli-reference.md +327 -0
  4. package/assets/skill/examples.md +234 -0
  5. package/assets/skill/mcp-reference.md +159 -0
  6. package/package.json +90 -0
  7. package/src/app/constants.ts +313 -0
  8. package/src/cli/colors.ts +65 -0
  9. package/src/cli/commands/ask.ts +545 -0
  10. package/src/cli/commands/cleanup.ts +105 -0
  11. package/src/cli/commands/collection/add.ts +120 -0
  12. package/src/cli/commands/collection/index.ts +10 -0
  13. package/src/cli/commands/collection/list.ts +108 -0
  14. package/src/cli/commands/collection/remove.ts +64 -0
  15. package/src/cli/commands/collection/rename.ts +95 -0
  16. package/src/cli/commands/context/add.ts +67 -0
  17. package/src/cli/commands/context/check.ts +153 -0
  18. package/src/cli/commands/context/index.ts +10 -0
  19. package/src/cli/commands/context/list.ts +109 -0
  20. package/src/cli/commands/context/rm.ts +52 -0
  21. package/src/cli/commands/doctor.ts +393 -0
  22. package/src/cli/commands/embed.ts +462 -0
  23. package/src/cli/commands/get.ts +356 -0
  24. package/src/cli/commands/index-cmd.ts +119 -0
  25. package/src/cli/commands/index.ts +102 -0
  26. package/src/cli/commands/init.ts +328 -0
  27. package/src/cli/commands/ls.ts +217 -0
  28. package/src/cli/commands/mcp/config.ts +300 -0
  29. package/src/cli/commands/mcp/index.ts +24 -0
  30. package/src/cli/commands/mcp/install.ts +203 -0
  31. package/src/cli/commands/mcp/paths.ts +470 -0
  32. package/src/cli/commands/mcp/status.ts +222 -0
  33. package/src/cli/commands/mcp/uninstall.ts +158 -0
  34. package/src/cli/commands/mcp.ts +20 -0
  35. package/src/cli/commands/models/clear.ts +103 -0
  36. package/src/cli/commands/models/index.ts +32 -0
  37. package/src/cli/commands/models/list.ts +214 -0
  38. package/src/cli/commands/models/path.ts +51 -0
  39. package/src/cli/commands/models/pull.ts +199 -0
  40. package/src/cli/commands/models/use.ts +85 -0
  41. package/src/cli/commands/multi-get.ts +400 -0
  42. package/src/cli/commands/query.ts +220 -0
  43. package/src/cli/commands/ref-parser.ts +108 -0
  44. package/src/cli/commands/reset.ts +191 -0
  45. package/src/cli/commands/search.ts +136 -0
  46. package/src/cli/commands/shared.ts +156 -0
  47. package/src/cli/commands/skill/index.ts +19 -0
  48. package/src/cli/commands/skill/install.ts +197 -0
  49. package/src/cli/commands/skill/paths-cmd.ts +81 -0
  50. package/src/cli/commands/skill/paths.ts +191 -0
  51. package/src/cli/commands/skill/show.ts +73 -0
  52. package/src/cli/commands/skill/uninstall.ts +141 -0
  53. package/src/cli/commands/status.ts +205 -0
  54. package/src/cli/commands/update.ts +68 -0
  55. package/src/cli/commands/vsearch.ts +188 -0
  56. package/src/cli/context.ts +64 -0
  57. package/src/cli/errors.ts +64 -0
  58. package/src/cli/format/search-results.ts +211 -0
  59. package/src/cli/options.ts +183 -0
  60. package/src/cli/program.ts +1330 -0
  61. package/src/cli/run.ts +213 -0
  62. package/src/cli/ui.ts +92 -0
  63. package/src/config/defaults.ts +20 -0
  64. package/src/config/index.ts +55 -0
  65. package/src/config/loader.ts +161 -0
  66. package/src/config/paths.ts +87 -0
  67. package/src/config/saver.ts +153 -0
  68. package/src/config/types.ts +280 -0
  69. package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
  70. package/src/converters/adapters/officeparser/adapter.ts +126 -0
  71. package/src/converters/canonicalize.ts +89 -0
  72. package/src/converters/errors.ts +218 -0
  73. package/src/converters/index.ts +51 -0
  74. package/src/converters/mime.ts +163 -0
  75. package/src/converters/native/markdown.ts +115 -0
  76. package/src/converters/native/plaintext.ts +56 -0
  77. package/src/converters/path.ts +48 -0
  78. package/src/converters/pipeline.ts +159 -0
  79. package/src/converters/registry.ts +74 -0
  80. package/src/converters/types.ts +123 -0
  81. package/src/converters/versions.ts +24 -0
  82. package/src/index.ts +27 -0
  83. package/src/ingestion/chunker.ts +238 -0
  84. package/src/ingestion/index.ts +32 -0
  85. package/src/ingestion/language.ts +276 -0
  86. package/src/ingestion/sync.ts +671 -0
  87. package/src/ingestion/types.ts +219 -0
  88. package/src/ingestion/walker.ts +235 -0
  89. package/src/llm/cache.ts +467 -0
  90. package/src/llm/errors.ts +191 -0
  91. package/src/llm/index.ts +58 -0
  92. package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
  93. package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
  94. package/src/llm/nodeLlamaCpp/generation.ts +88 -0
  95. package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
  96. package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
  97. package/src/llm/registry.ts +86 -0
  98. package/src/llm/types.ts +129 -0
  99. package/src/mcp/resources/index.ts +151 -0
  100. package/src/mcp/server.ts +229 -0
  101. package/src/mcp/tools/get.ts +220 -0
  102. package/src/mcp/tools/index.ts +160 -0
  103. package/src/mcp/tools/multi-get.ts +263 -0
  104. package/src/mcp/tools/query.ts +226 -0
  105. package/src/mcp/tools/search.ts +119 -0
  106. package/src/mcp/tools/status.ts +81 -0
  107. package/src/mcp/tools/vsearch.ts +198 -0
  108. package/src/pipeline/chunk-lookup.ts +44 -0
  109. package/src/pipeline/expansion.ts +256 -0
  110. package/src/pipeline/explain.ts +115 -0
  111. package/src/pipeline/fusion.ts +185 -0
  112. package/src/pipeline/hybrid.ts +535 -0
  113. package/src/pipeline/index.ts +64 -0
  114. package/src/pipeline/query-language.ts +118 -0
  115. package/src/pipeline/rerank.ts +223 -0
  116. package/src/pipeline/search.ts +261 -0
  117. package/src/pipeline/types.ts +328 -0
  118. package/src/pipeline/vsearch.ts +348 -0
  119. package/src/store/index.ts +41 -0
  120. package/src/store/migrations/001-initial.ts +196 -0
  121. package/src/store/migrations/index.ts +20 -0
  122. package/src/store/migrations/runner.ts +187 -0
  123. package/src/store/sqlite/adapter.ts +1242 -0
  124. package/src/store/sqlite/index.ts +7 -0
  125. package/src/store/sqlite/setup.ts +129 -0
  126. package/src/store/sqlite/types.ts +28 -0
  127. package/src/store/types.ts +506 -0
  128. package/src/store/vector/index.ts +13 -0
  129. package/src/store/vector/sqlite-vec.ts +373 -0
  130. package/src/store/vector/stats.ts +152 -0
  131. package/src/store/vector/types.ts +115 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Global CLI context resolution.
3
+ * Handles global options, NO_COLOR support, etc.
4
+ *
5
+ * @module src/cli/context
6
+ */
7
+
8
+ import { setColorsEnabled } from './colors';
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Types
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ export interface GlobalOptions {
15
+ index: string;
16
+ config?: string;
17
+ color: boolean;
18
+ verbose: boolean;
19
+ yes: boolean;
20
+ quiet: boolean;
21
+ json: boolean;
22
+ }
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Parsing (pure - no side effects)
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Parse global options from Commander raw opts (pure function).
30
+ * Supports NO_COLOR env var (https://no-color.org/).
31
+ */
32
+ export function parseGlobalOptions(
33
+ raw: Record<string, unknown>,
34
+ env = process.env
35
+ ): GlobalOptions {
36
+ // NO_COLOR env var support (https://no-color.org/)
37
+ const noColorEnv = env.NO_COLOR !== undefined && env.NO_COLOR !== '';
38
+ // --no-color sets color to false in Commander
39
+ const noColorFlag = raw.color === false;
40
+
41
+ const colorEnabled = !(noColorEnv || noColorFlag);
42
+
43
+ return {
44
+ index: (raw.index as string) ?? 'default',
45
+ config: raw.config as string | undefined,
46
+ color: colorEnabled,
47
+ verbose: Boolean(raw.verbose),
48
+ yes: Boolean(raw.yes),
49
+ quiet: Boolean(raw.quiet),
50
+ json: Boolean(raw.json),
51
+ };
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Side Effects
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Apply global options side effects (colors, etc).
60
+ * Should be called exactly once per CLI invocation.
61
+ */
62
+ export function applyGlobalOptions(globals: GlobalOptions): void {
63
+ setColorsEnabled(globals.color);
64
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * CLI error model aligned to spec.
3
+ * Exit codes: 0=success, 1=validation, 2=runtime
4
+ *
5
+ * @module src/cli/errors
6
+ */
7
+
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // Error Types
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+
12
+ export type CliErrorCode = 'VALIDATION' | 'RUNTIME';
13
+
14
+ export class CliError extends Error {
15
+ readonly code: CliErrorCode;
16
+ readonly details?: Record<string, unknown>;
17
+
18
+ constructor(
19
+ code: CliErrorCode,
20
+ message: string,
21
+ details?: Record<string, unknown>
22
+ ) {
23
+ super(message);
24
+ this.code = code;
25
+ this.details = details;
26
+ this.name = 'CliError';
27
+ }
28
+ }
29
+
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+ // Exit Codes
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ export function exitCodeFor(err: CliError): 1 | 2 {
35
+ return err.code === 'VALIDATION' ? 1 : 2;
36
+ }
37
+
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ // Error Formatting
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+
42
+ export interface ErrorFormatOptions {
43
+ json?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Format error for output.
48
+ * JSON mode returns { error: { code, message, details } } envelope.
49
+ */
50
+ export function formatErrorForOutput(
51
+ err: CliError,
52
+ options: ErrorFormatOptions = {}
53
+ ): string {
54
+ if (options.json) {
55
+ return JSON.stringify({
56
+ error: {
57
+ code: err.code,
58
+ message: err.message,
59
+ ...(err.details && { details: err.details }),
60
+ },
61
+ });
62
+ }
63
+ return `Error: ${err.message}`;
64
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Shared formatters for search/vsearch commands.
3
+ * Centralizes output formatting to avoid duplication.
4
+ *
5
+ * @module src/cli/format/searchResults
6
+ */
7
+
8
+ import type { SearchResults } from '../../pipeline/types';
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Types
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ export interface FormatOptions {
15
+ full?: boolean;
16
+ lineNumbers?: boolean;
17
+ format: 'terminal' | 'json' | 'files' | 'csv' | 'md' | 'xml';
18
+ }
19
+
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+ // Constants
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ const SNIPPET_LIMIT_TERMINAL = 200;
25
+ const SNIPPET_LIMIT_STRUCTURED = 500;
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // Main Formatter
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Format search results based on output format and options.
33
+ */
34
+ export function formatSearchResults(
35
+ data: SearchResults,
36
+ options: FormatOptions
37
+ ): string {
38
+ switch (options.format) {
39
+ case 'json':
40
+ return JSON.stringify(data, null, 2);
41
+ case 'files':
42
+ return formatFiles(data);
43
+ case 'csv':
44
+ return formatCsv(data);
45
+ case 'md':
46
+ return formatMarkdown(data, options);
47
+ case 'xml':
48
+ return formatXml(data, options);
49
+ default:
50
+ return formatTerminal(data, options);
51
+ }
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Format Helpers
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Format as line protocol per spec.
60
+ * Output: #docid,score,gno://collection/path
61
+ */
62
+ function formatFiles(data: SearchResults): string {
63
+ return data.results
64
+ .map((r) => {
65
+ // Defensive: ensure docid starts with #
66
+ const docid = r.docid.startsWith('#') ? r.docid : `#${r.docid}`;
67
+ return `${docid},${r.score.toFixed(4)},${r.uri}`;
68
+ })
69
+ .join('\n');
70
+ }
71
+
72
+ function formatTerminal(data: SearchResults, options: FormatOptions): string {
73
+ if (data.results.length === 0) {
74
+ return 'No results found.';
75
+ }
76
+
77
+ const lines: string[] = [];
78
+ for (const r of data.results) {
79
+ lines.push(`[${r.docid}] ${r.uri} (score: ${r.score.toFixed(2)})`);
80
+ if (r.title) {
81
+ lines.push(` ${r.title}`);
82
+ }
83
+ if (r.snippet) {
84
+ const content = options.full
85
+ ? r.snippet
86
+ : truncate(r.snippet, SNIPPET_LIMIT_TERMINAL);
87
+ // For --full, snippetRange is undefined; start at line 1
88
+ const startLine = r.snippetRange?.startLine ?? 1;
89
+ const formatted = options.lineNumbers
90
+ ? addLineNumbers(content, startLine)
91
+ : content;
92
+ // Indent multiline snippets
93
+ lines.push(` ${formatted.replace(/\n/g, '\n ')}`);
94
+ }
95
+ lines.push('');
96
+ }
97
+ lines.push(
98
+ `${data.meta.totalResults} result(s) for "${data.meta.query}" (${data.meta.mode})`
99
+ );
100
+ return lines.join('\n');
101
+ }
102
+
103
+ function formatMarkdown(data: SearchResults, options: FormatOptions): string {
104
+ const modeLabel = data.meta.mode === 'vector' ? 'Vector ' : '';
105
+ if (data.results.length === 0) {
106
+ return `# ${modeLabel}Search Results\n\nNo results found for "${data.meta.query}".`;
107
+ }
108
+
109
+ const lines: string[] = [];
110
+ lines.push(`# ${modeLabel}Search Results for "${data.meta.query}"`);
111
+ lines.push('');
112
+ lines.push(`*${data.meta.totalResults} result(s)*`);
113
+ lines.push('');
114
+
115
+ for (const r of data.results) {
116
+ lines.push(`## ${r.title || r.source.relPath}`);
117
+ lines.push('');
118
+ lines.push(`- **URI**: \`${r.uri}\``);
119
+ lines.push(`- **Score**: ${r.score.toFixed(2)}`);
120
+ lines.push(`- **DocID**: \`${r.docid}\``);
121
+ if (r.snippet) {
122
+ const content = options.full
123
+ ? r.snippet
124
+ : truncate(r.snippet, SNIPPET_LIMIT_STRUCTURED);
125
+ const startLine = r.snippetRange?.startLine ?? 1;
126
+ const formatted = options.lineNumbers
127
+ ? addLineNumbers(content, startLine)
128
+ : content;
129
+ lines.push('');
130
+ lines.push('```');
131
+ lines.push(formatted);
132
+ lines.push('```');
133
+ }
134
+ lines.push('');
135
+ }
136
+
137
+ return lines.join('\n');
138
+ }
139
+
140
+ function formatCsv(data: SearchResults): string {
141
+ const lines: string[] = [];
142
+ lines.push('docid,score,uri,title,relPath');
143
+ for (const r of data.results) {
144
+ const title = escapeCsv(r.title ?? '');
145
+ const relPath = escapeCsv(r.source.relPath);
146
+ lines.push(
147
+ `"${r.docid}",${r.score.toFixed(4)},"${r.uri}","${title}","${relPath}"`
148
+ );
149
+ }
150
+ return lines.join('\n');
151
+ }
152
+
153
+ function formatXml(data: SearchResults, options: FormatOptions): string {
154
+ const lines: string[] = [];
155
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
156
+ lines.push('<searchResults>');
157
+ lines.push(
158
+ ` <meta query="${escapeXml(data.meta.query)}" mode="${data.meta.mode}" total="${data.meta.totalResults}"/>`
159
+ );
160
+ for (const r of data.results) {
161
+ lines.push(' <result>');
162
+ lines.push(` <docid>${escapeXml(r.docid)}</docid>`);
163
+ lines.push(` <score>${r.score}</score>`);
164
+ lines.push(` <uri>${escapeXml(r.uri)}</uri>`);
165
+ if (r.title) {
166
+ lines.push(` <title>${escapeXml(r.title)}</title>`);
167
+ }
168
+ lines.push(` <relPath>${escapeXml(r.source.relPath)}</relPath>`);
169
+ if (r.snippet) {
170
+ const content = options.full
171
+ ? r.snippet
172
+ : truncate(r.snippet, SNIPPET_LIMIT_STRUCTURED);
173
+ const startLine = r.snippetRange?.startLine ?? 1;
174
+ const formatted = options.lineNumbers
175
+ ? addLineNumbers(content, startLine)
176
+ : content;
177
+ lines.push(` <snippet>${escapeXml(formatted)}</snippet>`);
178
+ }
179
+ lines.push(' </result>');
180
+ }
181
+ lines.push('</searchResults>');
182
+ return lines.join('\n');
183
+ }
184
+
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+ // Utility Functions
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+
189
+ function addLineNumbers(text: string, startLine: number): string {
190
+ return text
191
+ .split('\n')
192
+ .map((line, i) => `${startLine + i}: ${line}`)
193
+ .join('\n');
194
+ }
195
+
196
+ function truncate(text: string, limit: number): string {
197
+ return text.length > limit ? `${text.slice(0, limit)}...` : text;
198
+ }
199
+
200
+ function escapeCsv(str: string): string {
201
+ return str.replace(/"/g, '""');
202
+ }
203
+
204
+ function escapeXml(str: string): string {
205
+ return str
206
+ .replace(/&/g, '&amp;')
207
+ .replace(/</g, '&lt;')
208
+ .replace(/>/g, '&gt;')
209
+ .replace(/"/g, '&quot;')
210
+ .replace(/'/g, '&apos;');
211
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Output format selection and validation.
3
+ * Implements conditional defaults per spec.
4
+ *
5
+ * @module src/cli/options
6
+ */
7
+
8
+ import { CliError } from './errors';
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Types
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ export type OutputFormat = 'terminal' | 'json' | 'files' | 'csv' | 'md' | 'xml';
15
+
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // Format Support Matrix (per spec/cli.md)
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+
20
+ // Command IDs for consistent referencing
21
+ export const CMD = {
22
+ search: 'search',
23
+ vsearch: 'vsearch',
24
+ query: 'query',
25
+ ask: 'ask',
26
+ get: 'get',
27
+ multiGet: 'multi-get',
28
+ ls: 'ls',
29
+ status: 'status',
30
+ collectionList: 'collection.list',
31
+ contextList: 'context.list',
32
+ contextCheck: 'context.check',
33
+ modelsList: 'models.list',
34
+ } as const;
35
+
36
+ export type CommandId = (typeof CMD)[keyof typeof CMD];
37
+
38
+ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
39
+ [CMD.search]: ['terminal', 'json', 'files', 'csv', 'md', 'xml'],
40
+ [CMD.vsearch]: ['terminal', 'json', 'files', 'csv', 'md', 'xml'],
41
+ [CMD.query]: ['terminal', 'json', 'files', 'csv', 'md', 'xml'],
42
+ [CMD.ask]: ['terminal', 'json', 'md'],
43
+ [CMD.get]: ['terminal', 'json', 'md'],
44
+ [CMD.multiGet]: ['terminal', 'json', 'files', 'md'],
45
+ [CMD.ls]: ['terminal', 'json', 'files', 'md'],
46
+ [CMD.status]: ['terminal', 'json'],
47
+ [CMD.collectionList]: ['terminal', 'json', 'md'],
48
+ [CMD.contextList]: ['terminal', 'json', 'md'],
49
+ [CMD.contextCheck]: ['terminal', 'json', 'md'],
50
+ [CMD.modelsList]: ['terminal', 'json'],
51
+ };
52
+
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+ // Format Selection
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+
57
+ export interface FormatFlags {
58
+ json?: boolean;
59
+ files?: boolean;
60
+ csv?: boolean;
61
+ md?: boolean;
62
+ xml?: boolean;
63
+ }
64
+
65
+ /**
66
+ * Select output format from flags.
67
+ * Throws if multiple format flags are set.
68
+ */
69
+ export function selectOutputFormat(flags: FormatFlags): OutputFormat {
70
+ const selected: OutputFormat[] = [];
71
+ if (flags.json) {
72
+ selected.push('json');
73
+ }
74
+ if (flags.files) {
75
+ selected.push('files');
76
+ }
77
+ if (flags.csv) {
78
+ selected.push('csv');
79
+ }
80
+ if (flags.md) {
81
+ selected.push('md');
82
+ }
83
+ if (flags.xml) {
84
+ selected.push('xml');
85
+ }
86
+
87
+ if (selected.length > 1) {
88
+ throw new CliError(
89
+ 'VALIDATION',
90
+ `Conflicting output formats: ${selected.join(', ')}. Choose one.`
91
+ );
92
+ }
93
+
94
+ return selected[0] ?? 'terminal';
95
+ }
96
+
97
+ /**
98
+ * Assert format is supported for command.
99
+ */
100
+ export function assertFormatSupported(
101
+ cmd: CommandId,
102
+ format: OutputFormat
103
+ ): void {
104
+ const supported = FORMAT_SUPPORT[cmd];
105
+ if (!supported.includes(format)) {
106
+ throw new CliError(
107
+ 'VALIDATION',
108
+ `Format --${format} is not supported by '${cmd}'. Supported: ${supported.join(', ')}`
109
+ );
110
+ }
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // Conditional Defaults
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Get default limit based on format.
119
+ * Spec: 5 for terminal, 20 for structured output.
120
+ */
121
+ export function getDefaultLimit(format: OutputFormat): number {
122
+ return format === 'terminal' ? 5 : 20;
123
+ }
124
+
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+ // Numeric Option Parsing
127
+ // ─────────────────────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Parse and validate a positive integer option.
131
+ * Throws CliError on invalid input.
132
+ */
133
+ export function parsePositiveInt(name: string, value: unknown): number {
134
+ if (value === undefined || value === null) {
135
+ throw new CliError('VALIDATION', `--${name} requires a value`);
136
+ }
137
+ const strValue = String(value);
138
+ const num = Number.parseInt(strValue, 10);
139
+ if (Number.isNaN(num)) {
140
+ throw new CliError(
141
+ 'VALIDATION',
142
+ `--${name} must be a number, got: ${strValue}`
143
+ );
144
+ }
145
+ if (num < 1) {
146
+ throw new CliError('VALIDATION', `--${name} must be positive, got: ${num}`);
147
+ }
148
+ return num;
149
+ }
150
+
151
+ /**
152
+ * Parse optional positive integer, returning undefined if not provided.
153
+ */
154
+ export function parseOptionalPositiveInt(
155
+ name: string,
156
+ value: unknown
157
+ ): number | undefined {
158
+ if (value === undefined || value === null) {
159
+ return;
160
+ }
161
+ return parsePositiveInt(name, value);
162
+ }
163
+
164
+ /**
165
+ * Parse optional float, returning undefined if not provided.
166
+ */
167
+ export function parseOptionalFloat(
168
+ name: string,
169
+ value: unknown
170
+ ): number | undefined {
171
+ if (value === undefined || value === null) {
172
+ return;
173
+ }
174
+ const strValue = String(value);
175
+ const num = Number.parseFloat(strValue);
176
+ if (Number.isNaN(num)) {
177
+ throw new CliError(
178
+ 'VALIDATION',
179
+ `--${name} must be a number, got: ${strValue}`
180
+ );
181
+ }
182
+ return num;
183
+ }