@f5xc-salesdemos/xcsh 19.9.1 → 19.11.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": "19.9.1",
4
+ "version": "19.11.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.9.1",
54
- "@f5xc-salesdemos/pi-agent-core": "19.9.1",
55
- "@f5xc-salesdemos/pi-ai": "19.9.1",
56
- "@f5xc-salesdemos/pi-natives": "19.9.1",
57
- "@f5xc-salesdemos/pi-tui": "19.9.1",
58
- "@f5xc-salesdemos/pi-utils": "19.9.1",
53
+ "@f5xc-salesdemos/xcsh-stats": "19.11.0",
54
+ "@f5xc-salesdemos/pi-agent-core": "19.11.0",
55
+ "@f5xc-salesdemos/pi-ai": "19.11.0",
56
+ "@f5xc-salesdemos/pi-natives": "19.11.0",
57
+ "@f5xc-salesdemos/pi-tui": "19.11.0",
58
+ "@f5xc-salesdemos/pi-utils": "19.11.0",
59
59
  "@sinclair/typebox": "^0.34",
60
60
  "@xterm/headless": "^6.0",
61
61
  "ajv": "^8.20",
@@ -1,29 +1,230 @@
1
- import type { MarketplacePluginEntry } from "./types";
1
+ import { logger } from "@f5xc-salesdemos/pi-utils";
2
2
 
3
- const cache = new Map<string, boolean>();
3
+ // ── Types ────────────────────────────────────────────────────────────────────
4
+
5
+ export interface Prerequisite {
6
+ tool: string;
7
+ installCmd: string;
8
+ detectCmd: string;
9
+ authDetectCmd?: string;
10
+ authLoginCmd?: string;
11
+ }
12
+
13
+ export interface ToolStatus {
14
+ tool: string;
15
+ installed: boolean;
16
+ authenticated: boolean;
17
+ user?: string;
18
+ error?: string;
19
+ }
20
+
21
+ export interface SetupResult {
22
+ tool: string;
23
+ wasInstalled: boolean;
24
+ installAttempted: boolean;
25
+ installSuccess: boolean;
26
+ authenticated: boolean;
27
+ authLoginCmd?: string;
28
+ user?: string;
29
+ error?: string;
30
+ }
31
+
32
+ // ── Cache ────────────────────────────────────────────────────────────────────
33
+
34
+ const detectCache = new Map<string, boolean>();
35
+
36
+ export function clearPrerequisiteCache(): void {
37
+ detectCache.clear();
38
+ }
39
+
40
+ // ── Retry utility ────────────────────────────────────────────────────────────
41
+
42
+ export async function withRetry<T>(
43
+ fn: () => Promise<T>,
44
+ opts: { maxRetries?: number; baseDelayMs?: number; label?: string } = {},
45
+ ): Promise<T> {
46
+ const maxRetries = opts.maxRetries ?? 3;
47
+ const baseDelayMs = opts.baseDelayMs ?? 1000;
48
+ let lastError: unknown;
49
+
50
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
51
+ try {
52
+ return await fn();
53
+ } catch (err) {
54
+ lastError = err;
55
+ if (attempt < maxRetries) {
56
+ const delay = baseDelayMs * 2 ** attempt;
57
+ logger.debug(`Retry ${attempt + 1}/${maxRetries} for ${opts.label ?? "operation"} in ${delay}ms`);
58
+ await new Promise(resolve => setTimeout(resolve, delay));
59
+ }
60
+ }
61
+ }
62
+
63
+ throw lastError;
64
+ }
65
+
66
+ // ── Tool detection ───────────────────────────────────────────────────────────
4
67
 
5
68
  export async function checkPrerequisite(detectCmd: string): Promise<boolean> {
6
- const cached = cache.get(detectCmd);
69
+ const cached = detectCache.get(detectCmd);
7
70
  if (cached !== undefined) return cached;
8
71
 
9
72
  try {
10
73
  const [cmd, ...args] = detectCmd.split(/\s+/);
11
- const proc = Bun.spawn([cmd!, ...args], {
12
- stdout: "ignore",
13
- stderr: "ignore",
14
- });
74
+ const proc = Bun.spawn([cmd!, ...args], { stdout: "ignore", stderr: "ignore" });
15
75
  const exitCode = await proc.exited;
16
76
  const available = exitCode === 0;
17
- cache.set(detectCmd, available);
77
+ detectCache.set(detectCmd, available);
18
78
  return available;
19
79
  } catch {
20
- cache.set(detectCmd, false);
80
+ detectCache.set(detectCmd, false);
21
81
  return false;
22
82
  }
23
83
  }
24
84
 
85
+ // ── Tool installation ────────────────────────────────────────────────────────
86
+
87
+ export async function installTool(
88
+ installCmd: string,
89
+ opts?: { maxRetries?: number; baseDelayMs?: number },
90
+ ): Promise<{ success: boolean; error?: string }> {
91
+ try {
92
+ await withRetry(
93
+ async () => {
94
+ const parts = installCmd.split(/\s+/);
95
+ const proc = Bun.spawn(parts, { stdout: "ignore", stderr: "pipe" });
96
+ const exitCode = await proc.exited;
97
+ if (exitCode !== 0) {
98
+ const stderr = await new Response(proc.stderr).text();
99
+ throw new Error(stderr.trim() || `exit code ${exitCode}`);
100
+ }
101
+ },
102
+ { maxRetries: opts?.maxRetries ?? 2, baseDelayMs: opts?.baseDelayMs ?? 2000, label: installCmd },
103
+ );
104
+ return { success: true };
105
+ } catch (err) {
106
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
107
+ }
108
+ }
109
+
110
+ // ── Auth detection ───────────────────────────────────────────────────────────
111
+
112
+ export async function checkAuth(
113
+ authDetectCmd: string,
114
+ ): Promise<{ authenticated: boolean; user?: string; error?: string }> {
115
+ try {
116
+ const parts = authDetectCmd.split(/\s+/);
117
+ const proc = Bun.spawn(parts, { stdout: "pipe", stderr: "pipe" });
118
+ const exitCode = await proc.exited;
119
+
120
+ if (exitCode !== 0) {
121
+ return { authenticated: false };
122
+ }
123
+
124
+ let user: string | undefined;
125
+ try {
126
+ const stdout = await new Response(proc.stdout).text();
127
+ const parsed = JSON.parse(stdout);
128
+ user = parsed?.user?.name ?? parsed?.Account ?? parsed?.login ?? parsed?.username;
129
+ } catch {
130
+ // Non-JSON output is fine — exit code 0 means authenticated
131
+ }
132
+
133
+ return { authenticated: true, user };
134
+ } catch {
135
+ return { authenticated: false, error: "auth check command failed" };
136
+ }
137
+ }
138
+
139
+ // ── Full tool readiness check ────────────────────────────────────────────────
140
+
141
+ export async function checkToolReady(prereq: Prerequisite): Promise<ToolStatus> {
142
+ const installed = await checkPrerequisite(prereq.detectCmd);
143
+ if (!installed) {
144
+ return { tool: prereq.tool, installed: false, authenticated: false };
145
+ }
146
+
147
+ if (!prereq.authDetectCmd) {
148
+ return { tool: prereq.tool, installed: true, authenticated: true };
149
+ }
150
+
151
+ const auth = await checkAuth(prereq.authDetectCmd);
152
+ return {
153
+ tool: prereq.tool,
154
+ installed: true,
155
+ authenticated: auth.authenticated,
156
+ user: auth.user,
157
+ };
158
+ }
159
+
160
+ // ── Full setup orchestration for one tool ────────────────────────────────────
161
+
162
+ export async function setupTool(prereq: Prerequisite): Promise<SetupResult> {
163
+ // Step 1: Check if already installed
164
+ let installed = await checkPrerequisite(prereq.detectCmd);
165
+ let installAttempted = false;
166
+ let installSuccess = false;
167
+
168
+ if (!installed) {
169
+ // Step 2: Attempt installation with retry
170
+ installAttempted = true;
171
+ detectCache.delete(prereq.detectCmd);
172
+ const result = await installTool(prereq.installCmd);
173
+ installSuccess = result.success;
174
+
175
+ if (!result.success) {
176
+ return {
177
+ tool: prereq.tool,
178
+ wasInstalled: false,
179
+ installAttempted: true,
180
+ installSuccess: false,
181
+ authenticated: false,
182
+ error: `Install failed: ${result.error}`,
183
+ };
184
+ }
185
+
186
+ // Verify installation
187
+ detectCache.delete(prereq.detectCmd);
188
+ installed = await checkPrerequisite(prereq.detectCmd);
189
+ if (!installed) {
190
+ return {
191
+ tool: prereq.tool,
192
+ wasInstalled: false,
193
+ installAttempted: true,
194
+ installSuccess: false,
195
+ authenticated: false,
196
+ error: "Install appeared to succeed but tool not found on PATH",
197
+ };
198
+ }
199
+ }
200
+
201
+ // Step 3: Check auth
202
+ if (!prereq.authDetectCmd) {
203
+ return {
204
+ tool: prereq.tool,
205
+ wasInstalled: !installAttempted,
206
+ installAttempted,
207
+ installSuccess: installAttempted ? true : false,
208
+ authenticated: true,
209
+ };
210
+ }
211
+
212
+ const auth = await checkAuth(prereq.authDetectCmd);
213
+ return {
214
+ tool: prereq.tool,
215
+ wasInstalled: !installAttempted,
216
+ installAttempted,
217
+ installSuccess: installAttempted ? true : false,
218
+ authenticated: auth.authenticated,
219
+ user: auth.user,
220
+ authLoginCmd: auth.authenticated ? undefined : prereq.authLoginCmd,
221
+ };
222
+ }
223
+
224
+ // ── Batch operations ─────────────────────────────────────────────────────────
225
+
25
226
  export async function checkAllPrerequisites(
26
- plugins: MarketplacePluginEntry[],
227
+ plugins: Array<{ name: string; prerequisites?: Prerequisite[] }>,
27
228
  ): Promise<Map<string, { available: boolean; missing: string[] }>> {
28
229
  const results = new Map<string, { available: boolean; missing: string[] }>();
29
230
 
@@ -45,6 +246,10 @@ export async function checkAllPrerequisites(
45
246
  return results;
46
247
  }
47
248
 
48
- export function clearPrerequisiteCache(): void {
49
- cache.clear();
249
+ export async function setupAllTools(prerequisites: Prerequisite[]): Promise<SetupResult[]> {
250
+ const results: SetupResult[] = [];
251
+ for (const prereq of prerequisites) {
252
+ results.push(await setupTool(prereq));
253
+ }
254
+ return results;
50
255
  }
@@ -90,7 +90,13 @@ export interface MarketplacePluginEntry {
90
90
  strict?: boolean;
91
91
  defaultEnabled?: boolean;
92
92
  recommended?: boolean;
93
- prerequisites?: Array<{ tool: string; installCmd: string; detectCmd: string }>;
93
+ prerequisites?: Array<{
94
+ tool: string;
95
+ installCmd: string;
96
+ detectCmd: string;
97
+ authDetectCmd?: string;
98
+ authLoginCmd?: string;
99
+ }>;
94
100
  commands?: string | string[];
95
101
  agents?: string | string[];
96
102
  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.9.1",
21
- "commit": "d3150a05f1d9eb0b91c234b970ed08b8f679ad3f",
22
- "shortCommit": "d3150a0",
20
+ "version": "19.11.0",
21
+ "commit": "f1aa8804fd9d391462a8c13d4ff0de70b01e20b8",
22
+ "shortCommit": "f1aa880",
23
23
  "branch": "main",
24
- "tag": "v19.9.1",
25
- "commitDate": "2026-06-05T02:56:19Z",
26
- "buildDate": "2026-06-05T03:25:42.133Z",
24
+ "tag": "v19.11.0",
25
+ "commitDate": "2026-06-05T12:59:46Z",
26
+ "buildDate": "2026-06-05T13:30:16.635Z",
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/d3150a05f1d9eb0b91c234b970ed08b8f679ad3f",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.9.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/f1aa8804fd9d391462a8c13d4ff0de70b01e20b8",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.11.0"
33
33
  };
@@ -290,6 +290,7 @@ export class PluginDashboard extends Container {
290
290
  }
291
291
 
292
292
  async #installAllRecommended(): Promise<void> {
293
+ const { setupTool } = await import("../../../extensibility/plugins/marketplace/prerequisites");
293
294
  const recommended = this.#state.allPlugins.filter(p => !p.installed && p.recommended && p.marketplace);
294
295
  if (recommended.length === 0) {
295
296
  this.#state.notice = "All recommended plugins are already installed";
@@ -297,12 +298,33 @@ export class PluginDashboard extends Container {
297
298
  return;
298
299
  }
299
300
 
300
- this.#state.notice = `Installing ${recommended.length} recommended plugin(s)...`;
301
- this.#rebuildAndRender();
302
-
303
301
  let installed = 0;
304
302
  let failed = 0;
303
+ const authNeeded: string[] = [];
304
+
305
305
  for (const plugin of recommended) {
306
+ this.#state.notice = `Setting up ${plugin.displayName || plugin.name}... (${installed + failed + 1}/${recommended.length})`;
307
+ this.#rebuildAndRender();
308
+
309
+ // Check and install prerequisites
310
+ if (plugin.prerequisites && plugin.prerequisites.length > 0) {
311
+ let prereqReady = true;
312
+ for (const prereq of plugin.prerequisites) {
313
+ const result = await setupTool(prereq);
314
+ if (!result.installSuccess && result.installAttempted) {
315
+ prereqReady = false;
316
+ break;
317
+ }
318
+ if (!result.authenticated && prereq.authLoginCmd) {
319
+ authNeeded.push(`${prereq.tool}: ${prereq.authLoginCmd}`);
320
+ }
321
+ }
322
+ if (!prereqReady) {
323
+ failed++;
324
+ continue;
325
+ }
326
+ }
327
+
306
328
  try {
307
329
  await this.#mgr.installPlugin(plugin.name, plugin.marketplace!);
308
330
  installed++;
@@ -311,10 +333,10 @@ export class PluginDashboard extends Container {
311
333
  }
312
334
  }
313
335
 
314
- this.#state.notice =
315
- failed > 0
316
- ? `Installed ${installed}/${recommended.length} recommended plugin(s), ${failed} failed`
317
- : `Installed ${installed} recommended plugin(s)`;
336
+ const parts = [`Installed ${installed}/${recommended.length} recommended plugin(s)`];
337
+ if (failed > 0) parts.push(`${failed} failed`);
338
+ if (authNeeded.length > 0) parts.push(`Auth needed: ${authNeeded.join(", ")}`);
339
+ this.#state.notice = parts.join(". ");
318
340
  await this.#reloadData();
319
341
  }
320
342
 
@@ -3,12 +3,9 @@ import type { MarketplaceManager } from "../../../extensibility/plugins/marketpl
3
3
  import type { InstalledPluginSummary, MarketplacePluginEntry } from "../../../extensibility/plugins/marketplace/types";
4
4
  import type { DashboardPlugin, PluginDashboardState, PluginTab, PluginTabId } from "./types";
5
5
 
6
- export function normalizePluginDisplayName(name: string): string {
7
- let result = name;
8
- if (result.startsWith("f5xc-")) result = result.slice(5);
9
- if (result.length > 0 && result.endsWith("-status")) result = result.slice(0, -7);
10
- return result || name;
11
- }
6
+ export { normalizePluginDisplayName } from "./utils";
7
+
8
+ import { normalizePluginDisplayName } from "./utils";
12
9
 
13
10
  function npmToDashboard(npm: { name: string; version: string; enabled: boolean }): DashboardPlugin {
14
11
  return {
@@ -19,7 +19,13 @@ export interface DashboardPlugin {
19
19
  hasUpdate: boolean;
20
20
  updateVersion?: string;
21
21
  recommended?: boolean;
22
- prerequisites?: Array<{ tool: string; installCmd: string; detectCmd: string }>;
22
+ prerequisites?: Array<{
23
+ tool: string;
24
+ installCmd: string;
25
+ detectCmd: string;
26
+ authDetectCmd?: string;
27
+ authLoginCmd?: string;
28
+ }>;
23
29
  }
24
30
 
25
31
  export type PluginTabId = "installed" | "recommended" | "discover" | "updates";
@@ -0,0 +1,6 @@
1
+ export function normalizePluginDisplayName(name: string): string {
2
+ let result = name;
3
+ if (result.startsWith("f5xc-")) result = result.slice(5);
4
+ if (result.length > 0 && result.endsWith("-status")) result = result.slice(0, -7);
5
+ return result || name;
6
+ }
@@ -1,9 +1,17 @@
1
1
  import type { Model } from "@f5xc-salesdemos/pi-ai";
2
2
  import { validateApiKeyAgainstModelsEndpoint } from "@f5xc-salesdemos/pi-ai/utils/oauth/api-key-validation";
3
3
  import { logger } from "@f5xc-salesdemos/pi-utils";
4
+ import { MarketplaceManager } from "../../extensibility/plugins/marketplace";
5
+ import {
6
+ getInstalledPluginsRegistryPath,
7
+ getMarketplacesCacheDir,
8
+ getMarketplacesRegistryPath,
9
+ getPluginsCacheDir,
10
+ } from "../../extensibility/plugins/marketplace/registry";
4
11
  import { type AuthStatus, ContextService } from "../../services/f5xc-context";
5
12
  import { deriveTenantFromUrl } from "../../services/f5xc-env";
6
13
  import type { AuthStorage } from "../../session/auth-storage";
14
+ import { normalizePluginDisplayName } from "./plugins/utils";
7
15
 
8
16
  // Startup validation budget. These are longer than validateToken's 3000ms default because
9
17
  // the welcome path runs during TLS/DNS cold-start — a single 3s shot races against warm-up
@@ -214,3 +222,45 @@ export interface FixableService {
214
222
  command: string[];
215
223
  recheck: () => Promise<ServiceStatus>;
216
224
  }
225
+
226
+ export interface RecommendedPluginStatus {
227
+ name: string;
228
+ installed: boolean;
229
+ }
230
+
231
+ export async function checkRecommendedPlugins(): Promise<RecommendedPluginStatus[]> {
232
+ try {
233
+ const mgr = new MarketplaceManager({
234
+ marketplacesRegistryPath: getMarketplacesRegistryPath(),
235
+ installedRegistryPath: getInstalledPluginsRegistryPath(),
236
+ marketplacesCacheDir: getMarketplacesCacheDir(),
237
+ pluginsCacheDir: getPluginsCacheDir(),
238
+ clearPluginRootsCache: () => {},
239
+ });
240
+
241
+ const [marketplaces, installedSummaries] = await Promise.all([
242
+ mgr.listMarketplaces(),
243
+ mgr.listInstalledPlugins(),
244
+ ]);
245
+
246
+ const installedIds = new Set(installedSummaries.map(s => s.id));
247
+ const results: RecommendedPluginStatus[] = [];
248
+
249
+ for (const mkt of marketplaces) {
250
+ const available = await mgr.listAvailablePlugins(mkt.name).catch(() => []);
251
+ for (const entry of available) {
252
+ if (!entry.recommended) continue;
253
+ const pluginId = `${entry.name}@${mkt.name}`;
254
+ results.push({
255
+ name: normalizePluginDisplayName(entry.name),
256
+ installed: installedIds.has(pluginId),
257
+ });
258
+ }
259
+ }
260
+
261
+ return results.sort((a, b) => a.name.localeCompare(b.name));
262
+ } catch (err) {
263
+ logger.debug("checkRecommendedPlugins failed", { error: String(err) });
264
+ return [];
265
+ }
266
+ }
@@ -2,7 +2,7 @@ import { type Component, padding, truncateToWidth, visibleWidth } from "@f5xc-sa
2
2
  import { APP_NAME } from "@f5xc-salesdemos/pi-utils";
3
3
  import { theme } from "../../modes/theme/theme";
4
4
  import { formatStatusIcon } from "../../services/f5xc-context-indicators";
5
- import type { ModelStatus, ServiceStatus } from "./welcome-checks";
5
+ import type { ModelStatus, RecommendedPluginStatus, ServiceStatus } from "./welcome-checks";
6
6
 
7
7
  export interface UpdateStatus {
8
8
  available: boolean;
@@ -15,6 +15,7 @@ export class WelcomeComponent implements Component {
15
15
  private modelStatus: ModelStatus,
16
16
  private services: ServiceStatus[] = [],
17
17
  private updateStatus?: UpdateStatus,
18
+ private recommendedPlugins: RecommendedPluginStatus[] = [],
18
19
  ) {}
19
20
  invalidate(): void {}
20
21
  setModelStatus(status: ModelStatus): void {
@@ -26,6 +27,9 @@ export class WelcomeComponent implements Component {
26
27
  setUpdateStatus(status: UpdateStatus | undefined): void {
27
28
  this.updateStatus = status;
28
29
  }
30
+ setRecommendedPlugins(plugins: RecommendedPluginStatus[]): void {
31
+ this.recommendedPlugins = plugins;
32
+ }
29
33
 
30
34
  render(termWidth: number): string[] {
31
35
  const minLeftCol = 48;
@@ -136,6 +140,12 @@ export class WelcomeComponent implements Component {
136
140
  lines.push(this.#renderServiceLine(svc));
137
141
  }
138
142
  }
143
+ if (this.recommendedPlugins.length > 0) {
144
+ lines.push(" Recommended Plugins");
145
+ for (const p of this.recommendedPlugins) {
146
+ lines.push(this.#renderRecommendedLine(p));
147
+ }
148
+ }
139
149
  if (this.#showUpdateSection()) {
140
150
  lines.push(this.#renderUpdateLine());
141
151
  }
@@ -163,7 +173,11 @@ export class WelcomeComponent implements Component {
163
173
  lines.push("");
164
174
  const coreServices = this.services.filter(s => !s._isPlugin);
165
175
  const pluginServices = this.services.filter(s => s._isPlugin);
166
- const hasContent = coreServices.length > 0 || pluginServices.length > 0 || this.#showUpdateSection();
176
+ const hasContent =
177
+ coreServices.length > 0 ||
178
+ pluginServices.length > 0 ||
179
+ this.recommendedPlugins.length > 0 ||
180
+ this.#showUpdateSection();
167
181
  if (hasContent) {
168
182
  lines.push(separator);
169
183
  for (const svc of coreServices) {
@@ -179,6 +193,18 @@ export class WelcomeComponent implements Component {
179
193
  }
180
194
  }
181
195
  }
196
+ if (this.recommendedPlugins.length > 0) {
197
+ lines.push("");
198
+ lines.push(` ${theme.fg("dim", "Recommended Plugins")}`);
199
+ for (const p of this.recommendedPlugins) {
200
+ lines.push(this.#renderRecommendedLine(p));
201
+ }
202
+ const missing = this.recommendedPlugins.filter(p => !p.installed);
203
+ if (missing.length > 0) {
204
+ lines.push(` ${theme.fg("dim", "run: /plugin setup")}`);
205
+ }
206
+ }
207
+
182
208
  if (this.#showUpdateSection()) {
183
209
  lines.push(this.#renderUpdateLine());
184
210
  }
@@ -187,6 +213,13 @@ export class WelcomeComponent implements Component {
187
213
  return lines;
188
214
  }
189
215
 
216
+ #renderRecommendedLine(plugin: RecommendedPluginStatus): string {
217
+ if (plugin.installed) {
218
+ return ` ${formatStatusIcon("connected")} ${theme.fg("muted", plugin.name)}`;
219
+ }
220
+ return ` ${theme.fg("dim", "·")} ${theme.fg("dim", plugin.name)}`;
221
+ }
222
+
190
223
  #showUpdateSection(): boolean {
191
224
  return this.updateStatus?.available === true;
192
225
  }
@@ -53,6 +53,7 @@ import { StatusLineComponent } from "./components/status-line";
53
53
  import type { ToolExecutionHandle } from "./components/tool-execution";
54
54
  import { type UpdateStatus, WelcomeComponent } from "./components/welcome";
55
55
  import {
56
+ checkRecommendedPlugins,
56
57
  type FixableService,
57
58
  mapContextStatus,
58
59
  runWelcomeChecks,
@@ -386,12 +387,15 @@ export class InteractiveMode implements InteractiveModeContext {
386
387
  }
387
388
  }
388
389
 
390
+ const recommendedPlugins = !startupQuiet ? await checkRecommendedPlugins().catch(() => []) : [];
391
+
389
392
  if (!startupQuiet) {
390
393
  this.#welcomeComponent = new WelcomeComponent(
391
394
  this.#version,
392
395
  welcomeResult.model,
393
396
  services,
394
397
  this.#initialUpdateStatus,
398
+ recommendedPlugins,
395
399
  );
396
400
 
397
401
  // Setup UI layout
@@ -1164,7 +1164,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
1164
1164
  }
1165
1165
  // ── Setup (guided recommended plugin install) ──
1166
1166
  case "setup": {
1167
- const { checkPrerequisite } = await import("../extensibility/plugins/marketplace/prerequisites");
1167
+ const { setupTool } = await import("../extensibility/plugins/marketplace/prerequisites");
1168
1168
  const allPlugins = await mgr.listAvailablePlugins();
1169
1169
  const recommended = allPlugins.filter(p => p.recommended);
1170
1170
  if (recommended.length === 0) {
@@ -1172,66 +1172,86 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
1172
1172
  break;
1173
1173
  }
1174
1174
 
1175
- const installed = await mgr.listInstalledPlugins();
1176
- const installedIds = new Set(installed.map(p => p.id));
1177
- const toInstall = recommended.filter(
1175
+ const installedPlugins = await mgr.listInstalledPlugins();
1176
+ const installedIds = new Set(installedPlugins.map(p => p.id));
1177
+ const toSetup = recommended.filter(
1178
1178
  p => !Array.from(installedIds).some(id => id.startsWith(`${p.name}@`)),
1179
1179
  );
1180
1180
 
1181
- if (toInstall.length === 0) {
1181
+ if (toSetup.length === 0) {
1182
1182
  runtime.ctx.showStatus("All recommended plugins are already installed");
1183
1183
  break;
1184
1184
  }
1185
1185
 
1186
1186
  const lines: string[] = ["Recommended plugins setup:\n"];
1187
- let installedCount = 0;
1187
+ let pluginInstalledCount = 0;
1188
1188
  let skippedCount = 0;
1189
- const skippedReasons: string[] = [];
1190
1189
 
1191
- for (const plugin of toInstall) {
1190
+ for (const plugin of toSetup) {
1191
+ const name = plugin.displayName || plugin.name;
1192
+
1193
+ // Step 1: Setup prerequisites (detect → install → auth)
1192
1194
  if (plugin.prerequisites && plugin.prerequisites.length > 0) {
1193
- const missing: string[] = [];
1195
+ let allReady = true;
1194
1196
  for (const prereq of plugin.prerequisites) {
1195
- const ok = await checkPrerequisite(prereq.detectCmd);
1196
- if (!ok) missing.push(`${prereq.tool} (${prereq.installCmd})`);
1197
+ const result = await setupTool(prereq);
1198
+
1199
+ if (!result.installSuccess && result.installAttempted) {
1200
+ lines.push(` x ${name} — ${prereq.tool}: install failed (${result.error})`);
1201
+ lines.push(` Fix: ${prereq.installCmd}`);
1202
+ allReady = false;
1203
+ break;
1204
+ }
1205
+
1206
+ if (result.installAttempted && result.installSuccess) {
1207
+ lines.push(` + ${name} — ${prereq.tool}: installed`);
1208
+ }
1209
+
1210
+ if (!result.authenticated && prereq.authLoginCmd) {
1211
+ lines.push(` ~ ${name} — ${prereq.tool}: not authenticated`);
1212
+ lines.push(` Run: ${prereq.authLoginCmd}`);
1213
+ } else if (result.authenticated && result.user) {
1214
+ lines.push(` ✓ ${name} — ${prereq.tool}: authenticated as ${result.user}`);
1215
+ } else if (result.authenticated) {
1216
+ lines.push(` ✓ ${name} — ${prereq.tool}: ready`);
1217
+ }
1197
1218
  }
1198
- if (missing.length > 0) {
1199
- lines.push(` ⊘ ${plugin.displayName || plugin.name} — missing: ${missing.join(", ")}`);
1219
+ if (!allReady) {
1200
1220
  skippedCount++;
1201
- skippedReasons.push(...missing);
1202
1221
  continue;
1203
1222
  }
1204
1223
  }
1205
1224
 
1225
+ // Step 2: Install the plugin
1206
1226
  const marketplaces = await mgr.listMarketplaces();
1207
- let installed = false;
1227
+ let didInstall = false;
1208
1228
  for (const mkt of marketplaces) {
1209
1229
  const available = await mgr.listAvailablePlugins(mkt.name);
1210
1230
  if (available.some(a => a.name === plugin.name)) {
1211
1231
  try {
1212
1232
  await mgr.installPlugin(plugin.name, mkt.name);
1213
- lines.push(` + ${plugin.displayName || plugin.name} — installed`);
1214
- installedCount++;
1215
- installed = true;
1233
+ lines.push(` ${name} — plugin installed`);
1234
+ pluginInstalledCount++;
1235
+ didInstall = true;
1216
1236
  } catch (err) {
1217
1237
  lines.push(
1218
- ` ! ${plugin.displayName || plugin.name} — ${err instanceof Error ? err.message : String(err)}`,
1238
+ ` ! ${name} — plugin install failed: ${err instanceof Error ? err.message : String(err)}`,
1219
1239
  );
1220
1240
  skippedCount++;
1221
1241
  }
1222
1242
  break;
1223
1243
  }
1224
1244
  }
1225
- if (!installed && skippedCount === 0) {
1226
- lines.push(` ? ${plugin.displayName || plugin.name} — not found in any marketplace`);
1245
+ if (!didInstall && skippedCount === 0) {
1246
+ lines.push(` ? ${name} — not found in any marketplace`);
1227
1247
  skippedCount++;
1228
1248
  }
1229
1249
  }
1230
1250
 
1231
1251
  lines.push("");
1232
- lines.push(`Installed ${installedCount}/${toInstall.length} recommended plugin(s)`);
1252
+ lines.push(`Installed ${pluginInstalledCount}/${toSetup.length} recommended plugin(s)`);
1233
1253
  if (skippedCount > 0) {
1234
- lines.push(`${skippedCount} skipped — install missing tools and run /plugin setup again`);
1254
+ lines.push(`${skippedCount} skipped — fix issues above and run /plugin setup again (idempotent)`);
1235
1255
  }
1236
1256
  runtime.ctx.showStatus(lines.join("\n"));
1237
1257
  break;