@f5xc-salesdemos/xcsh 18.36.2 → 18.37.1

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.36.2",
4
+ "version": "18.37.1",
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",
@@ -48,12 +48,12 @@
48
48
  "dependencies": {
49
49
  "@agentclientprotocol/sdk": "0.16.1",
50
50
  "@mozilla/readability": "^0.6",
51
- "@f5xc-salesdemos/xcsh-stats": "18.36.2",
52
- "@f5xc-salesdemos/pi-agent-core": "18.36.2",
53
- "@f5xc-salesdemos/pi-ai": "18.36.2",
54
- "@f5xc-salesdemos/pi-natives": "18.36.2",
55
- "@f5xc-salesdemos/pi-tui": "18.36.2",
56
- "@f5xc-salesdemos/pi-utils": "18.36.2",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.37.1",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.37.1",
53
+ "@f5xc-salesdemos/pi-ai": "18.37.1",
54
+ "@f5xc-salesdemos/pi-natives": "18.37.1",
55
+ "@f5xc-salesdemos/pi-tui": "18.37.1",
56
+ "@f5xc-salesdemos/pi-utils": "18.37.1",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -1788,6 +1788,9 @@ export const SETTINGS_SCHEMA = {
1788
1788
  /** Per-session environment variables injected into bash (used by f5xc context system) */
1789
1789
  "bash.environment": { type: "record", default: {} as Record<string, string> },
1790
1790
 
1791
+ /** Sensitive env var key names from the active f5xc context (populated by ContextService) */
1792
+ "f5xc.sensitiveKeys": { type: "array", default: [] as string[] },
1793
+
1791
1794
  /** Clear terminal on startup */
1792
1795
  "startup.clearScreen": { type: "boolean", default: false },
1793
1796
 
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.36.2",
21
- "commit": "bb67816d2bcb454ea8926788b836edae474dcec2",
22
- "shortCommit": "bb67816",
20
+ "version": "18.37.1",
21
+ "commit": "f8fdc560fa88d310e9c7f39ed258471a9a01d892",
22
+ "shortCommit": "f8fdc56",
23
23
  "branch": "main",
24
- "tag": "v18.36.2",
25
- "commitDate": "2026-05-04T05:42:12Z",
26
- "buildDate": "2026-05-04T06:06:55.405Z",
24
+ "tag": "v18.37.1",
25
+ "commitDate": "2026-05-04T14:41:09Z",
26
+ "buildDate": "2026-05-04T15:10:02.388Z",
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/bb67816d2bcb454ea8926788b836edae474dcec2",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.36.2"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/f8fdc560fa88d310e9c7f39ed258471a9a01d892",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.37.1"
33
33
  };
@@ -35,6 +35,7 @@ import { HistoryStorage } from "../session/history-storage";
35
35
  import type { SessionContext, SessionManager } from "../session/session-manager";
36
36
  import { STTController, type SttState } from "../stt";
37
37
  import type { ExitPlanModeDetails } from "../tools";
38
+ import { glabStartupWarning } from "../tools/glab/config";
38
39
  import type { EventBus } from "../utils/event-bus";
39
40
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
40
41
  import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
@@ -310,15 +311,20 @@ export class InteractiveMode implements InteractiveModeContext {
310
311
  getProjectDir(),
311
312
  );
312
313
 
313
- // Run blocking welcome screen status checks (model + context)
314
- const welcomeResult = await logger.time("InteractiveMode.init:welcomeChecks", () =>
315
- runWelcomeChecks(this.session.model, this.session.modelRegistry.authStorage),
316
- );
314
+ // Run blocking welcome screen status checks (model + context) and glab setup check in parallel
315
+ const [welcomeResult, glabWarning] = await Promise.all([
316
+ logger.time("InteractiveMode.init:welcomeChecks", () =>
317
+ runWelcomeChecks(this.session.model, this.session.modelRegistry.authStorage),
318
+ ),
319
+ glabStartupWarning(getProjectDir()).catch(() => null),
320
+ ]);
317
321
 
318
322
  const startupQuiet = settings.get("startup.quiet");
319
323
  this.#welcomeComponent = undefined;
320
324
 
321
- for (const warning of this.session.configWarnings) {
325
+ const allWarnings = [...this.session.configWarnings];
326
+ if (glabWarning) allWarnings.push(glabWarning);
327
+ for (const warning of allWarnings) {
322
328
  this.ui.addChild(new Text(theme.fg("warning", `Warning: ${warning}`), 1, 0));
323
329
  this.ui.addChild(new Spacer(1));
324
330
  }
@@ -141,6 +141,15 @@ You are currently connected to F5 XC tenant: {{context.tenant}}, namespace: {{co
141
141
  Credential source: {{context.credentialSource}}.
142
142
  Auth status: {{context.authStatus}}.
143
143
  All F5 XC operations should target this tenant and namespace unless explicitly told otherwise.
144
+ {{#if context.envVars}}
145
+
146
+ ### Context Variables
147
+
148
+ {{#each context.envVars}}- {{@key}}: {{this}}
149
+ {{/each}}
150
+
151
+ Use these values when constructing API payloads and resource names.
152
+ {{/if}}
144
153
  {{#if knowledgeTopics}}
145
154
  Available F5 XC documentation topics: {{knowledgeTopics}}.
146
155
  {{/if}}
package/src/sdk.ts CHANGED
@@ -92,6 +92,7 @@ import {
92
92
  type SecretEntry,
93
93
  SecretObfuscator,
94
94
  } from "./secrets";
95
+ import { createContextEnv } from "./services/context-env";
95
96
  import { AgentSession } from "./session/agent-session";
96
97
  import { AuthStorage } from "./session/auth-storage";
97
98
  import { convertToLlm } from "./session/messages";
@@ -1361,7 +1362,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1361
1362
  // rebuilds reflect the most recent /context activate. Mid-session context changes without a
1362
1363
  // tool change are handled via custom_message injection in the onContextChange listener.
1363
1364
  let contextForPrompt:
1364
- | { tenant: string; namespace: string; credentialSource: string; authStatus: string }
1365
+ | {
1366
+ tenant: string;
1367
+ namespace: string;
1368
+ credentialSource: string;
1369
+ authStatus: string;
1370
+ envVars?: Record<string, string>;
1371
+ }
1365
1372
  | undefined;
1366
1373
  try {
1367
1374
  const status = contextServiceRef?.instance?.getStatus();
@@ -1377,6 +1384,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1377
1384
  credentialSource: status.credentialSource,
1378
1385
  authStatus: status.authStatus,
1379
1386
  };
1387
+ const sensitiveKeys = contextServiceRef?.instance?.getActiveSensitiveKeys();
1388
+ const ctxEnv = createContextEnv(settings, sensitiveKeys?.size ? { sensitiveKeys } : undefined);
1389
+ const envVars = ctxEnv.getNonSensitiveVars();
1390
+ if (Object.keys(envVars).length > 0) {
1391
+ contextForPrompt.envVars = envVars;
1392
+ }
1380
1393
  }
1381
1394
  } catch {
1382
1395
  // ContextService not available or not initialized — leave contextForPrompt undefined.
@@ -0,0 +1,122 @@
1
+ import { SECRET_ENV_PATTERNS } from "../secrets/index";
2
+ import { F5XC_API_TOKEN, F5XC_API_URL, F5XC_NAMESPACE, F5XC_TENANT } from "./f5xc-env";
3
+
4
+ /** Keys excluded from the system prompt context variables listing. */
5
+ const PROMPT_HIDDEN: ReadonlySet<string> = new Set([F5XC_API_TOKEN, F5XC_API_URL, F5XC_TENANT, F5XC_NAMESPACE]);
6
+
7
+ /** Keys never expanded in payloads — credentials that must not leak into request bodies. */
8
+ const PAYLOAD_HIDDEN: ReadonlySet<string> = new Set([F5XC_API_TOKEN, F5XC_API_URL]);
9
+
10
+ export interface ContextEnv {
11
+ /** Get a single env var value from bash.environment, or undefined. */
12
+ get(key: string): string | undefined;
13
+
14
+ /**
15
+ * Resolve `{placeholder}` values in a URL path.
16
+ * Explicit params are applied first. Remaining `{key}` placeholders are
17
+ * resolved from bash.environment: `{namespace}` → F5XC_NAMESPACE,
18
+ * `{key}` → F5XC_{KEY.toUpperCase()}.
19
+ * Unresolvable placeholders are left intact.
20
+ */
21
+ resolvePath(path: string, explicitParams?: Record<string, string>): string;
22
+
23
+ /**
24
+ * Expand `$F5XC_*` variable references in a serialized JSON payload string.
25
+ * Unresolvable references are left intact.
26
+ */
27
+ resolvePayloadVars(payloadJson: string): string;
28
+
29
+ /**
30
+ * Return non-sensitive F5XC_* env vars from bash.environment, suitable for
31
+ * display in the LLM system prompt. Excludes ALWAYS_HIDDEN keys, keys
32
+ * matching SECRET_ENV_PATTERNS, and explicitly provided sensitiveKeys.
33
+ */
34
+ getNonSensitiveVars(): Record<string, string>;
35
+ }
36
+
37
+ export interface ContextEnvOptions {
38
+ /** Additional keys to treat as sensitive (e.g. from context.sensitiveKeys). */
39
+ sensitiveKeys?: ReadonlySet<string>;
40
+ }
41
+
42
+ /**
43
+ * Create a ContextEnv instance bound to the current bash.environment settings.
44
+ *
45
+ * @param settings - Any object with a `get(key)` method returning the value of
46
+ * "bash.environment" as `Record<string, string>`. Pass `Settings.instance` or
47
+ * `session.settings` in production; pass a stub in tests.
48
+ * @param options - Optional configuration (sensitiveKeys to exclude).
49
+ */
50
+ export function createContextEnv(settings: { get(key: string): unknown }, options?: ContextEnvOptions): ContextEnv {
51
+ function bashEnv(): Record<string, string> {
52
+ return (settings.get("bash.environment") ?? {}) as Record<string, string>;
53
+ }
54
+
55
+ function allSensitiveKeys(): ReadonlySet<string> {
56
+ if (options?.sensitiveKeys) return options.sensitiveKeys;
57
+ const fromSettings = settings.get("f5xc.sensitiveKeys");
58
+ return new Set(Array.isArray(fromSettings) ? (fromSettings as string[]) : []);
59
+ }
60
+
61
+ return {
62
+ get(key: string): string | undefined {
63
+ return bashEnv()[key];
64
+ },
65
+
66
+ resolvePath(path: string, explicitParams?: Record<string, string>): string {
67
+ let resolved = path;
68
+
69
+ // Apply explicit params first
70
+ if (explicitParams) {
71
+ for (const [key, value] of Object.entries(explicitParams)) {
72
+ resolved = resolved.replaceAll(`{${key}}`, value);
73
+ }
74
+ }
75
+
76
+ // Auto-resolve remaining {placeholder} values from bash.environment
77
+ const env = bashEnv();
78
+ const sensitive = allSensitiveKeys();
79
+ resolved = resolved.replace(/\{(\w+)\}/g, (match, key) => {
80
+ // {namespace} maps directly to F5XC_NAMESPACE
81
+ const envKey = key === "namespace" ? F5XC_NAMESPACE : `F5XC_${key.toUpperCase()}`;
82
+ // Never auto-inject credential or sensitive values into URL paths
83
+ if (PAYLOAD_HIDDEN.has(envKey)) return match;
84
+ if (SECRET_ENV_PATTERNS.test(envKey)) return match;
85
+ if (sensitive.has(envKey)) return match;
86
+ return env[envKey] ?? match;
87
+ });
88
+
89
+ return resolved;
90
+ },
91
+
92
+ resolvePayloadVars(payloadJson: string): string {
93
+ const env = bashEnv();
94
+ const sensitive = allSensitiveKeys();
95
+ return payloadJson.replace(/\$F5XC_([A-Z0-9_]+)/g, (match, suffix) => {
96
+ const key = `F5XC_${suffix}`;
97
+ // Never expand credential keys into payloads
98
+ if (PAYLOAD_HIDDEN.has(key)) return match;
99
+ if (SECRET_ENV_PATTERNS.test(key)) return match;
100
+ if (sensitive.has(key)) return match;
101
+ const value = env[key];
102
+ if (value === undefined) return match;
103
+ // JSON-escape the substituted value to prevent injection
104
+ return JSON.stringify(value).slice(1, -1);
105
+ });
106
+ },
107
+
108
+ getNonSensitiveVars(): Record<string, string> {
109
+ const env = bashEnv();
110
+ const sensitive = allSensitiveKeys();
111
+ const result: Record<string, string> = {};
112
+ for (const [key, value] of Object.entries(env)) {
113
+ if (!key.startsWith("F5XC_")) continue;
114
+ if (PROMPT_HIDDEN.has(key)) continue;
115
+ if (SECRET_ENV_PATTERNS.test(key)) continue;
116
+ if (sensitive.has(key)) continue;
117
+ result[key] = value;
118
+ }
119
+ return result;
120
+ },
121
+ };
122
+ }
@@ -1051,6 +1051,11 @@ export class ContextService {
1051
1051
  return Object.keys(this.#activeContext?.env ?? {}).sort();
1052
1052
  }
1053
1053
 
1054
+ /** Sync set of sensitive env var keys on the active context. Empty set if none. */
1055
+ getActiveSensitiveKeys(): ReadonlySet<string> {
1056
+ return new Set(this.#activeContext?.sensitiveKeys ?? []);
1057
+ }
1058
+
1054
1059
  /** Sync list of known context names, sorted. [] before the first listContexts()/loadActive(). */
1055
1060
  listContextNamesCached(): string[] {
1056
1061
  return this.#contextsCache.map(p => p.name);
@@ -1331,6 +1336,7 @@ export class ContextService {
1331
1336
  }
1332
1337
 
1333
1338
  Settings.instance.override("bash.environment", merged);
1339
+ Settings.instance.override("f5xc.sensitiveKeys", context.sensitiveKeys ?? []);
1334
1340
 
1335
1341
  // Notify listeners (e.g. obfuscator refresh) about the context change.
1336
1342
  for (const cb of ContextService.#onContextChangeListeners) {
package/src/tools/bash.ts CHANGED
@@ -761,51 +761,57 @@ export const bashToolRenderer = {
761
761
  // REACTIVE: read mutable options at render time
762
762
  const { renderContext } = options;
763
763
  const expanded = renderContext?.expanded ?? options.expanded;
764
- const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
765
-
766
- // Get output from context (preferred) or fall back to result content
767
- const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
768
- const displayOutput = output.trimEnd();
769
- const showingFullOutput = expanded && renderContext?.isFullOutput === true;
770
764
 
771
- const rawOutputLines = displayOutput.split("\n");
772
- const sixelLineMask =
773
- TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(rawOutputLines) : undefined;
774
- const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
775
-
776
- // Collapsed mode: single status line when bash.verbose=false
765
+ // Collapsed mode check first — before heavy computation.
766
+ // This ensures the collapsed line is always returned when bash.verbose=false,
767
+ // preventing any transient verbose output flash.
777
768
  const verbose = getBashVerboseSetting();
778
- const hasAsyncDetails = details?.async != null;
779
- const forceExpand = isError || hasAsyncDetails || hasSixelOutput;
780
- if (!verbose && !expanded && !forceExpand) {
781
- const rawCmd = args?.command?.replace(/\s*\\\r?\n\s*/g, " ");
782
- const summaryText = args?.description ?? rawCmd ?? undefined;
783
-
784
- if (options.isPartial) {
785
- const lineCount = rawOutputLines.filter(l => l.trim().length > 0).length;
769
+ if (!verbose && !expanded) {
770
+ const hasAsyncDetails = details?.async != null;
771
+ const outputText = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
772
+ const hasSixel = TERMINAL.imageProtocol === ImageProtocol.Sixel && outputText.includes("\x1bP");
773
+ if (!isError && !hasAsyncDetails && !hasSixel) {
774
+ const rawCmd = args?.command?.replace(/\s*\\\r?\n\s*/g, " ");
775
+ const summaryText = args?.description ?? rawCmd ?? undefined;
776
+
777
+ if (options.isPartial) {
778
+ const lineCount = outputText.split("\n").filter(l => l.trim().length > 0).length;
779
+ const line = renderStatusLine(
780
+ {
781
+ icon: "running",
782
+ spinnerFrame: options.spinnerFrame,
783
+ title: "Bash",
784
+ description: summaryText,
785
+ meta: lineCount > 0 ? [`${lineCount} lines`] : undefined,
786
+ },
787
+ uiTheme,
788
+ );
789
+ return [truncateToWidth(line, width)];
790
+ }
791
+
786
792
  const line = renderStatusLine(
787
793
  {
788
- icon: "running",
789
- spinnerFrame: options.spinnerFrame,
790
794
  title: "Bash",
791
795
  description: summaryText,
792
- meta: lineCount > 0 ? [`${lineCount} lines`] : undefined,
793
796
  },
794
797
  uiTheme,
795
798
  );
796
799
  return [truncateToWidth(line, width)];
797
800
  }
798
-
799
- const line = renderStatusLine(
800
- {
801
- title: "Bash",
802
- description: summaryText,
803
- },
804
- uiTheme,
805
- );
806
- return [truncateToWidth(line, width)];
807
801
  }
808
802
 
803
+ const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
804
+
805
+ // Get output from context (preferred) or fall back to result content
806
+ const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
807
+ const displayOutput = output.trimEnd();
808
+ const showingFullOutput = expanded && renderContext?.isFullOutput === true;
809
+
810
+ const rawOutputLines = displayOutput.split("\n");
811
+ const sixelLineMask =
812
+ TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(rawOutputLines) : undefined;
813
+ const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
814
+
809
815
  // Build truncation warning
810
816
  const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
811
817
  const timeoutLine =
@@ -6,6 +6,10 @@ import type { GlabConfig } from "./types";
6
6
  const CONFIG_FILENAME = "glab-config.json";
7
7
  const XCSH_DIR = ".xcsh";
8
8
 
9
+ function homeDir(): string {
10
+ return process.env.HOME || os.homedir();
11
+ }
12
+
9
13
  export async function loadConfig(cwd: string): Promise<GlabConfig | null> {
10
14
  const projectConfig = path.join(cwd, XCSH_DIR, CONFIG_FILENAME);
11
15
  try {
@@ -15,7 +19,7 @@ export async function loadConfig(cwd: string): Promise<GlabConfig | null> {
15
19
  // try user-level fallback
16
20
  }
17
21
 
18
- const userConfig = path.join(os.homedir(), ".xcsh", "agent", CONFIG_FILENAME);
22
+ const userConfig = path.join(homeDir(), ".xcsh", "agent", CONFIG_FILENAME);
19
23
  try {
20
24
  const raw = await fs.readFile(userConfig, "utf8");
21
25
  return JSON.parse(raw) as GlabConfig;
@@ -25,13 +29,46 @@ export async function loadConfig(cwd: string): Promise<GlabConfig | null> {
25
29
  }
26
30
 
27
31
  export async function saveConfig(cwd: string, config: GlabConfig): Promise<void> {
28
- const dir = path.join(cwd, XCSH_DIR);
29
- await fs.mkdir(dir, { recursive: true });
30
- await fs.writeFile(path.join(dir, CONFIG_FILENAME), JSON.stringify(config, null, 2), "utf8");
32
+ const json = JSON.stringify(config, null, 2);
33
+ const projectDir = path.join(cwd, XCSH_DIR);
34
+ await fs.mkdir(projectDir, { recursive: true });
35
+ await fs.writeFile(path.join(projectDir, CONFIG_FILENAME), json, "utf8");
36
+ const userDir = path.join(homeDir(), ".xcsh", "agent");
37
+ await fs.mkdir(userDir, { recursive: true });
38
+ await fs.writeFile(path.join(userDir, CONFIG_FILENAME), json, "utf8");
31
39
  }
32
40
 
33
- export async function resolveProject(paramProject: string | undefined, cwd: string): Promise<string | null> {
41
+ export async function resolveProject(
42
+ paramProject: string | undefined,
43
+ cwd: string,
44
+ exec?: (cmd: string, args: string[]) => Promise<{ stdout: string; code: number }>,
45
+ ): Promise<string | null> {
34
46
  if (paramProject) return paramProject;
35
47
  const config = await loadConfig(cwd);
36
- return config?.project ?? null;
48
+ if (config?.project) return config.project;
49
+ // Auto-detect: check if cwd has a gitlab remote
50
+ if (exec) {
51
+ try {
52
+ const result = await exec("glab", ["repo", "view", "--output", "json"]);
53
+ if (result.code === 0 && result.stdout) {
54
+ const repo = JSON.parse(result.stdout);
55
+ if (repo.path_with_namespace) return repo.path_with_namespace;
56
+ }
57
+ } catch {
58
+ // Not a GitLab repo or glab not available — fall through
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /** Idempotent startup check: returns a warning string if glab is installed but not configured, null otherwise. */
65
+ export async function glabStartupWarning(cwd: string): Promise<string | null> {
66
+ // Fast path: if glab is not installed, nothing to warn about
67
+ const { $which } = await import("@f5xc-salesdemos/pi-utils");
68
+ if (!$which("glab")) return null;
69
+ // Check if config already exists at project or user level
70
+ const config = await loadConfig(cwd);
71
+ if (config?.project) return null;
72
+ // glab is installed but no project configured — emit a one-time warning
73
+ return "GitLab (glab) is installed but no project is configured. Run: glab_setup with action save_project and project GROUP/REPO";
37
74
  }
package/src/tools/glab.ts CHANGED
@@ -23,13 +23,10 @@ function makeExecApi(cwd: string): GlabExecApi {
23
23
  return {
24
24
  cwd,
25
25
  async exec(command: string, args: string[], options?: { signal?: AbortSignal; cwd?: string }) {
26
- // Check abort before spawning, but do NOT pass signal to Bun.spawn.
27
- // glab commands complete in <1s passing the signal causes a race where
28
- // xcsh's AbortSignal fires (e.g. on model completion) and kills the child
29
- // process before we can read its output.
30
- if (options?.signal?.aborted) {
31
- return { stdout: "", stderr: "Command was cancelled before starting", code: 1, killed: true };
32
- }
26
+ // Never pass signal to Bun.spawn and never pre-check signal.aborted.
27
+ // glab commands finish in 1-3s. Passing the signal or pre-checking causes
28
+ // false cancellations when xcsh's AbortSignal fires between multi-turn
29
+ // tool calls (the signal is stale from a prior turn).
33
30
  const child = Bun.spawn([command, ...args], {
34
31
  cwd: options?.cwd ?? cwd,
35
32
  stdin: "ignore",
@@ -245,7 +242,7 @@ export class GlabIssueListTool implements AgentTool<typeof glabIssueListSchema,
245
242
  _context?: AgentToolContext,
246
243
  ): Promise<AgentToolResult<GlabToolDetails>> {
247
244
  const api = makeExecApi(this.session.cwd);
248
- const project = await resolveProject(params.project, this.session.cwd);
245
+ const project = await resolveProject(params.project, this.session.cwd, (cmd, args) => api.exec(cmd, args));
249
246
  if (!project) {
250
247
  return textResult("No GitLab project configured. Run glab_setup to set one up.");
251
248
  }
@@ -294,7 +291,7 @@ export class GlabIssueViewTool implements AgentTool<typeof glabIssueViewSchema,
294
291
  _context?: AgentToolContext,
295
292
  ): Promise<AgentToolResult<GlabToolDetails>> {
296
293
  const api = makeExecApi(this.session.cwd);
297
- const project = await resolveProject(params.project, this.session.cwd);
294
+ const project = await resolveProject(params.project, this.session.cwd, (cmd, args) => api.exec(cmd, args));
298
295
  if (!project) {
299
296
  return textResult("No GitLab project configured. Run glab_setup to set one up.");
300
297
  }
@@ -336,7 +333,7 @@ export class GlabSearchTool implements AgentTool<typeof glabSearchSchema, GlabTo
336
333
  _context?: AgentToolContext,
337
334
  ): Promise<AgentToolResult<GlabToolDetails>> {
338
335
  const api = makeExecApi(this.session.cwd);
339
- const project = await resolveProject(params.project, this.session.cwd);
336
+ const project = await resolveProject(params.project, this.session.cwd, (cmd, args) => api.exec(cmd, args));
340
337
  if (!project) {
341
338
  return textResult("No GitLab project configured. Run glab_setup to set one up.");
342
339
  }
@@ -2,6 +2,7 @@ import type { AgentTool, AgentToolResult } from "@f5xc-salesdemos/pi-agent-core"
2
2
  import { prompt } from "@f5xc-salesdemos/pi-utils";
3
3
  import { type Static, Type } from "@sinclair/typebox";
4
4
  import xcshApiDescription from "../prompts/tools/xcsh-api.md" with { type: "text" };
5
+ import { createContextEnv } from "../services/context-env";
5
6
  import type { ToolSession } from ".";
6
7
 
7
8
  const xcshApiSchema = Type.Object({
@@ -13,7 +14,8 @@ const xcshApiSchema = Type.Object({
13
14
  params: Type.Optional(
14
15
  Type.Record(Type.String(), Type.String(), {
15
16
  description:
16
- "Path parameter substitutions, e.g. { namespace: 'default', name: 'example-lb', vh_name: 'example-vh' }",
17
+ "Path parameter substitutions, e.g. { namespace: 'example-ns', vh_name: 'example-vh' }. " +
18
+ "Unspecified params are auto-resolved from context env (e.g. {namespace} from F5XC_NAMESPACE).",
17
19
  }),
18
20
  ),
19
21
  payload: Type.Optional(Type.Unknown({ description: "JSON body for POST/PUT/PATCH/DELETE requests" })),
@@ -36,48 +38,53 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
36
38
  readonly description: string;
37
39
  readonly parameters = xcshApiSchema;
38
40
 
39
- #apiBase: string;
40
- #apiToken: string;
41
+ #contextEnv: ReturnType<typeof createContextEnv>;
41
42
 
42
- constructor(_session: ToolSession) {
43
+ constructor(session: ToolSession) {
43
44
  this.description = prompt.render(xcshApiDescription);
44
- this.#apiBase = (process.env.F5XC_API_URL ?? "").replace(/\/+$/, "");
45
- this.#apiToken = process.env.F5XC_API_TOKEN ?? "";
45
+ this.#contextEnv = createContextEnv(session.settings);
46
46
 
47
- if (this.#apiBase && this.#apiToken) {
48
- fetch(`${this.#apiBase}/api/web/namespaces`, {
47
+ const apiBase = this.#resolveApiBase();
48
+ const apiToken = this.#resolveApiToken();
49
+ if (apiBase && apiToken) {
50
+ fetch(`${apiBase}/api/web/namespaces`, {
49
51
  method: "HEAD",
50
- headers: { Authorization: `APIToken ${this.#apiToken}` },
52
+ headers: { Authorization: `APIToken ${apiToken}` },
51
53
  }).catch(() => {});
52
54
  }
53
55
  }
54
56
 
57
+ #resolveApiBase(): string {
58
+ return (process.env.F5XC_API_URL ?? this.#contextEnv.get("F5XC_API_URL") ?? "").replace(/\/+$/, "");
59
+ }
60
+
61
+ #resolveApiToken(): string {
62
+ return process.env.F5XC_API_TOKEN ?? this.#contextEnv.get("F5XC_API_TOKEN") ?? "";
63
+ }
64
+
55
65
  async execute(_toolCallId: string, params: XcshApiParams): Promise<XcshApiResult> {
56
- if (!this.#apiBase) {
66
+ const apiBase = this.#resolveApiBase();
67
+ if (!apiBase) {
57
68
  return {
58
69
  content: [{ type: "text", text: "Error: F5XC_API_URL environment variable is not set." }],
59
70
  isError: true,
60
71
  };
61
72
  }
62
73
 
63
- if (!this.#apiToken) {
74
+ const apiToken = this.#resolveApiToken();
75
+ if (!apiToken) {
64
76
  return {
65
77
  content: [{ type: "text", text: "Error: F5XC_API_TOKEN environment variable is not set." }],
66
78
  isError: true,
67
79
  };
68
80
  }
69
81
 
70
- let resolvedPath = params.path;
71
- if (params.params) {
72
- for (const [key, value] of Object.entries(params.params)) {
73
- resolvedPath = resolvedPath.replaceAll(`{${key}}`, value);
74
- }
75
- }
82
+ const resolvedPath = this.#contextEnv.resolvePath(params.path, params.params);
76
83
 
77
- const url = `${this.#apiBase}${resolvedPath}`;
84
+ const url = `${apiBase}${resolvedPath}`;
78
85
  const requestId = crypto.randomUUID();
79
86
  const headers: Record<string, string> = {
80
- Authorization: `APIToken ${this.#apiToken}`,
87
+ Authorization: `APIToken ${apiToken}`,
81
88
  Accept: "application/json",
82
89
  "X-Request-ID": requestId,
83
90
  };
@@ -90,7 +97,8 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
90
97
 
91
98
  if (params.payload && params.method !== "GET") {
92
99
  headers["Content-Type"] = "application/json";
93
- init.body = JSON.stringify(params.payload);
100
+ const payloadJson = JSON.stringify(params.payload);
101
+ init.body = this.#contextEnv.resolvePayloadVars(payloadJson);
94
102
  }
95
103
 
96
104
  try {