@f5-sales-demo/xcsh 20.2.7 → 20.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,41 @@
1
+ <!-- markdownlint-configure-file { "MD024": { "siblings_only": true } } -->
2
+
1
3
  # Changelog
2
4
 
3
- ## [Unreleased]
5
+ ## [20.3.0] - 2026-08-04
6
+
7
+ ### Added
8
+
9
+ - Added a live rotating model benchmark matrix for xcsh TTFT, completion timing, token usage, throughput, correctness, and failure capture ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
10
+ - Added a progressive three-model scenario library for identity, built-in tools, plugin skills and tools, authenticated F5 XC context, and read-only GitHub, Azure, GitLab, Salesforce, and MEDDPICC contracts ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
11
+ - Added per-effort benchmark matrices for low, medium, high, xhigh, and max reasoning, including requested-to-effective effort reporting, provider-error fail-fast capture, deterministic output grading, and explicit unavailability instead of ranking failed providers ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
12
+ - Added explicit `--context <name>` launch selection for reproducible non-interactive and benchmark sessions ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
13
+ - Added `--no-memories` for hermetic sessions that must not load or maintain persisted project memory ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
14
+ - Allowed `--tools` to select plugin and extension tools after runtime discovery ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
15
+
16
+ ### Changed
17
+
18
+ - Changed the binary production default to LiteLLM GPT-5.6 Sol with High reasoning, including Low and High effort role defaults for lightweight and thinking work ([#2886](https://github.com/f5-sales-demo/xcsh/issues/2886))
19
+
20
+ ### Fixed
21
+
22
+ - Made Office gateway configuration defer a blank model to xcsh's GPT-5.6 Sol default, preserve OpenAI-compatible URL paths, and ignore legacy Anthropic-era saved settings ([#2890](https://github.com/f5-sales-demo/xcsh/issues/2890))
23
+ - Added a deterministic five-step MEDDPICC Office/Excel certification harness with live-model, plugin, filesystem, host-tool, workbook, and idempotency evidence ([#2890](https://github.com/f5-sales-demo/xcsh/issues/2890))
24
+ - Rejected invalid lazy-command syntax before loading heavy command implementations while preserving command-specific launch-flag diagnostics ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
25
+ - Added dependency-install verification and automatic repair so stale workspace links and release-bumped lock entries cannot silently load SDK versions older than manifests, and aligned development with Bun 1.3.14 ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
26
+ - Reconciled out-of-band worker bridge shutdowns even while the process is still draining or unreaped, so immediate reprovisioning cannot retain a dead session ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
27
+ - Isolated live model benchmarks from persisted project memory so unrelated mandatory reads cannot contaminate tool-compliance results ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
28
+ - Refreshed entitlement-scoped OAuth credentials before model discovery so newly available Antigravity models remain resolvable after access-token expiry ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
29
+ - Kept extension-injected and hidden context before the active prompt so the user's explicit instruction remains the final provider input ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
30
+ - Waited for runtime model discovery before resolving explicit model selectors and model scopes ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
31
+ - Preserved the launch directory for `bun run dev` and excluded non-display extension context from JSON output ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
32
+ - Made explicit tool scopes, including `--no-tools`, exclude the bundled image-generation tool even when image credentials are configured ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
33
+ - Made explicit tool scopes authoritative for built-in, custom, and extension-provided tools ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
34
+ - Preserved exact collection counts in model-visible `xcsh_api` output even when large response bodies are truncated ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
35
+ - Made `plugin upgrade --dry-run` fetch current catalogs without changing marketplace registries, persistent caches, installed plugins, timestamps, manifests, or lockfiles ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
36
+ - Migrated generated LiteLLM configuration to schema v4 from literal credentials as well as environment variables while preserving custom API base paths ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
37
+ - Stored live benchmark reports under the private xcsh data directory with collision-resistant filenames by default and resolved context-quality criteria from the selected context so concurrent reports cannot overwrite each other and source never hard-codes live tenant identifiers ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
38
+ - Matched selected benchmark context names as case-insensitive literal text so CLI input cannot become an executable regular expression ([#2874](https://github.com/f5-sales-demo/xcsh/issues/2874))
4
39
 
5
40
  ## [20.2.5] - 2026-08-02
6
41
 
@@ -228,6 +263,7 @@
228
263
 
229
264
  - Autoresearch subsystem code quality: -513 lines (18.8% reduction), ~13% faster type checking. Un-exported internal symbols, relocated types, consolidated duplicate patterns, replaced manual deep copies with `structuredClone`, replaced `while(exec)` with `matchAll`, compressed control flow, extracted shared interfaces ([#734](https://github.com/f5-sales-demo/xcsh/pull/734))
230
265
 
266
+ <!-- markdownlint-disable-next-line MD024 -->
231
267
  ## [18.53.0] - 2026-05-09
232
268
 
233
269
  ### Fixed
@@ -637,6 +673,7 @@ Refs: #173.
637
673
  - `/autoresearch` toggles like `/plan` when empty; slash completion no longer suggests `off`/`clear` on an empty prefix after the command
638
674
  - Chunk-mode read/edit edge cases (zero-width gap replaces, stale batch diagnostics, grouped Go receivers, line-count headers, parse error locations)
639
675
 
676
+ <!-- markdownlint-disable-next-line MD024 -->
640
677
  ### Added
641
678
 
642
679
  - `/review` command now accepts inline args as custom instructions appended to the generated prompt for all structured review modes (PR-style, uncommitted, specific commit). When inline args are provided, option 4 (editor) is suppressed from the menu. The no-UI (Task tool) path forwards args as a focus hint.
@@ -1044,10 +1081,12 @@ Refs: #173.
1044
1081
  - Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments
1045
1082
  - Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments
1046
1083
 
1084
+ <!-- markdownlint-disable-next-line MD024 -->
1047
1085
  ### Fixed
1048
1086
 
1049
1087
  - Fixed resumed and session-switched GitHub Copilot/OpenAI Responses conversations replaying stale assistant native history from older saved sessions by sanitizing persisted assistant replay metadata on rehydration and resetting provider session state across live session boundaries ([#505](https://github.com/can1357/oh-my-pi/issues/505))
1050
1088
 
1089
+ <!-- markdownlint-disable-next-line MD024 -->
1051
1090
  ### Added
1052
1091
 
1053
1092
  - Session observer overlay (`Ctrl+S`): view running subagent sessions with a picker and read-only transcript showing thinking, text, tool calls, and results
@@ -1278,6 +1317,7 @@ Refs: #173.
1278
1317
 
1279
1318
  - Path resolution on Linux redirects to XDG locations when `XDG_DATA_HOME` / `XDG_STATE_HOME` / `XDG_CACHE_HOME` environment variables are set
1280
1319
 
1320
+ <!-- markdownlint-disable-next-line MD024 -->
1281
1321
  ### Changed
1282
1322
 
1283
1323
  - Changed TTSR interrupt logic to respect per-rule `interruptMode` settings, falling back to global `ttsr.interruptMode` when rule-level override is not specified
@@ -5453,6 +5493,7 @@ Refs: #173.
5453
5493
 
5454
5494
  - Removed `submit_review` tool - reviewers now finish via `complete` tool with structured output
5455
5495
 
5496
+ <!-- markdownlint-disable-next-line MD024 -->
5456
5497
  ### Fixed
5457
5498
 
5458
5499
  - Fixed session persistence to call fsync before renaming temp file for durability
@@ -5564,6 +5605,7 @@ Refs: #173.
5564
5605
  - Added support for file-based slash commands from `commands/` directories
5565
5606
  - Added `$ARGUMENTS` placeholder for slash command argument substitution, aligning with Claude and Codex conventions
5566
5607
 
5608
+ <!-- markdownlint-disable-next-line MD024 -->
5567
5609
  ### Changed
5568
5610
 
5569
5611
  - Refactored tool renderers to be colocated with their respective tool implementations for improved code organization
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "20.2.7",
4
+ "version": "20.3.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -31,11 +31,14 @@
31
31
  "xcsh": "src/cli.ts"
32
32
  },
33
33
  "scripts": {
34
+ "prebuild": "bun ../../scripts/check-installed-dependencies.ts",
34
35
  "build": "bun run generate-build-info && bun run generate-extension-capabilities && test -f src/internal-urls/api-spec-index.generated.ts && bun --cwd=../stats scripts/generate-client-bundle.ts --generate && bun --cwd=../office-pane scripts/generate-client-bundle.ts --generate && bun --cwd=../natives run embed:native && bun build --compile --define PI_COMPILED=true --external mupdf --root ../.. ./src/cli.ts --outfile dist/xcsh && bun --cwd=../natives run embed:native --reset && bun --cwd=../stats scripts/generate-client-bundle.ts --reset && bun --cwd=../office-pane scripts/generate-client-bundle.ts --reset",
36
+ "precheck": "bun ../../scripts/check-installed-dependencies.ts",
35
37
  "check": "biome check . && bun run format-prompts -- --check && bun run check:types",
36
38
  "check:types": "bun run generate-build-info && tsgo -p tsconfig.json --noEmit",
37
39
  "lint": "biome lint .",
38
40
  "check:bundle": "bun scripts/check-bundle.ts",
41
+ "pretest": "bun ../../scripts/check-installed-dependencies.ts",
39
42
  "test": "bun run generate-build-info && bun scripts/bun-test-guarded.ts --max-concurrency 1",
40
43
  "fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-api-spec-index && bun run generate-build-info",
41
44
  "fmt": "biome format --write . && bun run format-prompts",
@@ -57,13 +60,13 @@
57
60
  "dependencies": {
58
61
  "@agentclientprotocol/sdk": "1.3.0",
59
62
  "@mozilla/readability": "^0.6",
60
- "@f5-sales-demo/xcsh-stats": "20.2.7",
61
- "@f5-sales-demo/pi-agent-core": "20.2.7",
62
- "@f5-sales-demo/pi-ai": "20.2.7",
63
- "@f5-sales-demo/pi-natives": "20.2.7",
64
- "@f5-sales-demo/pi-resource-management": "20.2.7",
65
- "@f5-sales-demo/pi-tui": "20.2.7",
66
- "@f5-sales-demo/pi-utils": "20.2.7",
63
+ "@f5-sales-demo/xcsh-stats": "20.3.0",
64
+ "@f5-sales-demo/pi-agent-core": "20.3.0",
65
+ "@f5-sales-demo/pi-ai": "20.3.0",
66
+ "@f5-sales-demo/pi-natives": "20.3.0",
67
+ "@f5-sales-demo/pi-resource-management": "20.3.0",
68
+ "@f5-sales-demo/pi-tui": "20.3.0",
69
+ "@f5-sales-demo/pi-utils": "20.3.0",
67
70
  "@sinclair/typebox": "^0.34",
68
71
  "@xterm/headless": "^6.0",
69
72
  "ajv": "^8.20",
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AssistantMessage, ImageContent } from "@f5-sales-demo/pi-ai";
4
+ import { parseModelString } from "../config/model-resolver";
4
5
  import { settings } from "../config/settings";
5
6
  import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
6
7
  import { toSkillSummaries } from "../extensibility/skills";
@@ -376,13 +377,16 @@ export class ChatHandler {
376
377
  * SQLite credential store. Mirrors #handleSetHostTools's try/ack-or-nack shape;
377
378
  * never throws out of the handler (a nack keeps a waiting client from hanging).
378
379
  *
379
- * The baked F5 gateway registers its models under the "anthropic" provider
380
- * (DEFAULT_MODEL_ROLE = "anthropic/claude-opus-5"), so that is the provider we
381
- * (re)configure here. */
380
+ * The baked production role identifies the concrete provider, model, and effort,
381
+ * so the bridge follows the same LiteLLM default as every other xcsh entry point. */
382
382
  async #handleConfigure(msg: Configure): Promise<void> {
383
383
  try {
384
384
  const registry = this.#session.modelRegistry;
385
- const [provider, defaultModelId] = DEFAULT_MODEL_ROLE.split("/");
385
+ const defaultModel = parseModelString(DEFAULT_MODEL_ROLE);
386
+ if (!defaultModel) {
387
+ throw new Error("Invalid baked default model selector");
388
+ }
389
+ const { provider, id: defaultModelId, thinkingLevel } = defaultModel;
386
390
 
387
391
  if (msg.baseUrl) {
388
392
  // SSRF guard: only an `https:` gateway URL may be dialed. Validate BEFORE
@@ -401,12 +405,12 @@ export class ChatHandler {
401
405
  // baseUrl + apiKey, no models[] → sets the in-memory runtime API key AND
402
406
  // overrides the existing provider models' baseUrl/headers (reusing their
403
407
  // metadata). Nothing is persisted to disk.
408
+ registry.authStorage.setRuntimeApiKey(provider, msg.token);
404
409
  registry.registerProvider(
405
410
  provider,
406
411
  {
407
412
  baseUrl,
408
413
  apiKey: msg.token,
409
- headers: { "anthropic-beta": "context-1m-2025-08-07" },
410
414
  },
411
415
  "office-configure",
412
416
  );
@@ -415,13 +419,21 @@ export class ChatHandler {
415
419
  registry.authStorage.setRuntimeApiKey(provider, msg.token);
416
420
  }
417
421
 
418
- const modelId = msg.model ?? this.#session.model?.id ?? defaultModelId;
422
+ const currentDefaultModelId =
423
+ this.#session.model?.provider === provider ? this.#session.model.id : defaultModelId;
424
+ const modelId = msg.model ?? currentDefaultModelId;
419
425
  const model = registry.find(provider, modelId);
420
426
  if (!model) {
421
427
  throw new Error(`No model ${provider}/${modelId} available`);
422
428
  }
423
429
  // setModel validates the API key and throws if missing → becomes configure_error.
424
- await this.#session.setModel(model);
430
+ await this.#session.setModel(model, "default", {
431
+ selector: `${provider}/${modelId}`,
432
+ thinkingLevel,
433
+ });
434
+ if (thinkingLevel) {
435
+ this.#session.setThinkingLevel(thinkingLevel);
436
+ }
425
437
 
426
438
  this.#server.send({ type: "configure_ack", model: model.id } satisfies ConfigureAck);
427
439
  } catch {
@@ -279,8 +279,8 @@ export interface SetHostToolsError {
279
279
  // ---------------------------------------------------------------------------
280
280
 
281
281
  /** Inbound: the client configures the LLM provider. `token` is required and
282
- * non-empty. `baseUrl` (optional) is an Anthropic-compatible gateway base; when
283
- * omitted, the baked F5 gateway is reused and only the runtime API key is set.
282
+ * non-empty. `baseUrl` (optional) is an OpenAI-compatible LiteLLM API base; when
283
+ * omitted, the baked LiteLLM gateway is reused and only the runtime API key is set.
284
284
  * `model` (optional) selects the model id; when omitted, the session default is
285
285
  * kept. The token lives in session/runtime memory only — never written to disk. */
286
286
  export interface Configure {
package/src/cli/args.ts CHANGED
@@ -5,7 +5,6 @@ import { type Effort, THINKING_EFFORTS } from "@f5-sales-demo/pi-ai";
5
5
  import { APP_NAME, CONFIG_DIR_NAME, logger } from "@f5-sales-demo/pi-utils";
6
6
  import chalk from "chalk";
7
7
  import { parseEffort } from "../thinking";
8
- import { BUILTIN_TOOLS } from "../tools";
9
8
  import {
10
9
  flagNameForChar,
11
10
  flagSpec,
@@ -25,6 +24,7 @@ export interface Args {
25
24
  /** Extra directories the session may read AND write, beyond its CWD subtree (repeatable). */
26
25
  allowPath?: string[];
27
26
  provider?: string;
27
+ context?: string;
28
28
  model?: string;
29
29
  smol?: string;
30
30
  slow?: string;
@@ -39,6 +39,7 @@ export interface Args {
39
39
  version?: boolean;
40
40
  mode?: Mode;
41
41
  noSession?: boolean;
42
+ noMemories?: boolean;
42
43
  sessionDir?: string;
43
44
  providerSessionId?: string;
44
45
  fork?: string;
@@ -95,6 +96,9 @@ const APPLY: Record<LaunchFlagName, (result: Args, value: string | true) => void
95
96
  provider: (r, v) => {
96
97
  r.provider = v as string;
97
98
  },
99
+ context: (r, v) => {
100
+ r.context = v as string;
101
+ },
98
102
  "api-key": (r, v) => {
99
103
  r.apiKey = v as string;
100
104
  },
@@ -138,6 +142,9 @@ const APPLY: Record<LaunchFlagName, (result: Args, value: string | true) => void
138
142
  "no-session": r => {
139
143
  r.noSession = true;
140
144
  },
145
+ "no-memories": r => {
146
+ r.noMemories = true;
147
+ },
141
148
  "provider-session-id": (r, v) => {
142
149
  r.providerSessionId = v as string;
143
150
  },
@@ -157,22 +164,10 @@ const APPLY: Record<LaunchFlagName, (result: Args, value: string | true) => void
157
164
  r.noPty = true;
158
165
  },
159
166
  tools: (r, v) => {
160
- const toolNames = (v as string)
167
+ r.tools = (v as string)
161
168
  .split(",")
162
169
  .map(s => s.trim().toLowerCase())
163
170
  .filter(Boolean);
164
- const validTools: string[] = [];
165
- for (const name of toolNames) {
166
- if (name in BUILTIN_TOOLS) {
167
- validTools.push(name);
168
- } else {
169
- logger.warn("Unknown tool passed to --tools", {
170
- tool: name,
171
- validTools: Object.keys(BUILTIN_TOOLS),
172
- });
173
- }
174
- }
175
- r.tools = validTools;
176
171
  },
177
172
  thinking: (r, v) => {
178
173
  const thinking = parseEffort(v as string);
@@ -12,7 +12,7 @@
12
12
  * A leaf module on purpose — it imports only the thinking-effort list, so `commands/launch.ts` and
13
13
  * `cli/args.ts` can both depend on it without a cycle.
14
14
  */
15
- import { THINKING_EFFORTS } from "@f5-sales-demo/pi-ai";
15
+ import { THINKING_EFFORTS } from "@f5-sales-demo/pi-ai/model-thinking";
16
16
  import { CliUsageError, type FlagDescriptor, Flags } from "@f5-sales-demo/pi-utils/cli";
17
17
 
18
18
  export type FlagArity = "boolean" | "value" | "optional-value" | "repeatable-value";
@@ -46,6 +46,7 @@ export const LAUNCH_FLAGS = defineFlags({
46
46
  slow: { arity: "value", description: "Slow/reasoning model for thorough analysis (or PI_SLOW_MODEL env)" },
47
47
  plan: { arity: "value", description: "Plan model for architectural planning (or PI_PLAN_MODEL env)" },
48
48
  provider: { arity: "value", description: "Provider to use (legacy; prefer --model)" },
49
+ context: { arity: "value", description: "Bind this session to a named F5 XC context" },
49
50
  "api-key": { arity: "value", description: "API key (defaults to env vars)" },
50
51
  "system-prompt": { arity: "value", description: "System prompt (default: coding assistant prompt)" },
51
52
  "append-system-prompt": { arity: "value", description: "Append text or file contents to the system prompt" },
@@ -74,6 +75,7 @@ export const LAUNCH_FLAGS = defineFlags({
74
75
  fork: { arity: "value", description: "Fork an existing session by ID prefix or path" },
75
76
  "session-dir": { arity: "value", description: "Directory for session storage and lookup" },
76
77
  "no-session": { arity: "boolean", description: "Don't save session (ephemeral)" },
78
+ "no-memories": { arity: "boolean", description: "Disable project memory loading and maintenance" },
77
79
  "provider-session-id": { arity: "value", description: "Resume a provider-side session", hidden: true },
78
80
  models: { arity: "value", description: "Comma-separated model patterns for Ctrl+P cycling" },
79
81
  "no-tools": { arity: "boolean", description: "Disable all built-in tools" },
@@ -157,6 +159,29 @@ export interface UnrecognizedFlag {
157
159
  name: string;
158
160
  }
159
161
 
162
+ type ExtensionFlagSpec = ReadonlyMap<string, { type: "boolean" | "string" }>;
163
+
164
+ function inlineFlag(arg: string): { name: string; value: string } | undefined {
165
+ const match = /^--([^=]+)=([\s\S]*)$/.exec(arg);
166
+ if (!match) return undefined;
167
+ return { name: match[1], value: match[2] };
168
+ }
169
+
170
+ /** Reject inline values for known boolean flags without loading the agent command. */
171
+ export function validateInlineFlagSyntax(args: readonly string[], extensionFlags?: ExtensionFlagSpec): void {
172
+ for (const arg of args) {
173
+ if (arg === "--") return;
174
+ const inline = inlineFlag(arg);
175
+ if (!inline) continue;
176
+
177
+ const spec = flagSpec(inline.name);
178
+ const isBoolean = spec?.arity === "boolean" || extensionFlags?.get(inline.name)?.type === "boolean";
179
+ if (isBoolean) {
180
+ throw new CliUsageError(`--${inline.name} is a boolean flag and does not take a value`);
181
+ }
182
+ }
183
+ }
184
+
160
185
  /**
161
186
  * Rewrite `--name=value` into `["--name", "value"]` for every flag that takes a value.
162
187
  *
@@ -167,10 +192,8 @@ export interface UnrecognizedFlag {
167
192
  *
168
193
  * Short forms are untouched: no shell convention makes `-p=x` mean `-p x`.
169
194
  */
170
- export function normalizeFlagTokens(
171
- args: readonly string[],
172
- extensionFlags?: ReadonlyMap<string, { type: "boolean" | "string" }>,
173
- ): string[] {
195
+ export function normalizeFlagTokens(args: readonly string[], extensionFlags?: ExtensionFlagSpec): string[] {
196
+ validateInlineFlagSyntax(args, extensionFlags);
174
197
  const normalized: string[] = [];
175
198
  let afterTerminator = false;
176
199
 
@@ -185,27 +208,21 @@ export function normalizeFlagTokens(
185
208
  continue;
186
209
  }
187
210
 
188
- const match = /^--([^=]+)=([\s\S]*)$/.exec(arg);
189
- if (!match) {
211
+ const inline = inlineFlag(arg);
212
+ if (!inline) {
190
213
  normalized.push(arg);
191
214
  continue;
192
215
  }
193
216
 
194
- const [, name, value] = match;
217
+ const { name, value } = inline;
195
218
  const spec = flagSpec(name);
196
219
  if (spec) {
197
- if (!takesValue(spec)) {
198
- throw new CliUsageError(`--${name} is a boolean flag and does not take a value`);
199
- }
200
220
  normalized.push(`--${name}`, value);
201
221
  continue;
202
222
  }
203
223
 
204
224
  const extension = extensionFlags?.get(name);
205
225
  if (extension) {
206
- if (extension.type === "boolean") {
207
- throw new CliUsageError(`--${name} is a boolean flag and does not take a value`);
208
- }
209
226
  normalized.push(`--${name}`, value);
210
227
  continue;
211
228
  }
@@ -309,6 +309,41 @@ async function handleUpgrade(args: string[], flags: PluginCommandArgs["flags"]):
309
309
  const manager = await makeMarketplaceManager();
310
310
  const pluginId = args[0];
311
311
  try {
312
+ if (flags.dryRun) {
313
+ const installed = pluginId
314
+ ? (await manager.listInstalledPlugins()).filter(entry => entry.id === pluginId)
315
+ : [];
316
+ if (pluginId && installed.length === 0) {
317
+ throw new Error(`Plugin "${pluginId}" is not installed`);
318
+ }
319
+ if (pluginId && flags.scope && !installed.some(entry => entry.scope === flags.scope)) {
320
+ throw new Error(`Plugin "${pluginId}" is not installed in ${flags.scope} scope`);
321
+ }
322
+
323
+ const available = await manager.previewPluginUpdates();
324
+ const updates = available.filter(
325
+ update =>
326
+ (pluginId === undefined || update.pluginId === pluginId) &&
327
+ (flags.scope === undefined || update.scope === flags.scope),
328
+ );
329
+ if (flags.json) {
330
+ process.stdout.write(`${JSON.stringify({ dryRun: true, updates }, null, 2)}\n`);
331
+ } else if (updates.length === 0) {
332
+ process.stdout.write(
333
+ `${pluginId ? `${pluginId} is up to date.` : "All marketplace plugins are up to date."}\n`,
334
+ );
335
+ } else {
336
+ for (const update of updates) {
337
+ process.stdout.write(
338
+ `${chalk.dim(
339
+ `[dry-run] Would upgrade ${update.pluginId} (${update.scope}): ${update.from} -> ${update.to}`,
340
+ )}\n`,
341
+ );
342
+ }
343
+ }
344
+ return;
345
+ }
346
+
312
347
  if (pluginId) {
313
348
  if (flags.scope) {
314
349
  const result = await manager.upgradePlugin(pluginId, flags.scope, { refresh: true });
@@ -13,7 +13,8 @@ import {
13
13
  SANDBOX_OPERATOR_HOME_ENV,
14
14
  SANDBOX_SESSION_ROOT_ENV,
15
15
  } from "../sandbox/session-fence";
16
- import { BashTool, type ToolSession } from "../tools";
16
+ import type { ToolSession } from "../tools";
17
+ import { BashTool } from "../tools/bash";
17
18
 
18
19
  export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP" | "ERROR";
19
20
 
@@ -0,0 +1,22 @@
1
+ import { Args, CliUsageError, Flags } from "@f5-sales-demo/pi-utils/cli";
2
+ import { findCommandLaunchFlags, launchFlagScopeMessage } from "./root-command-routing";
3
+
4
+ export const sandboxArgs = {
5
+ action: Args.string({
6
+ description: "Sandbox action",
7
+ required: true,
8
+ options: ["check"],
9
+ }),
10
+ };
11
+
12
+ export const sandboxFlags = {
13
+ json: Flags.boolean({ description: "Output JSON" }),
14
+ verbose: Flags.boolean({ char: "v", description: "Show failure details" }),
15
+ };
16
+
17
+ export function validateSandboxInvocation(argv: readonly string[], bin: string): void {
18
+ const launchFlags = findCommandLaunchFlags(argv, sandboxFlags);
19
+ if (launchFlags.length > 0) {
20
+ throw new CliUsageError(launchFlagScopeMessage(launchFlags, "sandbox", argv, bin));
21
+ }
22
+ }
package/src/cli.ts CHANGED
@@ -4,8 +4,10 @@ import { APP_NAME, initI18n, MIN_BUN_VERSION, registerLocales, t, VERSION } from
4
4
  * CLI entry point — registers all commands explicitly and delegates to the
5
5
  * lightweight CLI runner from pi-utils.
6
6
  */
7
- import { type CommandEntry, run } from "@f5-sales-demo/pi-utils/cli";
7
+ import { CliUsageError, type CommandEntry, run } from "@f5-sales-demo/pi-utils/cli";
8
+ import { validateInlineFlagSyntax } from "./cli/flag-spec";
8
9
  import { findPrefixedCommand, launchFlagScopeMessage } from "./cli/root-command-routing";
10
+ import { sandboxArgs, sandboxFlags, validateSandboxInvocation } from "./cli/sandbox-spec";
9
11
  import { locales } from "./locales/index";
10
12
 
11
13
  registerLocales(locales);
@@ -57,7 +59,12 @@ const commands: CommandEntry[] = [
57
59
  { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
58
60
  { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
59
61
  { name: "read", load: () => import("./commands/read").then(m => m.default) },
60
- { name: "sandbox", load: () => import("./commands/sandbox").then(m => m.default) },
62
+ {
63
+ name: "sandbox",
64
+ load: () => import("./commands/sandbox").then(m => m.default),
65
+ validate: argv => validateSandboxInvocation(argv, APP_NAME),
66
+ syntax: { args: sandboxArgs, flags: sandboxFlags },
67
+ },
61
68
  { name: "jupyter", load: () => import("./commands/jupyter").then(m => m.default) },
62
69
  { name: "manager", load: () => import("./commands/manager").then(m => m.default) },
63
70
  { name: "office", load: () => import("./commands/office").then(m => m.default) },
@@ -103,6 +110,16 @@ export function runCli(argv: string[]): Promise<void> {
103
110
  // --help and --version are handled by run() directly, don't rewrite those.
104
111
  // Everything else that isn't a known subcommand routes to "launch".
105
112
  const first = argv[0];
113
+ if (!isSubcommand(first)) {
114
+ try {
115
+ validateInlineFlagSyntax(argv);
116
+ } catch (error) {
117
+ if (!(error instanceof CliUsageError)) throw error;
118
+ process.stderr.write(`Error: ${error.message}\n`);
119
+ process.exitCode = 2;
120
+ return Promise.resolve();
121
+ }
122
+ }
106
123
  const prefixedCommand = findPrefixedCommand(argv, token => isSubcommand(token));
107
124
  if (
108
125
  prefixedCommand !== undefined &&