@nemus-cli/nemus 0.12.0 → 0.14.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.
- package/CHANGELOG.md +25 -0
- package/README.md +3 -1
- package/dist/commands/completion.js +106 -12
- package/install-shell-integration.sh +21 -9
- package/package.json +1 -1
- package/remove-shell-block.awk +8 -0
- package/skills/nemus/references/completion.md +7 -2
- package/src/commands/completion.test.ts +103 -2
- package/src/commands/completion.ts +121 -12
- package/uninstall.sh +16 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Shell integration now keeps its generated functions out of your shell RC
|
|
15
|
+
files.** Nemus writes them to `~/.nemus/shell-integration.sh` and adds only a
|
|
16
|
+
single guarded `source` line to `.zshrc`/`.bashrc`, so upgrades no longer
|
|
17
|
+
churn hundreds of lines in your personal shell config. Existing inline
|
|
18
|
+
installations migrate automatically without disturbing surrounding user
|
|
19
|
+
content, and reinstalls stay idempotent. Uninstall removes the generated file
|
|
20
|
+
too. Thanks to @uzikilon for the idea and original implementation (#50).
|
|
21
|
+
|
|
22
|
+
## [0.13.0] - 2026-09-02
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Shell completions now cover second-level subcommands and value arguments.**
|
|
27
|
+
`nemus config <TAB>` completes `get/set/unset/list/path/edit`, `nemus config
|
|
28
|
+
set <TAB>` completes the config **keys**, and `nemus reflect <TAB>` completes
|
|
29
|
+
`history/show` — across bash, zsh, and fish. Subcommands are auto-derived from
|
|
30
|
+
the command tree (so `suite`/`branch`/`cache`/`mcp` are covered too). (#73)
|
|
31
|
+
- **`nemus completion` infers the shell from `$SHELL`** when no argument is
|
|
32
|
+
given (`nemus completion` → emits your shell's script). An explicit
|
|
33
|
+
`bash|zsh|fish` still wins; an unknown/invalid value errors as before. (#75)
|
|
34
|
+
|
|
10
35
|
## [0.12.0] - 2026-09-02
|
|
11
36
|
|
|
12
37
|
### Added
|
package/README.md
CHANGED
|
@@ -138,7 +138,9 @@ npm install -g @nemus-cli/nemus
|
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
The postinstall step sets up optional shell integration (auto-cd into new
|
|
141
|
-
workspaces + a quick-navigate helper).
|
|
141
|
+
workspaces + a quick-navigate helper). It keeps the generated functions in
|
|
142
|
+
`~/.nemus/shell-integration.sh` and adds only a guarded source line to your
|
|
143
|
+
shell RC file, so upgrades don't churn your `.zshrc`/`.bashrc`.
|
|
142
144
|
|
|
143
145
|
### From source
|
|
144
146
|
|
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.COMPLETION_BINS = void 0;
|
|
3
|
+
exports.SHELLS = exports.COMPLETION_BINS = void 0;
|
|
4
|
+
exports.detectShell = detectShell;
|
|
4
5
|
exports.generateCompletion = generateCompletion;
|
|
5
6
|
exports.specsFromProgram = specsFromProgram;
|
|
6
7
|
exports.registerCompletionCommand = registerCompletionCommand;
|
|
7
8
|
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
8
9
|
const logger_1 = require("../utils/logger");
|
|
10
|
+
const config_schema_1 = require("../utils/config-schema");
|
|
9
11
|
/** Binaries that get completion registered (the CLI's bins). */
|
|
10
12
|
exports.COMPLETION_BINS = ['nemus', 'nem'];
|
|
13
|
+
exports.SHELLS = ['bash', 'zsh', 'fish'];
|
|
14
|
+
/** Commands whose subcommands are positional args (not nested commander commands). */
|
|
15
|
+
const POSITIONAL_SUBCOMMANDS = { reflect: ['history', 'show'] };
|
|
16
|
+
/**
|
|
17
|
+
* Infer a shell from a `$SHELL`-style path (e.g. `/bin/zsh` -> `zsh`). Returns
|
|
18
|
+
* null for an unknown/empty value. Pure + exported for testing.
|
|
19
|
+
*/
|
|
20
|
+
function detectShell(shellPath) {
|
|
21
|
+
if (!shellPath)
|
|
22
|
+
return null;
|
|
23
|
+
const base = shellPath.trim().split('/').pop()?.toLowerCase() ?? '';
|
|
24
|
+
return exports.SHELLS.find((s) => s === base) ?? null;
|
|
25
|
+
}
|
|
11
26
|
/** Every token (name + aliases) that should complete as a subcommand. */
|
|
12
27
|
function allTokens(cmds) {
|
|
13
28
|
return cmds.flatMap((c) => [c.name, ...c.aliases]);
|
|
@@ -16,23 +31,46 @@ function allTokens(cmds) {
|
|
|
16
31
|
function workspaceTokens(cmds) {
|
|
17
32
|
return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
|
|
18
33
|
}
|
|
34
|
+
/** All tokens (name + aliases) that route to a given command spec. */
|
|
35
|
+
function cmdTokens(c) {
|
|
36
|
+
return [c.name, ...c.aliases];
|
|
37
|
+
}
|
|
19
38
|
/** Escape a description for a fish single-quoted string. */
|
|
20
39
|
function fishDesc(s) {
|
|
21
40
|
return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
|
|
22
41
|
}
|
|
23
42
|
/**
|
|
24
43
|
* Generate a shell completion script. Pure (no I/O) so it's unit-tested. The
|
|
25
|
-
* generated script completes
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
44
|
+
* generated script completes, in order: top-level commands (position 1); then
|
|
45
|
+
* either workspace names (for workspace-scoped commands) or a command's own
|
|
46
|
+
* subcommands (position 2); then value completions like `config` keys
|
|
47
|
+
* (position 3). Workspace names stay dynamic via a callback into
|
|
48
|
+
* `<bin> completion --workspaces`.
|
|
29
49
|
*/
|
|
30
50
|
function generateCompletion(shell, cmds, bins = exports.COMPLETION_BINS) {
|
|
31
51
|
const commands = allTokens(cmds).join(' ');
|
|
32
52
|
const wsCommands = workspaceTokens(cmds).join(' ');
|
|
53
|
+
const withSubs = cmds.filter((c) => c.subcommands && c.subcommands.length);
|
|
54
|
+
const withArgVals = cmds.filter((c) => c.argValues && c.argValues.values.length);
|
|
33
55
|
if (shell === 'bash') {
|
|
56
|
+
const subArms = withSubs
|
|
57
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) echo "${c.subcommands.join(' ')}" ;;`)
|
|
58
|
+
.join('\n');
|
|
59
|
+
const argArms = withArgVals
|
|
60
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) case "$2" in ${c.argValues.after.join('|')}) echo "${c.argValues.values.join(' ')}" ;; esac ;;`)
|
|
61
|
+
.join('\n');
|
|
34
62
|
return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
|
|
35
63
|
# (or: nemus completion bash >> ~/.bashrc)
|
|
64
|
+
_nemus_subcmds() {
|
|
65
|
+
case "$1" in
|
|
66
|
+
${subArms}
|
|
67
|
+
esac
|
|
68
|
+
}
|
|
69
|
+
_nemus_argvals() {
|
|
70
|
+
case "$1" in
|
|
71
|
+
${argArms}
|
|
72
|
+
esac
|
|
73
|
+
}
|
|
36
74
|
_nemus_complete() {
|
|
37
75
|
local cur bin sub
|
|
38
76
|
# bash does not clear COMPREPLY between completions; reset so a stale result
|
|
@@ -54,6 +92,20 @@ _nemus_complete() {
|
|
|
54
92
|
COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
|
|
55
93
|
return 0
|
|
56
94
|
fi
|
|
95
|
+
local subs
|
|
96
|
+
subs="\$(_nemus_subcmds "\$sub")"
|
|
97
|
+
if [ -n "\$subs" ]; then
|
|
98
|
+
COMPREPLY=( \$(compgen -W "\$subs" -- "\$cur") )
|
|
99
|
+
fi
|
|
100
|
+
return 0
|
|
101
|
+
fi
|
|
102
|
+
if [ "\$COMP_CWORD" -eq 3 ]; then
|
|
103
|
+
local vals
|
|
104
|
+
vals="\$(_nemus_argvals "\${COMP_WORDS[1]}" "\${COMP_WORDS[2]}")"
|
|
105
|
+
if [ -n "\$vals" ]; then
|
|
106
|
+
COMPREPLY=( \$(compgen -W "\$vals" -- "\$cur") )
|
|
107
|
+
fi
|
|
108
|
+
return 0
|
|
57
109
|
fi
|
|
58
110
|
return 0
|
|
59
111
|
}
|
|
@@ -61,6 +113,12 @@ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
|
|
|
61
113
|
`;
|
|
62
114
|
}
|
|
63
115
|
if (shell === 'zsh') {
|
|
116
|
+
const subArms = withSubs
|
|
117
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) compadd -- ${c.subcommands.join(' ')} ;;`)
|
|
118
|
+
.join('\n');
|
|
119
|
+
const argArms = withArgVals
|
|
120
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) case \${words[3]} in ${c.argValues.after.join('|')}) compadd -- ${c.argValues.values.join(' ')} ;; esac ;;`)
|
|
121
|
+
.join('\n');
|
|
64
122
|
// Autoloaded form: save as a file named `_nemus` on your $fpath.
|
|
65
123
|
return `#compdef ${bins.join(' ')}
|
|
66
124
|
# nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
|
|
@@ -77,7 +135,17 @@ if (( CURRENT == 3 )); then
|
|
|
77
135
|
local -a _nemus_names
|
|
78
136
|
_nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
|
|
79
137
|
compadd -- $_nemus_names
|
|
138
|
+
return
|
|
80
139
|
fi
|
|
140
|
+
case $sub in
|
|
141
|
+
${subArms}
|
|
142
|
+
esac
|
|
143
|
+
return
|
|
144
|
+
fi
|
|
145
|
+
if (( CURRENT == 4 )); then
|
|
146
|
+
case \${words[2]} in
|
|
147
|
+
${argArms}
|
|
148
|
+
esac
|
|
81
149
|
fi
|
|
82
150
|
`;
|
|
83
151
|
}
|
|
@@ -86,7 +154,7 @@ fi
|
|
|
86
154
|
for (const bin of bins) {
|
|
87
155
|
lines.push(`complete -c ${bin} -f`);
|
|
88
156
|
for (const c of cmds) {
|
|
89
|
-
for (const tok of
|
|
157
|
+
for (const tok of cmdTokens(c)) {
|
|
90
158
|
lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
|
|
91
159
|
}
|
|
92
160
|
}
|
|
@@ -94,6 +162,14 @@ fi
|
|
|
94
162
|
if (wsToks) {
|
|
95
163
|
lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`);
|
|
96
164
|
}
|
|
165
|
+
// Second-level subcommands.
|
|
166
|
+
for (const c of withSubs) {
|
|
167
|
+
lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}' -a '${c.subcommands.join(' ')}'`);
|
|
168
|
+
}
|
|
169
|
+
// Third-level value completions (e.g. config keys after get/set/unset).
|
|
170
|
+
for (const c of withArgVals) {
|
|
171
|
+
lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}; and __fish_seen_subcommand_from ${c.argValues.after.join(' ')}' -a '${c.argValues.values.join(' ')}'`);
|
|
172
|
+
}
|
|
97
173
|
}
|
|
98
174
|
return lines.join('\n') + '\n';
|
|
99
175
|
}
|
|
@@ -103,11 +179,19 @@ function specsFromProgram(program) {
|
|
|
103
179
|
.map((c) => {
|
|
104
180
|
const args = c.registeredArguments ?? [];
|
|
105
181
|
const firstArg = args[0]?.name?.();
|
|
182
|
+
const name = c.name();
|
|
183
|
+
// Nested commander subcommands (config/suite/branch/cache/mcp), else a
|
|
184
|
+
// manual override for commands whose subcommands are positional (reflect).
|
|
185
|
+
const nested = c.commands.flatMap((s) => [s.name(), ...s.aliases()]).filter(Boolean);
|
|
186
|
+
const subcommands = nested.length ? nested : POSITIONAL_SUBCOMMANDS[name];
|
|
187
|
+
const argValues = name === 'config' ? { after: ['get', 'set', 'unset'], values: [...config_schema_1.CONFIG_KEYS] } : undefined;
|
|
106
188
|
return {
|
|
107
|
-
name
|
|
189
|
+
name,
|
|
108
190
|
aliases: c.aliases(),
|
|
109
191
|
takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
|
|
110
192
|
description: c.description() ?? '',
|
|
193
|
+
...(subcommands ? { subcommands } : {}),
|
|
194
|
+
...(argValues ? { argValues } : {}),
|
|
111
195
|
};
|
|
112
196
|
})
|
|
113
197
|
// The completion command itself and any hidden helper needn't clutter, but
|
|
@@ -117,7 +201,7 @@ function specsFromProgram(program) {
|
|
|
117
201
|
function registerCompletionCommand(program) {
|
|
118
202
|
program
|
|
119
203
|
.command('completion [shell]')
|
|
120
|
-
.description('Output a shell completion script (bash|zsh|fish)')
|
|
204
|
+
.description('Output a shell completion script (bash|zsh|fish; inferred from $SHELL if omitted)')
|
|
121
205
|
.option('--workspaces', 'Print workspace names (used internally by completion scripts)')
|
|
122
206
|
.action(async (shell, opts) => {
|
|
123
207
|
// Data helper the generated scripts call back into.
|
|
@@ -132,12 +216,22 @@ function registerCompletionCommand(program) {
|
|
|
132
216
|
}
|
|
133
217
|
return;
|
|
134
218
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
219
|
+
// An explicit argument wins; only fall back to $SHELL when none is given
|
|
220
|
+
// (an invalid explicit arg is an error, not a reason to guess).
|
|
221
|
+
let target = null;
|
|
222
|
+
if (shell) {
|
|
223
|
+
target = exports.SHELLS.includes(shell) ? shell : null;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
target = detectShell(process.env.SHELL);
|
|
227
|
+
if (target)
|
|
228
|
+
(0, logger_1.logInfo)(`completion: no shell given — using ${target} (from $SHELL)`);
|
|
229
|
+
}
|
|
230
|
+
if (!target) {
|
|
231
|
+
(0, logger_1.logError)(`completion: specify a shell — one of ${exports.SHELLS.join(', ')}`);
|
|
138
232
|
(0, logger_1.logError)('e.g. nemus completion bash');
|
|
139
233
|
process.exit(1);
|
|
140
234
|
}
|
|
141
|
-
process.stdout.write(generateCompletion(
|
|
235
|
+
process.stdout.write(generateCompletion(target, specsFromProgram(program)));
|
|
142
236
|
});
|
|
143
237
|
}
|
|
@@ -491,17 +491,30 @@ else
|
|
|
491
491
|
exit 1
|
|
492
492
|
fi
|
|
493
493
|
|
|
494
|
-
#
|
|
495
|
-
|
|
494
|
+
# Keep the generated functions out of the user's shell config. The same file
|
|
495
|
+
# works in both bash and zsh; the RC file only needs this guarded source line.
|
|
496
|
+
SHELL_INTEGRATION_DIR="$HOME/.nemus"
|
|
497
|
+
SHELL_INTEGRATION_FILE="$SHELL_INTEGRATION_DIR/shell-integration.sh"
|
|
498
|
+
SOURCE_LINE='[ -f "$HOME/.nemus/shell-integration.sh" ] && source "$HOME/.nemus/shell-integration.sh"'
|
|
499
|
+
|
|
500
|
+
mkdir -p "$SHELL_INTEGRATION_DIR"
|
|
501
|
+
SHELL_TMPFILE=$(mktemp "$SHELL_INTEGRATION_DIR/.shell-integration.XXXXXX")
|
|
502
|
+
printf '%s\n' "$SHELL_FUNCTION" > "$SHELL_TMPFILE"
|
|
503
|
+
mv "$SHELL_TMPFILE" "$SHELL_INTEGRATION_FILE"
|
|
504
|
+
|
|
505
|
+
append_source_line() {
|
|
506
|
+
printf '\n# Nemus - Shell Integration\n%s\n' "$SOURCE_LINE" >> "$1"
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
# Check if already installed
|
|
510
|
+
if grep -Fq "$SOURCE_LINE" "$RC_FILE" 2>/dev/null; then
|
|
496
511
|
echo "✅ Shell integration already up to date in $RC_FILE"
|
|
497
512
|
elif grep -q "# Nemus - Shell Integration" "$RC_FILE" 2>/dev/null; then
|
|
498
|
-
# Old version installed — remove it and
|
|
513
|
+
# Old inline version installed — remove it and leave only the source line
|
|
499
514
|
echo "🔄 Upgrading shell integration in $RC_FILE..."
|
|
500
515
|
TMPFILE=$(mktemp)
|
|
501
516
|
awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$RC_FILE" > "$TMPFILE"
|
|
502
|
-
|
|
503
|
-
echo "" >> "$TMPFILE"
|
|
504
|
-
echo "$SHELL_FUNCTION" >> "$TMPFILE"
|
|
517
|
+
append_source_line "$TMPFILE"
|
|
505
518
|
cp "$RC_FILE" "${RC_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
|
|
506
519
|
chmod --reference="$RC_FILE" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$RC_FILE")" "$TMPFILE" 2>/dev/null || true
|
|
507
520
|
mv "$TMPFILE" "$RC_FILE"
|
|
@@ -514,9 +527,8 @@ else
|
|
|
514
527
|
echo "📦 Backup created: ${RC_FILE}.backup.*"
|
|
515
528
|
fi
|
|
516
529
|
|
|
517
|
-
# Append
|
|
518
|
-
|
|
519
|
-
echo "$SHELL_FUNCTION" >> "$RC_FILE"
|
|
530
|
+
# Append the source line (creates the file if it doesn't exist)
|
|
531
|
+
append_source_line "$RC_FILE"
|
|
520
532
|
|
|
521
533
|
echo "✅ Shell integration installed in $RC_FILE"
|
|
522
534
|
echo ""
|
package/package.json
CHANGED
package/remove-shell-block.awk
CHANGED
|
@@ -33,6 +33,14 @@ skip {
|
|
|
33
33
|
saw_func = 1
|
|
34
34
|
next
|
|
35
35
|
}
|
|
36
|
+
if (/\.nemus\/shell-integration\.sh/ && /source/) {
|
|
37
|
+
# Current installs keep the functions in a generated file and leave only
|
|
38
|
+
# this source line in the RC file.
|
|
39
|
+
skip = 0
|
|
40
|
+
saw_func = 0
|
|
41
|
+
buf = ""
|
|
42
|
+
next
|
|
43
|
+
}
|
|
36
44
|
# Non-function line: block is over — emit any buffered lines
|
|
37
45
|
skip = 0
|
|
38
46
|
if (buf != "") printf "%s", buf
|
|
@@ -18,5 +18,10 @@ nemus completion zsh > "${fpath[1]}/_nemus"
|
|
|
18
18
|
nemus completion fish > ~/.config/fish/completions/nemus.fish
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Then restart the shell (or `source` the file).
|
|
22
|
-
|
|
21
|
+
Then restart the shell (or `source` the file). The shell argument is inferred
|
|
22
|
+
from `$SHELL` when omitted (`nemus completion`), so you usually don't need to
|
|
23
|
+
pass it; an explicit `bash|zsh|fish` always wins.
|
|
24
|
+
|
|
25
|
+
Completions cover second-level subcommands too — e.g. `nemus config <TAB>`
|
|
26
|
+
(get/set/…), `nemus config set <TAB>` (config keys), and `nemus reflect <TAB>`
|
|
27
|
+
(history/show).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { generateCompletion, specsFromProgram, CommandSpec } from './completion';
|
|
3
|
+
import { generateCompletion, specsFromProgram, detectShell, registerCompletionCommand, CommandSpec } from './completion';
|
|
4
4
|
|
|
5
5
|
const specs: CommandSpec[] = [
|
|
6
6
|
{ name: 'list', aliases: ['l'], takesWorkspace: false, description: 'List workspaces' },
|
|
@@ -8,6 +8,49 @@ const specs: CommandSpec[] = [
|
|
|
8
8
|
{ name: 'doctor', aliases: ['doc'], takesWorkspace: true, description: 'Health checks' },
|
|
9
9
|
];
|
|
10
10
|
|
|
11
|
+
// A spec set exercising second-level subcommands and third-level value completion.
|
|
12
|
+
const subSpecs: CommandSpec[] = [
|
|
13
|
+
{
|
|
14
|
+
name: 'config', aliases: [], takesWorkspace: false, description: 'Configure',
|
|
15
|
+
subcommands: ['get', 'set', 'list', 'ls'],
|
|
16
|
+
argValues: { after: ['get', 'set'], values: ['githubOrg', 'cloneProtocol'] },
|
|
17
|
+
},
|
|
18
|
+
{ name: 'reflect', aliases: ['retro'], takesWorkspace: false, description: 'Retrospective', subcommands: ['history', 'show'] },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe('detectShell', () => {
|
|
22
|
+
it('infers the shell from a $SHELL-style path', () => {
|
|
23
|
+
expect(detectShell('/bin/zsh')).toBe('zsh');
|
|
24
|
+
expect(detectShell('/usr/bin/fish')).toBe('fish');
|
|
25
|
+
expect(detectShell('/bin/bash')).toBe('bash');
|
|
26
|
+
});
|
|
27
|
+
it('returns null for unknown/empty values', () => {
|
|
28
|
+
expect(detectShell('/bin/tcsh')).toBeNull();
|
|
29
|
+
expect(detectShell('')).toBeNull();
|
|
30
|
+
expect(detectShell(undefined)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('generateCompletion — second/third level', () => {
|
|
35
|
+
it('bash completes subcommands and config-key values, and stays valid', () => {
|
|
36
|
+
const s = generateCompletion('bash', subSpecs);
|
|
37
|
+
expect(s).toContain('config) echo "get set list ls" ;;');
|
|
38
|
+
expect(s).toContain('reflect|retro) echo "history show" ;;');
|
|
39
|
+
expect(s).toContain('case "$2" in get|set) echo "githubOrg cloneProtocol"');
|
|
40
|
+
});
|
|
41
|
+
it('zsh completes subcommands and values at CURRENT 3/4', () => {
|
|
42
|
+
const s = generateCompletion('zsh', subSpecs);
|
|
43
|
+
expect(s).toContain('config) compadd -- get set list ls ;;');
|
|
44
|
+
expect(s).toContain('reflect|retro) compadd -- history show ;;');
|
|
45
|
+
expect(s).toContain('case ${words[3]} in get|set) compadd -- githubOrg cloneProtocol');
|
|
46
|
+
});
|
|
47
|
+
it('fish emits seen-subcommand conditions for subcommands and values', () => {
|
|
48
|
+
const s = generateCompletion('fish', subSpecs);
|
|
49
|
+
expect(s).toContain("-n '__fish_seen_subcommand_from config' -a 'get set list ls'");
|
|
50
|
+
expect(s).toContain("__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
11
54
|
describe('generateCompletion — bash', () => {
|
|
12
55
|
const s = generateCompletion('bash', specs);
|
|
13
56
|
it('resets COMPREPLY, lists tokens, and registers both bins', () => {
|
|
@@ -43,6 +86,42 @@ describe('generateCompletion — fish', () => {
|
|
|
43
86
|
});
|
|
44
87
|
});
|
|
45
88
|
|
|
89
|
+
describe('registerCompletionCommand — stdout hygiene on inferred shell', () => {
|
|
90
|
+
it('writes ONLY the script to stdout; the $SHELL inference note goes to stderr', async () => {
|
|
91
|
+
// The documented install path is `nemus completion zsh > _nemus`, so if the
|
|
92
|
+
// inference note leaked to stdout it would corrupt the generated script.
|
|
93
|
+
const program = new Command();
|
|
94
|
+
program.exitOverride();
|
|
95
|
+
program.command('list').description('list');
|
|
96
|
+
registerCompletionCommand(program);
|
|
97
|
+
|
|
98
|
+
const stdoutChunks: string[] = [];
|
|
99
|
+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((c: unknown) => {
|
|
100
|
+
stdoutChunks.push(String(c));
|
|
101
|
+
return true;
|
|
102
|
+
}) as typeof process.stdout.write);
|
|
103
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
104
|
+
const prevShell = process.env.SHELL;
|
|
105
|
+
process.env.SHELL = '/bin/zsh';
|
|
106
|
+
|
|
107
|
+
let errCalls: string[] = [];
|
|
108
|
+
try {
|
|
109
|
+
await program.parseAsync(['node', 'nemus', 'completion']);
|
|
110
|
+
errCalls = errSpy.mock.calls.map((c) => c.map(String).join(' '));
|
|
111
|
+
} finally {
|
|
112
|
+
if (prevShell === undefined) delete process.env.SHELL;
|
|
113
|
+
else process.env.SHELL = prevShell;
|
|
114
|
+
stdoutSpy.mockRestore();
|
|
115
|
+
errSpy.mockRestore();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const stdout = stdoutChunks.join('');
|
|
119
|
+
expect(stdout.startsWith('#compdef')).toBe(true); // the zsh script, nothing prepended
|
|
120
|
+
expect(stdout).not.toContain('no shell given'); // the note never reached stdout
|
|
121
|
+
expect(errCalls.some((l) => l.includes('no shell given'))).toBe(true); // it went to stderr
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
46
125
|
describe('specsFromProgram', () => {
|
|
47
126
|
it('detects workspace args + aliases from a commander program', () => {
|
|
48
127
|
const program = new Command();
|
|
@@ -57,4 +136,26 @@ describe('specsFromProgram', () => {
|
|
|
57
136
|
expect(byName.list.takesWorkspace).toBe(false);
|
|
58
137
|
expect(byName.create.takesWorkspace).toBe(false);
|
|
59
138
|
});
|
|
139
|
+
|
|
140
|
+
it('derives nested subcommands, the reflect override, and config keys', () => {
|
|
141
|
+
const program = new Command();
|
|
142
|
+
const cfg = program.command('config').description('config');
|
|
143
|
+
cfg.command('get').description('get');
|
|
144
|
+
cfg.command('set').description('set');
|
|
145
|
+
program.command('reflect').description('reflect'); // positional subcommands
|
|
146
|
+
program.command('list').description('list');
|
|
147
|
+
|
|
148
|
+
const byName = Object.fromEntries(specsFromProgram(program).map((s) => [s.name, s]));
|
|
149
|
+
// nested commander subcommands are auto-derived
|
|
150
|
+
expect(byName.config.subcommands).toContain('get');
|
|
151
|
+
expect(byName.config.subcommands).toContain('set');
|
|
152
|
+
// reflect's positional subcommands come from the override
|
|
153
|
+
expect(byName.reflect.subcommands).toEqual(['history', 'show']);
|
|
154
|
+
// config gets key-value completion from the authoritative CONFIG_KEYS
|
|
155
|
+
expect(byName.config.argValues?.after).toEqual(['get', 'set', 'unset']);
|
|
156
|
+
expect(byName.config.argValues?.values).toContain('githubOrg');
|
|
157
|
+
// a plain command has neither
|
|
158
|
+
expect(byName.list.subcommands).toBeUndefined();
|
|
159
|
+
expect(byName.list.argValues).toBeUndefined();
|
|
160
|
+
});
|
|
60
161
|
});
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { listWorkspaces } from '../utils/workspace-meta';
|
|
3
|
-
import { logError } from '../utils/logger';
|
|
3
|
+
import { logError, logInfo } from '../utils/logger';
|
|
4
|
+
import { CONFIG_KEYS } from '../utils/config-schema';
|
|
4
5
|
|
|
5
6
|
/** Binaries that get completion registered (the CLI's bins). */
|
|
6
7
|
export const COMPLETION_BINS = ['nemus', 'nem'];
|
|
7
8
|
|
|
8
9
|
export type Shell = 'bash' | 'zsh' | 'fish';
|
|
10
|
+
export const SHELLS: Shell[] = ['bash', 'zsh', 'fish'];
|
|
11
|
+
|
|
12
|
+
/** Commands whose subcommands are positional args (not nested commander commands). */
|
|
13
|
+
const POSITIONAL_SUBCOMMANDS: Record<string, string[]> = { reflect: ['history', 'show'] };
|
|
9
14
|
|
|
10
15
|
/** One top-level command, distilled to what a completion script needs. */
|
|
11
16
|
export interface CommandSpec {
|
|
@@ -14,6 +19,20 @@ export interface CommandSpec {
|
|
|
14
19
|
/** True if its first positional argument is a workspace name. */
|
|
15
20
|
takesWorkspace: boolean;
|
|
16
21
|
description: string;
|
|
22
|
+
/** Second-level tokens (e.g. `config get`, `reflect history`), if any. */
|
|
23
|
+
subcommands?: string[];
|
|
24
|
+
/** Third-level value completion: for these subcommands, complete `values`. */
|
|
25
|
+
argValues?: { after: string[]; values: string[] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Infer a shell from a `$SHELL`-style path (e.g. `/bin/zsh` -> `zsh`). Returns
|
|
30
|
+
* null for an unknown/empty value. Pure + exported for testing.
|
|
31
|
+
*/
|
|
32
|
+
export function detectShell(shellPath: string | undefined): Shell | null {
|
|
33
|
+
if (!shellPath) return null;
|
|
34
|
+
const base = shellPath.trim().split('/').pop()?.toLowerCase() ?? '';
|
|
35
|
+
return SHELLS.find((s) => s === base) ?? null;
|
|
17
36
|
}
|
|
18
37
|
|
|
19
38
|
/** Every token (name + aliases) that should complete as a subcommand. */
|
|
@@ -26,6 +45,11 @@ function workspaceTokens(cmds: CommandSpec[]): string[] {
|
|
|
26
45
|
return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
|
|
27
46
|
}
|
|
28
47
|
|
|
48
|
+
/** All tokens (name + aliases) that route to a given command spec. */
|
|
49
|
+
function cmdTokens(c: CommandSpec): string[] {
|
|
50
|
+
return [c.name, ...c.aliases];
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
/** Escape a description for a fish single-quoted string. */
|
|
30
54
|
function fishDesc(s: string): string {
|
|
31
55
|
return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
|
|
@@ -33,18 +57,40 @@ function fishDesc(s: string): string {
|
|
|
33
57
|
|
|
34
58
|
/**
|
|
35
59
|
* Generate a shell completion script. Pure (no I/O) so it's unit-tested. The
|
|
36
|
-
* generated script completes
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
60
|
+
* generated script completes, in order: top-level commands (position 1); then
|
|
61
|
+
* either workspace names (for workspace-scoped commands) or a command's own
|
|
62
|
+
* subcommands (position 2); then value completions like `config` keys
|
|
63
|
+
* (position 3). Workspace names stay dynamic via a callback into
|
|
64
|
+
* `<bin> completion --workspaces`.
|
|
40
65
|
*/
|
|
41
66
|
export function generateCompletion(shell: Shell, cmds: CommandSpec[], bins: string[] = COMPLETION_BINS): string {
|
|
42
67
|
const commands = allTokens(cmds).join(' ');
|
|
43
68
|
const wsCommands = workspaceTokens(cmds).join(' ');
|
|
69
|
+
const withSubs = cmds.filter((c) => c.subcommands && c.subcommands.length);
|
|
70
|
+
const withArgVals = cmds.filter((c) => c.argValues && c.argValues.values.length);
|
|
44
71
|
|
|
45
72
|
if (shell === 'bash') {
|
|
73
|
+
const subArms = withSubs
|
|
74
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) echo "${c.subcommands!.join(' ')}" ;;`)
|
|
75
|
+
.join('\n');
|
|
76
|
+
const argArms = withArgVals
|
|
77
|
+
.map(
|
|
78
|
+
(c) =>
|
|
79
|
+
` ${cmdTokens(c).join('|')}) case "$2" in ${c.argValues!.after.join('|')}) echo "${c.argValues!.values.join(' ')}" ;; esac ;;`,
|
|
80
|
+
)
|
|
81
|
+
.join('\n');
|
|
46
82
|
return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
|
|
47
83
|
# (or: nemus completion bash >> ~/.bashrc)
|
|
84
|
+
_nemus_subcmds() {
|
|
85
|
+
case "$1" in
|
|
86
|
+
${subArms}
|
|
87
|
+
esac
|
|
88
|
+
}
|
|
89
|
+
_nemus_argvals() {
|
|
90
|
+
case "$1" in
|
|
91
|
+
${argArms}
|
|
92
|
+
esac
|
|
93
|
+
}
|
|
48
94
|
_nemus_complete() {
|
|
49
95
|
local cur bin sub
|
|
50
96
|
# bash does not clear COMPREPLY between completions; reset so a stale result
|
|
@@ -66,6 +112,20 @@ _nemus_complete() {
|
|
|
66
112
|
COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
|
|
67
113
|
return 0
|
|
68
114
|
fi
|
|
115
|
+
local subs
|
|
116
|
+
subs="\$(_nemus_subcmds "\$sub")"
|
|
117
|
+
if [ -n "\$subs" ]; then
|
|
118
|
+
COMPREPLY=( \$(compgen -W "\$subs" -- "\$cur") )
|
|
119
|
+
fi
|
|
120
|
+
return 0
|
|
121
|
+
fi
|
|
122
|
+
if [ "\$COMP_CWORD" -eq 3 ]; then
|
|
123
|
+
local vals
|
|
124
|
+
vals="\$(_nemus_argvals "\${COMP_WORDS[1]}" "\${COMP_WORDS[2]}")"
|
|
125
|
+
if [ -n "\$vals" ]; then
|
|
126
|
+
COMPREPLY=( \$(compgen -W "\$vals" -- "\$cur") )
|
|
127
|
+
fi
|
|
128
|
+
return 0
|
|
69
129
|
fi
|
|
70
130
|
return 0
|
|
71
131
|
}
|
|
@@ -74,6 +134,15 @@ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
|
|
|
74
134
|
}
|
|
75
135
|
|
|
76
136
|
if (shell === 'zsh') {
|
|
137
|
+
const subArms = withSubs
|
|
138
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) compadd -- ${c.subcommands!.join(' ')} ;;`)
|
|
139
|
+
.join('\n');
|
|
140
|
+
const argArms = withArgVals
|
|
141
|
+
.map(
|
|
142
|
+
(c) =>
|
|
143
|
+
` ${cmdTokens(c).join('|')}) case \${words[3]} in ${c.argValues!.after.join('|')}) compadd -- ${c.argValues!.values.join(' ')} ;; esac ;;`,
|
|
144
|
+
)
|
|
145
|
+
.join('\n');
|
|
77
146
|
// Autoloaded form: save as a file named `_nemus` on your $fpath.
|
|
78
147
|
return `#compdef ${bins.join(' ')}
|
|
79
148
|
# nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
|
|
@@ -90,7 +159,17 @@ if (( CURRENT == 3 )); then
|
|
|
90
159
|
local -a _nemus_names
|
|
91
160
|
_nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
|
|
92
161
|
compadd -- $_nemus_names
|
|
162
|
+
return
|
|
93
163
|
fi
|
|
164
|
+
case $sub in
|
|
165
|
+
${subArms}
|
|
166
|
+
esac
|
|
167
|
+
return
|
|
168
|
+
fi
|
|
169
|
+
if (( CURRENT == 4 )); then
|
|
170
|
+
case \${words[2]} in
|
|
171
|
+
${argArms}
|
|
172
|
+
esac
|
|
94
173
|
fi
|
|
95
174
|
`;
|
|
96
175
|
}
|
|
@@ -100,7 +179,7 @@ fi
|
|
|
100
179
|
for (const bin of bins) {
|
|
101
180
|
lines.push(`complete -c ${bin} -f`);
|
|
102
181
|
for (const c of cmds) {
|
|
103
|
-
for (const tok of
|
|
182
|
+
for (const tok of cmdTokens(c)) {
|
|
104
183
|
lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
|
|
105
184
|
}
|
|
106
185
|
}
|
|
@@ -110,6 +189,18 @@ fi
|
|
|
110
189
|
`complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`,
|
|
111
190
|
);
|
|
112
191
|
}
|
|
192
|
+
// Second-level subcommands.
|
|
193
|
+
for (const c of withSubs) {
|
|
194
|
+
lines.push(
|
|
195
|
+
`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}' -a '${c.subcommands!.join(' ')}'`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// Third-level value completions (e.g. config keys after get/set/unset).
|
|
199
|
+
for (const c of withArgVals) {
|
|
200
|
+
lines.push(
|
|
201
|
+
`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}; and __fish_seen_subcommand_from ${c.argValues!.after.join(' ')}' -a '${c.argValues!.values.join(' ')}'`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
113
204
|
}
|
|
114
205
|
return lines.join('\n') + '\n';
|
|
115
206
|
}
|
|
@@ -120,11 +211,20 @@ export function specsFromProgram(program: Command): CommandSpec[] {
|
|
|
120
211
|
.map((c) => {
|
|
121
212
|
const args = (c as any).registeredArguments ?? [];
|
|
122
213
|
const firstArg: string | undefined = args[0]?.name?.();
|
|
214
|
+
const name = c.name();
|
|
215
|
+
// Nested commander subcommands (config/suite/branch/cache/mcp), else a
|
|
216
|
+
// manual override for commands whose subcommands are positional (reflect).
|
|
217
|
+
const nested = c.commands.flatMap((s) => [s.name(), ...s.aliases()]).filter(Boolean);
|
|
218
|
+
const subcommands = nested.length ? nested : POSITIONAL_SUBCOMMANDS[name];
|
|
219
|
+
const argValues =
|
|
220
|
+
name === 'config' ? { after: ['get', 'set', 'unset'], values: [...CONFIG_KEYS] } : undefined;
|
|
123
221
|
return {
|
|
124
|
-
name
|
|
222
|
+
name,
|
|
125
223
|
aliases: c.aliases(),
|
|
126
224
|
takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
|
|
127
225
|
description: c.description() ?? '',
|
|
226
|
+
...(subcommands ? { subcommands } : {}),
|
|
227
|
+
...(argValues ? { argValues } : {}),
|
|
128
228
|
};
|
|
129
229
|
})
|
|
130
230
|
// The completion command itself and any hidden helper needn't clutter, but
|
|
@@ -135,7 +235,7 @@ export function specsFromProgram(program: Command): CommandSpec[] {
|
|
|
135
235
|
export function registerCompletionCommand(program: Command) {
|
|
136
236
|
program
|
|
137
237
|
.command('completion [shell]')
|
|
138
|
-
.description('Output a shell completion script (bash|zsh|fish)')
|
|
238
|
+
.description('Output a shell completion script (bash|zsh|fish; inferred from $SHELL if omitted)')
|
|
139
239
|
.option('--workspaces', 'Print workspace names (used internally by completion scripts)')
|
|
140
240
|
.action(async (shell: string | undefined, opts: { workspaces?: boolean }) => {
|
|
141
241
|
// Data helper the generated scripts call back into.
|
|
@@ -149,12 +249,21 @@ export function registerCompletionCommand(program: Command) {
|
|
|
149
249
|
return;
|
|
150
250
|
}
|
|
151
251
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
252
|
+
// An explicit argument wins; only fall back to $SHELL when none is given
|
|
253
|
+
// (an invalid explicit arg is an error, not a reason to guess).
|
|
254
|
+
let target: Shell | null = null;
|
|
255
|
+
if (shell) {
|
|
256
|
+
target = SHELLS.includes(shell as Shell) ? (shell as Shell) : null;
|
|
257
|
+
} else {
|
|
258
|
+
target = detectShell(process.env.SHELL);
|
|
259
|
+
if (target) logInfo(`completion: no shell given — using ${target} (from $SHELL)`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!target) {
|
|
263
|
+
logError(`completion: specify a shell — one of ${SHELLS.join(', ')}`);
|
|
155
264
|
logError('e.g. nemus completion bash');
|
|
156
265
|
process.exit(1);
|
|
157
266
|
}
|
|
158
|
-
process.stdout.write(generateCompletion(
|
|
267
|
+
process.stdout.write(generateCompletion(target, specsFromProgram(program)));
|
|
159
268
|
});
|
|
160
269
|
}
|
package/uninstall.sh
CHANGED
|
@@ -38,7 +38,7 @@ echo -e "${YELLOW}This will NOT remove:${NC}"
|
|
|
38
38
|
echo " • Your workspaces ($HOME/workspaces/)"
|
|
39
39
|
echo " • Cache files (~/.workspace-manager-cache/)"
|
|
40
40
|
echo " • Configuration files (~/.workspace-manager-claude-config.json)"
|
|
41
|
-
echo " • Shell integration (from .zshrc or .bashrc)"
|
|
41
|
+
echo " • Shell integration (from .zshrc or .bashrc and ~/.nemus/)"
|
|
42
42
|
echo " • ghq (if installed)"
|
|
43
43
|
echo ""
|
|
44
44
|
|
|
@@ -85,8 +85,11 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
|
85
85
|
if grep -q "# Nemus - Shell Integration" "$HOME/.zshrc"; then
|
|
86
86
|
# Create backup
|
|
87
87
|
cp "$HOME/.zshrc" "$HOME/.zshrc.backup.$(date +%Y%m%d_%H%M%S)"
|
|
88
|
-
# Remove the
|
|
89
|
-
|
|
88
|
+
# Remove the block/source line (awk handles both formats safely)
|
|
89
|
+
TMPFILE=$(mktemp)
|
|
90
|
+
awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$HOME/.zshrc" > "$TMPFILE"
|
|
91
|
+
chmod --reference="$HOME/.zshrc" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$HOME/.zshrc")" "$TMPFILE" 2>/dev/null || true
|
|
92
|
+
mv "$TMPFILE" "$HOME/.zshrc"
|
|
90
93
|
print_success "Removed from .zshrc (backup created)"
|
|
91
94
|
fi
|
|
92
95
|
fi
|
|
@@ -96,11 +99,19 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
|
96
99
|
if grep -q "# Nemus - Shell Integration" "$HOME/.bashrc"; then
|
|
97
100
|
# Create backup
|
|
98
101
|
cp "$HOME/.bashrc" "$HOME/.bashrc.backup.$(date +%Y%m%d_%H%M%S)"
|
|
99
|
-
# Remove the
|
|
100
|
-
|
|
102
|
+
# Remove the block/source line (awk handles both formats safely)
|
|
103
|
+
TMPFILE=$(mktemp)
|
|
104
|
+
awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$HOME/.bashrc" > "$TMPFILE"
|
|
105
|
+
chmod --reference="$HOME/.bashrc" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$HOME/.bashrc")" "$TMPFILE" 2>/dev/null || true
|
|
106
|
+
mv "$TMPFILE" "$HOME/.bashrc"
|
|
101
107
|
print_success "Removed from .bashrc (backup created)"
|
|
102
108
|
fi
|
|
103
109
|
fi
|
|
110
|
+
|
|
111
|
+
if [ -f "$HOME/.nemus/shell-integration.sh" ]; then
|
|
112
|
+
rm -f "$HOME/.nemus/shell-integration.sh"
|
|
113
|
+
print_success "Removed ~/.nemus/shell-integration.sh"
|
|
114
|
+
fi
|
|
104
115
|
fi
|
|
105
116
|
|
|
106
117
|
# Optionally remove cache
|