@f5xc-salesdemos/xcsh 18.13.0 → 18.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.13.0",
4
+ "version": "18.14.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "dependencies": {
48
48
  "@agentclientprotocol/sdk": "0.16.1",
49
49
  "@mozilla/readability": "^0.6",
50
- "@f5xc-salesdemos/xcsh-stats": "18.13.0",
51
- "@f5xc-salesdemos/pi-agent-core": "18.13.0",
52
- "@f5xc-salesdemos/pi-ai": "18.13.0",
53
- "@f5xc-salesdemos/pi-natives": "18.13.0",
54
- "@f5xc-salesdemos/pi-tui": "18.13.0",
55
- "@f5xc-salesdemos/pi-utils": "18.13.0",
50
+ "@f5xc-salesdemos/xcsh-stats": "18.14.0",
51
+ "@f5xc-salesdemos/pi-agent-core": "18.14.0",
52
+ "@f5xc-salesdemos/pi-ai": "18.14.0",
53
+ "@f5xc-salesdemos/pi-natives": "18.14.0",
54
+ "@f5xc-salesdemos/pi-tui": "18.14.0",
55
+ "@f5xc-salesdemos/pi-utils": "18.14.0",
56
56
  "@sinclair/typebox": "^0.34",
57
57
  "@xterm/headless": "^6.0",
58
58
  "ajv": "^8.18",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.13.0",
21
- "commit": "90af1747f04e90d8ebc6ccfe4d0bd8c7f6347760",
22
- "shortCommit": "90af174",
20
+ "version": "18.14.0",
21
+ "commit": "f1b1d44fd6a0481b3745e233a70756df5d45f335",
22
+ "shortCommit": "f1b1d44",
23
23
  "branch": "main",
24
- "tag": "v18.13.0",
25
- "commitDate": "2026-04-23T15:10:43Z",
26
- "buildDate": "2026-04-23T15:30:07.473Z",
24
+ "tag": "v18.14.0",
25
+ "commitDate": "2026-04-23T21:05:32Z",
26
+ "buildDate": "2026-04-23T21:32:33.280Z",
27
27
  "dirty": false,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
30
30
  "repoSlug": "f5xc-salesdemos/xcsh",
31
- "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/90af1747f04e90d8ebc6ccfe4d0bd8c7f6347760",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.13.0"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/f1b1d44fd6a0481b3745e233a70756df5d45f335",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.14.0"
33
33
  };
@@ -11,7 +11,9 @@ const STARTUP_FIRST_TIMEOUT_MS = 4000;
11
11
  const STARTUP_RETRY_TIMEOUT_MS = 5000;
12
12
  const STARTUP_RETRY_DELAY_MS = 500;
13
13
 
14
- type ProfileValidator = (opts: { timeoutMs: number }) => Promise<{ status: AuthStatus; latencyMs?: number }>;
14
+ type ProfileValidator = (opts: {
15
+ timeoutMs: number;
16
+ }) => Promise<{ status: AuthStatus; latencyMs?: number; errorClass?: "network" | "credential" }>;
15
17
 
16
18
  /**
17
19
  * Runs the profile validator once with a startup-sized timeout; if the result is `offline`
@@ -8,8 +8,14 @@ import {
8
8
  F5XC_TENANT,
9
9
  F5XC_USERNAME,
10
10
  } from "./f5xc-env";
11
- import { ProfileError, ProfileService } from "./f5xc-profile";
12
- import { formatAuthIndicator, renderF5XCTable, type TableRow } from "./f5xc-table";
11
+ import { CURRENT_SCHEMA_VERSION, ProfileError, ProfileService } from "./f5xc-profile";
12
+ import {
13
+ formatAuthIndicator,
14
+ formatExpiration,
15
+ formatRelativeTime,
16
+ renderF5XCTable,
17
+ type TableRow,
18
+ } from "./f5xc-table";
13
19
 
14
20
  interface CommandContext {
15
21
  showStatus(msg: string): void;
@@ -81,14 +87,16 @@ async function handleList(ctx: CommandContext, service: ProfileService): Promise
81
87
  const status = service.getStatus();
82
88
  const lines = profiles.map(p => {
83
89
  const marker = p.name === status.activeProfileName ? "*" : " ";
84
- return ` ${marker} ${sanitize(p.name).padEnd(20)} ${sanitize(p.apiUrl)}`;
90
+ const versionSuffix =
91
+ p.version !== undefined && p.version > CURRENT_SCHEMA_VERSION ? ` (v${p.version} — upgrade required)` : "";
92
+ return ` ${marker} ${sanitize(p.name).padEnd(20)} ${sanitize(p.apiUrl)}${versionSuffix}`;
85
93
  });
86
94
  ctx.showStatus(lines.join("\n"));
87
95
  }
88
96
 
89
97
  async function handleActivate(ctx: CommandContext, service: ProfileService, name: string): Promise<void> {
90
98
  if (!name) {
91
- ctx.showError("Usage: /profile activate <name>");
99
+ ctx.showError("Usage: /profile activate <name>. Run `/profile list` to see available profiles.");
92
100
  return;
93
101
  }
94
102
  try {
@@ -110,13 +118,15 @@ function isSensitiveKey(key: string): boolean {
110
118
  async function handleShow(ctx: CommandContext, service: ProfileService, name?: string): Promise<void> {
111
119
  const targetName = name || service.getStatus().activeProfileName;
112
120
  if (!targetName) {
113
- ctx.showError("No active profile. Use /profile activate <name> first.");
121
+ ctx.showError(
122
+ "No active profile. Run `/profile create <name>` to create one, or `/profile activate <name>` if profiles exist.",
123
+ );
114
124
  return;
115
125
  }
116
126
  const profiles = await service.listProfiles();
117
127
  const profile = profiles.find(p => p.name === targetName);
118
128
  if (!profile) {
119
- ctx.showError(`Profile '${targetName}' not found.`);
129
+ ctx.showError(`Profile '${targetName}' not found. Run \`/profile list\` to see available profiles.`);
120
130
  return;
121
131
  }
122
132
 
@@ -143,7 +153,7 @@ async function handleShow(ctx: CommandContext, service: ProfileService, name?: s
143
153
  }
144
154
 
145
155
  // Auth status indicator
146
- rows.push({ key: "Status", value: formatAuthIndicator(auth.status, auth.latencyMs) });
156
+ rows.push({ key: "Status", value: formatAuthIndicator(auth.status, auth.latencyMs, auth.errorClass) });
147
157
 
148
158
  // Track where environment section starts
149
159
  const envDividerIndex = rows.length;
@@ -157,7 +167,28 @@ async function handleShow(ctx: CommandContext, service: ProfileService, name?: s
157
167
  }
158
168
  }
159
169
 
160
- ctx.showStatus(renderF5XCTable(profile.name, rows, { dividerBefore: envDividerIndex }));
170
+ // Metadata section (only rendered when at least one field is present)
171
+ const metaRows: TableRow[] = [];
172
+ if (profile.metadata?.createdAt) {
173
+ metaRows.push({ key: "Created", value: formatRelativeTime(profile.metadata.createdAt) });
174
+ }
175
+ if (profile.metadata?.expiresAt) {
176
+ metaRows.push({ key: "Expires", value: formatExpiration(profile.metadata.expiresAt) });
177
+ }
178
+ if (profile.metadata?.lastRotatedAt) {
179
+ metaRows.push({ key: "Last Rotated", value: formatRelativeTime(profile.metadata.lastRotatedAt) });
180
+ }
181
+ if (profile.metadata?.rotateAfterDays) {
182
+ metaRows.push({ key: "Rotation", value: `every ${profile.metadata.rotateAfterDays} days` });
183
+ }
184
+
185
+ const dividers: Array<{ before: number; label: string }> = [{ before: envDividerIndex, label: "Environment" }];
186
+ if (metaRows.length > 0) {
187
+ dividers.push({ before: rows.length, label: "Metadata" });
188
+ rows.push(...metaRows);
189
+ }
190
+
191
+ ctx.showStatus(renderF5XCTable(profile.name, rows, { dividers }));
161
192
  }
162
193
 
163
194
  async function handleStatus(ctx: CommandContext, service: ProfileService): Promise<void> {
@@ -172,7 +203,7 @@ async function handleStatus(ctx: CommandContext, service: ProfileService): Promi
172
203
  { key: "Source", value: status.credentialSource },
173
204
  { key: "API URL", value: status.activeProfileUrl ?? "(not set)" },
174
205
  { key: "Namespace", value: status.activeProfileNamespace ?? "(not set)" },
175
- { key: "Status", value: formatAuthIndicator(auth.status, auth.latencyMs) },
206
+ { key: "Status", value: formatAuthIndicator(auth.status, auth.latencyMs, auth.errorClass) },
176
207
  ];
177
208
  ctx.showStatus(renderF5XCTable(status.activeProfileName ?? "status", rows));
178
209
  }
@@ -219,7 +250,7 @@ async function handleDelete(ctx: CommandContext, service: ProfileService, args:
219
250
  }
220
251
  const status = service.getStatus();
221
252
  if (name === status.activeProfileName) {
222
- ctx.showError("Cannot delete the active profile. Activate a different profile first.");
253
+ ctx.showError("Cannot delete the active profile. Run `/profile activate <other>` to switch first.");
223
254
  return;
224
255
  }
225
256
  if (!confirmed) {
@@ -12,6 +12,8 @@ import {
12
12
  hasEnvOverride,
13
13
  } from "./f5xc-env";
14
14
 
15
+ export const CURRENT_SCHEMA_VERSION = 1;
16
+
15
17
  export interface F5XCProfile {
16
18
  name: string;
17
19
  apiUrl: string;
@@ -20,6 +22,7 @@ export interface F5XCProfile {
20
22
  env?: Record<string, string>;
21
23
  /** Env var names from `env` whose values should be masked in output (e.g. ["F5XC_USERNAME"]). */
22
24
  sensitiveKeys?: string[];
25
+ version?: number;
23
26
  metadata?: {
24
27
  createdAt?: string;
25
28
  expiresAt?: string;
@@ -38,7 +41,6 @@ export interface ProfileStatus {
38
41
  credentialSource: "profile" | "environment" | "mixed" | "none";
39
42
  authStatus: AuthStatus;
40
43
  isConfigured: boolean;
41
- watcherActive: boolean;
42
44
  }
43
45
 
44
46
  export class ProfileError extends Error {
@@ -173,6 +175,17 @@ export class ProfileService {
173
175
  return null;
174
176
  }
175
177
 
178
+ // Gate: incompatible schema version — log warning and return null (don't crash startup)
179
+ try {
180
+ this.#assertCompatibleVersion(profile);
181
+ } catch (err) {
182
+ logger.warn("F5XC: profile uses incompatible schema version, skipping", {
183
+ name: profileName,
184
+ error: String(err),
185
+ });
186
+ return null;
187
+ }
188
+
176
189
  // Only persist active_profile after the profile validates
177
190
  if (autoActivated) {
178
191
  this.#atomicWrite(this.activeProfilePath, profileName);
@@ -190,17 +203,18 @@ export class ProfileService {
190
203
  // Reject activation when env overrides are present — would create mismatched credentials
191
204
  if (process.env[F5XC_API_URL]) {
192
205
  throw new ProfileError(
193
- "Cannot activate a profile while F5XC_API_URL is set in the environment. " +
194
- "Unset F5XC_API_URL first, or restart xcsh without it.",
206
+ "Cannot activate: F5XC_API_URL environment variable overrides profile. Run `unset F5XC_API_URL` first, or restart without it.",
195
207
  );
196
208
  }
197
209
 
198
210
  this.#validateProfileName(name);
199
211
  const profile = this.#readProfile(name);
200
212
  if (!profile) {
201
- throw new ProfileError(`Profile '${name}' not found.`, name);
213
+ throw new ProfileError(`Profile '${name}' not found. Run \`/profile list\` to see available profiles.`, name);
202
214
  }
203
215
 
216
+ this.#assertCompatibleVersion(profile);
217
+
204
218
  // NFR-402: write active_profile first — if it fails, don't update settings
205
219
  this.#atomicWrite(this.activeProfilePath, name);
206
220
 
@@ -223,7 +237,7 @@ export class ProfileService {
223
237
  return profiles;
224
238
  }
225
239
 
226
- async createProfile(profile: Omit<F5XCProfile, "metadata">): Promise<void> {
240
+ async createProfile(profile: Omit<F5XCProfile, "metadata" | "version">): Promise<void> {
227
241
  this.#validateProfileName(profile.name);
228
242
  const profilePath = path.join(this.profilesDir, `${profile.name}.json`);
229
243
  if (fs.existsSync(profilePath)) {
@@ -233,6 +247,7 @@ export class ProfileService {
233
247
  fs.mkdirSync(this.#configDir, { recursive: true, mode: 0o700 });
234
248
  const data: F5XCProfile = {
235
249
  ...profile,
250
+ version: CURRENT_SCHEMA_VERSION,
236
251
  metadata: { createdAt: new Date().toISOString() },
237
252
  };
238
253
  const tmpPath = `${profilePath}.tmp`;
@@ -256,6 +271,8 @@ export class ProfileService {
256
271
  const profile = this.#readProfile(name);
257
272
  if (!profile) throw new ProfileError(`Profile '${name}' not found.`, name);
258
273
 
274
+ this.#assertCompatibleVersion(profile);
275
+
259
276
  const env = { ...(profile.env ?? {}), ...vars };
260
277
  const sensitiveSet = new Set(profile.sensitiveKeys ?? []);
261
278
  const newSensitive: string[] = [];
@@ -289,6 +306,8 @@ export class ProfileService {
289
306
  const profile = this.#readProfile(name);
290
307
  if (!profile) throw new ProfileError(`Profile '${name}' not found.`, name);
291
308
 
309
+ this.#assertCompatibleVersion(profile);
310
+
292
311
  const env = { ...(profile.env ?? {}) };
293
312
  const removed: string[] = [];
294
313
  for (const key of keys) {
@@ -322,7 +341,7 @@ export class ProfileService {
322
341
  timeoutMs?: number;
323
342
  apiUrl?: string;
324
343
  apiToken?: string;
325
- }): Promise<{ status: AuthStatus; latencyMs?: number }> {
344
+ }): Promise<{ status: AuthStatus; latencyMs?: number; errorClass?: "network" | "credential" }> {
326
345
  // Use explicit credentials if provided (for non-active profiles or env-backed sessions),
327
346
  // otherwise fall back to effective credentials (env override > active profile)
328
347
  const effectiveUrl = options?.apiUrl ?? process.env[F5XC_API_URL] ?? this.#activeProfile?.apiUrl;
@@ -344,19 +363,20 @@ export class ProfileService {
344
363
  }
345
364
  if (response.status === 401 || response.status === 403) {
346
365
  this.#authStatus = "auth_error";
347
- return { status: "auth_error", latencyMs };
366
+ return { status: "auth_error", latencyMs, errorClass: "credential" };
348
367
  }
349
- this.#authStatus = "connected";
350
- return { status: "connected", latencyMs };
368
+ // 5xx, 429, etc. — server reachable but unhealthy; treat as offline so startup retry fires
369
+ this.#authStatus = "offline";
370
+ return { status: "offline", latencyMs, errorClass: "network" };
351
371
  } catch {
352
372
  this.#authStatus = "offline";
353
- return { status: "offline" };
373
+ return { status: "offline", errorClass: "network" };
354
374
  }
355
375
  }
356
376
 
357
377
  setNamespace(namespace: string): void {
358
378
  if (!this.#activeProfile) {
359
- throw new ProfileError("No active profile. Activate a profile first.");
379
+ throw new ProfileError("No active profile. Run `/profile activate <name>` to select one.");
360
380
  }
361
381
  this.#activeProfile = { ...this.#activeProfile, defaultNamespace: namespace };
362
382
  // Re-apply settings with the new namespace
@@ -375,7 +395,6 @@ export class ProfileService {
375
395
  credentialSource: this.#credentialSource,
376
396
  authStatus: this.#authStatus,
377
397
  isConfigured: this.#credentialSource !== "none",
378
- watcherActive: false,
379
398
  };
380
399
  }
381
400
 
@@ -401,6 +420,15 @@ export class ProfileService {
401
420
  }
402
421
  }
403
422
 
423
+ #assertCompatibleVersion(profile: F5XCProfile): void {
424
+ if (profile.version !== undefined && profile.version > CURRENT_SCHEMA_VERSION) {
425
+ throw new ProfileError(
426
+ `Profile '${profile.name}' uses schema version ${profile.version}, but this version of xcsh only supports version ${CURRENT_SCHEMA_VERSION}. Upgrade xcsh to use this profile, or run \`/profile create\` to create a new one.`,
427
+ profile.name,
428
+ );
429
+ }
430
+ }
431
+
404
432
  #readActiveProfileName(): string | null {
405
433
  try {
406
434
  if (!fs.existsSync(this.activeProfilePath)) return null;
@@ -468,6 +496,7 @@ export class ProfileService {
468
496
  defaultNamespace: parsed.defaultNamespace ?? "default",
469
497
  env,
470
498
  sensitiveKeys,
499
+ version: typeof parsed.version === "number" ? parsed.version : undefined,
471
500
  metadata: parsed.metadata,
472
501
  };
473
502
  } catch (err) {
@@ -20,27 +20,64 @@ const BOX = {
20
20
 
21
21
  const r = (s: string) => `${F5_RED}${s}${RESET}`;
22
22
 
23
- export function formatAuthIndicator(status: AuthStatus, latencyMs?: number): string {
23
+ export function formatAuthIndicator(
24
+ status: AuthStatus,
25
+ latencyMs?: number,
26
+ errorClass?: "network" | "credential",
27
+ ): string {
24
28
  const ms = latencyMs !== undefined ? ` (${latencyMs}ms)` : "";
25
29
  switch (status) {
26
30
  case "connected":
27
31
  return `${formatStatusIcon("connected")} Connected${ms}`;
28
32
  case "auth_error":
29
- return `${formatStatusIcon("error")} Auth Error${ms}`;
33
+ return `${formatStatusIcon("error")} Auth Error — check token${ms}`;
30
34
  case "offline":
31
- return `${formatStatusIcon("warning")} Offline`;
35
+ return `${formatStatusIcon("warning")} Offline — ${errorClass === "credential" ? "auth issue" : "network issue"}${ms}`;
32
36
  default:
33
37
  return `${formatStatusIcon("unknown")} Unknown`;
34
38
  }
35
39
  }
36
40
 
41
+ export function formatRelativeTime(isoDate: string, now?: Date): string {
42
+ const nowMs = (now ?? new Date()).getTime();
43
+ const thenMs = new Date(isoDate).getTime();
44
+ const diffMs = nowMs - thenMs;
45
+ const absDiffMs = Math.abs(diffMs);
46
+ const minutes = Math.floor(absDiffMs / 60_000);
47
+ const hours = Math.floor(minutes / 60);
48
+ const days = Math.floor(hours / 24);
49
+ const months = Math.floor(days / 30);
50
+
51
+ if (months > 0) return `${months} month${months > 1 ? "s" : ""} ago`;
52
+ if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
53
+ if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
54
+ if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
55
+ return "just now";
56
+ }
57
+
58
+ export function formatExpiration(isoDate: string, now?: Date): string {
59
+ const nowMs = (now ?? new Date()).getTime();
60
+ const expiresMs = new Date(isoDate).getTime();
61
+ const dateStr = isoDate.split("T")[0];
62
+ const diffDays = Math.ceil((expiresMs - nowMs) / 86_400_000);
63
+
64
+ if (diffDays < 0) {
65
+ const ago = Math.abs(diffDays);
66
+ return `${dateStr} ${formatStatusIcon("warning")} expired ${ago} day${ago > 1 ? "s" : ""} ago`;
67
+ }
68
+ if (diffDays <= 7) {
69
+ return `${dateStr} ${formatStatusIcon("warning")} expires in ${diffDays} day${diffDays !== 1 ? "s" : ""}`;
70
+ }
71
+ return dateStr;
72
+ }
73
+
37
74
  export interface TableRow {
38
75
  key: string;
39
76
  value: string;
40
77
  }
41
78
 
42
79
  export interface TableOptions {
43
- dividerBefore?: number; // insert ├──┤ divider before this row index
80
+ dividers?: Array<{ before: number; label: string }>;
44
81
  }
45
82
 
46
83
  // Measures the visible terminal column width of a string.
@@ -64,9 +101,10 @@ export function renderF5XCTable(title: string, rows: TableRow[], options?: Table
64
101
 
65
102
  // Rows
66
103
  for (let i = 0; i < rows.length; i++) {
67
- // Optional divider
68
- if (options?.dividerBefore === i) {
69
- const divLabel = " Environment ";
104
+ // Optional labeled dividers
105
+ const divider = options?.dividers?.find(d => d.before === i);
106
+ if (divider) {
107
+ const divLabel = ` ${divider.label} `;
70
108
  const divPad = innerWidth - visibleWidth(divLabel) - 1;
71
109
  lines.push(`${r(BOX.lt + BOX.h)}${BOLD}${divLabel}${RESET}${r(BOX.h.repeat(Math.max(0, divPad)) + BOX.rt)}`);
72
110
  }