@f5xc-salesdemos/xcsh 19.7.0 → 19.9.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.
@@ -118,7 +118,7 @@ export default function (pi: ExtensionAPI) {
118
118
  for (const entry of ctx.sessionManager.getBranch()) {
119
119
  if (entry.type !== "message") continue;
120
120
  const msg = (entry as { message?: { role?: string; toolName?: string; details?: unknown } }).message;
121
- if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue;
121
+ if (msg?.role !== "toolResult" || msg.toolName !== "todo") continue;
122
122
 
123
123
  const details = msg.details as TodoDetails | undefined;
124
124
  if (details) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "19.7.0",
4
+ "version": "19.9.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",
@@ -50,12 +50,12 @@
50
50
  "dependencies": {
51
51
  "@agentclientprotocol/sdk": "0.16.1",
52
52
  "@mozilla/readability": "^0.6",
53
- "@f5xc-salesdemos/xcsh-stats": "19.7.0",
54
- "@f5xc-salesdemos/pi-agent-core": "19.7.0",
55
- "@f5xc-salesdemos/pi-ai": "19.7.0",
56
- "@f5xc-salesdemos/pi-natives": "19.7.0",
57
- "@f5xc-salesdemos/pi-tui": "19.7.0",
58
- "@f5xc-salesdemos/pi-utils": "19.7.0",
53
+ "@f5xc-salesdemos/xcsh-stats": "19.9.0",
54
+ "@f5xc-salesdemos/pi-agent-core": "19.9.0",
55
+ "@f5xc-salesdemos/pi-ai": "19.9.0",
56
+ "@f5xc-salesdemos/pi-natives": "19.9.0",
57
+ "@f5xc-salesdemos/pi-tui": "19.9.0",
58
+ "@f5xc-salesdemos/pi-utils": "19.9.0",
59
59
  "@sinclair/typebox": "^0.34",
60
60
  "@xterm/headless": "^6.0",
61
61
  "ajv": "^8.20",
@@ -0,0 +1,50 @@
1
+ import type { MarketplacePluginEntry } from "./types";
2
+
3
+ const cache = new Map<string, boolean>();
4
+
5
+ export async function checkPrerequisite(detectCmd: string): Promise<boolean> {
6
+ const cached = cache.get(detectCmd);
7
+ if (cached !== undefined) return cached;
8
+
9
+ try {
10
+ const [cmd, ...args] = detectCmd.split(/\s+/);
11
+ const proc = Bun.spawn([cmd!, ...args], {
12
+ stdout: "ignore",
13
+ stderr: "ignore",
14
+ });
15
+ const exitCode = await proc.exited;
16
+ const available = exitCode === 0;
17
+ cache.set(detectCmd, available);
18
+ return available;
19
+ } catch {
20
+ cache.set(detectCmd, false);
21
+ return false;
22
+ }
23
+ }
24
+
25
+ export async function checkAllPrerequisites(
26
+ plugins: MarketplacePluginEntry[],
27
+ ): Promise<Map<string, { available: boolean; missing: string[] }>> {
28
+ const results = new Map<string, { available: boolean; missing: string[] }>();
29
+
30
+ for (const plugin of plugins) {
31
+ if (!plugin.prerequisites || plugin.prerequisites.length === 0) {
32
+ results.set(plugin.name, { available: true, missing: [] });
33
+ continue;
34
+ }
35
+
36
+ const missing: string[] = [];
37
+ for (const prereq of plugin.prerequisites) {
38
+ const ok = await checkPrerequisite(prereq.detectCmd);
39
+ if (!ok) missing.push(prereq.tool);
40
+ }
41
+
42
+ results.set(plugin.name, { available: missing.length === 0, missing });
43
+ }
44
+
45
+ return results;
46
+ }
47
+
48
+ export function clearPrerequisiteCache(): void {
49
+ cache.clear();
50
+ }
@@ -89,6 +89,8 @@ export interface MarketplacePluginEntry {
89
89
  tags?: string[];
90
90
  strict?: boolean;
91
91
  defaultEnabled?: boolean;
92
+ recommended?: boolean;
93
+ prerequisites?: Array<{ tool: string; installCmd: string; detectCmd: string }>;
92
94
  commands?: string | string[];
93
95
  agents?: string | string[];
94
96
  hooks?: string | Record<string, unknown>;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.7.0",
21
- "commit": "dbd20dc81eb4648b7941336222fccc8630c1200d",
22
- "shortCommit": "dbd20dc",
20
+ "version": "19.9.0",
21
+ "commit": "146551aadd2d3231eb436e0f5f760c96dc3ee602",
22
+ "shortCommit": "146551a",
23
23
  "branch": "main",
24
- "tag": "v19.7.0",
25
- "commitDate": "2026-06-04T19:13:51Z",
26
- "buildDate": "2026-06-04T19:45:06.219Z",
24
+ "tag": "v19.9.0",
25
+ "commitDate": "2026-06-05T02:16:52Z",
26
+ "buildDate": "2026-06-05T02:38:22.277Z",
27
27
  "dirty": true,
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/dbd20dc81eb4648b7941336222fccc8630c1200d",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.7.0"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/146551aadd2d3231eb436e0f5f760c96dc3ee602",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.9.0"
33
33
  };
package/src/lsp/render.ts CHANGED
@@ -110,7 +110,7 @@ export function renderResult(
110
110
  args?: LspParams,
111
111
  ): Component {
112
112
  const content = result.content?.[0];
113
- if (!content || content.type !== "text" || !("text" in content) || !content.text) {
113
+ if (content?.type !== "text" || !("text" in content) || !content.text) {
114
114
  const icon = formatStatusIcon("warning", theme, options.spinnerFrame);
115
115
  const header = `${icon} LSP`;
116
116
  return new Text([header, theme.fg("dim", "No result")].join("\n"), 0, 0);
@@ -42,7 +42,7 @@ function getUriPort(uri: URL): number {
42
42
 
43
43
  function validateRedirectConfig(config: MCPOAuthConfig, redirectUri: string | undefined): void {
44
44
  const parsed = parseRedirectUri(redirectUri);
45
- if (!parsed || parsed.protocol !== "https:" || !isLoopbackHostname(parsed.hostname)) {
45
+ if (parsed?.protocol !== "https:" || !isLoopbackHostname(parsed.hostname)) {
46
46
  return;
47
47
  }
48
48
 
@@ -63,7 +63,7 @@ function resolveCallbackPort(callbackPort: number | undefined, redirectUri: stri
63
63
  if (callbackPort !== undefined) return callbackPort;
64
64
 
65
65
  const parsed = parseRedirectUri(redirectUri);
66
- if (!parsed || parsed.protocol !== "http:" || !isLoopbackHostname(parsed.hostname)) {
66
+ if (parsed?.protocol !== "http:" || !isLoopbackHostname(parsed.hostname)) {
67
67
  return DEFAULT_PORT;
68
68
  }
69
69
 
@@ -121,7 +121,7 @@ function matchAgent(agent: DashboardAgent, query: string): boolean {
121
121
  function extractAssistantText(messages: AgentMessage[]): string | null {
122
122
  for (let i = messages.length - 1; i >= 0; i--) {
123
123
  const message = messages[i];
124
- if (!message || message.role !== "assistant") continue;
124
+ if (message?.role !== "assistant") continue;
125
125
  const blocks = message.content;
126
126
  if (!Array.isArray(blocks)) continue;
127
127
  const text = blocks
@@ -132,7 +132,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
132
132
  const removedLines: { lineNum: string; content: string }[] = [];
133
133
  while (i < lines.length) {
134
134
  const p = parseDiffLine(lines[i]);
135
- if (!p || p.prefix !== "-") break;
135
+ if (p?.prefix !== "-") break;
136
136
  removedLines.push({ lineNum: p.lineNum, content: p.content });
137
137
  i++;
138
138
  }
@@ -141,7 +141,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
141
141
  const addedLines: { lineNum: string; content: string }[] = [];
142
142
  while (i < lines.length) {
143
143
  const p = parseDiffLine(lines[i]);
144
- if (!p || p.prefix !== "+") break;
144
+ if (p?.prefix !== "+") break;
145
145
  addedLines.push({ lineNum: p.lineNum, content: p.content });
146
146
  i++;
147
147
  }
@@ -251,7 +251,7 @@ export class PluginDashboard extends Container {
251
251
  if (!plugin) return;
252
252
  const tabId = this.#activeTabId();
253
253
 
254
- if (tabId === "discover" && !plugin.installed) {
254
+ if ((tabId === "discover" || tabId === "recommended") && !plugin.installed) {
255
255
  await this.#installPlugin(plugin);
256
256
  } else if (tabId === "updates" && plugin.hasUpdate) {
257
257
  await this.#upgradePlugin(plugin);
@@ -289,6 +289,35 @@ export class PluginDashboard extends Container {
289
289
  }
290
290
  }
291
291
 
292
+ async #installAllRecommended(): Promise<void> {
293
+ const recommended = this.#state.allPlugins.filter(p => !p.installed && p.recommended && p.marketplace);
294
+ if (recommended.length === 0) {
295
+ this.#state.notice = "All recommended plugins are already installed";
296
+ this.#rebuildAndRender();
297
+ return;
298
+ }
299
+
300
+ this.#state.notice = `Installing ${recommended.length} recommended plugin(s)...`;
301
+ this.#rebuildAndRender();
302
+
303
+ let installed = 0;
304
+ let failed = 0;
305
+ for (const plugin of recommended) {
306
+ try {
307
+ await this.#mgr.installPlugin(plugin.name, plugin.marketplace!);
308
+ installed++;
309
+ } catch {
310
+ failed++;
311
+ }
312
+ }
313
+
314
+ this.#state.notice =
315
+ failed > 0
316
+ ? `Installed ${installed}/${recommended.length} recommended plugin(s), ${failed} failed`
317
+ : `Installed ${installed} recommended plugin(s)`;
318
+ await this.#reloadData();
319
+ }
320
+
292
321
  async #upgradePlugin(plugin: DashboardPlugin): Promise<void> {
293
322
  this.#state.notice = `Upgrading ${plugin.name}...`;
294
323
  this.#rebuildAndRender();
@@ -320,6 +349,8 @@ export class PluginDashboard extends Container {
320
349
  #getHelpText(): string {
321
350
  const tabId = this.#activeTabId();
322
351
  switch (tabId) {
352
+ case "recommended":
353
+ return " ↑/↓: navigate Enter: install A: install all Tab: next tab Ctrl+R: reload Esc: close";
323
354
  case "discover":
324
355
  return " ↑/↓: navigate Enter: install Tab: next tab Ctrl+R: reload Esc: close";
325
356
  case "updates":
@@ -421,6 +452,11 @@ export class PluginDashboard extends Container {
421
452
  return;
422
453
  }
423
454
 
455
+ if (data.toLowerCase() === "a" && this.#activeTabId() === "recommended") {
456
+ void this.#installAllRecommended();
457
+ return;
458
+ }
459
+
424
460
  if (data.toLowerCase() === "u") {
425
461
  const plugin = this.#selectedPlugin();
426
462
  if (plugin?.hasUpdate) {
@@ -24,9 +24,11 @@ export class PluginListPane implements Component {
24
24
  const msg =
25
25
  this.activeTab === "discover"
26
26
  ? "No plugins available. Add a marketplace first."
27
- : this.activeTab === "updates"
28
- ? "All plugins are up to date."
29
- : "No plugins installed.";
27
+ : this.activeTab === "recommended"
28
+ ? "All recommended plugins are installed."
29
+ : this.activeTab === "updates"
30
+ ? "All plugins are up to date."
31
+ : "No plugins installed.";
30
32
  lines.push(theme.fg("muted", ` ${msg}`));
31
33
  return lines;
32
34
  }
@@ -66,6 +68,8 @@ export class PluginListPane implements Component {
66
68
  } else {
67
69
  parts.push(theme.fg("dim", theme.status.disabled));
68
70
  }
71
+ } else if (plugin.recommended) {
72
+ parts.push(theme.fg("warning", "*"));
69
73
  } else {
70
74
  parts.push(theme.fg("dim", "·"));
71
75
  }
@@ -62,6 +62,8 @@ function catalogToDashboard(entry: MarketplacePluginEntry, marketplace: string):
62
62
  installed: false,
63
63
  enabled: false,
64
64
  hasUpdate: false,
65
+ recommended: entry.recommended,
66
+ prerequisites: entry.prerequisites,
65
67
  };
66
68
  }
67
69
 
@@ -98,6 +100,8 @@ export async function loadAllPlugins(mgr: MarketplaceManager, npmMgr: PluginMana
98
100
  const existing = plugins.find(p => p.id === pluginId);
99
101
  if (existing) {
100
102
  existing.displayName = existing.displayName || entry.displayName;
103
+ existing.recommended = existing.recommended || entry.recommended;
104
+ existing.prerequisites = existing.prerequisites || entry.prerequisites;
101
105
  existing.description = existing.description || entry.description;
102
106
  existing.category = existing.category || entry.category;
103
107
  existing.tags = existing.tags || entry.tags;
@@ -126,10 +130,14 @@ export async function loadAllPlugins(mgr: MarketplaceManager, npmMgr: PluginMana
126
130
  export function buildTabs(plugins: DashboardPlugin[]): PluginTab[] {
127
131
  const tabs: PluginTab[] = [];
128
132
  const installedCount = plugins.filter(p => p.installed).length;
133
+ const recommendedCount = plugins.filter(p => !p.installed && p.recommended).length;
129
134
  const discoverCount = plugins.filter(p => !p.installed).length;
130
135
  const updatesCount = plugins.filter(p => p.hasUpdate).length;
131
136
 
132
137
  tabs.push({ id: "installed", label: "Installed", count: installedCount });
138
+ if (recommendedCount > 0) {
139
+ tabs.push({ id: "recommended", label: "Recommended", count: recommendedCount });
140
+ }
133
141
  if (discoverCount > 0) {
134
142
  tabs.push({ id: "discover", label: "Discover", count: discoverCount });
135
143
  }
@@ -143,6 +151,8 @@ export function filterByTab(plugins: DashboardPlugin[], tabId: PluginTabId): Das
143
151
  switch (tabId) {
144
152
  case "installed":
145
153
  return plugins.filter(p => p.installed);
154
+ case "recommended":
155
+ return plugins.filter(p => !p.installed && p.recommended);
146
156
  case "discover":
147
157
  return plugins.filter(p => !p.installed);
148
158
  case "updates":
@@ -18,9 +18,11 @@ export interface DashboardPlugin {
18
18
  shadowedBy?: "project";
19
19
  hasUpdate: boolean;
20
20
  updateVersion?: string;
21
+ recommended?: boolean;
22
+ prerequisites?: Array<{ tool: string; installCmd: string; detectCmd: string }>;
21
23
  }
22
24
 
23
- export type PluginTabId = "installed" | "discover" | "updates";
25
+ export type PluginTabId = "installed" | "recommended" | "discover" | "updates";
24
26
 
25
27
  export interface PluginTab {
26
28
  id: PluginTabId;
@@ -22,3 +22,34 @@ API calls to the same F5 XC tenant reuse a single TLS connection — sequential
22
22
 
23
23
  **Relationship queries**: When the batch response says "Inventory complete" and includes a `Resource relationships:` section, the specs and relationships are already fully fetched. Answer directly from that data. Do NOT make additional GET calls to read individual resources you already have from the batch.
24
24
  **Tenant-wide queries**: When asked about resources across ALL namespaces (e.g. "show all LBs in the entire tenant"), use `paths: ["*"]` with `params: {namespace: "*"}` to batch every namespace in ONE call. Do NOT list namespaces first — the wildcard handles discovery automatically.
25
+
26
+ **HTTP load balancer with origin pool** — when asked to create an LB routing to a named pool, use this exact payload structure (POST to `http_loadbalancers`):
27
+
28
+ ```json
29
+ {
30
+ "metadata": { "name": "<lb-name>", "namespace": "<ns>" },
31
+ "spec": {
32
+ "domains": ["<domain>"],
33
+ "advertise_on_public_default_vip": {},
34
+ "http": { "port": 80 },
35
+ "default_route_pools": [
36
+ { "pool": { "namespace": "<ns>", "name": "<pool-name>" }, "weight": 1, "priority": 1 }
37
+ ]
38
+ }
39
+ }
40
+ ```
41
+
42
+ For HTTPS: replace `"http": {"port": 80}` with `"https_auto_cert": {"http_redirect": true, "default_header": {}, "tls_config": {"default_security": {}}, "no_mtls": {}}`. For no pool (advertise only): omit `default_route_pools`.
43
+
44
+ **Resource disambiguation**: Several F5 XC resource types have similar names but different API paths. When the user's intent maps to one of these, use the exact API path shown:
45
+
46
+ |User says|Catalog category|API path segment|NOT|
47
+ |---|---|---|---|
48
+ |"rate limiter policy"|`rate-limiter-policys`|`rate_limiter_policys`|`policers`, `rate_limiters`|
49
+ |"policer"|`policers`|`policers`|`rate_limiter_policys`|
50
+ |"rate limiter"|`rate-limiters`|`rate_limiters`|`rate_limiter_policys`, `policers`|
51
+
52
+ **payload schemas for rate-limiting resources:**
53
+ - `policers` / "policer": `{"metadata":{"name":"<n>","namespace":"<ns>"},"spec":{"burst_size":<int>,"committed_information_rate":<int>}}` — network-level byte/Mbps limiting
54
+ - `rate_limiters` / "rate limiter": `{"metadata":{"name":"<n>","namespace":"<ns>"},"spec":{"burst_size":<int>,"committed_information_rate":<int>}}` — HTTP request-level limiting (rps)
55
+ - `rate_limiter_policys` / "rate limiter policy": requires existing `rate_limiter` reference; use `{"metadata":{"name":"<n>","namespace":"<ns>"},"spec":{"any_server":{},"rules":[{"metadata":{"name":"r"},"spec":{"any_client":{},"any_ip":{},"rate_limiter":{"namespace":"<ns>","name":"<rl-name>"}}}]}}`
@@ -4517,7 +4517,7 @@ export class AgentSession {
4517
4517
 
4518
4518
  #closeCodexProviderSessionsForHistoryRewrite(): void {
4519
4519
  const currentModel = this.model;
4520
- if (!currentModel || currentModel.api !== "openai-codex-responses") return;
4520
+ if (currentModel?.api !== "openai-codex-responses") return;
4521
4521
  this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
4522
4522
  }
4523
4523
 
@@ -6115,7 +6115,7 @@ export class AgentSession {
6115
6115
  const previousSessionFile = this.sessionFile;
6116
6116
  const selectedEntry = this.sessionManager.getEntry(entryId);
6117
6117
 
6118
- if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
6118
+ if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
6119
6119
  throw new Error("Invalid entry ID for branching");
6120
6120
  }
6121
6121
 
@@ -875,6 +875,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
875
875
  { name: "discover", description: "Browse available plugins", usage: "[marketplace]" },
876
876
  { name: "list", description: "List all installed plugins" },
877
877
  { name: "validate", description: "Validate marketplace or plugin manifest", usage: "[path]" },
878
+ { name: "setup", description: "Guided setup for recommended plugins" },
878
879
  { name: "help", description: "Show usage guide" },
879
880
  ],
880
881
  allowArgs: true,
@@ -969,7 +970,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
969
970
  }
970
971
  break;
971
972
  }
972
- case "list":
973
973
  default: {
974
974
  const marketplaces = await mgr.listMarketplaces();
975
975
  if (marketplaces.length === 0) {
@@ -1162,6 +1162,80 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
1162
1162
  }
1163
1163
  break;
1164
1164
  }
1165
+ // ── Setup (guided recommended plugin install) ──
1166
+ case "setup": {
1167
+ const { checkPrerequisite } = await import("../extensibility/plugins/marketplace/prerequisites");
1168
+ const allPlugins = await mgr.listAvailablePlugins();
1169
+ const recommended = allPlugins.filter(p => p.recommended);
1170
+ if (recommended.length === 0) {
1171
+ runtime.ctx.showStatus("No recommended plugins found in configured marketplaces");
1172
+ break;
1173
+ }
1174
+
1175
+ const installed = await mgr.listInstalledPlugins();
1176
+ const installedIds = new Set(installed.map(p => p.id));
1177
+ const toInstall = recommended.filter(
1178
+ p => !Array.from(installedIds).some(id => id.startsWith(`${p.name}@`)),
1179
+ );
1180
+
1181
+ if (toInstall.length === 0) {
1182
+ runtime.ctx.showStatus("All recommended plugins are already installed");
1183
+ break;
1184
+ }
1185
+
1186
+ const lines: string[] = ["Recommended plugins setup:\n"];
1187
+ let installedCount = 0;
1188
+ let skippedCount = 0;
1189
+ const skippedReasons: string[] = [];
1190
+
1191
+ for (const plugin of toInstall) {
1192
+ if (plugin.prerequisites && plugin.prerequisites.length > 0) {
1193
+ const missing: string[] = [];
1194
+ for (const prereq of plugin.prerequisites) {
1195
+ const ok = await checkPrerequisite(prereq.detectCmd);
1196
+ if (!ok) missing.push(`${prereq.tool} (${prereq.installCmd})`);
1197
+ }
1198
+ if (missing.length > 0) {
1199
+ lines.push(` ⊘ ${plugin.displayName || plugin.name} — missing: ${missing.join(", ")}`);
1200
+ skippedCount++;
1201
+ skippedReasons.push(...missing);
1202
+ continue;
1203
+ }
1204
+ }
1205
+
1206
+ const marketplaces = await mgr.listMarketplaces();
1207
+ let installed = false;
1208
+ for (const mkt of marketplaces) {
1209
+ const available = await mgr.listAvailablePlugins(mkt.name);
1210
+ if (available.some(a => a.name === plugin.name)) {
1211
+ try {
1212
+ await mgr.installPlugin(plugin.name, mkt.name);
1213
+ lines.push(` + ${plugin.displayName || plugin.name} — installed`);
1214
+ installedCount++;
1215
+ installed = true;
1216
+ } catch (err) {
1217
+ lines.push(
1218
+ ` ! ${plugin.displayName || plugin.name} — ${err instanceof Error ? err.message : String(err)}`,
1219
+ );
1220
+ skippedCount++;
1221
+ }
1222
+ break;
1223
+ }
1224
+ }
1225
+ if (!installed && skippedCount === 0) {
1226
+ lines.push(` ? ${plugin.displayName || plugin.name} — not found in any marketplace`);
1227
+ skippedCount++;
1228
+ }
1229
+ }
1230
+
1231
+ lines.push("");
1232
+ lines.push(`Installed ${installedCount}/${toInstall.length} recommended plugin(s)`);
1233
+ if (skippedCount > 0) {
1234
+ lines.push(`${skippedCount} skipped — install missing tools and run /plugin setup again`);
1235
+ }
1236
+ runtime.ctx.showStatus(lines.join("\n"));
1237
+ break;
1238
+ }
1165
1239
  // ── Help ──
1166
1240
  case "help": {
1167
1241
  runtime.ctx.showStatus(
@@ -1180,6 +1254,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
1180
1254
  " /plugin upgrade [name@marketplace] Upgrade plugin(s)",
1181
1255
  " /plugin list List installed plugins",
1182
1256
  " /plugin validate [path] Validate marketplace or plugin",
1257
+ " /plugin setup Guided setup for recommended plugins",
1183
1258
  "",
1184
1259
  "Quick start:",
1185
1260
  " /plugin marketplace add f5xc-salesdemos/marketplace",
package/src/vim/engine.ts CHANGED
@@ -867,7 +867,7 @@ export class VimEngine {
867
867
  }
868
868
  case "r": {
869
869
  const replacement = tokens[nextIndex + 1];
870
- if (!replacement || replacement.value.length !== 1) {
870
+ if (replacement?.value.length !== 1) {
871
871
  throw new VimError("Visual replace requires a literal character", opToken);
872
872
  }
873
873
  const visual = expandVisualOffsets(
@@ -1134,7 +1134,7 @@ export class VimEngine {
1134
1134
  return nextIndex + 1;
1135
1135
  case "r": {
1136
1136
  const replacement = tokens[nextIndex + 1];
1137
- if (!replacement || replacement.value.length !== 1) {
1137
+ if (replacement?.value.length !== 1) {
1138
1138
  throw new VimError("r requires a replacement character", token);
1139
1139
  }
1140
1140
  await this.#applyAtomicChange(["r", replacement.value], () => {
@@ -1763,7 +1763,7 @@ export class VimEngine {
1763
1763
  case "t":
1764
1764
  case "T": {
1765
1765
  const searchToken = tokens[index + 1];
1766
- if (!searchToken || searchToken.value.length !== 1) {
1766
+ if (searchToken?.value.length !== 1) {
1767
1767
  throw new VimError(`${token.value} requires a literal character`, token);
1768
1768
  }
1769
1769
  this.lastCharFind = { char: searchToken.value, mode: token.value as "f" | "F" | "t" | "T" };