@f5xc-salesdemos/xcsh 18.36.1 → 18.37.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 +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 +44 -34
- package/src/tools/glab/config.ts +43 -6
- package/src/tools/glab/exec.ts +3 -1
- package/src/tools/glab.ts +9 -8
- package/src/tools/xcsh-api.ts +9 -9
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.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",
|
|
@@ -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.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-agent-core": "18.37.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-ai": "18.37.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-natives": "18.37.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-tui": "18.37.0",
|
|
56
|
+
"@f5xc-salesdemos/pi-utils": "18.37.0",
|
|
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.0",
|
|
21
|
+
"commit": "b0029e7c03c357e5da1c25e622408964845c0230",
|
|
22
|
+
"shortCommit": "b0029e7",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.37.0",
|
|
25
|
+
"commitDate": "2026-05-04T07:12:51Z",
|
|
26
|
+
"buildDate": "2026-05-04T07:31:37.330Z",
|
|
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/b0029e7c03c357e5da1c25e622408964845c0230",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.37.0"
|
|
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
|
@@ -730,7 +730,12 @@ function getBashVerboseSetting(): boolean {
|
|
|
730
730
|
|
|
731
731
|
export const bashToolRenderer = {
|
|
732
732
|
renderCall(args: BashRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
733
|
-
|
|
733
|
+
let summaryText: string | undefined;
|
|
734
|
+
if (args.description) {
|
|
735
|
+
summaryText = args.description;
|
|
736
|
+
} else if (args.command) {
|
|
737
|
+
summaryText = formatBashCommand(args).replace(/\s*\\\r?\n\s*/g, " ");
|
|
738
|
+
}
|
|
734
739
|
const text = renderStatusLine({ icon: "pending", title: "Bash", description: summaryText }, uiTheme);
|
|
735
740
|
return new Text(text, 0, 0);
|
|
736
741
|
},
|
|
@@ -756,52 +761,57 @@ export const bashToolRenderer = {
|
|
|
756
761
|
// REACTIVE: read mutable options at render time
|
|
757
762
|
const { renderContext } = options;
|
|
758
763
|
const expanded = renderContext?.expanded ?? options.expanded;
|
|
759
|
-
const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
|
|
760
|
-
|
|
761
|
-
// Get output from context (preferred) or fall back to result content
|
|
762
|
-
const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
|
|
763
|
-
const displayOutput = output.trimEnd();
|
|
764
|
-
const showingFullOutput = expanded && renderContext?.isFullOutput === true;
|
|
765
764
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
|
|
770
|
-
|
|
771
|
-
// 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.
|
|
772
768
|
const verbose = getBashVerboseSetting();
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
+
|
|
782
792
|
const line = renderStatusLine(
|
|
783
793
|
{
|
|
784
|
-
icon: "running",
|
|
785
|
-
spinnerFrame: options.spinnerFrame,
|
|
786
794
|
title: "Bash",
|
|
787
795
|
description: summaryText,
|
|
788
|
-
meta: lineCount > 0 ? [`${lineCount} lines`] : undefined,
|
|
789
796
|
},
|
|
790
797
|
uiTheme,
|
|
791
798
|
);
|
|
792
799
|
return [truncateToWidth(line, width)];
|
|
793
800
|
}
|
|
794
|
-
|
|
795
|
-
const line = renderStatusLine(
|
|
796
|
-
{
|
|
797
|
-
title: "Bash",
|
|
798
|
-
description: summaryText,
|
|
799
|
-
},
|
|
800
|
-
uiTheme,
|
|
801
|
-
);
|
|
802
|
-
return [truncateToWidth(line, width)];
|
|
803
801
|
}
|
|
804
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
|
+
|
|
805
815
|
// Build truncation warning
|
|
806
816
|
const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
|
|
807
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/exec.ts
CHANGED
|
@@ -46,7 +46,9 @@ export async function checkAuth(pi: GlabExecApi): Promise<boolean> {
|
|
|
46
46
|
|
|
47
47
|
export async function execGlab(pi: GlabExecApi, args: string[], signal?: AbortSignal): Promise<GlabExecResult> {
|
|
48
48
|
const result = await pi.exec("glab", args, { signal, cwd: pi.cwd });
|
|
49
|
-
|
|
49
|
+
// Bun.spawn sets killed=true even on successful exits — only treat as
|
|
50
|
+
// cancelled when killed AND no stdout was captured (actual signal kill).
|
|
51
|
+
if (result.killed && !result.stdout && result.code !== 0) throw new Error("Command was cancelled");
|
|
50
52
|
if (result.code !== 0) {
|
|
51
53
|
const stderr = result.stderr.toLowerCase();
|
|
52
54
|
if (stderr.includes("auth") || stderr.includes("not logged in") || stderr.includes("token")) {
|
package/src/tools/glab.ts
CHANGED
|
@@ -23,12 +23,15 @@ 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
|
+
// 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).
|
|
26
30
|
const child = Bun.spawn([command, ...args], {
|
|
27
31
|
cwd: options?.cwd ?? cwd,
|
|
28
32
|
stdin: "ignore",
|
|
29
33
|
stdout: "pipe",
|
|
30
34
|
stderr: "pipe",
|
|
31
|
-
signal: options?.signal,
|
|
32
35
|
});
|
|
33
36
|
if (!child.stdout || !child.stderr) {
|
|
34
37
|
return { stdout: "", stderr: "Failed to capture output", code: 1, killed: false };
|
|
@@ -239,14 +242,13 @@ export class GlabIssueListTool implements AgentTool<typeof glabIssueListSchema,
|
|
|
239
242
|
_context?: AgentToolContext,
|
|
240
243
|
): Promise<AgentToolResult<GlabToolDetails>> {
|
|
241
244
|
const api = makeExecApi(this.session.cwd);
|
|
242
|
-
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));
|
|
243
246
|
if (!project) {
|
|
244
247
|
return textResult("No GitLab project configured. Run glab_setup to set one up.");
|
|
245
248
|
}
|
|
246
249
|
|
|
247
250
|
const args = ["issue", "list", "--output", "json", "--repo", project];
|
|
248
|
-
if (params.state === "
|
|
249
|
-
else if (params.state === "closed") args.push("--closed");
|
|
251
|
+
if (params.state === "closed") args.push("--closed");
|
|
250
252
|
else if (params.state === "all") args.push("--all");
|
|
251
253
|
if (params.labels?.length) args.push("--label", params.labels.join(","));
|
|
252
254
|
if (params.assignee) args.push("--assignee", params.assignee);
|
|
@@ -289,7 +291,7 @@ export class GlabIssueViewTool implements AgentTool<typeof glabIssueViewSchema,
|
|
|
289
291
|
_context?: AgentToolContext,
|
|
290
292
|
): Promise<AgentToolResult<GlabToolDetails>> {
|
|
291
293
|
const api = makeExecApi(this.session.cwd);
|
|
292
|
-
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));
|
|
293
295
|
if (!project) {
|
|
294
296
|
return textResult("No GitLab project configured. Run glab_setup to set one up.");
|
|
295
297
|
}
|
|
@@ -331,7 +333,7 @@ export class GlabSearchTool implements AgentTool<typeof glabSearchSchema, GlabTo
|
|
|
331
333
|
_context?: AgentToolContext,
|
|
332
334
|
): Promise<AgentToolResult<GlabToolDetails>> {
|
|
333
335
|
const api = makeExecApi(this.session.cwd);
|
|
334
|
-
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));
|
|
335
337
|
if (!project) {
|
|
336
338
|
return textResult("No GitLab project configured. Run glab_setup to set one up.");
|
|
337
339
|
}
|
|
@@ -351,8 +353,7 @@ export class GlabSearchTool implements AgentTool<typeof glabSearchSchema, GlabTo
|
|
|
351
353
|
"--per-page",
|
|
352
354
|
String(limit),
|
|
353
355
|
];
|
|
354
|
-
if (params.state === "
|
|
355
|
-
else if (params.state === "closed") restArgs.push("--closed");
|
|
356
|
+
if (params.state === "closed") restArgs.push("--closed");
|
|
356
357
|
else if (params.state === "all") restArgs.push("--all");
|
|
357
358
|
if (params.labels?.length) restArgs.push("--label", params.labels.join(","));
|
|
358
359
|
|
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" })),
|
|
@@ -38,11 +40,13 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
38
40
|
|
|
39
41
|
#apiBase: string;
|
|
40
42
|
#apiToken: string;
|
|
43
|
+
#contextEnv: ReturnType<typeof createContextEnv>;
|
|
41
44
|
|
|
42
|
-
constructor(
|
|
45
|
+
constructor(session: ToolSession) {
|
|
43
46
|
this.description = prompt.render(xcshApiDescription);
|
|
44
47
|
this.#apiBase = (process.env.F5XC_API_URL ?? "").replace(/\/+$/, "");
|
|
45
48
|
this.#apiToken = process.env.F5XC_API_TOKEN ?? "";
|
|
49
|
+
this.#contextEnv = createContextEnv(session.settings);
|
|
46
50
|
|
|
47
51
|
if (this.#apiBase && this.#apiToken) {
|
|
48
52
|
fetch(`${this.#apiBase}/api/web/namespaces`, {
|
|
@@ -67,12 +71,7 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
67
71
|
};
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
if (params.params) {
|
|
72
|
-
for (const [key, value] of Object.entries(params.params)) {
|
|
73
|
-
resolvedPath = resolvedPath.replaceAll(`{${key}}`, value);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
74
|
+
const resolvedPath = this.#contextEnv.resolvePath(params.path, params.params);
|
|
76
75
|
|
|
77
76
|
const url = `${this.#apiBase}${resolvedPath}`;
|
|
78
77
|
const requestId = crypto.randomUUID();
|
|
@@ -90,7 +89,8 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
90
89
|
|
|
91
90
|
if (params.payload && params.method !== "GET") {
|
|
92
91
|
headers["Content-Type"] = "application/json";
|
|
93
|
-
|
|
92
|
+
const payloadJson = JSON.stringify(params.payload);
|
|
93
|
+
init.body = this.#contextEnv.resolvePayloadVars(payloadJson);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
try {
|