@f5xc-salesdemos/xcsh 19.42.2 → 19.43.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.42.2",
4
+ "version": "19.43.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",
@@ -52,13 +52,13 @@
52
52
  "dependencies": {
53
53
  "@agentclientprotocol/sdk": "0.16.1",
54
54
  "@mozilla/readability": "^0.6",
55
- "@f5xc-salesdemos/xcsh-stats": "19.42.2",
56
- "@f5xc-salesdemos/pi-agent-core": "19.42.2",
57
- "@f5xc-salesdemos/pi-ai": "19.42.2",
58
- "@f5xc-salesdemos/pi-natives": "19.42.2",
59
- "@f5xc-salesdemos/pi-resource-management": "19.42.2",
60
- "@f5xc-salesdemos/pi-tui": "19.42.2",
61
- "@f5xc-salesdemos/pi-utils": "19.42.2",
55
+ "@f5xc-salesdemos/xcsh-stats": "19.43.0",
56
+ "@f5xc-salesdemos/pi-agent-core": "19.43.0",
57
+ "@f5xc-salesdemos/pi-ai": "19.43.0",
58
+ "@f5xc-salesdemos/pi-natives": "19.43.0",
59
+ "@f5xc-salesdemos/pi-resource-management": "19.43.0",
60
+ "@f5xc-salesdemos/pi-tui": "19.43.0",
61
+ "@f5xc-salesdemos/pi-utils": "19.43.0",
62
62
  "@sinclair/typebox": "^0.34",
63
63
  "@xterm/headless": "^6.0",
64
64
  "ajv": "^8.20",
@@ -15,14 +15,8 @@ type Settings = { get(key: string): unknown };
15
15
 
16
16
  export type ChromeAction = "status" | "relaunch" | "setup";
17
17
 
18
- // Unpacked (keyed) build — installed from a GitHub release. The manifest `key`
19
- // pins this deterministic ID.
20
- export const EXTENSION_ID = "khlalklompggpfnmeclpligmcbknkemg";
21
- // Chrome Web Store build — the store assigns this ID (no `key` field).
22
- export const EXTENSION_ID_CWS = "klajkjdoehjidngligegnpknogmjjhkc";
23
- // Both are allowed in the native-host manifest so the bridge works regardless of
24
- // how the user installed the extension.
25
- export const EXTENSION_IDS = [EXTENSION_ID, EXTENSION_ID_CWS];
18
+ export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
19
+ export const EXTENSION_IDS = [EXTENSION_ID];
26
20
 
27
21
  const NATIVE_HOST_NAME = "com.f5xc.xcsh.chrome_host";
28
22
 
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.42.2",
21
- "commit": "a018af25c2adec4357b1aa63213417ef9331ef18",
22
- "shortCommit": "a018af2",
20
+ "version": "19.43.0",
21
+ "commit": "d8fefbb60bd96f414bb9c21da7df270bd10876eb",
22
+ "shortCommit": "d8fefbb",
23
23
  "branch": "main",
24
- "tag": "v19.42.2",
25
- "commitDate": "2026-06-24T03:15:55Z",
26
- "buildDate": "2026-06-24T03:45:49.205Z",
24
+ "tag": "v19.43.0",
25
+ "commitDate": "2026-06-24T04:38:55Z",
26
+ "buildDate": "2026-06-24T05:01:00.600Z",
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/a018af25c2adec4357b1aa63213417ef9331ef18",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.42.2"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/d8fefbb60bd96f414bb9c21da7df270bd10876eb",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.43.0"
33
33
  };
package/src/main.ts CHANGED
@@ -621,7 +621,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
621
621
  const { ContextService } = await import("./services/f5xc-context");
622
622
  const { getF5XCConfigDir } = await import("@f5xc-salesdemos/pi-utils");
623
623
  const contextService = ContextService.init(getF5XCConfigDir());
624
- await contextService.loadActive();
624
+ await contextService.loadActive(cwd);
625
625
  } catch {
626
626
  // F5 XC auth is optional — silently continue if anything fails
627
627
  }
@@ -1,5 +1,13 @@
1
1
  import * as fs from "node:fs";
2
- import { t } from "@f5xc-salesdemos/pi-utils";
2
+ import * as path from "node:path";
3
+ import {
4
+ getLocalF5XCActiveContextPath,
5
+ getLocalF5XCContextPath,
6
+ getLocalF5XCContextsDir,
7
+ getProjectDir,
8
+ isSafeContextName,
9
+ t,
10
+ } from "@f5xc-salesdemos/pi-utils";
3
11
  import { SECRET_ENV_PATTERNS } from "../secrets/index";
4
12
  import { expandTilde } from "../tools/path-utils";
5
13
  import { ContextError, ContextService, CURRENT_SCHEMA_VERSION } from "./f5xc-context";
@@ -39,7 +47,7 @@ export async function handleContextCommand(
39
47
  ): Promise<void> {
40
48
  const [sub, ...rest] = command.args.trim().split(/\s+/);
41
49
  const arg = rest.join(" ");
42
- const service = await ContextService.getOrInit();
50
+ const service = await ContextService.getOrInit(undefined, getProjectDir());
43
51
 
44
52
  ctx.editor.setText("");
45
53
 
@@ -75,6 +83,10 @@ export async function handleContextCommand(
75
83
  }
76
84
  case "namespace":
77
85
  return handleNamespace(ctx, service, arg);
86
+ case "link":
87
+ return handleLink(ctx, service, arg);
88
+ case "unlink":
89
+ return handleUnlink(ctx, service);
78
90
  case "env":
79
91
  return handleEnvSubcommand(ctx, service, rest);
80
92
  case "set":
@@ -102,6 +114,17 @@ function sanitize(value: string): string {
102
114
  return value.replace(/[\x00-\x1f\x7f]/g, "");
103
115
  }
104
116
 
117
+ /**
118
+ * Atomic file write: write to a .tmp file then rename.
119
+ * Ensures 0o600 permissions on the resulting file.
120
+ */
121
+ function atomicWriteLocal(filePath: string, content: string): void {
122
+ const tmpPath = `${filePath}.tmp`;
123
+ fs.writeFileSync(tmpPath, content, { mode: 0o600 });
124
+ fs.renameSync(tmpPath, filePath);
125
+ fs.chmodSync(filePath, 0o600);
126
+ }
127
+
105
128
  /**
106
129
  * Split a positional+flag argument list into two groups.
107
130
  *
@@ -129,6 +152,58 @@ function splitArgs(args: string[], knownFlags: Set<string>): { positionals: stri
129
152
  }
130
153
 
131
154
  async function handleList(ctx: CommandContext, service: ContextService): Promise<void> {
155
+ const localContextsDir = getLocalF5XCContextsDir(getProjectDir());
156
+ const hasLocalDir = fs.existsSync(localContextsDir);
157
+
158
+ if (hasLocalDir) {
159
+ // Build local context rows by reading .xcsh/contexts/*.json directly
160
+ const localRows: TableRow[] = [];
161
+ try {
162
+ const files = fs.readdirSync(localContextsDir).filter(f => f.endsWith(".json"));
163
+ for (const file of files.sort()) {
164
+ const name = file.slice(0, -5); // strip .json
165
+ try {
166
+ const raw = fs.readFileSync(path.join(localContextsDir, file), "utf-8");
167
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
168
+ const displayUrl =
169
+ typeof parsed.context === "string"
170
+ ? `→ ${sanitize(parsed.context)} (pointer)`
171
+ : sanitize(typeof parsed.apiUrl === "string" ? parsed.apiUrl : "");
172
+ localRows.push({ key: ` ${sanitize(name)}`, value: displayUrl });
173
+ } catch {
174
+ localRows.push({ key: ` ${sanitize(name)}`, value: "(unreadable)" });
175
+ }
176
+ }
177
+ } catch {
178
+ // skip unreadable dir
179
+ }
180
+
181
+ // Build global context rows
182
+ const globalContexts = await service.listContexts();
183
+ const status = service.getStatus();
184
+ const globalRows: TableRow[] = globalContexts.map(p => {
185
+ const isActive = p.name === status.activeContextName;
186
+ const marker = isActive ? `${formatStatusIcon("connected")} ` : " ";
187
+ const versionSuffix =
188
+ p.version !== undefined && p.version > CURRENT_SCHEMA_VERSION ? ` (v${p.version} — upgrade required)` : "";
189
+ return { key: `${marker}${sanitize(p.name)}`, value: `${sanitize(p.apiUrl)}${versionSuffix}` };
190
+ });
191
+
192
+ // Render combined table with group dividers
193
+ const rows: TableRow[] = [];
194
+ const dividers: Array<{ before: number; label: string }> = [];
195
+
196
+ dividers.push({ before: 0, label: "Local contexts (.xcsh/contexts/)" });
197
+ rows.push(...(localRows.length > 0 ? localRows : [{ key: " (none)", value: "" }]));
198
+
199
+ dividers.push({ before: rows.length, label: "Global contexts (~/.config/f5xc/contexts/)" });
200
+ rows.push(...(globalRows.length > 0 ? globalRows : [{ key: " (none)", value: "" }]));
201
+
202
+ ctx.showStatus(renderF5XCTable("contexts", rows, { dividers }), { dim: false });
203
+ return;
204
+ }
205
+
206
+ // No local dir — use original flat list behavior
132
207
  const contexts = await service.listContexts();
133
208
  if (contexts.length === 0) {
134
209
  const status = service.getStatus();
@@ -157,6 +232,56 @@ async function handleList(ctx: CommandContext, service: ContextService): Promise
157
232
  ctx.showStatus(renderF5XCTable("contexts", rows), { dim: false });
158
233
  }
159
234
 
235
+ async function handleLink(ctx: CommandContext, service: ContextService, name: string): Promise<void> {
236
+ if (!name) {
237
+ ctx.showError("Usage: /context link <global-context-name>");
238
+ return;
239
+ }
240
+ if (!isSafeContextName(name)) {
241
+ ctx.showError(
242
+ "Invalid context name. Names must be 1-64 characters using only letters, digits, hyphens, or underscores.",
243
+ );
244
+ return;
245
+ }
246
+ const contexts = await service.listContexts();
247
+ const exists = contexts.some(c => c.name === name);
248
+ if (!exists) {
249
+ ctx.showError(`Global context '${name}' not found. Run /context list to see available contexts.`);
250
+ return;
251
+ }
252
+ const localContextsDir = getLocalF5XCContextsDir(getProjectDir());
253
+ if (!fs.existsSync(localContextsDir)) {
254
+ fs.mkdirSync(localContextsDir, { recursive: true, mode: 0o700 });
255
+ }
256
+ const pointerPath = getLocalF5XCContextPath(name, getProjectDir());
257
+ atomicWriteLocal(pointerPath, JSON.stringify({ context: name }, null, 2));
258
+ const activeContextPath = getLocalF5XCActiveContextPath(getProjectDir());
259
+ atomicWriteLocal(activeContextPath, name);
260
+ ctx.showStatus(renderContextMessage(name, `Linked local context '${name}' → global context '${name}'.`), {
261
+ dim: false,
262
+ });
263
+ }
264
+
265
+ async function handleUnlink(ctx: CommandContext, _service: ContextService): Promise<void> {
266
+ const activeContextPath = getLocalF5XCActiveContextPath(getProjectDir());
267
+ if (!fs.existsSync(activeContextPath)) {
268
+ ctx.showError("No local active context found. Run /context link <name> to create one.");
269
+ return;
270
+ }
271
+ const name = fs.readFileSync(activeContextPath, "utf-8").trim();
272
+ if (!isSafeContextName(name)) {
273
+ ctx.showError("Corrupt active_context file — invalid context name. Removing the file.");
274
+ fs.unlinkSync(activeContextPath);
275
+ return;
276
+ }
277
+ const pointerPath = getLocalF5XCContextPath(name, getProjectDir());
278
+ if (fs.existsSync(pointerPath)) {
279
+ fs.unlinkSync(pointerPath);
280
+ }
281
+ fs.unlinkSync(activeContextPath);
282
+ ctx.showStatus(renderContextMessage(name, `Unlinked local context '${name}'.`), { dim: false });
283
+ }
284
+
160
285
  async function handleActivate(ctx: CommandContext, service: ContextService, name: string): Promise<void> {
161
286
  if (!name) {
162
287
  ctx.showError(t("context.activate.usage"));
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getF5XCConfigDir, logger } from "@f5xc-salesdemos/pi-utils";
3
+ import { ContextResolver, getF5XCConfigDir, logger } from "@f5xc-salesdemos/pi-utils";
4
4
  import { Settings } from "../config/settings";
5
5
  import { SECRET_ENV_PATTERNS } from "../secrets/index";
6
6
  import { F5XCApiClient } from "./f5xc-api-client";
@@ -303,12 +303,14 @@ export class ContextService {
303
303
  *
304
304
  * @param configDir — seed directory when bootstrapping; ignored when an
305
305
  * instance already exists.
306
+ * @param cwd — working directory for local context resolution; passed to
307
+ * loadActive(). Ignored when an instance already exists.
306
308
  */
307
- static async getOrInit(configDir?: string): Promise<ContextService> {
309
+ static async getOrInit(configDir?: string, cwd?: string): Promise<ContextService> {
308
310
  if (ContextService.#instance) return ContextService.#instance;
309
311
  const dir = configDir ?? getF5XCConfigDir();
310
312
  const service = ContextService.init(dir);
311
- await service.loadActive();
313
+ await service.loadActive(cwd);
312
314
  return service;
313
315
  }
314
316
 
@@ -375,7 +377,7 @@ export class ContextService {
375
377
  return this.#activeContext?.defaultNamespace ?? null;
376
378
  }
377
379
 
378
- async loadActive(): Promise<F5XCContext | null> {
380
+ async loadActive(cwd?: string): Promise<F5XCContext | null> {
379
381
  // FR-102: F5XC_API_URL is the signal to skip context loading entirely.
380
382
  // Subprocesses inherit process.env, so they already see the env vars directly.
381
383
  if (process.env[F5XC_API_URL]) {
@@ -383,6 +385,50 @@ export class ContextService {
383
385
  return null;
384
386
  }
385
387
 
388
+ // FR-201: Check for project-local context in .xcsh/contexts/
389
+ if (cwd) {
390
+ const resolver = new ContextResolver();
391
+ const localResult = await resolver.resolve(cwd);
392
+ if (localResult && localResult.source === "local") {
393
+ const localContext = localResult.context as F5XCContext;
394
+ // Gate: incompatible schema version
395
+ let versionOk = true;
396
+ try {
397
+ this.#assertCompatibleVersion(localContext);
398
+ } catch (err) {
399
+ versionOk = false;
400
+ logger.warn("F5XC: local context uses incompatible schema version, skipping", {
401
+ sourcePath: localResult.sourcePath,
402
+ error: String(err),
403
+ });
404
+ // Fall through to global resolution below
405
+ }
406
+ if (versionOk) {
407
+ this.#activeContext = localContext;
408
+ this.#applyToSettings(localContext);
409
+ this.#credentialSource = hasEnvOverride() ? "mixed" : "context";
410
+ this.#refreshApiClient(localContext);
411
+ logger.debug("F5XC: using project context", {
412
+ name: localContext.name,
413
+ source: localResult.sourcePath,
414
+ });
415
+ // Run git-tracking safety check asynchronously
416
+ resolver
417
+ .checkGitTracking(localResult.sourcePath)
418
+ .then(tracked => {
419
+ if (tracked) {
420
+ logger.warn(
421
+ `WARNING: ${localResult.sourcePath} is tracked by git! ` +
422
+ `This file may contain credentials. Run: git rm --cached ${localResult.sourcePath}`,
423
+ );
424
+ }
425
+ })
426
+ .catch(() => {});
427
+ return localContext;
428
+ }
429
+ }
430
+ }
431
+
386
432
  // Check if config dir exists
387
433
  if (!fs.existsSync(this.#configDir)) {
388
434
  return null;