@gmickel/gno 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -295,7 +295,7 @@ graph TD
295
295
  1. **Query Expansion**: LLM generates lexical variants, semantic rephrases, and a [HyDE](https://arxiv.org/abs/2212.10496) passage
296
296
  2. **Parallel Retrieval**: Document-level BM25 + chunk-level vector search on all variants
297
297
  3. **Fusion**: RRF with 2× weight for original query, tiered bonus for top ranks
298
- 4. **Reranking**: Qwen3-Reranker scores full documents (32K context), blended with fusion
298
+ 4. **Reranking**: Qwen3-Reranker scores best chunk per document (4K), blended with fusion
299
299
 
300
300
  > **Deep dive**: [How Search Works](https://gno.sh/docs/HOW-SEARCH-WORKS/)
301
301
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Shell completion commands - output scripts or auto-install.
3
+ *
4
+ * @module src/cli/commands/completion/completion
5
+ */
6
+
7
+ // node:fs/promises - no Bun equivalent for mkdir/appendFile/writeFile
8
+ import { appendFile, mkdir, writeFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { CLI_NAME } from "../../../app/constants.js";
13
+ import { CliError } from "../../errors.js";
14
+ import {
15
+ getCompletionScript,
16
+ SUPPORTED_SHELLS,
17
+ type Shell,
18
+ } from "./scripts.js";
19
+
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+ // Types
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ export interface OutputOptions {
25
+ shell: Shell;
26
+ }
27
+
28
+ export interface InstallOptions {
29
+ /** Override shell detection */
30
+ shell?: Shell;
31
+ /** JSON output */
32
+ json?: boolean;
33
+ }
34
+
35
+ interface InstallResult {
36
+ shell: Shell;
37
+ path: string;
38
+ action: "installed" | "already_installed";
39
+ }
40
+
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // Shell Detection
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Detect user's current shell.
47
+ */
48
+ function detectShell(): Shell | undefined {
49
+ // Check $SHELL env
50
+ const shellEnv = process.env.SHELL || "";
51
+ if (shellEnv.includes("zsh")) return "zsh";
52
+ if (shellEnv.includes("bash")) return "bash";
53
+ if (shellEnv.includes("fish")) return "fish";
54
+
55
+ // Check parent process name on Unix
56
+ // This is less reliable but can help
57
+ const parentName = process.env._ || "";
58
+ if (parentName.includes("zsh")) return "zsh";
59
+ if (parentName.includes("bash")) return "bash";
60
+ if (parentName.includes("fish")) return "fish";
61
+
62
+ return undefined;
63
+ }
64
+
65
+ /**
66
+ * Get the appropriate rc file for a shell.
67
+ */
68
+ function getShellRcPath(shell: Shell): string {
69
+ const home = homedir();
70
+ switch (shell) {
71
+ case "bash":
72
+ // Prefer .bashrc, but .bash_profile on macOS
73
+ return process.platform === "darwin"
74
+ ? join(home, ".bash_profile")
75
+ : join(home, ".bashrc");
76
+ case "zsh":
77
+ return join(home, ".zshrc");
78
+ case "fish":
79
+ return join(home, ".config", "fish", "completions", `${CLI_NAME}.fish`);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Check if completion is already installed.
85
+ */
86
+ async function isCompletionInstalled(shell: Shell): Promise<boolean> {
87
+ const path = getShellRcPath(shell);
88
+
89
+ // For fish, check if file exists
90
+ if (shell === "fish") {
91
+ return await Bun.file(path).exists();
92
+ }
93
+
94
+ // For bash/zsh, check for shell-specific patterns
95
+ try {
96
+ const content = await Bun.file(path).text();
97
+ // Generic header comment
98
+ const header = `# ${CLI_NAME}`;
99
+ // Bash function name
100
+ const bashFn = `_${CLI_NAME}_completions`;
101
+ // Zsh-specific patterns (function definition or completion header)
102
+ const zshFn = `_${CLI_NAME}()`;
103
+
104
+ if (shell === "bash") {
105
+ return content.includes(header) || content.includes(bashFn);
106
+ }
107
+ if (shell === "zsh") {
108
+ return content.includes(header) || content.includes(zshFn);
109
+ }
110
+ return false;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+ // Commands
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Output completion script to stdout.
122
+ */
123
+ export function completionOutput(options: OutputOptions): string {
124
+ const { shell } = options;
125
+
126
+ if (!SUPPORTED_SHELLS.includes(shell)) {
127
+ throw new CliError(
128
+ "VALIDATION",
129
+ `Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(", ")}`
130
+ );
131
+ }
132
+
133
+ return getCompletionScript(shell);
134
+ }
135
+
136
+ /**
137
+ * Auto-install completion to user's shell config.
138
+ */
139
+ export async function completionInstall(
140
+ options: InstallOptions
141
+ ): Promise<void> {
142
+ const { json = false } = options;
143
+ let { shell } = options;
144
+
145
+ // Auto-detect shell if not specified
146
+ if (!shell) {
147
+ shell = detectShell();
148
+ if (!shell) {
149
+ throw new CliError(
150
+ "VALIDATION",
151
+ "Could not detect shell. Please specify: gno completion install --shell <bash|zsh|fish>"
152
+ );
153
+ }
154
+ }
155
+
156
+ if (!SUPPORTED_SHELLS.includes(shell)) {
157
+ throw new CliError(
158
+ "VALIDATION",
159
+ `Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(", ")}`
160
+ );
161
+ }
162
+
163
+ // Check if already installed
164
+ const alreadyInstalled = await isCompletionInstalled(shell);
165
+ const rcPath = getShellRcPath(shell);
166
+ const script = getCompletionScript(shell);
167
+
168
+ let result: InstallResult;
169
+
170
+ if (alreadyInstalled) {
171
+ result = { shell, path: rcPath, action: "already_installed" };
172
+ } else {
173
+ // Install completion
174
+ if (shell === "fish") {
175
+ // Fish uses a separate file in completions dir
176
+ const dir = join(homedir(), ".config", "fish", "completions");
177
+ await mkdir(dir, { recursive: true });
178
+ await writeFile(rcPath, script, "utf-8");
179
+ } else {
180
+ // Bash/zsh append to rc file
181
+ const separator = "\n\n";
182
+ await appendFile(rcPath, separator + script, "utf-8");
183
+ }
184
+ result = { shell, path: rcPath, action: "installed" };
185
+ }
186
+
187
+ // Output result
188
+ if (json) {
189
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
190
+ } else {
191
+ if (result.action === "already_installed") {
192
+ process.stdout.write(`Completion already installed for ${shell}.\n`);
193
+ process.stdout.write(`Config: ${result.path}\n`);
194
+ } else {
195
+ process.stdout.write(`Completion installed for ${shell}.\n`);
196
+ process.stdout.write(`Config: ${result.path}\n`);
197
+ process.stdout.write(`\nRestart your shell or run:\n`);
198
+ process.stdout.write(` source ${result.path}\n`);
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shell completion command - output or install completion scripts.
3
+ *
4
+ * @module src/cli/commands/completion
5
+ */
6
+
7
+ export { completionOutput, completionInstall } from "./completion.js";
8
+ export { SUPPORTED_SHELLS, type Shell } from "./scripts.js";
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Shell completion scripts for bash, zsh, and fish.
3
+ *
4
+ * @module src/cli/commands/completion/scripts
5
+ */
6
+
7
+ import { CLI_NAME } from "../../../app/constants.js";
8
+
9
+ export type Shell = "bash" | "zsh" | "fish";
10
+ export const SUPPORTED_SHELLS: Shell[] = ["bash", "zsh", "fish"];
11
+
12
+ /**
13
+ * All gno commands and subcommands for completion.
14
+ */
15
+ const COMMANDS = [
16
+ "init",
17
+ "index",
18
+ "update",
19
+ "embed",
20
+ "status",
21
+ "doctor",
22
+ "cleanup",
23
+ "reset",
24
+ "search",
25
+ "vsearch",
26
+ "query",
27
+ "ask",
28
+ "get",
29
+ "multi-get",
30
+ "ls",
31
+ "serve",
32
+ "mcp",
33
+ "mcp serve",
34
+ "mcp install",
35
+ "mcp uninstall",
36
+ "mcp status",
37
+ "collection",
38
+ "collection add",
39
+ "collection list",
40
+ "collection remove",
41
+ "collection rename",
42
+ "context",
43
+ "context add",
44
+ "context list",
45
+ "context check",
46
+ "context rm",
47
+ "models",
48
+ "models list",
49
+ "models pull",
50
+ "models clear",
51
+ "models path",
52
+ "models use",
53
+ "skill",
54
+ "skill install",
55
+ "skill uninstall",
56
+ "skill show",
57
+ "skill paths",
58
+ "completion",
59
+ "completion output",
60
+ "completion install",
61
+ ];
62
+
63
+ /**
64
+ * Global flags available on all commands.
65
+ */
66
+ const GLOBAL_FLAGS = [
67
+ "--index",
68
+ "--config",
69
+ "--no-color",
70
+ "--no-pager",
71
+ "--verbose",
72
+ "--yes",
73
+ "--quiet",
74
+ "--json",
75
+ "--offline",
76
+ "--help",
77
+ "--version",
78
+ ];
79
+
80
+ /**
81
+ * Generate bash completion script.
82
+ */
83
+ export function generateBashCompletion(): string {
84
+ const commands = COMMANDS.filter((c) => !c.includes(" ")).join(" ");
85
+ const subcommands: Record<string, string> = {};
86
+
87
+ for (const cmd of COMMANDS) {
88
+ if (cmd.includes(" ")) {
89
+ const parts = cmd.split(" ");
90
+ const parent = parts[0];
91
+ const sub = parts[1];
92
+ if (parent && sub) {
93
+ subcommands[parent] = subcommands[parent]
94
+ ? `${subcommands[parent]} ${sub}`
95
+ : sub;
96
+ }
97
+ }
98
+ }
99
+
100
+ const subCases = Object.entries(subcommands)
101
+ .map(
102
+ ([parent, subs]) =>
103
+ ` ${parent}) COMPREPLY=($(compgen -W "${subs}" -- "\${cur}")) ;;`
104
+ )
105
+ .join("\n");
106
+
107
+ return `# ${CLI_NAME} bash completion
108
+ # Add to ~/.bashrc or ~/.bash_completion
109
+
110
+ _${CLI_NAME}_completions() {
111
+ local cur prev cword
112
+ # Portable: don't rely on _init_completion (requires bash-completion package)
113
+ cur="\${COMP_WORDS[COMP_CWORD]}"
114
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
115
+ cword="\${COMP_CWORD}"
116
+
117
+ local commands="${commands}"
118
+ local global_flags="${GLOBAL_FLAGS.join(" ")}"
119
+
120
+ # Complete subcommands for parent commands
121
+ case "\${COMP_WORDS[1]}" in
122
+ ${subCases}
123
+ esac
124
+
125
+ # Complete global flags
126
+ if [[ "\${cur}" == -* ]]; then
127
+ COMPREPLY=($(compgen -W "\${global_flags}" -- "\${cur}"))
128
+ return
129
+ fi
130
+
131
+ # Complete top-level commands
132
+ if [[ \${cword} -eq 1 ]]; then
133
+ COMPREPLY=($(compgen -W "\${commands}" -- "\${cur}"))
134
+ return
135
+ fi
136
+
137
+ # Dynamic collection completion for -c/--collection flag
138
+ if [[ "\${prev}" == "-c" || "\${prev}" == "--collection" ]]; then
139
+ local collections
140
+ collections=$(${CLI_NAME} collection list --json 2>/dev/null | grep -o '"name":"[^"]*"' | cut -d'"' -f4)
141
+ COMPREPLY=($(compgen -W "\${collections}" -- "\${cur}"))
142
+ return
143
+ fi
144
+ }
145
+
146
+ complete -F _${CLI_NAME}_completions ${CLI_NAME}
147
+ `;
148
+ }
149
+
150
+ /**
151
+ * Generate zsh completion script.
152
+ */
153
+ export function generateZshCompletion(): string {
154
+ const topLevel = COMMANDS.filter((c) => !c.includes(" "));
155
+ const subcommands: Record<string, string[]> = {};
156
+
157
+ for (const cmd of COMMANDS) {
158
+ if (cmd.includes(" ")) {
159
+ const parts = cmd.split(" ");
160
+ const parent = parts[0];
161
+ const sub = parts[1];
162
+ if (parent && sub) {
163
+ subcommands[parent] = subcommands[parent] || [];
164
+ subcommands[parent].push(sub);
165
+ }
166
+ }
167
+ }
168
+
169
+ const subCases = Object.entries(subcommands)
170
+ .map(
171
+ ([parent, subs]) =>
172
+ ` ${parent})
173
+ _arguments '2:subcommand:(${subs.join(" ")})'
174
+ ;;`
175
+ )
176
+ .join("\n");
177
+
178
+ return `# ${CLI_NAME} zsh completion
179
+ # Add to ~/.zshrc or copy to a directory in $fpath
180
+ # If autoloading from fpath, add "#compdef ${CLI_NAME}" as first line
181
+
182
+ _${CLI_NAME}() {
183
+ local -a commands
184
+ commands=(
185
+ ${topLevel.map((c) => ` '${c}:${getCommandDescription(c)}'`).join("\n")}
186
+ )
187
+
188
+ local -a global_flags
189
+ global_flags=(
190
+ '--index[index name]:name:'
191
+ '--config[config file path]:file:_files'
192
+ '-c[filter by collection]:collection:_${CLI_NAME}_collections'
193
+ '--collection[filter by collection]:collection:_${CLI_NAME}_collections'
194
+ '--no-color[disable colors]'
195
+ '--no-pager[disable paging]'
196
+ '--verbose[verbose logging]'
197
+ '--yes[non-interactive mode]'
198
+ '--quiet[suppress non-essential output]'
199
+ '--json[JSON output]'
200
+ '--offline[offline mode]'
201
+ '--help[show help]'
202
+ '--version[show version]'
203
+ )
204
+
205
+ _arguments -C \\
206
+ "\${global_flags[@]}" \\
207
+ '1:command:->command' \\
208
+ '*::arg:->args'
209
+
210
+ case "$state" in
211
+ command)
212
+ _describe -t commands 'gno commands' commands
213
+ ;;
214
+ args)
215
+ # words[1] is the program name in zsh, words[2] is the command
216
+ case "\${words[2]}" in
217
+ ${subCases}
218
+ esac
219
+ ;;
220
+ esac
221
+ }
222
+
223
+ # Dynamic collection completion
224
+ _${CLI_NAME}_collections() {
225
+ local -a collections
226
+ collections=(\${(f)"$($CLI_NAME collection list --json 2>/dev/null | grep -o '"name":"[^"]*"' | cut -d'"' -f4)"})
227
+ _describe -t collections 'collections' collections
228
+ }
229
+
230
+ # Register completion (works when sourced or autoloaded)
231
+ (( $+functions[compdef] )) && compdef _${CLI_NAME} ${CLI_NAME}
232
+ `;
233
+ }
234
+
235
+ /**
236
+ * Generate fish completion script.
237
+ */
238
+ export function generateFishCompletion(): string {
239
+ const topLevel = COMMANDS.filter((c) => !c.includes(" "));
240
+ const subcommands: Record<string, string[]> = {};
241
+
242
+ for (const cmd of COMMANDS) {
243
+ if (cmd.includes(" ")) {
244
+ const parts = cmd.split(" ");
245
+ const parent = parts[0];
246
+ const sub = parts[1];
247
+ if (parent && sub) {
248
+ subcommands[parent] = subcommands[parent] || [];
249
+ subcommands[parent].push(sub);
250
+ }
251
+ }
252
+ }
253
+
254
+ const topLevelCompletions = topLevel
255
+ .map(
256
+ (c) =>
257
+ `complete -c ${CLI_NAME} -f -n "__fish_use_subcommand" -a "${c}" -d "${getCommandDescription(c)}"`
258
+ )
259
+ .join("\n");
260
+
261
+ const subCompletions = Object.entries(subcommands)
262
+ .flatMap(([parent, subs]) =>
263
+ subs.map(
264
+ (sub) =>
265
+ `complete -c ${CLI_NAME} -f -n "__fish_seen_subcommand_from ${parent}" -a "${sub}" -d "${getCommandDescription(`${parent} ${sub}`)}"`
266
+ )
267
+ )
268
+ .join("\n");
269
+
270
+ return `# ${CLI_NAME} fish completion
271
+ # Copy to ~/.config/fish/completions/${CLI_NAME}.fish
272
+
273
+ # Disable file completion by default
274
+ complete -c ${CLI_NAME} -f
275
+
276
+ # Global flags
277
+ complete -c ${CLI_NAME} -l index -d "index name" -r
278
+ complete -c ${CLI_NAME} -l config -d "config file path" -r
279
+ complete -c ${CLI_NAME} -l no-color -d "disable colors"
280
+ complete -c ${CLI_NAME} -l verbose -d "verbose logging"
281
+ complete -c ${CLI_NAME} -l yes -d "non-interactive mode"
282
+ complete -c ${CLI_NAME} -l quiet -d "suppress non-essential output"
283
+ complete -c ${CLI_NAME} -l json -d "JSON output"
284
+ complete -c ${CLI_NAME} -l offline -d "offline mode"
285
+ complete -c ${CLI_NAME} -s h -l help -d "show help"
286
+ complete -c ${CLI_NAME} -s V -l version -d "show version"
287
+
288
+ # Top-level commands
289
+ ${topLevelCompletions}
290
+
291
+ # Subcommands
292
+ ${subCompletions}
293
+
294
+ # Dynamic collection completion for -c/--collection
295
+ complete -c ${CLI_NAME} -s c -l collection -d "filter by collection" -xa "(${CLI_NAME} collection list --json 2>/dev/null | string match -r '"name":"[^"]*"' | string replace -r '"name":"([^"]*)"' '$1')"
296
+ `;
297
+ }
298
+
299
+ /**
300
+ * Get script for a specific shell.
301
+ */
302
+ export function getCompletionScript(shell: Shell): string {
303
+ switch (shell) {
304
+ case "bash":
305
+ return generateBashCompletion();
306
+ case "zsh":
307
+ return generateZshCompletion();
308
+ case "fish":
309
+ return generateFishCompletion();
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Get brief description for a command.
315
+ */
316
+ function getCommandDescription(cmd: string): string {
317
+ const descriptions: Record<string, string> = {
318
+ init: "Initialize GNO configuration",
319
+ index: "Index files from collections",
320
+ update: "Sync files from disk",
321
+ embed: "Generate embeddings",
322
+ status: "Show index status",
323
+ doctor: "Diagnose configuration issues",
324
+ cleanup: "Clean orphaned data",
325
+ reset: "Delete all GNO data",
326
+ search: "BM25 keyword search",
327
+ vsearch: "Vector similarity search",
328
+ query: "Hybrid search with reranking",
329
+ ask: "Query with grounded answer",
330
+ get: "Get document by URI",
331
+ "multi-get": "Get multiple documents",
332
+ ls: "List indexed documents",
333
+ serve: "Start web UI server",
334
+ mcp: "MCP server and configuration",
335
+ "mcp serve": "Start MCP server",
336
+ "mcp install": "Install MCP server to client",
337
+ "mcp uninstall": "Remove MCP server from client",
338
+ "mcp status": "Show MCP installation status",
339
+ collection: "Manage collections",
340
+ "collection add": "Add a collection",
341
+ "collection list": "List collections",
342
+ "collection remove": "Remove a collection",
343
+ "collection rename": "Rename a collection",
344
+ context: "Manage context items",
345
+ "context add": "Add context metadata",
346
+ "context list": "List context items",
347
+ "context check": "Check context configuration",
348
+ "context rm": "Remove context item",
349
+ models: "Manage LLM models",
350
+ "models list": "List available models",
351
+ "models pull": "Download models",
352
+ "models clear": "Clear model cache",
353
+ "models path": "Show model cache path",
354
+ "models use": "Switch active model preset",
355
+ skill: "Manage GNO agent skill",
356
+ "skill install": "Install GNO skill",
357
+ "skill uninstall": "Uninstall GNO skill",
358
+ "skill show": "Preview skill files",
359
+ "skill paths": "Show skill installation paths",
360
+ completion: "Shell completion scripts",
361
+ "completion output": "Output completion script",
362
+ "completion install": "Install shell completions",
363
+ };
364
+ return descriptions[cmd] || cmd;
365
+ }
@@ -21,6 +21,7 @@ export interface GlobalOptions {
21
21
  quiet: boolean;
22
22
  json: boolean;
23
23
  offline: boolean;
24
+ noPager: boolean;
24
25
  }
25
26
 
26
27
  // ─────────────────────────────────────────────────────────────────────────────
@@ -58,6 +59,8 @@ export function parseGlobalOptions(
58
59
  quiet: Boolean(raw.quiet),
59
60
  json: Boolean(raw.json),
60
61
  offline: offlineEnabled,
62
+ // Commander: --no-pager => opts().pager === false
63
+ noPager: raw.pager === false,
61
64
  };
62
65
  }
63
66
 
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Pager utility for long CLI output.
3
+ * Pipes output through less/more when terminal height exceeded.
4
+ *
5
+ * @module src/cli/pager
6
+ */
7
+
8
+ // node:os - no Bun equivalent for platform()
9
+ import { platform } from "node:os";
10
+
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // Types
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+
15
+ export interface PagerOptions {
16
+ /** Force disable paging (--no-pager flag) */
17
+ noPager?: boolean;
18
+ /** Override terminal height detection */
19
+ terminalHeight?: number;
20
+ }
21
+
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+ // Pager Detection
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Find available pager command.
28
+ * Priority: $PAGER env → less (with -R for colors) → more
29
+ */
30
+ function findPager(): string[] | null {
31
+ // Check $PAGER env first (cross-platform)
32
+ const pagerEnv = process.env.PAGER;
33
+ if (pagerEnv) {
34
+ // Split in case user has args like "less -R"
35
+ return pagerEnv.split(/\s+/);
36
+ }
37
+
38
+ // Platform-specific fallbacks
39
+ const isWindows = platform() === "win32";
40
+
41
+ if (isWindows) {
42
+ // Windows: use more.com (basic but universally available)
43
+ return ["more.com"];
44
+ }
45
+
46
+ // Unix: prefer less with -R (preserve ANSI colors)
47
+ return ["less", "-R"];
48
+ }
49
+
50
+ /**
51
+ * Check if paging should be enabled.
52
+ */
53
+ function shouldPage(options: PagerOptions): boolean {
54
+ // Explicitly disabled
55
+ if (options.noPager) {
56
+ return false;
57
+ }
58
+
59
+ // Not a TTY (piped output)
60
+ if (!process.stdout.isTTY) {
61
+ return false;
62
+ }
63
+
64
+ return true;
65
+ }
66
+
67
+ /**
68
+ * Get terminal height.
69
+ */
70
+ function getTerminalHeight(options: PagerOptions): number {
71
+ if (options.terminalHeight !== undefined) {
72
+ return options.terminalHeight;
73
+ }
74
+ return process.stdout.rows || 24; // Default to 24 if unknown
75
+ }
76
+
77
+ // ─────────────────────────────────────────────────────────────────────────────
78
+ // Pager Class
79
+ // ─────────────────────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Pager that buffers output and pipes through pager if needed.
83
+ */
84
+ export class Pager {
85
+ private options: PagerOptions;
86
+ private buffer: string[] = [];
87
+ private terminalHeight: number;
88
+ private pagerProcess: ReturnType<typeof Bun.spawn> | null = null;
89
+ private enabled: boolean;
90
+
91
+ constructor(options: PagerOptions = {}) {
92
+ this.options = options;
93
+ this.terminalHeight = getTerminalHeight(options);
94
+ this.enabled = shouldPage(options);
95
+ }
96
+
97
+ /**
98
+ * Write a line to the pager buffer.
99
+ */
100
+ writeLine(line: string): void {
101
+ this.buffer.push(line);
102
+ }
103
+
104
+ /**
105
+ * Write multiple lines to the pager buffer.
106
+ */
107
+ writeLines(lines: string[]): void {
108
+ this.buffer.push(...lines);
109
+ }
110
+
111
+ /**
112
+ * Write raw content (may contain newlines).
113
+ */
114
+ write(content: string): void {
115
+ const lines = content.split("\n");
116
+ // Don't add empty string from trailing newline
117
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
118
+ lines.pop();
119
+ }
120
+ this.buffer.push(...lines);
121
+ }
122
+
123
+ /**
124
+ * Flush buffer to stdout, using pager if content exceeds terminal height.
125
+ */
126
+ async flush(): Promise<void> {
127
+ const content = this.buffer.join("\n");
128
+ const lineCount = this.buffer.length;
129
+
130
+ // If paging disabled or content fits in terminal, write directly
131
+ if (!this.enabled || lineCount <= this.terminalHeight - 1) {
132
+ if (content) {
133
+ process.stdout.write(content + "\n");
134
+ }
135
+ return;
136
+ }
137
+
138
+ // Try to spawn pager
139
+ const pagerCmd = findPager();
140
+ if (!pagerCmd) {
141
+ // No pager available, write directly
142
+ process.stdout.write(content + "\n");
143
+ return;
144
+ }
145
+
146
+ // Spawn pager and pipe content
147
+ await this.spawnPager(pagerCmd, content);
148
+ }
149
+
150
+ /**
151
+ * Spawn pager process and pipe content.
152
+ */
153
+ private async spawnPager(pagerCmd: string[], content: string): Promise<void> {
154
+ const [cmd, ...args] = pagerCmd;
155
+ if (!cmd) {
156
+ process.stdout.write(content + "\n");
157
+ return;
158
+ }
159
+
160
+ try {
161
+ const proc = Bun.spawn([cmd, ...args], {
162
+ stdin: "pipe",
163
+ stdout: "inherit",
164
+ stderr: "inherit",
165
+ });
166
+ this.pagerProcess = proc;
167
+
168
+ // Write content to pager stdin
169
+ if (proc.stdin) {
170
+ proc.stdin.write(content + "\n");
171
+ await proc.stdin.end();
172
+ }
173
+
174
+ // Wait for pager to exit
175
+ await proc.exited;
176
+ this.pagerProcess = null;
177
+ } catch {
178
+ // Spawn failed - fall back to direct output
179
+ process.stdout.write(content + "\n");
180
+ }
181
+ }
182
+ }
183
+
184
+ // ─────────────────────────────────────────────────────────────────────────────
185
+ // Helper Function
186
+ // ─────────────────────────────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Convenience function to page content.
190
+ * @param content Content to potentially page
191
+ * @param options Pager options
192
+ */
193
+ export async function pageContent(
194
+ content: string,
195
+ options: PagerOptions = {}
196
+ ): Promise<void> {
197
+ const pager = new Pager(options);
198
+ pager.write(content);
199
+ await pager.flush();
200
+ }
@@ -110,6 +110,25 @@ function getFormat(
110
110
  return "terminal";
111
111
  }
112
112
 
113
+ /**
114
+ * Write output with optional paging for terminal format.
115
+ * Paging only applies to terminal format with list-like output.
116
+ */
117
+ async function writeOutput(
118
+ content: string,
119
+ format: "terminal" | "json" | "files" | "csv" | "md" | "xml"
120
+ ): Promise<void> {
121
+ const globals = getGlobals();
122
+
123
+ // Only page terminal output when paging enabled
124
+ if (format === "terminal" && !globals.noPager) {
125
+ const { pageContent } = await import("./pager.js");
126
+ await pageContent(content);
127
+ } else {
128
+ process.stdout.write(content + "\n");
129
+ }
130
+ }
131
+
113
132
  // ─────────────────────────────────────────────────────────────────────────────
114
133
  // Program Factory
115
134
  // ─────────────────────────────────────────────────────────────────────────────
@@ -134,7 +153,8 @@ export function createProgram(): Command {
134
153
  .option("--yes", "non-interactive mode")
135
154
  .option("-q, --quiet", "suppress non-essential output")
136
155
  .option("--json", "JSON output (for errors and supported commands)")
137
- .option("--offline", "offline mode (use cached models only)");
156
+ .option("--offline", "offline mode (use cached models only)")
157
+ .option("--no-pager", "disable automatic paging of long output");
138
158
 
139
159
  // Resolve globals ONCE before any command runs (ensures consistency)
140
160
  program.hook("preAction", (thisCommand) => {
@@ -152,6 +172,7 @@ export function createProgram(): Command {
152
172
  wireMcpCommand(program);
153
173
  wireSkillCommands(program);
154
174
  wireServeCommand(program);
175
+ wireCompletionCommand(program);
155
176
 
156
177
  // Add docs/support links to help footer
157
178
  program.addHelpText(
@@ -226,17 +247,16 @@ function wireSearchCommands(program: Command): void {
226
247
  result.error
227
248
  );
228
249
  }
229
- process.stdout.write(
230
- `${formatSearch(result, {
231
- json: format === "json",
232
- md: format === "md",
233
- csv: format === "csv",
234
- xml: format === "xml",
235
- files: format === "files",
236
- full: Boolean(cmdOpts.full),
237
- lineNumbers: Boolean(cmdOpts.lineNumbers),
238
- })}\n`
239
- );
250
+ const output = formatSearch(result, {
251
+ json: format === "json",
252
+ md: format === "md",
253
+ csv: format === "csv",
254
+ xml: format === "xml",
255
+ files: format === "files",
256
+ full: Boolean(cmdOpts.full),
257
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
258
+ });
259
+ await writeOutput(output, format);
240
260
  });
241
261
 
242
262
  // vsearch - Vector similarity search
@@ -291,17 +311,16 @@ function wireSearchCommands(program: Command): void {
291
311
  if (!result.success) {
292
312
  throw new CliError("RUNTIME", result.error);
293
313
  }
294
- process.stdout.write(
295
- `${formatVsearch(result, {
296
- json: format === "json",
297
- md: format === "md",
298
- csv: format === "csv",
299
- xml: format === "xml",
300
- files: format === "files",
301
- full: Boolean(cmdOpts.full),
302
- lineNumbers: Boolean(cmdOpts.lineNumbers),
303
- })}\n`
304
- );
314
+ const output = formatVsearch(result, {
315
+ json: format === "json",
316
+ md: format === "md",
317
+ csv: format === "csv",
318
+ xml: format === "xml",
319
+ files: format === "files",
320
+ full: Boolean(cmdOpts.full),
321
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
322
+ });
323
+ await writeOutput(output, format);
305
324
  });
306
325
 
307
326
  // query - Hybrid search with expansion and reranking
@@ -388,13 +407,12 @@ function wireSearchCommands(program: Command): void {
388
407
  if (!result.success) {
389
408
  throw new CliError("RUNTIME", result.error);
390
409
  }
391
- process.stdout.write(
392
- `${formatQuery(result, {
393
- format,
394
- full: Boolean(cmdOpts.full),
395
- lineNumbers: Boolean(cmdOpts.lineNumbers),
396
- })}\n`
397
- );
410
+ const output = formatQuery(result, {
411
+ format,
412
+ full: Boolean(cmdOpts.full),
413
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
414
+ });
415
+ await writeOutput(output, format);
398
416
  });
399
417
 
400
418
  // ask - Human-friendly query with grounded answer
@@ -464,9 +482,12 @@ function wireSearchCommands(program: Command): void {
464
482
  if (!result.success) {
465
483
  throw new CliError("RUNTIME", result.error);
466
484
  }
467
- process.stdout.write(
468
- `${formatAsk(result, { json: format === "json", md: format === "md", showSources })}\n`
469
- );
485
+ const output = formatAsk(result, {
486
+ json: format === "json",
487
+ md: format === "md",
488
+ showSources,
489
+ });
490
+ await writeOutput(output, format);
470
491
  });
471
492
  }
472
493
 
@@ -738,13 +759,12 @@ function wireRetrievalCommands(program: Command): void {
738
759
  );
739
760
  }
740
761
 
741
- process.stdout.write(
742
- `${formatLs(result, {
743
- json: format === "json",
744
- files: format === "files",
745
- md: format === "md",
746
- })}\n`
747
- );
762
+ const output = formatLs(result, {
763
+ json: format === "json",
764
+ files: format === "files",
765
+ md: format === "md",
766
+ });
767
+ await writeOutput(output, format);
748
768
  }
749
769
  );
750
770
  }
@@ -1407,3 +1427,68 @@ function wireServeCommand(program: Command): void {
1407
1427
  // Server runs until SIGINT/SIGTERM - no output needed here
1408
1428
  });
1409
1429
  }
1430
+
1431
+ // ─────────────────────────────────────────────────────────────────────────────
1432
+ // Completion Command (shell completions)
1433
+ // ─────────────────────────────────────────────────────────────────────────────
1434
+
1435
+ function wireCompletionCommand(program: Command): void {
1436
+ const completionCmd = program
1437
+ .command("completion")
1438
+ .description("Shell completion scripts");
1439
+
1440
+ // Output completion script
1441
+ completionCmd
1442
+ .command("output <shell>", { isDefault: true })
1443
+ .description("Output completion script for a shell (bash, zsh, fish)")
1444
+ .action(async (shell: string) => {
1445
+ const { completionOutput, SUPPORTED_SHELLS } =
1446
+ await import("./commands/completion/index.js");
1447
+
1448
+ if (!SUPPORTED_SHELLS.includes(shell as "bash" | "zsh" | "fish")) {
1449
+ throw new CliError(
1450
+ "VALIDATION",
1451
+ `Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(", ")}`
1452
+ );
1453
+ }
1454
+
1455
+ const script = completionOutput({
1456
+ shell: shell as "bash" | "zsh" | "fish",
1457
+ });
1458
+ process.stdout.write(script);
1459
+ });
1460
+
1461
+ // Install completion to user's shell config
1462
+ completionCmd
1463
+ .command("install")
1464
+ .description("Auto-install completion to shell config")
1465
+ .option(
1466
+ "-s, --shell <shell>",
1467
+ "shell to install for (bash, zsh, fish) - auto-detected if omitted"
1468
+ )
1469
+ .option("--json", "JSON output")
1470
+ .action(async (...args: unknown[]) => {
1471
+ // Last arg is the Command object - use optsWithGlobals for subcommands
1472
+ const cmd = args[args.length - 1] as Command;
1473
+ const cmdOpts = cmd.optsWithGlobals<{ shell?: string; json?: boolean }>();
1474
+ const { shell, json } = cmdOpts;
1475
+
1476
+ const { completionInstall, SUPPORTED_SHELLS } =
1477
+ await import("./commands/completion/index.js");
1478
+
1479
+ if (
1480
+ shell &&
1481
+ !SUPPORTED_SHELLS.includes(shell as "bash" | "zsh" | "fish")
1482
+ ) {
1483
+ throw new CliError(
1484
+ "VALIDATION",
1485
+ `Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(", ")}`
1486
+ );
1487
+ }
1488
+
1489
+ await completionInstall({
1490
+ shell: shell as "bash" | "zsh" | "fish" | undefined,
1491
+ json: Boolean(json),
1492
+ });
1493
+ });
1494
+ }