@f5xc-salesdemos/xcsh 18.25.2 → 18.27.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.25.2",
4
+ "version": "18.27.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "dependencies": {
48
48
  "@agentclientprotocol/sdk": "0.16.1",
49
49
  "@mozilla/readability": "^0.6",
50
- "@f5xc-salesdemos/xcsh-stats": "18.25.2",
51
- "@f5xc-salesdemos/pi-agent-core": "18.25.2",
52
- "@f5xc-salesdemos/pi-ai": "18.25.2",
53
- "@f5xc-salesdemos/pi-natives": "18.25.2",
54
- "@f5xc-salesdemos/pi-tui": "18.25.2",
55
- "@f5xc-salesdemos/pi-utils": "18.25.2",
50
+ "@f5xc-salesdemos/xcsh-stats": "18.27.0",
51
+ "@f5xc-salesdemos/pi-agent-core": "18.27.0",
52
+ "@f5xc-salesdemos/pi-ai": "18.27.0",
53
+ "@f5xc-salesdemos/pi-natives": "18.27.0",
54
+ "@f5xc-salesdemos/pi-tui": "18.27.0",
55
+ "@f5xc-salesdemos/pi-utils": "18.27.0",
56
56
  "@sinclair/typebox": "^0.34",
57
57
  "@xterm/headless": "^6.0",
58
58
  "ajv": "^8.18",
@@ -122,7 +122,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<
122
122
  if (cmd.subcommands) {
123
123
  return {
124
124
  ...cmd,
125
- getArgumentCompletions: buildArgumentCompletions(cmd.subcommands),
125
+ getArgumentCompletions: cmd.getArgumentCompletions ?? buildArgumentCompletions(cmd.subcommands),
126
126
  getInlineHint: buildSubcommandInlineHint(cmd.subcommands),
127
127
  };
128
128
  }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.25.2",
21
- "commit": "abd4234179059ccda04559a3a8611a93ce52f98b",
22
- "shortCommit": "abd4234",
20
+ "version": "18.27.0",
21
+ "commit": "5c365003b8532b2252bde64eeb9cc751e927752d",
22
+ "shortCommit": "5c36500",
23
23
  "branch": "main",
24
- "tag": "v18.25.2",
25
- "commitDate": "2026-04-29T07:33:34Z",
26
- "buildDate": "2026-04-29T07:59:50.398Z",
24
+ "tag": "v18.27.0",
25
+ "commitDate": "2026-04-29T19:40:39Z",
26
+ "buildDate": "2026-04-29T20:01:37.365Z",
27
27
  "dirty": false,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
30
30
  "repoSlug": "f5xc-salesdemos/xcsh",
31
- "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/abd4234179059ccda04559a3a8611a93ce52f98b",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.25.2"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/5c365003b8532b2252bde64eeb9cc751e927752d",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.27.0"
33
33
  };
@@ -80,14 +80,16 @@ export async function handleContextCommand(
80
80
  case "remove":
81
81
  case "clear":
82
82
  return handleEnvUnset(ctx, service, arg);
83
- default:
84
- // Natural language fallback: detect KEY=VALUE patterns
83
+ default: {
84
+ ENV_SET_PATTERN.lastIndex = 0;
85
85
  if (ENV_SET_PATTERN.test(command.args)) {
86
86
  return handleEnvSet(ctx, service, command.args);
87
87
  }
88
- ctx.showError(
89
- `Unknown subcommand: ${sub}. Use /context list|activate|validate|show|status|create|delete|rename|export|import|namespace|env|set|unset`,
90
- );
88
+ if (sub === "-") {
89
+ return handleActivatePrevious(ctx, service);
90
+ }
91
+ return handleDirectSwitch(ctx, service, sub!);
92
+ }
91
93
  }
92
94
  }
93
95
 
@@ -161,6 +163,36 @@ async function handleActivate(ctx: CommandContext, service: ContextService, name
161
163
  }
162
164
  }
163
165
 
166
+ async function handleDirectSwitch(ctx: CommandContext, service: ContextService, name: string): Promise<void> {
167
+ try {
168
+ await service.activate(name);
169
+ ctx.statusLine?.invalidate();
170
+ ctx.updateEditorTopBorder?.();
171
+ ctx.ui?.requestRender();
172
+ return handleShow(ctx, service);
173
+ } catch (err) {
174
+ if (err instanceof ContextError && err.message.includes("not found")) {
175
+ ctx.showError(
176
+ `Context '${name}' not found. Run /context list to see available contexts, or /context create ${name} <url> <token> to create one.`,
177
+ );
178
+ return;
179
+ }
180
+ ctx.showError(err instanceof ContextError ? err.message : String(err));
181
+ }
182
+ }
183
+
184
+ async function handleActivatePrevious(ctx: CommandContext, service: ContextService): Promise<void> {
185
+ try {
186
+ await service.activatePrevious();
187
+ ctx.statusLine?.invalidate();
188
+ ctx.updateEditorTopBorder?.();
189
+ ctx.ui?.requestRender();
190
+ return handleShow(ctx, service);
191
+ } catch (err) {
192
+ ctx.showError(err instanceof ContextError ? err.message : String(err));
193
+ }
194
+ }
195
+
164
196
  function isSensitiveKey(key: string): boolean {
165
197
  return SECRET_ENV_PATTERNS.test(key);
166
198
  }
@@ -498,6 +530,7 @@ const ENV_SET_PATTERN = /([A-Za-z_][A-Za-z0-9_]*)=(\S+)/g;
498
530
 
499
531
  function parseEnvPairs(text: string): Record<string, string> {
500
532
  const vars: Record<string, string> = {};
533
+ ENV_SET_PATTERN.lastIndex = 0;
501
534
  for (const match of text.matchAll(ENV_SET_PATTERN)) {
502
535
  vars[match[1]] = match[2];
503
536
  }
@@ -72,6 +72,7 @@ export interface BuiltinSlashCommand {
72
72
  subcommands?: SubcommandDef[];
73
73
  /** Static inline hint when command takes a simple argument (no subcommands). */
74
74
  inlineHint?: string;
75
+ getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null;
75
76
  }
76
77
 
77
78
  interface ParsedBuiltinSlashCommand {
@@ -129,6 +130,235 @@ const shutdownHandler = (_command: ParsedBuiltinSlashCommand, runtime: BuiltinSl
129
130
  void runtime.ctx.shutdown();
130
131
  };
131
132
 
133
+ const CONTEXT_SUBCOMMANDS: SubcommandDef[] = [
134
+ { name: "list", description: "List all contexts" },
135
+ {
136
+ name: "activate",
137
+ description: "Switch to a named context",
138
+ usage: "<name>",
139
+ getArgumentCompletions(prefix: string) {
140
+ if (prefix.includes(" ")) return null;
141
+ const svc = tryGetContextService();
142
+ if (!svc) return null;
143
+ const lower = prefix.toLowerCase();
144
+ const items = svc
145
+ .listContextNamesCached()
146
+ .filter(n => n.toLowerCase().startsWith(lower))
147
+ .map(n => {
148
+ const hint = svc.getContextHint(n);
149
+ const parts: string[] = [];
150
+ if (hint?.apiUrl) parts.push(hint.apiUrl);
151
+ if (hint?.incompatible && hint.schemaVersion !== undefined) {
152
+ parts.push(`incompatible: v${hint.schemaVersion}`);
153
+ }
154
+ return {
155
+ value: n,
156
+ label: n,
157
+ description: parts.length > 0 ? parts.join(" · ") : undefined,
158
+ };
159
+ });
160
+ return items.length > 0 ? items : null;
161
+ },
162
+ },
163
+ {
164
+ name: "validate",
165
+ description: "Validate credentials for a context without activating",
166
+ usage: "<name>",
167
+ getArgumentCompletions(prefix: string) {
168
+ if (prefix.includes(" ")) return null;
169
+ const svc = tryGetContextService();
170
+ if (!svc) return null;
171
+ const lower = prefix.toLowerCase();
172
+ const items = svc
173
+ .listContextNamesCached()
174
+ .filter(n => n.toLowerCase().startsWith(lower))
175
+ .map(n => {
176
+ const hint = svc.getContextHint(n);
177
+ const parts: string[] = [];
178
+ if (hint?.apiUrl) parts.push(hint.apiUrl);
179
+ if (hint?.incompatible && hint.schemaVersion !== undefined) {
180
+ parts.push(`incompatible: v${hint.schemaVersion}`);
181
+ }
182
+ return {
183
+ value: n,
184
+ label: n,
185
+ description: parts.length > 0 ? parts.join(" · ") : undefined,
186
+ };
187
+ });
188
+ return items.length > 0 ? items : null;
189
+ },
190
+ },
191
+ { name: "show", description: "Show context details (masked)", usage: "[name]" },
192
+ { name: "status", description: "Show current auth status" },
193
+ { name: "create", description: "Create a new context", usage: "<name> <url> <token> [namespace]" },
194
+ { name: "delete", description: "Delete a context", usage: "<name> --confirm" },
195
+ {
196
+ name: "rename",
197
+ description: "Rename a context",
198
+ usage: "<old> <new>",
199
+ getArgumentCompletions(prefix: string) {
200
+ if (prefix.includes(" ")) return null;
201
+ const svc = tryGetContextService();
202
+ if (!svc) return null;
203
+ const lower = prefix.toLowerCase();
204
+ const items = svc
205
+ .listContextNamesCached()
206
+ .filter(n => n.toLowerCase().startsWith(lower))
207
+ .map(n => ({ value: n, label: n }));
208
+ return items.length > 0 ? items : null;
209
+ },
210
+ },
211
+ {
212
+ name: "export",
213
+ description: "Export a context (or all contexts) as JSON",
214
+ usage: "[name] [--include-token]",
215
+ getArgumentCompletions(prefix: string) {
216
+ const svc = tryGetContextService();
217
+ if (!svc) return null;
218
+ const tokens = prefix.split(/\s+/).filter(Boolean);
219
+ const hasIncludeToken = tokens.includes("--include-token");
220
+ const positionalsTyped = tokens.filter(t => !t.startsWith("--"));
221
+ // Last token is "in-progress" if the prefix does not end with space.
222
+ const trailingSpace = prefix.endsWith(" ") || prefix === "";
223
+ const typedPositionalCount = trailingSpace
224
+ ? positionalsTyped.length
225
+ : Math.max(0, positionalsTyped.length - 1);
226
+ const completingToken = trailingSpace ? "" : (tokens[tokens.length - 1] ?? "");
227
+ // `head` is every already-typed token EXCEPT the one being
228
+ // completed. getArgumentCompletions.value replaces the whole
229
+ // argument tail, so value must carry every token the user
230
+ // should keep — otherwise accepting a suggestion silently
231
+ // drops the other args. Contract: see SubcommandDef JSDoc
232
+ // above (line ~58).
233
+ const headTokens = trailingSpace ? tokens : tokens.slice(0, -1);
234
+ const head = headTokens.length > 0 ? `${headTokens.join(" ")} ` : "";
235
+
236
+ const items: { value: string; label: string; description?: string }[] = [];
237
+
238
+ // Offer context names only if no positional has been filled yet.
239
+ // No startsWith("--") guard: context names legitimately allow
240
+ // leading dashes (the regex is /^[a-zA-Z0-9_-]{1,64}$/), and
241
+ // the handler's splitArgs uses a known-flags allowlist that
242
+ // treats only --include-token as a flag. So a context like
243
+ // `--prod` is valid; the completion filters by prefix and
244
+ // matches it naturally. When the user types `--in`, the
245
+ // flag-completion branch below matches `--include-token` by
246
+ // prefix; if there's ALSO a context starting with `--in` it
247
+ // is offered here. Both lists are disjoint by filter so
248
+ // there's no double-offer of the same token.
249
+ if (typedPositionalCount === 0) {
250
+ const lower = completingToken.toLowerCase();
251
+ for (const n of svc.listContextNamesCached()) {
252
+ if (!n.toLowerCase().startsWith(lower)) continue;
253
+ const hint = svc.getContextHint(n);
254
+ items.push({
255
+ value: `${head}${n}`,
256
+ label: n,
257
+ description: hint?.apiUrl,
258
+ });
259
+ }
260
+ }
261
+
262
+ // Offer --include-token unless already present. Match is
263
+ // case-sensitive because the handler's flag check uses
264
+ // exact-match `flags.has("--include-token")` — offering
265
+ // the suggestion for mis-cased prefixes (e.g. `--INCLUDE`)
266
+ // would produce a suggestion the handler then ignores.
267
+ if (!hasIncludeToken && "--include-token".startsWith(completingToken)) {
268
+ items.push({
269
+ value: `${head}--include-token`,
270
+ label: "--include-token",
271
+ description: "emit unmasked tokens",
272
+ });
273
+ }
274
+
275
+ return items.length > 0 ? items : null;
276
+ },
277
+ },
278
+ {
279
+ name: "import",
280
+ description: "Import contexts from a file path or inline JSON",
281
+ usage: "<path-or-json> [--overwrite]",
282
+ // No dynamic completion — paths are hard to complete correctly,
283
+ // and faking it would only mislead. Users pre-expand paths in
284
+ // their shell.
285
+ },
286
+ {
287
+ name: "namespace",
288
+ description: "Switch namespace within active context",
289
+ usage: "<namespace>",
290
+ getArgumentCompletions(prefix: string) {
291
+ if (prefix.includes(" ")) return null;
292
+ const svc = tryGetContextService();
293
+ if (!svc) return null;
294
+ // setNamespace() requires an active context. Don't offer completions that
295
+ // would lead the user into a command path that cannot succeed (e.g. an
296
+ // env-backed session where cached namespaces came from startup validation
297
+ // but there is no active context to apply them to).
298
+ if (!svc.getStatus().activeContextName) return null;
299
+ const lower = prefix.toLowerCase();
300
+ const items = svc
301
+ .getCachedNamespaces()
302
+ .filter(n => n.toLowerCase().startsWith(lower))
303
+ .map(n => ({ value: n, label: n }));
304
+ return items.length > 0 ? items : null;
305
+ },
306
+ },
307
+ { name: "env", description: "Manage environment variables", usage: "set|unset|list [KEY=VALUE ...]" },
308
+ { name: "set", description: "Set environment variable(s)", usage: "KEY=VALUE [KEY2=VALUE2 ...]" },
309
+ {
310
+ name: "unset",
311
+ description: "Remove environment variable(s)",
312
+ usage: "KEY [KEY2 ...]",
313
+ getArgumentCompletions(prefix: string) {
314
+ const lastSpace = prefix.lastIndexOf(" ");
315
+ const headRaw = lastSpace === -1 ? "" : prefix.slice(0, lastSpace + 1);
316
+ const tail = lastSpace === -1 ? prefix : prefix.slice(lastSpace + 1);
317
+ const svc = tryGetContextService();
318
+ if (!svc) return null;
319
+ const knownKeys = svc.getActiveEnvKeys();
320
+ const knownExact = new Set(knownKeys);
321
+ // Group known keys by their lowercased form so we can detect
322
+ // case-distinct collisions (e.g. both `Foo` and `FOO` present).
323
+ const variantsByLower = new Map<string, string[]>();
324
+ for (const k of knownKeys) {
325
+ const lower = k.toLowerCase();
326
+ const existing = variantsByLower.get(lower);
327
+ if (existing) existing.push(k);
328
+ else variantsByLower.set(lower, [k]);
329
+ }
330
+ // Normalization priority:
331
+ // 1. Exact-case match → preserve user's token verbatim
332
+ // 2. Lowercase maps to exactly one canonical → rewrite (the common
333
+ // "user typed lowercase, context has uppercase" path)
334
+ // 3. Lowercase maps to multiple canonicals (ambiguous) → preserve
335
+ // as-typed. Auto-picking one would silently target the wrong
336
+ // variable. The handler will match nothing and report a no-op,
337
+ // letting the user retype the exact case they meant.
338
+ // 4. No match → preserve as-typed so typos surface via handler.
339
+ const typedTokens = headRaw.trim().split(/\s+/).filter(Boolean);
340
+ const normalizedTokens = typedTokens.map(t => {
341
+ if (knownExact.has(t)) return t;
342
+ const variants = variantsByLower.get(t.toLowerCase());
343
+ if (variants && variants.length === 1) return variants[0];
344
+ return t;
345
+ });
346
+ const head = normalizedTokens.length > 0 ? `${normalizedTokens.join(" ")} ` : "";
347
+ const alreadyExact = new Set(normalizedTokens);
348
+ const items = knownKeys
349
+ .filter(k => !alreadyExact.has(k))
350
+ .filter(k => k.toLowerCase().startsWith(tail.toLowerCase()))
351
+ .map(k => ({
352
+ value: `${head}${k} `,
353
+ label: k,
354
+ description: "env var on active context",
355
+ }));
356
+ return items.length > 0 ? items : null;
357
+ },
358
+ },
359
+ { name: "wizard", description: "Guided interactive context setup" },
360
+ ];
361
+
132
362
  const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
133
363
  {
134
364
  name: "settings",
@@ -985,234 +1215,50 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
985
1215
  name: "context",
986
1216
  description: "Manage F5 XC authentication contexts",
987
1217
  allowArgs: true,
988
- subcommands: [
989
- { name: "list", description: "List all contexts" },
990
- {
991
- name: "activate",
992
- description: "Switch to a named context",
993
- usage: "<name>",
994
- getArgumentCompletions(prefix: string) {
995
- if (prefix.includes(" ")) return null;
996
- const svc = tryGetContextService();
997
- if (!svc) return null;
998
- const lower = prefix.toLowerCase();
999
- const items = svc
1000
- .listContextNamesCached()
1001
- .filter(n => n.toLowerCase().startsWith(lower))
1002
- .map(n => {
1003
- const hint = svc.getContextHint(n);
1004
- const parts: string[] = [];
1005
- if (hint?.apiUrl) parts.push(hint.apiUrl);
1006
- if (hint?.incompatible && hint.schemaVersion !== undefined) {
1007
- parts.push(`incompatible: v${hint.schemaVersion}`);
1008
- }
1009
- return {
1010
- value: n,
1011
- label: n,
1012
- description: parts.length > 0 ? parts.join(" · ") : undefined,
1013
- };
1014
- });
1015
- return items.length > 0 ? items : null;
1016
- },
1017
- },
1018
- {
1019
- name: "validate",
1020
- description: "Validate credentials for a context without activating",
1021
- usage: "<name>",
1022
- getArgumentCompletions(prefix: string) {
1023
- if (prefix.includes(" ")) return null;
1024
- const svc = tryGetContextService();
1025
- if (!svc) return null;
1026
- const lower = prefix.toLowerCase();
1027
- const items = svc
1028
- .listContextNamesCached()
1029
- .filter(n => n.toLowerCase().startsWith(lower))
1030
- .map(n => {
1031
- const hint = svc.getContextHint(n);
1032
- const parts: string[] = [];
1033
- if (hint?.apiUrl) parts.push(hint.apiUrl);
1034
- if (hint?.incompatible && hint.schemaVersion !== undefined) {
1035
- parts.push(`incompatible: v${hint.schemaVersion}`);
1036
- }
1037
- return {
1038
- value: n,
1039
- label: n,
1040
- description: parts.length > 0 ? parts.join(" · ") : undefined,
1041
- };
1042
- });
1043
- return items.length > 0 ? items : null;
1044
- },
1045
- },
1046
- { name: "show", description: "Show context details (masked)", usage: "[name]" },
1047
- { name: "status", description: "Show current auth status" },
1048
- { name: "create", description: "Create a new context", usage: "<name> <url> <token> [namespace]" },
1049
- { name: "delete", description: "Delete a context", usage: "<name> --confirm" },
1050
- {
1051
- name: "rename",
1052
- description: "Rename a context",
1053
- usage: "<old> <new>",
1054
- getArgumentCompletions(prefix: string) {
1055
- if (prefix.includes(" ")) return null;
1056
- const svc = tryGetContextService();
1057
- if (!svc) return null;
1058
- const lower = prefix.toLowerCase();
1059
- const items = svc
1060
- .listContextNamesCached()
1061
- .filter(n => n.toLowerCase().startsWith(lower))
1062
- .map(n => ({ value: n, label: n }));
1063
- return items.length > 0 ? items : null;
1064
- },
1065
- },
1066
- {
1067
- name: "export",
1068
- description: "Export a context (or all contexts) as JSON",
1069
- usage: "[name] [--include-token]",
1070
- getArgumentCompletions(prefix: string) {
1071
- const svc = tryGetContextService();
1072
- if (!svc) return null;
1073
- const tokens = prefix.split(/\s+/).filter(Boolean);
1074
- const hasIncludeToken = tokens.includes("--include-token");
1075
- const positionalsTyped = tokens.filter(t => !t.startsWith("--"));
1076
- // Last token is "in-progress" if the prefix does not end with space.
1077
- const trailingSpace = prefix.endsWith(" ") || prefix === "";
1078
- const typedPositionalCount = trailingSpace
1079
- ? positionalsTyped.length
1080
- : Math.max(0, positionalsTyped.length - 1);
1081
- const completingToken = trailingSpace ? "" : (tokens[tokens.length - 1] ?? "");
1082
- // `head` is every already-typed token EXCEPT the one being
1083
- // completed. getArgumentCompletions.value replaces the whole
1084
- // argument tail, so value must carry every token the user
1085
- // should keep — otherwise accepting a suggestion silently
1086
- // drops the other args. Contract: see SubcommandDef JSDoc
1087
- // above (line ~58).
1088
- const headTokens = trailingSpace ? tokens : tokens.slice(0, -1);
1089
- const head = headTokens.length > 0 ? `${headTokens.join(" ")} ` : "";
1090
-
1091
- const items: { value: string; label: string; description?: string }[] = [];
1092
-
1093
- // Offer context names only if no positional has been filled yet.
1094
- // No startsWith("--") guard: context names legitimately allow
1095
- // leading dashes (the regex is /^[a-zA-Z0-9_-]{1,64}$/), and
1096
- // the handler's splitArgs uses a known-flags allowlist that
1097
- // treats only --include-token as a flag. So a context like
1098
- // `--prod` is valid; the completion filters by prefix and
1099
- // matches it naturally. When the user types `--in`, the
1100
- // flag-completion branch below matches `--include-token` by
1101
- // prefix; if there's ALSO a context starting with `--in` it
1102
- // is offered here. Both lists are disjoint by filter so
1103
- // there's no double-offer of the same token.
1104
- if (typedPositionalCount === 0) {
1105
- const lower = completingToken.toLowerCase();
1106
- for (const n of svc.listContextNamesCached()) {
1107
- if (!n.toLowerCase().startsWith(lower)) continue;
1108
- const hint = svc.getContextHint(n);
1109
- items.push({
1110
- value: `${head}${n}`,
1111
- label: n,
1112
- description: hint?.apiUrl,
1113
- });
1114
- }
1115
- }
1116
-
1117
- // Offer --include-token unless already present. Match is
1118
- // case-sensitive because the handler's flag check uses
1119
- // exact-match `flags.has("--include-token")` — offering
1120
- // the suggestion for mis-cased prefixes (e.g. `--INCLUDE`)
1121
- // would produce a suggestion the handler then ignores.
1122
- if (!hasIncludeToken && "--include-token".startsWith(completingToken)) {
1123
- items.push({
1124
- value: `${head}--include-token`,
1125
- label: "--include-token",
1126
- description: "emit unmasked tokens",
1127
- });
1128
- }
1129
-
1130
- return items.length > 0 ? items : null;
1131
- },
1132
- },
1133
- {
1134
- name: "import",
1135
- description: "Import contexts from a file path or inline JSON",
1136
- usage: "<path-or-json> [--overwrite]",
1137
- // No dynamic completion — paths are hard to complete correctly,
1138
- // and faking it would only mislead. Users pre-expand paths in
1139
- // their shell.
1140
- },
1141
- {
1142
- name: "namespace",
1143
- description: "Switch namespace within active context",
1144
- usage: "<namespace>",
1145
- getArgumentCompletions(prefix: string) {
1146
- if (prefix.includes(" ")) return null;
1147
- const svc = tryGetContextService();
1148
- if (!svc) return null;
1149
- // setNamespace() requires an active context. Don't offer completions that
1150
- // would lead the user into a command path that cannot succeed (e.g. an
1151
- // env-backed session where cached namespaces came from startup validation
1152
- // but there is no active context to apply them to).
1153
- if (!svc.getStatus().activeContextName) return null;
1154
- const lower = prefix.toLowerCase();
1155
- const items = svc
1156
- .getCachedNamespaces()
1157
- .filter(n => n.toLowerCase().startsWith(lower))
1158
- .map(n => ({ value: n, label: n }));
1159
- return items.length > 0 ? items : null;
1160
- },
1161
- },
1162
- { name: "env", description: "Manage environment variables", usage: "set|unset|list [KEY=VALUE ...]" },
1163
- { name: "set", description: "Set environment variable(s)", usage: "KEY=VALUE [KEY2=VALUE2 ...]" },
1164
- {
1165
- name: "unset",
1166
- description: "Remove environment variable(s)",
1167
- usage: "KEY [KEY2 ...]",
1168
- getArgumentCompletions(prefix: string) {
1169
- const lastSpace = prefix.lastIndexOf(" ");
1170
- const headRaw = lastSpace === -1 ? "" : prefix.slice(0, lastSpace + 1);
1171
- const tail = lastSpace === -1 ? prefix : prefix.slice(lastSpace + 1);
1172
- const svc = tryGetContextService();
1173
- if (!svc) return null;
1174
- const knownKeys = svc.getActiveEnvKeys();
1175
- const knownExact = new Set(knownKeys);
1176
- // Group known keys by their lowercased form so we can detect
1177
- // case-distinct collisions (e.g. both `Foo` and `FOO` present).
1178
- const variantsByLower = new Map<string, string[]>();
1179
- for (const k of knownKeys) {
1180
- const lower = k.toLowerCase();
1181
- const existing = variantsByLower.get(lower);
1182
- if (existing) existing.push(k);
1183
- else variantsByLower.set(lower, [k]);
1184
- }
1185
- // Normalization priority:
1186
- // 1. Exact-case match → preserve user's token verbatim
1187
- // 2. Lowercase maps to exactly one canonical → rewrite (the common
1188
- // "user typed lowercase, context has uppercase" path)
1189
- // 3. Lowercase maps to multiple canonicals (ambiguous) → preserve
1190
- // as-typed. Auto-picking one would silently target the wrong
1191
- // variable. The handler will match nothing and report a no-op,
1192
- // letting the user retype the exact case they meant.
1193
- // 4. No match → preserve as-typed so typos surface via handler.
1194
- const typedTokens = headRaw.trim().split(/\s+/).filter(Boolean);
1195
- const normalizedTokens = typedTokens.map(t => {
1196
- if (knownExact.has(t)) return t;
1197
- const variants = variantsByLower.get(t.toLowerCase());
1198
- if (variants && variants.length === 1) return variants[0];
1199
- return t;
1218
+ getArgumentCompletions(argumentPrefix: string) {
1219
+ const firstSpace = argumentPrefix.indexOf(" ");
1220
+ if (firstSpace !== -1) {
1221
+ const subName = argumentPrefix.slice(0, firstSpace).toLowerCase();
1222
+ const subPrefix = argumentPrefix.slice(firstSpace + 1).replace(/^ +/, "");
1223
+ const sub = CONTEXT_SUBCOMMANDS.find(s => s.name === subName);
1224
+ if (!sub?.getArgumentCompletions) return null;
1225
+ const items = sub.getArgumentCompletions(subPrefix);
1226
+ if (!items || items.length === 0) return null;
1227
+ return items.map(item => ({ ...item, value: `${subName} ${item.value}` }));
1228
+ }
1229
+ const lower = argumentPrefix.toLowerCase();
1230
+ const items: { value: string; label: string; description?: string; hint?: string }[] = [];
1231
+ const svc = tryGetContextService();
1232
+ if (svc) {
1233
+ for (const n of svc.listContextNamesCached()) {
1234
+ if (!n.toLowerCase().startsWith(lower)) continue;
1235
+ const hint = svc.getContextHint(n);
1236
+ items.push({
1237
+ value: `${n} `,
1238
+ label: n,
1239
+ description: hint?.apiUrl,
1200
1240
  });
1201
- const head = normalizedTokens.length > 0 ? `${normalizedTokens.join(" ")} ` : "";
1202
- const alreadyExact = new Set(normalizedTokens);
1203
- const items = knownKeys
1204
- .filter(k => !alreadyExact.has(k))
1205
- .filter(k => k.toLowerCase().startsWith(tail.toLowerCase()))
1206
- .map(k => ({
1207
- value: `${head}${k} `,
1208
- label: k,
1209
- description: "env var on active context",
1210
- }));
1211
- return items.length > 0 ? items : null;
1212
- },
1213
- },
1214
- { name: "wizard", description: "Guided interactive context setup" },
1215
- ],
1241
+ }
1242
+ if (svc.previousContextName && "-".startsWith(lower)) {
1243
+ items.push({
1244
+ value: "- ",
1245
+ label: "-",
1246
+ description: `Switch to ${svc.previousContextName}`,
1247
+ });
1248
+ }
1249
+ }
1250
+ for (const sub of CONTEXT_SUBCOMMANDS) {
1251
+ if (!sub.name.toLowerCase().startsWith(lower)) continue;
1252
+ items.push({
1253
+ value: `${sub.name} `,
1254
+ label: sub.name,
1255
+ description: sub.description,
1256
+ hint: sub.usage,
1257
+ });
1258
+ }
1259
+ return items.length > 0 ? items : null;
1260
+ },
1261
+ subcommands: CONTEXT_SUBCOMMANDS,
1216
1262
  handle: async (command, runtime) => {
1217
1263
  runtime.ctx.editor.addToHistory(command.text);
1218
1264
  runtime.ctx.editor.setText("");
@@ -1238,6 +1284,7 @@ export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BU
1238
1284
  description: command.description,
1239
1285
  subcommands: command.subcommands,
1240
1286
  inlineHint: command.inlineHint,
1287
+ getArgumentCompletions: command.getArgumentCompletions,
1241
1288
  }),
1242
1289
  );
1243
1290