@f5-sales-demo/xcsh 19.95.4 → 19.97.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.
Files changed (36) hide show
  1. package/package.json +8 -8
  2. package/src/browser/capabilities.generated.ts +7 -3
  3. package/src/browser/capabilities.json +6 -2
  4. package/src/browser/chat-conformance.json +140 -1
  5. package/src/browser/chat-handler.ts +24 -1
  6. package/src/browser/chat-protocol.ts +28 -0
  7. package/src/browser/host-profiles.ts +5 -2
  8. package/src/cli/args.ts +233 -131
  9. package/src/cli/flag-spec.ts +219 -0
  10. package/src/cli/office-cli.ts +26 -3
  11. package/src/commands/launch.ts +3 -105
  12. package/src/config/settings-schema.ts +9 -2
  13. package/src/extensibility/extensions/bundled/sandbox-guard.ts +0 -0
  14. package/src/extensibility/skills.ts +27 -7
  15. package/src/extensibility/slash-commands.ts +20 -0
  16. package/src/internal-urls/build-info-runtime.ts +21 -2
  17. package/src/internal-urls/build-info.generated.ts +8 -8
  18. package/src/internal-urls/plugin-resolve.test.ts +61 -0
  19. package/src/internal-urls/plugin-resolve.ts +63 -9
  20. package/src/internal-urls/xcsh-protocol.active-model.test.ts +53 -0
  21. package/src/internal-urls/xcsh-protocol.ts +10 -1
  22. package/src/main.ts +50 -23
  23. package/src/modes/print-mode.ts +21 -6
  24. package/src/modes/rpc/rpc-client.ts +10 -0
  25. package/src/modes/rpc/rpc-mode.ts +8 -0
  26. package/src/modes/rpc/rpc-types.ts +9 -0
  27. package/src/prompts/internal-urls/active-model.md +32 -0
  28. package/src/sandbox/command-operands.ts +280 -0
  29. package/src/sandbox/enforce.ts +48 -4
  30. package/src/sandbox/session-policy.ts +0 -0
  31. package/src/sdk.ts +16 -0
  32. package/src/session/active-model.ts +86 -0
  33. package/src/session/agent-session.ts +29 -2
  34. package/src/tools/bash-skill-urls.ts +114 -61
  35. package/src/tools/bash.ts +24 -21
  36. package/src/tools/shell-lex.ts +652 -0
package/src/cli/args.ts CHANGED
@@ -6,6 +6,14 @@ 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
8
  import { BUILTIN_TOOLS } from "../tools";
9
+ import {
10
+ flagNameForChar,
11
+ flagSpec,
12
+ type LaunchFlagName,
13
+ normalizeFlagTokens,
14
+ takesValue,
15
+ type UnrecognizedFlag,
16
+ } from "./flag-spec";
9
17
 
10
18
  export type Mode = "text" | "json" | "rpc" | "acp";
11
19
 
@@ -55,6 +63,169 @@ export interface Args {
55
63
  fileArgs: string[];
56
64
  /** Unknown flags (potentially extension flags) - map of flag name to value */
57
65
  unknownFlags: Map<string, boolean | string>;
66
+ /**
67
+ * Flags matched by neither the spec nor a known extension flag.
68
+ *
69
+ * Collected rather than reported here: the extension flag registry does not exist yet during the
70
+ * first parse, so only `main.ts` can tell a genuine typo from a flag an extension will claim.
71
+ */
72
+ unrecognizedFlags: UnrecognizedFlag[];
73
+ }
74
+
75
+ /**
76
+ * Apply one parsed flag to the result, keeping each flag's own value coercion.
77
+ *
78
+ * Split from the scanning loop so arity, aliasing and the `=` form are handled once, in one place,
79
+ * rather than repeated per flag as an `arg === "--x" && i + 1 < args.length` chain — which is how
80
+ * `--x=value` came to match nothing at all and vanish silently (#2469).
81
+ */
82
+ const APPLY: Record<LaunchFlagName, (result: Args, value: string | true) => void> = {
83
+ model: (r, v) => {
84
+ r.model = v as string;
85
+ },
86
+ smol: (r, v) => {
87
+ r.smol = v as string;
88
+ },
89
+ slow: (r, v) => {
90
+ r.slow = v as string;
91
+ },
92
+ plan: (r, v) => {
93
+ r.plan = v as string;
94
+ },
95
+ provider: (r, v) => {
96
+ r.provider = v as string;
97
+ },
98
+ "api-key": (r, v) => {
99
+ r.apiKey = v as string;
100
+ },
101
+ "system-prompt": (r, v) => {
102
+ r.systemPrompt = v as string;
103
+ },
104
+ "append-system-prompt": (r, v) => {
105
+ r.appendSystemPrompt = v as string;
106
+ },
107
+ "allow-home": r => {
108
+ r.allowHome = true;
109
+ },
110
+ "no-sandbox": r => {
111
+ r.noSandbox = true;
112
+ },
113
+ "allow-path": (r, v) => {
114
+ r.allowPath = [...(r.allowPath ?? []), v as string];
115
+ },
116
+ mode: (r, v) => {
117
+ // Already validated against the spec's `options`.
118
+ r.mode = v as Mode;
119
+ },
120
+ print: r => {
121
+ r.print = true;
122
+ },
123
+ continue: r => {
124
+ r.continue = true;
125
+ },
126
+ resume: (r, v) => {
127
+ r.resume = v;
128
+ },
129
+ session: (r, v) => {
130
+ r.resume = v;
131
+ },
132
+ fork: (r, v) => {
133
+ r.fork = v as string;
134
+ },
135
+ "session-dir": (r, v) => {
136
+ r.sessionDir = v as string;
137
+ },
138
+ "no-session": r => {
139
+ r.noSession = true;
140
+ },
141
+ "provider-session-id": (r, v) => {
142
+ r.providerSessionId = v as string;
143
+ },
144
+ models: (r, v) => {
145
+ r.models = (v as string).split(",").map(s => s.trim());
146
+ },
147
+ "no-tools": r => {
148
+ r.noTools = true;
149
+ },
150
+ "no-mcp": r => {
151
+ r.noMcp = true;
152
+ },
153
+ "no-lsp": r => {
154
+ r.noLsp = true;
155
+ },
156
+ "no-pty": r => {
157
+ r.noPty = true;
158
+ },
159
+ tools: (r, v) => {
160
+ const toolNames = (v as string)
161
+ .split(",")
162
+ .map(s => s.trim().toLowerCase())
163
+ .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
+ },
177
+ thinking: (r, v) => {
178
+ const thinking = parseEffort(v as string);
179
+ if (thinking !== undefined) {
180
+ r.thinking = thinking;
181
+ } else {
182
+ logger.warn("Invalid thinking level passed to --thinking", {
183
+ level: v,
184
+ validThinkingLevels: THINKING_EFFORTS,
185
+ });
186
+ }
187
+ },
188
+ hook: (r, v) => {
189
+ r.hooks = [...(r.hooks ?? []), v as string];
190
+ },
191
+ extension: (r, v) => {
192
+ r.extensions = [...(r.extensions ?? []), v as string];
193
+ },
194
+ "plugin-dir": (r, v) => {
195
+ r.pluginDirs = [...(r.pluginDirs ?? []), v as string];
196
+ },
197
+ "no-extensions": r => {
198
+ r.noExtensions = true;
199
+ },
200
+ "no-skills": r => {
201
+ r.noSkills = true;
202
+ },
203
+ skills: (r, v) => {
204
+ r.skills = (v as string).split(",").map(s => s.trim());
205
+ },
206
+ "no-rules": r => {
207
+ r.noRules = true;
208
+ },
209
+ export: (r, v) => {
210
+ r.export = v as string;
211
+ },
212
+ "list-models": (r, v) => {
213
+ r.listModels = v;
214
+ },
215
+ "no-title": r => {
216
+ r.noTitle = true;
217
+ },
218
+ help: r => {
219
+ r.help = true;
220
+ },
221
+ version: r => {
222
+ r.version = true;
223
+ },
224
+ };
225
+
226
+ /** True when the token could be an optional flag's value rather than the next flag or a file arg. */
227
+ function isValueToken(token: string | undefined): token is string {
228
+ return token !== undefined && !token.startsWith("-") && !token.startsWith("@");
58
229
  }
59
230
 
60
231
  export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
@@ -62,146 +233,77 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
62
233
  messages: [],
63
234
  fileArgs: [],
64
235
  unknownFlags: new Map(),
236
+ unrecognizedFlags: [],
65
237
  };
66
238
 
67
- for (let i = 0; i < args.length; i++) {
68
- const arg = args[i];
239
+ const tokens = normalizeFlagTokens(args, extensionFlags);
69
240
 
70
- if (arg === "--help" || arg === "-h") {
71
- result.help = true;
72
- } else if (arg === "--version" || arg === "-v") {
73
- result.version = true;
74
- } else if (arg === "--allow-home") {
75
- result.allowHome = true;
76
- } else if (arg === "--no-sandbox") {
77
- result.noSandbox = true;
78
- } else if (arg === "--allow-path" && i + 1 < args.length) {
79
- result.allowPath = result.allowPath ?? [];
80
- result.allowPath.push(args[++i]);
81
- } else if (arg === "--mode" && i + 1 < args.length) {
82
- const mode = args[++i];
83
- if (mode === "text" || mode === "json" || mode === "rpc" || mode === "acp") {
84
- result.mode = mode;
85
- }
86
- } else if (arg === "--continue" || arg === "-c") {
87
- result.continue = true;
88
- } else if (arg === "--resume" || arg === "-r" || arg === "--session") {
89
- const next = args[i + 1];
90
- if (next && !next.startsWith("-")) {
91
- result.resume = args[++i];
92
- } else {
93
- result.resume = true;
241
+ for (let i = 0; i < tokens.length; i++) {
242
+ const token = tokens[i];
243
+
244
+ // Everything after `--` is content, not flags. Needed because an unrecognized flag is now a
245
+ // hard error, so there has to be a way to pass flag-looking text as a prompt.
246
+ if (token === "--") {
247
+ result.messages.push(...tokens.slice(i + 1));
248
+ break;
249
+ }
250
+
251
+ if (token.startsWith("@")) {
252
+ result.fileArgs.push(token.slice(1));
253
+ continue;
254
+ }
255
+
256
+ if (!token.startsWith("-") || token === "-") {
257
+ result.messages.push(token);
258
+ continue;
259
+ }
260
+
261
+ // An unknown `--name=value` survives normalization intact, so strip the value to get a name
262
+ // that can still be matched against the extension flag registry.
263
+ const name = token.startsWith("--") ? token.slice(2).split("=")[0] : flagNameForChar(token.slice(1));
264
+ const spec = name === undefined ? undefined : flagSpec(name);
265
+
266
+ if (spec && name !== undefined) {
267
+ if (!takesValue(spec)) {
268
+ APPLY[name as LaunchFlagName](result, true);
269
+ continue;
94
270
  }
95
- } else if (arg === "--fork" && i + 1 < args.length) {
96
- result.fork = args[++i];
97
- } else if (arg === "--provider" && i + 1 < args.length) {
98
- result.provider = args[++i];
99
- } else if (arg === "--model" && i + 1 < args.length) {
100
- result.model = args[++i];
101
- } else if (arg === "--smol" && i + 1 < args.length) {
102
- result.smol = args[++i];
103
- } else if (arg === "--slow" && i + 1 < args.length) {
104
- result.slow = args[++i];
105
- } else if (arg === "--plan" && i + 1 < args.length) {
106
- result.plan = args[++i];
107
- } else if (arg === "--api-key" && i + 1 < args.length) {
108
- result.apiKey = args[++i];
109
- } else if (arg === "--system-prompt" && i + 1 < args.length) {
110
- result.systemPrompt = args[++i];
111
- } else if (arg === "--append-system-prompt" && i + 1 < args.length) {
112
- result.appendSystemPrompt = args[++i];
113
- } else if (arg === "--provider-session-id" && i + 1 < args.length) {
114
- result.providerSessionId = args[++i];
115
- } else if (arg === "--no-session") {
116
- result.noSession = true;
117
- } else if (arg === "--session-dir" && i + 1 < args.length) {
118
- result.sessionDir = args[++i];
119
- } else if (arg === "--models" && i + 1 < args.length) {
120
- result.models = args[++i].split(",").map(s => s.trim());
121
- } else if (arg === "--no-tools") {
122
- result.noTools = true;
123
- } else if (arg === "--no-lsp") {
124
- result.noLsp = true;
125
- } else if (arg === "--no-pty") {
126
- result.noPty = true;
127
- } else if (arg === "--no-mcp") {
128
- result.noMcp = true;
129
- } else if (arg === "--tools" && i + 1 < args.length) {
130
- const toolNames = args[++i]
131
- .split(",")
132
- .map(s => s.trim().toLowerCase())
133
- .filter(Boolean);
134
- const validTools: string[] = [];
135
- for (const name of toolNames) {
136
- if (name in BUILTIN_TOOLS) {
137
- validTools.push(name);
138
- } else {
139
- logger.warn("Unknown tool passed to --tools", {
140
- tool: name,
141
- validTools: Object.keys(BUILTIN_TOOLS),
142
- });
143
- }
271
+ if (spec.arity === "optional-value") {
272
+ APPLY[name as LaunchFlagName](result, isValueToken(tokens[i + 1]) ? tokens[++i] : true);
273
+ continue;
144
274
  }
145
- result.tools = validTools;
146
- } else if (arg === "--thinking" && i + 1 < args.length) {
147
- const rawThinking = args[++i];
148
- const thinking = parseEffort(rawThinking);
149
- if (thinking !== undefined) {
150
- result.thinking = thinking;
151
- } else {
152
- logger.warn("Invalid thinking level passed to --thinking", {
153
- level: rawThinking,
154
- validThinkingLevels: THINKING_EFFORTS,
155
- });
275
+ const value = tokens[i + 1];
276
+ if (value === undefined) {
277
+ result.unrecognizedFlags.push({ token, name });
278
+ continue;
156
279
  }
157
- } else if (arg === "--print" || arg === "-p") {
158
- result.print = true;
159
- } else if (arg === "--export" && i + 1 < args.length) {
160
- result.export = args[++i];
161
- } else if (arg === "--hook" && i + 1 < args.length) {
162
- result.hooks = result.hooks ?? [];
163
- result.hooks.push(args[++i]);
164
- } else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {
165
- result.extensions = result.extensions ?? [];
166
- result.extensions.push(args[++i]);
167
- } else if (arg === "--plugin-dir" && i + 1 < args.length) {
168
- result.pluginDirs = result.pluginDirs ?? [];
169
- result.pluginDirs.push(args[++i]);
170
- } else if (arg === "--no-extensions") {
171
- result.noExtensions = true;
172
- } else if (arg === "--no-skills") {
173
- result.noSkills = true;
174
- } else if (arg === "--no-rules") {
175
- result.noRules = true;
176
- } else if (arg === "--no-title") {
177
- result.noTitle = true;
178
- } else if (arg === "--skills" && i + 1 < args.length) {
179
- // Comma-separated glob patterns for skill filtering
180
- result.skills = args[++i].split(",").map(s => s.trim());
181
- } else if (arg === "--list-models") {
182
- // Check if next arg is a search pattern (not a flag or file arg)
183
- if (i + 1 < args.length && !args[i + 1].startsWith("-") && !args[i + 1].startsWith("@")) {
184
- result.listModels = args[++i];
185
- } else {
186
- result.listModels = true;
280
+ i++;
281
+ if (spec.options && !spec.options.includes(value)) {
282
+ logger.warn("Invalid value passed to flag", { flag: token, value, validValues: spec.options });
283
+ continue;
187
284
  }
188
- } else if (arg.startsWith("@")) {
189
- result.fileArgs.push(arg.slice(1)); // Remove @ prefix
190
- } else if (arg.startsWith("--") && extensionFlags) {
191
- // Check if it's an extension-registered flag
192
- const flagName = arg.slice(2);
193
- const extFlag = extensionFlags.get(flagName);
194
- if (extFlag) {
195
- if (extFlag.type === "boolean") {
196
- result.unknownFlags.set(flagName, true);
197
- } else if (extFlag.type === "string" && i + 1 < args.length) {
198
- result.unknownFlags.set(flagName, args[++i]);
199
- }
285
+ APPLY[name as LaunchFlagName](result, value);
286
+ continue;
287
+ }
288
+
289
+ // Extension flags are only known on the second parse, once extensions have loaded.
290
+ const extFlag = name === undefined ? undefined : extensionFlags?.get(name);
291
+ if (extFlag && name !== undefined) {
292
+ if (extFlag.type === "boolean") {
293
+ result.unknownFlags.set(name, true);
294
+ } else if (i + 1 < tokens.length) {
295
+ result.unknownFlags.set(name, tokens[++i]);
200
296
  }
201
- // Unknown flags without extensionFlags are silently ignored (first pass)
202
- } else if (!arg.startsWith("-")) {
203
- result.messages.push(arg);
297
+ continue;
204
298
  }
299
+
300
+ // Record the flag, but do NOT consume the token after it. The bootstrap parse runs before
301
+ // extensions load, so it cannot know whether an unrecognized flag takes a value: swallowing
302
+ // the next token silently discards the user's prompt whenever the flag turns out to be
303
+ // boolean (`xcsh -p --verbose "do work"`). Leaving it means a string extension flag's value
304
+ // still reaches `messages`, which is the pre-existing behaviour and the lesser harm — the
305
+ // real fix is to load extensions before the first parse, which is out of scope here.
306
+ result.unrecognizedFlags.push({ token, name: name ?? token.replace(/^-+/, "") });
205
307
  }
206
308
 
207
309
  return result;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The single source of truth for the root command's long flags.
3
+ *
4
+ * `xcsh --help` documented every long flag in the `=<value>` form while the parser matched only
5
+ * `--flag value`, so `xcsh --model=opus` silently ran the configured default and
6
+ * `xcsh --list-models=claude` fell through to the interactive TUI and hung (#2469). The two lived in
7
+ * separate tables — `commands/launch.ts` for help, `cli/args.ts` for parsing — and drifted.
8
+ *
9
+ * Both now derive from `LAUNCH_FLAGS`: `buildCliFlags` renders help from it, and `parseArgs` consumes
10
+ * it to resolve names and arity. Help and parser cannot disagree because they read the same datum.
11
+ *
12
+ * A leaf module on purpose — it imports only the thinking-effort list, so `commands/launch.ts` and
13
+ * `cli/args.ts` can both depend on it without a cycle.
14
+ */
15
+ import { THINKING_EFFORTS } from "@f5-sales-demo/pi-ai";
16
+ import { type FlagDescriptor, Flags } from "@f5-sales-demo/pi-utils/cli";
17
+
18
+ export type FlagArity = "boolean" | "value" | "optional-value" | "repeatable-value";
19
+
20
+ export interface FlagSpec {
21
+ arity: FlagArity;
22
+ /** Single-character alias, as `-p` for `--print`. */
23
+ char?: string;
24
+ description: string;
25
+ /** Enumerated legal values, validated by the parser and rendered into help. */
26
+ options?: readonly string[];
27
+ /** Accepted by the parser but omitted from help — aliases and internal plumbing. */
28
+ hidden?: true;
29
+ }
30
+
31
+ /** Keeps the key names as literals (so `APPLY` must cover every one) while widening each value. */
32
+ const defineFlags = <T extends Record<string, FlagSpec>>(flags: T): { [K in keyof T]: FlagSpec } => flags;
33
+
34
+ /**
35
+ * Every long flag the root command accepts.
36
+ *
37
+ * Where the old tables disagreed, the parser's behavior won, since that is what users observed:
38
+ * `--resume` and `--list-models` take an optional value, and `--mode` accepts `acp`.
39
+ */
40
+ export const LAUNCH_FLAGS = defineFlags({
41
+ model: {
42
+ arity: "value",
43
+ description: 'Model to use (fuzzy match: "opus", "gpt-5.2", or "p-openai/gpt-5.2")',
44
+ },
45
+ smol: { arity: "value", description: "Smol/fast model for lightweight tasks (or PI_SMOL_MODEL env)" },
46
+ slow: { arity: "value", description: "Slow/reasoning model for thorough analysis (or PI_SLOW_MODEL env)" },
47
+ plan: { arity: "value", description: "Plan model for architectural planning (or PI_PLAN_MODEL env)" },
48
+ provider: { arity: "value", description: "Provider to use (legacy; prefer --model)" },
49
+ "api-key": { arity: "value", description: "API key (defaults to env vars)" },
50
+ "system-prompt": { arity: "value", description: "System prompt (default: coding assistant prompt)" },
51
+ "append-system-prompt": { arity: "value", description: "Append text or file contents to the system prompt" },
52
+ "allow-home": { arity: "boolean", description: "Allow starting in ~ without auto-switching to a temp dir" },
53
+ "no-sandbox": {
54
+ arity: "boolean",
55
+ description: "Disable session filesystem isolation (allow access outside the CWD)",
56
+ },
57
+ "allow-path": {
58
+ arity: "repeatable-value",
59
+ description: "Grant read+write access to an extra directory (repeatable)",
60
+ },
61
+ mode: {
62
+ arity: "value",
63
+ description: "Output mode: text (default), json, rpc, or acp",
64
+ options: ["text", "json", "rpc", "acp"],
65
+ },
66
+ print: { arity: "boolean", char: "p", description: "Non-interactive mode: process prompt and exit" },
67
+ continue: { arity: "boolean", char: "c", description: "Continue previous session" },
68
+ resume: {
69
+ arity: "optional-value",
70
+ char: "r",
71
+ description: "Resume a session (by ID prefix, path, or picker if omitted)",
72
+ },
73
+ session: { arity: "optional-value", description: "Alias of --resume", hidden: true },
74
+ fork: { arity: "value", description: "Fork an existing session by ID prefix or path" },
75
+ "session-dir": { arity: "value", description: "Directory for session storage and lookup" },
76
+ "no-session": { arity: "boolean", description: "Don't save session (ephemeral)" },
77
+ "provider-session-id": { arity: "value", description: "Resume a provider-side session", hidden: true },
78
+ models: { arity: "value", description: "Comma-separated model patterns for Ctrl+P cycling" },
79
+ "no-tools": { arity: "boolean", description: "Disable all built-in tools" },
80
+ "no-mcp": { arity: "boolean", description: "Disable MCP server discovery and tools" },
81
+ "no-lsp": { arity: "boolean", description: "Disable LSP tools, formatting, and diagnostics" },
82
+ "no-pty": { arity: "boolean", description: "Disable PTY-based interactive bash execution" },
83
+ tools: { arity: "value", description: "Comma-separated list of tools to enable (default: all)" },
84
+ thinking: {
85
+ arity: "value",
86
+ description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`,
87
+ options: [...THINKING_EFFORTS],
88
+ },
89
+ hook: { arity: "repeatable-value", description: "Load a hook/extension file (can be used multiple times)" },
90
+ extension: {
91
+ arity: "repeatable-value",
92
+ char: "e",
93
+ description: "Load an extension file (can be used multiple times)",
94
+ },
95
+ "plugin-dir": { arity: "repeatable-value", description: "Load plugin from directory (repeatable)" },
96
+ "no-extensions": {
97
+ arity: "boolean",
98
+ description: "Disable extension discovery (explicit -e paths still work)",
99
+ },
100
+ "no-skills": { arity: "boolean", description: "Disable skills discovery and loading" },
101
+ skills: {
102
+ arity: "value",
103
+ description: "Comma-separated glob patterns to filter skills (e.g., git-*,docker)",
104
+ },
105
+ "no-rules": { arity: "boolean", description: "Disable rules discovery and loading" },
106
+ export: { arity: "value", description: "Export session file to HTML and exit" },
107
+ "list-models": { arity: "optional-value", description: "List available models (with optional fuzzy search)" },
108
+ "no-title": { arity: "boolean", description: "Disable title auto-generation" },
109
+ help: { arity: "boolean", char: "h", description: "Show help", hidden: true },
110
+ version: { arity: "boolean", char: "v", description: "Show version", hidden: true },
111
+ });
112
+
113
+ export type LaunchFlagName = keyof typeof LAUNCH_FLAGS;
114
+
115
+ /** Long-flag name for a single-character alias, e.g. `p` -> `print`. */
116
+ const CHAR_ALIASES: ReadonlyMap<string, LaunchFlagName> = new Map(
117
+ Object.entries(LAUNCH_FLAGS)
118
+ .filter((entry): entry is [LaunchFlagName, FlagSpec & { char: string }] => "char" in entry[1])
119
+ .map(([name, spec]) => [spec.char, name]),
120
+ );
121
+
122
+ export function flagSpec(name: string): FlagSpec | undefined {
123
+ return (LAUNCH_FLAGS as Record<string, FlagSpec>)[name];
124
+ }
125
+
126
+ export function flagNameForChar(char: string): LaunchFlagName | undefined {
127
+ return CHAR_ALIASES.get(char);
128
+ }
129
+
130
+ export function takesValue(spec: FlagSpec): boolean {
131
+ return spec.arity !== "boolean";
132
+ }
133
+
134
+ /** Render the help table from the spec, so the documented syntax is the accepted syntax. */
135
+ export function buildCliFlags(): Record<string, FlagDescriptor> {
136
+ const flags: Record<string, FlagDescriptor> = {};
137
+ for (const [name, spec] of Object.entries(LAUNCH_FLAGS) as Array<[string, FlagSpec]>) {
138
+ if (spec.hidden) continue;
139
+ flags[name] =
140
+ spec.arity === "boolean"
141
+ ? Flags.boolean({ char: spec.char, description: spec.description })
142
+ : Flags.string({
143
+ char: spec.char,
144
+ description: spec.description,
145
+ options: spec.options,
146
+ multiple: spec.arity === "repeatable-value",
147
+ });
148
+ }
149
+ return flags;
150
+ }
151
+
152
+ /** A `--name=value` token the parser could not attribute to any known flag. */
153
+ export interface UnrecognizedFlag {
154
+ /** The token as written, including any `=value`, for the diagnostic. */
155
+ token: string;
156
+ /** The flag name without dashes or `=value`, for matching against extension flags. */
157
+ name: string;
158
+ }
159
+
160
+ export class FlagUsageError extends Error {}
161
+
162
+ /**
163
+ * Rewrite `--name=value` into `["--name", "value"]` for every flag that takes a value.
164
+ *
165
+ * A boolean flag with `=` is an error rather than a guess: accepting `--no-sandbox=true` invites
166
+ * `--no-sandbox=false`, which the parser has no way to express, and quietly reading it as "on" would
167
+ * be exactly the class of bug #2469 reports. Unknown names are left intact so the unknown-flag path
168
+ * can report the token as the user wrote it.
169
+ *
170
+ * Short forms are untouched: no shell convention makes `-p=x` mean `-p x`.
171
+ */
172
+ export function normalizeFlagTokens(
173
+ args: readonly string[],
174
+ extensionFlags?: ReadonlyMap<string, { type: "boolean" | "string" }>,
175
+ ): string[] {
176
+ const normalized: string[] = [];
177
+ let afterTerminator = false;
178
+
179
+ for (const arg of args) {
180
+ if (afterTerminator) {
181
+ normalized.push(arg);
182
+ continue;
183
+ }
184
+ if (arg === "--") {
185
+ afterTerminator = true;
186
+ normalized.push(arg);
187
+ continue;
188
+ }
189
+
190
+ const match = /^--([^=]+)=([\s\S]*)$/.exec(arg);
191
+ if (!match) {
192
+ normalized.push(arg);
193
+ continue;
194
+ }
195
+
196
+ const [, name, value] = match;
197
+ const spec = flagSpec(name);
198
+ if (spec) {
199
+ if (!takesValue(spec)) {
200
+ throw new FlagUsageError(`--${name} is a boolean flag and does not take a value`);
201
+ }
202
+ normalized.push(`--${name}`, value);
203
+ continue;
204
+ }
205
+
206
+ const extension = extensionFlags?.get(name);
207
+ if (extension) {
208
+ if (extension.type === "boolean") {
209
+ throw new FlagUsageError(`--${name} is a boolean flag and does not take a value`);
210
+ }
211
+ normalized.push(`--${name}`, value);
212
+ continue;
213
+ }
214
+
215
+ normalized.push(arg);
216
+ }
217
+
218
+ return normalized;
219
+ }
@@ -222,11 +222,25 @@ async function runSideload(app: OfficeApp): Promise<void> {
222
222
  }
223
223
  }
224
224
 
225
+ /**
226
+ * The two long-running halves of the command, injectable so the dispatch order can be
227
+ * tested without binding :8444 or shelling out to `office-addin-debugging`.
228
+ */
229
+ export interface OfficeCommandDeps {
230
+ sideload: (app: OfficeApp) => Promise<void>;
231
+ serve: () => Promise<void>;
232
+ }
233
+
234
+ const defaultCommandDeps: OfficeCommandDeps = { sideload: runSideload, serve: runServe };
235
+
225
236
  /** Dispatch an `xcsh office <action>` invocation. */
226
- export async function runOfficeCommand(args: OfficeCommandArgs): Promise<void> {
237
+ export async function runOfficeCommand(
238
+ args: OfficeCommandArgs,
239
+ deps: OfficeCommandDeps = defaultCommandDeps,
240
+ ): Promise<void> {
227
241
  switch (args.action) {
228
242
  case "serve":
229
- await runServe();
243
+ await deps.serve();
230
244
  return;
231
245
  case "manifest": {
232
246
  const text = await writeManifest(args.out);
@@ -238,7 +252,16 @@ export async function runOfficeCommand(args: OfficeCommandArgs): Promise<void> {
238
252
  return;
239
253
  }
240
254
  case "sideload":
241
- await runSideload(args.app ?? "excel");
255
+ // Register FIRST, then serve — serve blocks until a signal, so anything
256
+ // sequenced after it would never run.
257
+ //
258
+ // Registering alone used to be the whole command, which quietly left the
259
+ // operator half-configured: the pane's cwd (what its file tools and shell are
260
+ // confined to) comes from wherever `office serve` was launched, so a bare
261
+ // sideload produced a pane pointed at some unrelated folder, or at nothing.
262
+ // One command from the folder you care about now does both.
263
+ await deps.sideload(args.app ?? "excel");
264
+ await deps.serve();
242
265
  return;
243
266
  case "recycle":
244
267
  console.log(await recycleOfficeServe());