@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 +7 -7
- package/src/config/settings-schema.ts +3 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/interactive-mode.ts +11 -5
- package/src/prompts/system/system-prompt.md +9 -0
- package/src/sdk.ts +14 -1
- package/src/services/context-env.ts +122 -0
- package/src/services/f5xc-context.ts +6 -0
- package/src/tools/bash.ts +38 -32
- package/src/tools/glab/config.ts +43 -6
- package/src/tools/glab.ts +7 -10
- package/src/tools/xcsh-api.ts +28 -20
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
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.
|
|
52
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
56
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.37.1",
|
|
21
|
+
"commit": "f8fdc560fa88d310e9c7f39ed258471a9a01d892",
|
|
22
|
+
"shortCommit": "f8fdc56",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
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
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
| {
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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 =
|
package/src/tools/glab/config.ts
CHANGED
|
@@ -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(
|
|
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
|
|
29
|
-
|
|
30
|
-
await fs.
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
27
|
-
// glab commands
|
|
28
|
-
// xcsh's AbortSignal fires
|
|
29
|
-
//
|
|
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
|
}
|
package/src/tools/xcsh-api.ts
CHANGED
|
@@ -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: '
|
|
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
|
-
#
|
|
40
|
-
#apiToken: string;
|
|
41
|
+
#contextEnv: ReturnType<typeof createContextEnv>;
|
|
41
42
|
|
|
42
|
-
constructor(
|
|
43
|
+
constructor(session: ToolSession) {
|
|
43
44
|
this.description = prompt.render(xcshApiDescription);
|
|
44
|
-
this.#
|
|
45
|
-
this.#apiToken = process.env.F5XC_API_TOKEN ?? "";
|
|
45
|
+
this.#contextEnv = createContextEnv(session.settings);
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = `${
|
|
84
|
+
const url = `${apiBase}${resolvedPath}`;
|
|
78
85
|
const requestId = crypto.randomUUID();
|
|
79
86
|
const headers: Record<string, string> = {
|
|
80
|
-
Authorization: `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
|
-
|
|
100
|
+
const payloadJson = JSON.stringify(params.payload);
|
|
101
|
+
init.body = this.#contextEnv.resolvePayloadVars(payloadJson);
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
try {
|