@gmickel/gno 0.9.1 → 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.
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Path resolution for skill installation.
3
3
  * Supports Claude Code and Codex targets with project/user scopes.
4
+ * Note: OpenCode and Amp use the same .claude path as Claude Code.
4
5
  *
5
6
  * @module src/cli/commands/skill/paths
6
7
  */
@@ -28,6 +29,8 @@ export const ENV_CODEX_SKILLS_DIR = "CODEX_SKILLS_DIR";
28
29
  export type SkillScope = "project" | "user";
29
30
  export type SkillTarget = "claude" | "codex";
30
31
 
32
+ export const SKILL_TARGETS: SkillTarget[] = ["claude", "codex"];
33
+
31
34
  export interface SkillPathOptions {
32
35
  scope: SkillScope;
33
36
  target: SkillTarget;
@@ -53,13 +56,27 @@ export interface SkillPaths {
53
56
  /** Skill name for the gno skill directory */
54
57
  export const SKILL_NAME = "gno";
55
58
 
56
- /** Directory name for skills within agent config */
57
- const SKILLS_SUBDIR = "skills";
59
+ /** Path configuration per target */
60
+ interface TargetPathConfig {
61
+ projectBase: string; // e.g., ".claude"
62
+ userBase: string; // e.g., ".claude" (joined with homedir) or ".config/opencode"
63
+ skillsSubdir: string; // e.g., "skills" or "skill"
64
+ envVar: string;
65
+ }
58
66
 
59
- /** Agent config directory names */
60
- const AGENT_DIRS: Record<SkillTarget, string> = {
61
- claude: ".claude",
62
- codex: ".codex",
67
+ const TARGET_CONFIGS: Record<SkillTarget, TargetPathConfig> = {
68
+ claude: {
69
+ projectBase: ".claude",
70
+ userBase: ".claude",
71
+ skillsSubdir: "skills",
72
+ envVar: ENV_CLAUDE_SKILLS_DIR,
73
+ },
74
+ codex: {
75
+ projectBase: ".codex",
76
+ userBase: ".codex",
77
+ skillsSubdir: "skills",
78
+ envVar: ENV_CODEX_SKILLS_DIR,
79
+ },
63
80
  };
64
81
 
65
82
  // ─────────────────────────────────────────────────────────────────────────────
@@ -71,19 +88,15 @@ const AGENT_DIRS: Record<SkillTarget, string> = {
71
88
  */
72
89
  export function resolveSkillPaths(opts: SkillPathOptions): SkillPaths {
73
90
  const { scope, target, cwd, homeDir } = opts;
91
+ const config = TARGET_CONFIGS[target];
74
92
 
75
93
  // Check for env overrides first
76
- const envOverride =
77
- target === "claude"
78
- ? process.env[ENV_CLAUDE_SKILLS_DIR]
79
- : process.env[ENV_CODEX_SKILLS_DIR];
94
+ const envOverride = process.env[config.envVar];
80
95
 
81
96
  if (envOverride) {
82
97
  // Require absolute path for security
83
98
  if (!isAbsolute(envOverride)) {
84
- throw new Error(
85
- `${target === "claude" ? ENV_CLAUDE_SKILLS_DIR : ENV_CODEX_SKILLS_DIR} must be an absolute path`
86
- );
99
+ throw new Error(`${config.envVar} must be an absolute path`);
87
100
  }
88
101
  const skillsDir = normalize(envOverride);
89
102
  return {
@@ -94,18 +107,17 @@ export function resolveSkillPaths(opts: SkillPathOptions): SkillPaths {
94
107
  }
95
108
 
96
109
  // Resolve base directory
97
- const agentDir = AGENT_DIRS[target];
98
110
  let base: string;
99
111
 
100
112
  if (scope === "user") {
101
113
  const home = homeDir ?? process.env[ENV_SKILLS_HOME_OVERRIDE] ?? homedir();
102
- base = join(home, agentDir);
114
+ base = join(home, config.userBase);
103
115
  } else {
104
116
  const projectRoot = cwd ?? process.cwd();
105
- base = join(projectRoot, agentDir);
117
+ base = join(projectRoot, config.projectBase);
106
118
  }
107
119
 
108
- const skillsDir = join(base, SKILLS_SUBDIR);
120
+ const skillsDir = join(base, config.skillsSubdir);
109
121
  const gnoDir = join(skillsDir, SKILL_NAME);
110
122
 
111
123
  return { base, skillsDir, gnoDir };
@@ -120,8 +132,7 @@ export function resolveAllPaths(
120
132
  overrides?: { cwd?: string; homeDir?: string }
121
133
  ): Array<{ scope: SkillScope; target: SkillTarget; paths: SkillPaths }> {
122
134
  const scopes: SkillScope[] = scope === "all" ? ["project", "user"] : [scope];
123
- const targets: SkillTarget[] =
124
- target === "all" ? ["claude", "codex"] : [target];
135
+ const targets: SkillTarget[] = target === "all" ? SKILL_TARGETS : [target];
125
136
 
126
137
  const results: Array<{
127
138
  scope: SkillScope;
@@ -147,11 +158,16 @@ export function resolveAllPaths(
147
158
  // ─────────────────────────────────────────────────────────────────────────────
148
159
 
149
160
  /**
150
- * Expected path suffix for gno skill directory.
151
- * Platform-aware (handles Windows backslash).
161
+ * Get expected path suffixes for gno skill directory.
162
+ * Returns all valid suffixes since different targets use different subdir names.
152
163
  */
153
- function getExpectedSuffix(): string {
154
- return `${sep}${SKILLS_SUBDIR}${sep}${SKILL_NAME}`;
164
+ function getExpectedSuffixes(): string[] {
165
+ const subdirs = new Set(
166
+ Object.values(TARGET_CONFIGS).map((c) => c.skillsSubdir)
167
+ );
168
+ return Array.from(subdirs).map(
169
+ (subdir) => `${sep}${subdir}${sep}${SKILL_NAME}`
170
+ );
155
171
  }
156
172
 
157
173
  /**
@@ -164,11 +180,14 @@ export function validatePathForDeletion(
164
180
  ): string | null {
165
181
  const normalized = normalize(destDir);
166
182
  const normalizedBase = normalize(base);
167
- const expectedSuffix = getExpectedSuffix();
168
-
169
- // Must end with /skills/gno (or \skills\gno on Windows)
170
- if (!normalized.endsWith(expectedSuffix)) {
171
- return `Path does not end with expected suffix (${expectedSuffix})`;
183
+ const expectedSuffixes = getExpectedSuffixes();
184
+
185
+ // Must end with /skills/gno or /skill/gno (platform-aware)
186
+ const hasValidSuffix = expectedSuffixes.some((suffix) =>
187
+ normalized.endsWith(suffix)
188
+ );
189
+ if (!hasValidSuffix) {
190
+ return `Path does not end with expected suffix (${expectedSuffixes.join(" or ")})`;
172
191
  }
173
192
 
174
193
  // Minimum length sanity check
@@ -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