@f5xc-salesdemos/xcsh 18.35.2 → 18.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.35.2",
4
+ "version": "18.36.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.35.2",
52
- "@f5xc-salesdemos/pi-agent-core": "18.35.2",
53
- "@f5xc-salesdemos/pi-ai": "18.35.2",
54
- "@f5xc-salesdemos/pi-natives": "18.35.2",
55
- "@f5xc-salesdemos/pi-tui": "18.35.2",
56
- "@f5xc-salesdemos/pi-utils": "18.35.2",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.36.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.36.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.36.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.36.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.36.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.36.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -1328,6 +1328,17 @@ export const SETTINGS_SCHEMA = {
1328
1328
  },
1329
1329
  },
1330
1330
 
1331
+ "gitlab.enabled": {
1332
+ type: "boolean",
1333
+ default: true,
1334
+ ui: {
1335
+ tab: "tools",
1336
+ label: "GitLab CLI",
1337
+ description:
1338
+ "Enable read-only glab_* tools for GitLab issue tracking, search, and work item access via glab CLI",
1339
+ },
1340
+ },
1341
+
1331
1342
  "web_search.enabled": {
1332
1343
  type: "boolean",
1333
1344
  default: true,
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.35.2",
21
- "commit": "4476717a972f57d501906d71b1377b21e00744df",
22
- "shortCommit": "4476717",
20
+ "version": "18.36.0",
21
+ "commit": "e184ba33c5bf2f52613a85f40b7ebd0d5043deb5",
22
+ "shortCommit": "e184ba3",
23
23
  "branch": "main",
24
- "tag": "v18.35.2",
25
- "commitDate": "2026-05-03T23:47:28Z",
26
- "buildDate": "2026-05-04T00:10:18.411Z",
24
+ "tag": "v18.36.0",
25
+ "commitDate": "2026-05-04T03:30:53Z",
26
+ "buildDate": "2026-05-04T03:50:55.993Z",
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/4476717a972f57d501906d71b1377b21e00744df",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.35.2"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/e184ba33c5bf2f52613a85f40b7ebd0d5043deb5",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.36.0"
33
33
  };
@@ -196,13 +196,22 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
196
196
 
197
197
  When the user needs to **make an API call** (create, read, update, delete):
198
198
 
199
- 1. `xcsh://api-catalog/?resource={resource_name}` → get endpoint path, method, minimum
200
- payload JSON, required fields, and response summary
199
+ 1. `xcsh://api-catalog/?resource={resource_name}&compact=true` → get endpoint path, method,
200
+ minimum payload JSON, OneOf recommendations, and response summary
201
201
  2. Call `xcsh_api` tool with `method`, `path`, `params` (all `{placeholder}` substitutions), and `payload`
202
202
 
203
+ When the resource type and required parameters are clear, your **first output
204
+ MUST be the catalog tool call** — do not preface with explanation or deliberation.
205
+ If required parameters (e.g., namespace) are ambiguous, ask first.
206
+
203
207
  The `xcsh_api` tool handles authentication, URL construction, and HTTP execution.
204
208
  Never construct curl commands for F5 XC API calls — use `xcsh_api` instead.
205
209
 
210
+ After `xcsh_api` returns a 200 or 201 response, report the result immediately.
211
+ Do not issue a follow-up GET to verify — the response body is the verification.
212
+ Only issue a GET if the user explicitly asks to read current state, or if the
213
+ initial call returned a non-2xx status.
214
+
206
215
  If the resource name is unknown, search first:
207
216
  `xcsh://api-catalog/?search={term}` → find the matching category, then read it.
208
217
 
@@ -0,0 +1,7 @@
1
+ List GitLab issues with structured filters via glab CLI. Returns a markdown summary table.
2
+
3
+ <instruction>
4
+ Use for "show open bugs", "list issues assigned to X", "show high priority issues".
5
+ Supports: state (opened/closed/all), labels, assignee, search text, milestone, sort field, order direction, limit.
6
+ Defaults to the configured project. Pass project explicitly to override.
7
+ </instruction>
@@ -0,0 +1,7 @@
1
+ View a single GitLab issue with full details, description, and comments via glab CLI.
2
+
3
+ <instruction>
4
+ Use when the user asks to "show issue #N" or "view details of issue X".
5
+ Returns structured markdown with title, state, author, labels, assignee, milestone, description, and comments thread.
6
+ System notes (automated messages) are filtered out.
7
+ </instruction>
@@ -0,0 +1,7 @@
1
+ Full-text search across GitLab issue titles, descriptions, labels, and comments via glab CLI.
2
+
3
+ <instruction>
4
+ Use for "find issues about Tempus", "search for login timeout bugs", "bugs mentioning Safari".
5
+ Three-tier search: REST API (fast, titles+descriptions), GraphQL (includes comments), client-side dedup.
6
+ Supports state filtering and label filtering alongside text search.
7
+ </instruction>
@@ -0,0 +1,6 @@
1
+ GitLab onboarding wizard via glab CLI. Check installation, authenticate, discover projects, and persist configuration.
2
+
3
+ <instruction>
4
+ Actions: "check" (verify glab installed), "status" (auth + config), "login" (browser OAuth), "select_project" (list available projects), "save_project" (persist selected project path).
5
+ Run this sequence when the user first asks about GitLab issues and no project is configured.
6
+ </instruction>
@@ -7,3 +7,5 @@ Pass all path `{placeholder}` values via `params`, e.g. `{ namespace: "default",
7
7
  Body is sent for all methods except GET when `payload` is provided — including DELETE operations that require a body.
8
8
 
9
9
  Use this tool after reading the API catalog to get the endpoint path and payload structure.
10
+
11
+ API calls to the same F5 XC tenant reuse a single TLS connection — sequential calls are faster than parallel calls. Do not issue multiple xcsh_api calls in the same turn; issue them one at a time.
package/src/tools/bash.ts CHANGED
@@ -772,10 +772,26 @@ export const bashToolRenderer = {
772
772
  const verbose = getBashVerboseSetting();
773
773
  const hasAsyncDetails = details?.async != null;
774
774
  const forceExpand = isError || hasAsyncDetails || hasSixelOutput;
775
- if (!verbose && !expanded && !forceExpand && !options.isPartial) {
775
+ if (!verbose && !expanded && !forceExpand) {
776
776
  const rawCmd = args?.command;
777
777
  const summaryText =
778
778
  args?.description ?? (rawCmd && rawCmd.length > 60 ? `${rawCmd.slice(0, 60)}…` : rawCmd) ?? "…";
779
+
780
+ if (options.isPartial) {
781
+ const lineCount = rawOutputLines.filter(l => l.trim().length > 0).length;
782
+ const line = renderStatusLine(
783
+ {
784
+ icon: "running",
785
+ spinnerFrame: options.spinnerFrame,
786
+ title: "Bash",
787
+ description: summaryText,
788
+ meta: lineCount > 0 ? [`${lineCount} lines`] : undefined,
789
+ },
790
+ uiTheme,
791
+ );
792
+ return [truncateToWidth(line, width)];
793
+ }
794
+
779
795
  const line = renderStatusLine(
780
796
  {
781
797
  title: "Bash",
@@ -0,0 +1,37 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { GlabConfig } from "./types";
5
+
6
+ const CONFIG_FILENAME = "glab-config.json";
7
+ const XCSH_DIR = ".xcsh";
8
+
9
+ export async function loadConfig(cwd: string): Promise<GlabConfig | null> {
10
+ const projectConfig = path.join(cwd, XCSH_DIR, CONFIG_FILENAME);
11
+ try {
12
+ const raw = await fs.readFile(projectConfig, "utf8");
13
+ return JSON.parse(raw) as GlabConfig;
14
+ } catch {
15
+ // try user-level fallback
16
+ }
17
+
18
+ const userConfig = path.join(os.homedir(), ".xcsh", "agent", CONFIG_FILENAME);
19
+ try {
20
+ const raw = await fs.readFile(userConfig, "utf8");
21
+ return JSON.parse(raw) as GlabConfig;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ 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");
31
+ }
32
+
33
+ export async function resolveProject(paramProject: string | undefined, cwd: string): Promise<string | null> {
34
+ if (paramProject) return paramProject;
35
+ const config = await loadConfig(cwd);
36
+ return config?.project ?? null;
37
+ }
@@ -0,0 +1,66 @@
1
+ export interface GlabExecResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ code: number;
5
+ killed: boolean;
6
+ }
7
+
8
+ export interface GlabExecApi {
9
+ cwd: string;
10
+ exec(command: string, args: string[], options?: { signal?: AbortSignal; cwd?: string }): Promise<GlabExecResult>;
11
+ }
12
+
13
+ export class GlabAuthError extends Error {
14
+ constructor(message: string) {
15
+ super(`GitLab auth error: ${message}. Run glab_setup with action "login".`);
16
+ this.name = "GlabAuthError";
17
+ }
18
+ }
19
+
20
+ export class GlabNotFoundError extends Error {
21
+ constructor(message: string) {
22
+ super(`GitLab resource not found (404/403): ${message}`);
23
+ this.name = "GlabNotFoundError";
24
+ }
25
+ }
26
+
27
+ export class GlabExecError extends Error {
28
+ constructor(
29
+ message: string,
30
+ public readonly code: number,
31
+ ) {
32
+ super(`glab command failed (exit ${code}): ${message}`);
33
+ this.name = "GlabExecError";
34
+ }
35
+ }
36
+
37
+ export async function checkInstalled(pi: GlabExecApi): Promise<boolean> {
38
+ const result = await pi.exec("which", ["glab"], { cwd: pi.cwd });
39
+ return result.code === 0;
40
+ }
41
+
42
+ export async function checkAuth(pi: GlabExecApi): Promise<boolean> {
43
+ const result = await pi.exec("glab", ["auth", "status"], { cwd: pi.cwd });
44
+ return result.code === 0;
45
+ }
46
+
47
+ export async function execGlab(pi: GlabExecApi, args: string[], signal?: AbortSignal): Promise<GlabExecResult> {
48
+ const result = await pi.exec("glab", args, { signal, cwd: pi.cwd });
49
+ if (result.killed) throw new Error("Command was cancelled");
50
+ if (result.code !== 0) {
51
+ const stderr = result.stderr.toLowerCase();
52
+ if (stderr.includes("auth") || stderr.includes("not logged in") || stderr.includes("token")) {
53
+ throw new GlabAuthError(result.stderr);
54
+ }
55
+ if (stderr.includes("404") || stderr.includes("403") || stderr.includes("not found")) {
56
+ throw new GlabNotFoundError(result.stderr);
57
+ }
58
+ throw new GlabExecError(result.stderr, result.code);
59
+ }
60
+ return result;
61
+ }
62
+
63
+ export async function execGlabJson<T = unknown>(pi: GlabExecApi, args: string[], signal?: AbortSignal): Promise<T> {
64
+ const result = await execGlab(pi, args, signal);
65
+ return JSON.parse(result.stdout) as T;
66
+ }
@@ -0,0 +1,73 @@
1
+ import type { GlabIssue } from "./types";
2
+
3
+ function formatDate(iso: string): string {
4
+ return iso.slice(0, 10);
5
+ }
6
+
7
+ function truncateLabels(labels: string[], maxLen = 30): string {
8
+ if (!labels.length) return "(none)";
9
+ const joined = labels.join(", ");
10
+ if (joined.length <= maxLen) return joined;
11
+ const truncated = labels.slice(0, 3).join(", ");
12
+ const remaining = labels.length - 3;
13
+ return remaining > 0 ? `${truncated}, +${remaining}` : truncated;
14
+ }
15
+
16
+ export function formatIssueTable(issues: GlabIssue[]): string {
17
+ if (issues.length === 0) {
18
+ return "No issues found matching the criteria.";
19
+ }
20
+
21
+ const header = "| IID | Title | State | Labels | Assignee | Updated |";
22
+ const divider = "|-----|-------|-------|--------|----------|---------|";
23
+
24
+ const rows = issues.map(issue => {
25
+ const title = issue.title.length > 50 ? `${issue.title.slice(0, 47)}...` : issue.title;
26
+ const labels = truncateLabels(issue.labels);
27
+ const assignee = issue.assignees.length > 0 ? `@${issue.assignees[0].username}` : "unassigned";
28
+ const updated = formatDate(issue.updated_at);
29
+ return `| ${issue.iid} | ${title} | ${issue.state} | ${labels} | ${assignee} | ${updated} |`;
30
+ });
31
+
32
+ return [header, divider, ...rows].join("\n");
33
+ }
34
+
35
+ export function formatIssueDetail(issue: GlabIssue): string {
36
+ const lines: string[] = [];
37
+
38
+ lines.push(`# Issue #${issue.iid}: ${issue.title}`);
39
+ lines.push("");
40
+ lines.push(
41
+ `State: ${issue.state} | Author: @${issue.author.username} | Created: ${formatDate(issue.created_at)} | Updated: ${formatDate(issue.updated_at)}`,
42
+ );
43
+
44
+ if (issue.labels.length > 0) {
45
+ lines.push(`Labels: ${issue.labels.join(", ")}`);
46
+ }
47
+
48
+ const assigneeStr =
49
+ issue.assignees.length > 0 ? issue.assignees.map(a => `@${a.username}`).join(", ") : "unassigned";
50
+ lines.push(`Assignee: ${assigneeStr}${issue.milestone ? ` | Milestone: ${issue.milestone.title}` : ""}`);
51
+ lines.push("");
52
+ lines.push("---");
53
+ lines.push("");
54
+ lines.push(issue.description || "(no description)");
55
+
56
+ const humanNotes = (issue.notes ?? []).filter(n => !n.system);
57
+ lines.push("");
58
+ lines.push("---");
59
+ lines.push("");
60
+
61
+ if (humanNotes.length === 0) {
62
+ lines.push("No comments.");
63
+ } else {
64
+ lines.push(`## Comments (${humanNotes.length})`);
65
+ for (const note of humanNotes) {
66
+ lines.push("");
67
+ lines.push(`**@${note.author.username}** (${formatDate(note.created_at)}):`);
68
+ lines.push("> " + note.body.split("\n").join("\n> "));
69
+ }
70
+ }
71
+
72
+ return lines.join("\n");
73
+ }
@@ -0,0 +1,59 @@
1
+ import type { GlabExecApi } from "./exec";
2
+ import { execGlab } from "./exec";
3
+ import type { GraphQLIssueNode, GraphQLSearchResponse } from "./types";
4
+
5
+ export function buildSearchQuery(includeState: boolean): string {
6
+ const stateVar = includeState ? ", $state: IssuableState" : "";
7
+ const stateArg = includeState ? ", state: $state" : "";
8
+ return `
9
+ query SearchIssues($projectPath: ID!, $search: String!, $first: Int!${stateVar}) {
10
+ project(fullPath: $projectPath) {
11
+ issues(search: $search, first: $first${stateArg}) {
12
+ nodes {
13
+ iid
14
+ title
15
+ state
16
+ labels { nodes { title } }
17
+ assignees { nodes { username } }
18
+ updatedAt
19
+ notes(first: 20) {
20
+ nodes {
21
+ body
22
+ author { username }
23
+ createdAt
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }`.trim();
30
+ }
31
+
32
+ export async function executeGraphQL(
33
+ pi: GlabExecApi,
34
+ projectPath: string,
35
+ searchText: string,
36
+ limit: number,
37
+ signal?: AbortSignal,
38
+ state?: string,
39
+ ): Promise<GraphQLIssueNode[]> {
40
+ const includeState = !!state && state !== "all";
41
+ const query = buildSearchQuery(includeState);
42
+ const vars: Record<string, unknown> = { projectPath, search: searchText, first: limit };
43
+ if (includeState) vars.state = state === "closed" ? "closed" : "opened";
44
+ const variables = JSON.stringify(vars);
45
+
46
+ const result = await execGlab(
47
+ pi,
48
+ ["api", "graphql", "-f", `query=${query}`, "-f", `variables=${variables}`],
49
+ signal,
50
+ );
51
+
52
+ const response = JSON.parse(result.stdout) as GraphQLSearchResponse;
53
+
54
+ if (response.errors && response.errors.length > 0) {
55
+ throw new Error(response.errors.map(e => e.message).join("; "));
56
+ }
57
+
58
+ return response.data?.project?.issues?.nodes ?? [];
59
+ }
@@ -0,0 +1,86 @@
1
+ // .xcsh/tools/glab/lib/types.ts
2
+
3
+ export interface GlabLabel {
4
+ name: string;
5
+ color: string;
6
+ }
7
+
8
+ export interface GlabUser {
9
+ username: string;
10
+ name: string;
11
+ }
12
+
13
+ export interface GlabMilestone {
14
+ title: string;
15
+ iid: number;
16
+ }
17
+
18
+ export interface GlabNote {
19
+ id: number;
20
+ body: string;
21
+ author: GlabUser;
22
+ created_at: string;
23
+ updated_at: string;
24
+ system: boolean;
25
+ }
26
+
27
+ export interface GlabIssue {
28
+ id: number;
29
+ iid: number;
30
+ title: string;
31
+ description: string;
32
+ state: "opened" | "closed";
33
+ labels: string[];
34
+ assignees: GlabUser[];
35
+ author: GlabUser;
36
+ milestone: GlabMilestone | null;
37
+ created_at: string;
38
+ updated_at: string;
39
+ web_url: string;
40
+ references: { full: string };
41
+ issue_type: string;
42
+ notes?: GlabNote[];
43
+ }
44
+
45
+ export interface GlabProject {
46
+ id: number;
47
+ name: string;
48
+ name_with_namespace: string;
49
+ path_with_namespace: string;
50
+ web_url: string;
51
+ description: string | null;
52
+ }
53
+
54
+ export interface GlabConfig {
55
+ project: string;
56
+ hostname: string;
57
+ defaultState: "opened" | "closed" | "all";
58
+ perPage: number;
59
+ }
60
+
61
+ export interface GraphQLIssueNode {
62
+ iid: string;
63
+ title: string;
64
+ state: string;
65
+ labels: { nodes: Array<{ title: string }> };
66
+ assignees: { nodes: Array<{ username: string }> };
67
+ updatedAt: string;
68
+ notes: {
69
+ nodes: Array<{
70
+ body: string;
71
+ author: { username: string };
72
+ createdAt: string;
73
+ }>;
74
+ };
75
+ }
76
+
77
+ export interface GraphQLSearchResponse {
78
+ data?: {
79
+ project?: {
80
+ issues?: {
81
+ nodes: GraphQLIssueNode[];
82
+ };
83
+ };
84
+ };
85
+ errors?: Array<{ message: string }>;
86
+ }
@@ -0,0 +1,409 @@
1
+ import type {
2
+ AgentTool,
3
+ AgentToolContext,
4
+ AgentToolResult,
5
+ AgentToolUpdateCallback,
6
+ } from "@f5xc-salesdemos/pi-agent-core";
7
+ import { prompt } from "@f5xc-salesdemos/pi-utils";
8
+ import { type Static, Type } from "@sinclair/typebox";
9
+ import glabIssueListDescription from "../prompts/tools/glab-issue-list.md" with { type: "text" };
10
+ import glabIssueViewDescription from "../prompts/tools/glab-issue-view.md" with { type: "text" };
11
+ import glabSearchDescription from "../prompts/tools/glab-search.md" with { type: "text" };
12
+ import glabSetupDescription from "../prompts/tools/glab-setup.md" with { type: "text" };
13
+ import * as git from "../utils/git";
14
+ import type { ToolSession } from ".";
15
+ import { loadConfig, resolveProject, saveConfig } from "./glab/config";
16
+ import type { GlabExecApi } from "./glab/exec";
17
+ import { checkAuth, checkInstalled, execGlabJson, GlabAuthError } from "./glab/exec";
18
+ import { formatIssueDetail, formatIssueTable } from "./glab/formatters";
19
+ import { executeGraphQL } from "./glab/graphql";
20
+ import type { GlabIssue, GlabProject, GraphQLIssueNode } from "./glab/types";
21
+
22
+ function makeExecApi(cwd: string): GlabExecApi {
23
+ return {
24
+ cwd,
25
+ async exec(command: string, args: string[], options?: { signal?: AbortSignal; cwd?: string }) {
26
+ const child = Bun.spawn([command, ...args], {
27
+ cwd: options?.cwd ?? cwd,
28
+ stdin: "ignore",
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ signal: options?.signal,
32
+ });
33
+ if (!child.stdout || !child.stderr) {
34
+ return { stdout: "", stderr: "Failed to capture output", code: 1, killed: false };
35
+ }
36
+ const [stdout, stderr, exitCode] = await Promise.all([
37
+ new Response(child.stdout).text(),
38
+ new Response(child.stderr).text(),
39
+ child.exited,
40
+ ]);
41
+ return {
42
+ stdout: stdout.trim(),
43
+ stderr: stderr.trim(),
44
+ code: exitCode ?? 0,
45
+ killed: child.killed,
46
+ };
47
+ },
48
+ };
49
+ }
50
+
51
+ // ─── Schemas ─────────────────────────────────────────────────────────────
52
+
53
+ const glabSetupSchema = Type.Object({
54
+ action: Type.Union(
55
+ [
56
+ Type.Literal("check"),
57
+ Type.Literal("login"),
58
+ Type.Literal("select_project"),
59
+ Type.Literal("save_project"),
60
+ Type.Literal("status"),
61
+ ],
62
+ { description: "Onboarding action to perform" },
63
+ ),
64
+ project: Type.Optional(Type.String({ description: "Project path to persist (used with save_project)" })),
65
+ });
66
+
67
+ const glabIssueListSchema = Type.Object({
68
+ project: Type.Optional(
69
+ Type.String({ description: "GitLab project path (e.g. group/repo). Defaults to configured project." }),
70
+ ),
71
+ state: Type.Optional(
72
+ Type.Union([Type.Literal("opened"), Type.Literal("closed"), Type.Literal("all")], {
73
+ description: "Filter by issue state",
74
+ }),
75
+ ),
76
+ labels: Type.Optional(Type.Array(Type.String(), { description: "Filter by labels" })),
77
+ assignee: Type.Optional(Type.String({ description: "Filter by assignee username" })),
78
+ search: Type.Optional(Type.String({ description: "Search text in title and description" })),
79
+ milestone: Type.Optional(Type.String()),
80
+ sort: Type.Optional(
81
+ Type.Union(
82
+ [Type.Literal("created_at"), Type.Literal("updated_at"), Type.Literal("priority"), Type.Literal("due_date")],
83
+ { description: "Sort field" },
84
+ ),
85
+ ),
86
+ order: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")], { description: "Sort direction" })),
87
+ limit: Type.Optional(Type.Number({ default: 30, maximum: 100, description: "Max results" })),
88
+ });
89
+
90
+ const glabIssueViewSchema = Type.Object({
91
+ issue: Type.Union([Type.Number(), Type.String()], { description: "Issue IID number or full URL" }),
92
+ project: Type.Optional(Type.String()),
93
+ comments: Type.Optional(Type.Boolean({ default: true })),
94
+ });
95
+
96
+ const glabSearchSchema = Type.Object({
97
+ query: Type.String({ description: "Search text to find across issue titles, descriptions, labels, and comments" }),
98
+ project: Type.Optional(Type.String()),
99
+ state: Type.Optional(
100
+ Type.Union([Type.Literal("opened"), Type.Literal("closed"), Type.Literal("all")], {
101
+ description: "Filter by issue state",
102
+ }),
103
+ ),
104
+ labels: Type.Optional(Type.Array(Type.String())),
105
+ limit: Type.Optional(Type.Number({ default: 20, maximum: 100 })),
106
+ });
107
+
108
+ type GlabSetupInput = Static<typeof glabSetupSchema>;
109
+ type GlabIssueListInput = Static<typeof glabIssueListSchema>;
110
+ type GlabIssueViewInput = Static<typeof glabIssueViewSchema>;
111
+ type GlabSearchInput = Static<typeof glabSearchSchema>;
112
+
113
+ interface GlabToolDetails {
114
+ items?: GlabIssue[];
115
+ issue?: GlabIssue;
116
+ projects?: GlabProject[];
117
+ total?: number;
118
+ project?: string;
119
+ query?: string;
120
+ }
121
+
122
+ function textResult(text: string, details?: GlabToolDetails): AgentToolResult<GlabToolDetails> {
123
+ return { content: [{ type: "text", text }], details };
124
+ }
125
+
126
+ // ─── GlabSetupTool ───────────────────────────────────────────────────────
127
+
128
+ export class GlabSetupTool implements AgentTool<typeof glabSetupSchema, GlabToolDetails> {
129
+ readonly name = "glab_setup";
130
+ readonly label = "GitLab Setup";
131
+ readonly description = prompt.render(glabSetupDescription);
132
+ readonly parameters = glabSetupSchema;
133
+
134
+ constructor(private readonly session: ToolSession) {}
135
+
136
+ static createIf(session: ToolSession): GlabSetupTool | null {
137
+ if (!git.gitlab.available()) return null;
138
+ return new GlabSetupTool(session);
139
+ }
140
+
141
+ async execute(
142
+ _toolCallId: string,
143
+ params: GlabSetupInput,
144
+ signal?: AbortSignal,
145
+ _onUpdate?: AgentToolUpdateCallback<GlabToolDetails>,
146
+ _context?: AgentToolContext,
147
+ ): Promise<AgentToolResult<GlabToolDetails>> {
148
+ const api = makeExecApi(this.session.cwd);
149
+
150
+ switch (params.action) {
151
+ case "check": {
152
+ const installed = await checkInstalled(api);
153
+ if (!installed) {
154
+ return textResult(
155
+ 'glab is not installed.\n\nInstall it with:\n- **macOS**: `brew install glab`\n- **Linux**: Download from https://gitlab.com/gitlab-org/cli/-/releases\n- **Windows**: `winget install gitlab.glab`\n\nAfter installing, call glab_setup with action "login" to authenticate.',
156
+ );
157
+ }
158
+ const ver = await api.exec("glab", ["--version"], { signal });
159
+ return textResult(`glab is installed: ${ver.stdout.trim()}`);
160
+ }
161
+
162
+ case "status": {
163
+ const authResult = await api.exec("glab", ["auth", "status"], { signal });
164
+ const config = await loadConfig(this.session.cwd);
165
+ const projectInfo = config?.project
166
+ ? `\nConfigured project: ${config.project}`
167
+ : "\nNo project configured. Run select_project to choose one.";
168
+ const authStatus = authResult.code === 0 ? authResult.stdout : `Not authenticated: ${authResult.stderr}`;
169
+ return textResult(authStatus + projectInfo);
170
+ }
171
+
172
+ case "login":
173
+ return textResult(
174
+ "Starting GitLab authentication...\n\nRunning: `glab auth login --hostname gitlab.com --git-protocol https --web`\n\nYour browser will open for you to authorize access. Return here after authorizing.",
175
+ );
176
+
177
+ case "select_project": {
178
+ const authenticated = await checkAuth(api);
179
+ if (!authenticated) {
180
+ return textResult('Not authenticated. Run glab_setup with action "login" first.');
181
+ }
182
+ const projects = await execGlabJson<GlabProject[]>(
183
+ api,
184
+ ["repo", "list", "--member", "--output", "json", "--per-page", "50"],
185
+ signal,
186
+ );
187
+ if (!projects.length) {
188
+ return textResult("No projects found for your account.");
189
+ }
190
+ const list = projects
191
+ .map((p, i) => `${i + 1}. **${p.name_with_namespace}** — \`${p.path_with_namespace}\``)
192
+ .join("\n");
193
+ return textResult(
194
+ `Found ${projects.length} projects:\n\n${list}\n\nWhich project do you want to use for GitLab issue tracking? Reply with the number or full path.`,
195
+ { projects },
196
+ );
197
+ }
198
+
199
+ case "save_project": {
200
+ if (!params.project) {
201
+ return textResult("Error: project parameter is required for save_project action.");
202
+ }
203
+ const existing = (await loadConfig(this.session.cwd)) ?? {
204
+ project: "",
205
+ hostname: "gitlab.com",
206
+ defaultState: "opened" as const,
207
+ perPage: 30,
208
+ };
209
+ await saveConfig(this.session.cwd, { ...existing, project: params.project });
210
+ return textResult(`Configuration saved. Default project set to: **${params.project}**`);
211
+ }
212
+
213
+ default:
214
+ return textResult(`Unknown action: ${params.action}`);
215
+ }
216
+ }
217
+ }
218
+
219
+ // ─── GlabIssueListTool ──────────────────────────────────────────────────
220
+
221
+ export class GlabIssueListTool implements AgentTool<typeof glabIssueListSchema, GlabToolDetails> {
222
+ readonly name = "glab_issue_list";
223
+ readonly label = "GitLab Issues";
224
+ readonly description = prompt.render(glabIssueListDescription);
225
+ readonly parameters = glabIssueListSchema;
226
+
227
+ constructor(private readonly session: ToolSession) {}
228
+
229
+ static createIf(session: ToolSession): GlabIssueListTool | null {
230
+ if (!git.gitlab.available()) return null;
231
+ return new GlabIssueListTool(session);
232
+ }
233
+
234
+ async execute(
235
+ _toolCallId: string,
236
+ params: GlabIssueListInput,
237
+ signal?: AbortSignal,
238
+ _onUpdate?: AgentToolUpdateCallback<GlabToolDetails>,
239
+ _context?: AgentToolContext,
240
+ ): Promise<AgentToolResult<GlabToolDetails>> {
241
+ const api = makeExecApi(this.session.cwd);
242
+ const project = await resolveProject(params.project, this.session.cwd);
243
+ if (!project) {
244
+ return textResult("No GitLab project configured. Run glab_setup to set one up.");
245
+ }
246
+
247
+ const args = ["issue", "list", "--output", "json", "--repo", project];
248
+ if (params.state === "opened") args.push("--opened");
249
+ else if (params.state === "closed") args.push("--closed");
250
+ else if (params.state === "all") args.push("--all");
251
+ if (params.labels?.length) args.push("--label", params.labels.join(","));
252
+ if (params.assignee) args.push("--assignee", params.assignee);
253
+ if (params.search) args.push("--search", params.search);
254
+ if (params.milestone) args.push("--milestone", params.milestone);
255
+ if (params.sort) args.push("--order", params.sort);
256
+ if (params.order) args.push("--sort", params.order);
257
+ args.push("--per-page", String(Math.min(params.limit ?? 30, 100)));
258
+
259
+ try {
260
+ const issues = await execGlabJson<GlabIssue[]>(api, args, signal);
261
+ return textResult(formatIssueTable(issues), { items: issues, total: issues.length, project });
262
+ } catch (err) {
263
+ if (err instanceof GlabAuthError) return textResult((err as Error).message);
264
+ throw err;
265
+ }
266
+ }
267
+ }
268
+
269
+ // ─── GlabIssueViewTool ──────────────────────────────────────────────────
270
+
271
+ export class GlabIssueViewTool implements AgentTool<typeof glabIssueViewSchema, GlabToolDetails> {
272
+ readonly name = "glab_issue_view";
273
+ readonly label = "GitLab Issue";
274
+ readonly description = prompt.render(glabIssueViewDescription);
275
+ readonly parameters = glabIssueViewSchema;
276
+
277
+ constructor(private readonly session: ToolSession) {}
278
+
279
+ static createIf(session: ToolSession): GlabIssueViewTool | null {
280
+ if (!git.gitlab.available()) return null;
281
+ return new GlabIssueViewTool(session);
282
+ }
283
+
284
+ async execute(
285
+ _toolCallId: string,
286
+ params: GlabIssueViewInput,
287
+ signal?: AbortSignal,
288
+ _onUpdate?: AgentToolUpdateCallback<GlabToolDetails>,
289
+ _context?: AgentToolContext,
290
+ ): Promise<AgentToolResult<GlabToolDetails>> {
291
+ const api = makeExecApi(this.session.cwd);
292
+ const project = await resolveProject(params.project, this.session.cwd);
293
+ if (!project) {
294
+ return textResult("No GitLab project configured. Run glab_setup to set one up.");
295
+ }
296
+
297
+ const issueId = String(params.issue);
298
+ const args = ["issue", "view", issueId, "--output", "json", "--repo", project];
299
+ if (params.comments !== false) args.push("--comments");
300
+
301
+ try {
302
+ const issue = await execGlabJson<GlabIssue>(api, args, signal);
303
+ return textResult(formatIssueDetail(issue), { issue, project });
304
+ } catch (err) {
305
+ if (err instanceof GlabAuthError) return textResult((err as Error).message);
306
+ throw err;
307
+ }
308
+ }
309
+ }
310
+
311
+ // ─── GlabSearchTool ─────────────────────────────────────────────────────
312
+
313
+ export class GlabSearchTool implements AgentTool<typeof glabSearchSchema, GlabToolDetails> {
314
+ readonly name = "glab_search";
315
+ readonly label = "GitLab Search";
316
+ readonly description = prompt.render(glabSearchDescription);
317
+ readonly parameters = glabSearchSchema;
318
+
319
+ constructor(private readonly session: ToolSession) {}
320
+
321
+ static createIf(session: ToolSession): GlabSearchTool | null {
322
+ if (!git.gitlab.available()) return null;
323
+ return new GlabSearchTool(session);
324
+ }
325
+
326
+ async execute(
327
+ _toolCallId: string,
328
+ params: GlabSearchInput,
329
+ signal?: AbortSignal,
330
+ _onUpdate?: AgentToolUpdateCallback<GlabToolDetails>,
331
+ _context?: AgentToolContext,
332
+ ): Promise<AgentToolResult<GlabToolDetails>> {
333
+ const api = makeExecApi(this.session.cwd);
334
+ const project = await resolveProject(params.project, this.session.cwd);
335
+ if (!project) {
336
+ return textResult("No GitLab project configured. Run glab_setup to set one up.");
337
+ }
338
+
339
+ const limit = Math.min(params.limit ?? 20, 100);
340
+ let issues: GlabIssue[] = [];
341
+
342
+ const restArgs = [
343
+ "issue",
344
+ "list",
345
+ "--output",
346
+ "json",
347
+ "--repo",
348
+ project,
349
+ "--search",
350
+ params.query,
351
+ "--per-page",
352
+ String(limit),
353
+ ];
354
+ if (params.state === "opened") restArgs.push("--opened");
355
+ else if (params.state === "closed") restArgs.push("--closed");
356
+ else if (params.state === "all") restArgs.push("--all");
357
+ if (params.labels?.length) restArgs.push("--label", params.labels.join(","));
358
+
359
+ try {
360
+ issues = await execGlabJson<GlabIssue[]>(api, restArgs, signal);
361
+ } catch (err) {
362
+ if (err instanceof GlabAuthError) return textResult((err as Error).message);
363
+ }
364
+
365
+ let graphqlNodes: GraphQLIssueNode[] = [];
366
+ try {
367
+ graphqlNodes = await executeGraphQL(api, project, params.query, limit, signal, params.state);
368
+ } catch {
369
+ // GraphQL unavailable — use REST results only
370
+ }
371
+
372
+ if (graphqlNodes.length > 0) {
373
+ const seenIids = new Set(issues.map(i => i.iid));
374
+ for (const node of graphqlNodes) {
375
+ const iid = parseInt(node.iid, 10);
376
+ if (seenIids.has(iid)) continue;
377
+ seenIids.add(iid);
378
+ const lowerQuery = params.query.toLowerCase();
379
+ const inTitle = node.title.toLowerCase().includes(lowerQuery);
380
+ const inComments = node.notes.nodes.some(n => n.body.toLowerCase().includes(lowerQuery));
381
+ if (inTitle || inComments) {
382
+ issues.push({
383
+ id: iid,
384
+ iid,
385
+ title: node.title,
386
+ description: "",
387
+ state: node.state === "OPEN" ? "opened" : "closed",
388
+ labels: node.labels.nodes.map(l => l.title),
389
+ assignees: node.assignees.nodes.map(a => ({ username: a.username, name: a.username })),
390
+ author: { username: "", name: "" },
391
+ milestone: null,
392
+ created_at: node.updatedAt,
393
+ updated_at: node.updatedAt,
394
+ web_url: `https://gitlab.com/${project}/-/issues/${iid}`,
395
+ references: { full: `${project}#${iid}` },
396
+ issue_type: "issue",
397
+ });
398
+ }
399
+ }
400
+ }
401
+
402
+ return textResult(formatIssueTable(issues), {
403
+ items: issues,
404
+ total: issues.length,
405
+ project,
406
+ query: params.query,
407
+ });
408
+ }
409
+ }
@@ -40,6 +40,7 @@ import {
40
40
  GhSearchIssuesTool,
41
41
  GhSearchPrsTool,
42
42
  } from "./gh";
43
+ import { GlabIssueListTool, GlabIssueViewTool, GlabSearchTool, GlabSetupTool } from "./glab";
43
44
  import { GrepTool } from "./grep";
44
45
  import { InspectImageTool } from "./inspect-image";
45
46
  import { NotebookTool } from "./notebook";
@@ -80,6 +81,7 @@ export * from "./exit-plan-mode";
80
81
  export * from "./find";
81
82
  export * from "./gemini-image";
82
83
  export * from "./gh";
84
+ export * from "./glab";
83
85
  export * from "./grep";
84
86
  export * from "./inspect-image";
85
87
  export * from "./notebook";
@@ -229,6 +231,10 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
229
231
  gh_run_watch: GhRunWatchTool.createIf,
230
232
  gh_search_issues: GhSearchIssuesTool.createIf,
231
233
  gh_search_prs: GhSearchPrsTool.createIf,
234
+ glab_setup: GlabSetupTool.createIf,
235
+ glab_issue_list: GlabIssueListTool.createIf,
236
+ glab_issue_view: GlabIssueViewTool.createIf,
237
+ glab_search: GlabSearchTool.createIf,
232
238
  find: s => new FindTool(s),
233
239
  grep: s => new GrepTool(s),
234
240
  lsp: LspTool.createIf,
@@ -398,6 +404,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
398
404
  if (name === "find") return session.settings.get("find.enabled");
399
405
  if (name === "grep") return session.settings.get("grep.enabled");
400
406
  if (name.startsWith("gh_")) return session.settings.get("github.enabled");
407
+ if (name.startsWith("glab_")) return session.settings.get("gitlab.enabled");
401
408
  if (name === "ast_grep") return session.settings.get("astGrep.enabled");
402
409
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");
403
410
  if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
@@ -25,6 +25,7 @@ export interface XcshApiToolDetails {
25
25
  status: number;
26
26
  url: string;
27
27
  method: string;
28
+ requestId: string;
28
29
  }
29
30
 
30
31
  type XcshApiResult = AgentToolResult<XcshApiToolDetails> & { isError?: boolean };
@@ -35,21 +36,31 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
35
36
  readonly description: string;
36
37
  readonly parameters = xcshApiSchema;
37
38
 
39
+ #apiBase: string;
40
+ #apiToken: string;
41
+
38
42
  constructor(_session: ToolSession) {
39
43
  this.description = prompt.render(xcshApiDescription);
44
+ this.#apiBase = (process.env.F5XC_API_URL ?? "").replace(/\/+$/, "");
45
+ this.#apiToken = process.env.F5XC_API_TOKEN ?? "";
46
+
47
+ if (this.#apiBase && this.#apiToken) {
48
+ fetch(`${this.#apiBase}/api/web/namespaces`, {
49
+ method: "HEAD",
50
+ headers: { Authorization: `APIToken ${this.#apiToken}` },
51
+ }).catch(() => {});
52
+ }
40
53
  }
41
54
 
42
55
  async execute(_toolCallId: string, params: XcshApiParams): Promise<XcshApiResult> {
43
- const apiUrl = process.env.F5XC_API_URL;
44
- if (!apiUrl) {
56
+ if (!this.#apiBase) {
45
57
  return {
46
58
  content: [{ type: "text", text: "Error: F5XC_API_URL environment variable is not set." }],
47
59
  isError: true,
48
60
  };
49
61
  }
50
62
 
51
- const apiToken = process.env.F5XC_API_TOKEN;
52
- if (!apiToken) {
63
+ if (!this.#apiToken) {
53
64
  return {
54
65
  content: [{ type: "text", text: "Error: F5XC_API_TOKEN environment variable is not set." }],
55
66
  isError: true,
@@ -63,13 +74,19 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
63
74
  }
64
75
  }
65
76
 
66
- const url = `${apiUrl.replace(/\/+$/, "")}${resolvedPath}`;
77
+ const url = `${this.#apiBase}${resolvedPath}`;
78
+ const requestId = crypto.randomUUID();
67
79
  const headers: Record<string, string> = {
68
- Authorization: `APIToken ${apiToken}`,
80
+ Authorization: `APIToken ${this.#apiToken}`,
69
81
  Accept: "application/json",
82
+ "X-Request-ID": requestId,
70
83
  };
71
84
 
72
- const init: RequestInit = { method: params.method, headers };
85
+ const init: RequestInit = {
86
+ method: params.method,
87
+ headers,
88
+ signal: AbortSignal.timeout(30_000),
89
+ };
73
90
 
74
91
  if (params.payload && params.method !== "GET") {
75
92
  headers["Content-Type"] = "application/json";
@@ -78,21 +95,12 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
78
95
 
79
96
  try {
80
97
  const response = await fetch(url, init);
81
- const contentType = response.headers.get("content-type") ?? "";
82
- let bodyText: string;
83
-
84
- if (contentType.includes("application/json")) {
85
- const json = await response.json();
86
- bodyText = JSON.stringify(json, null, 2);
87
- } else {
88
- bodyText = await response.text();
89
- }
90
-
98
+ const bodyText = await response.text();
91
99
  const statusLine = `${response.status} ${response.statusText}`;
92
100
 
93
101
  return {
94
102
  content: [{ type: "text", text: `${statusLine}\n\n${bodyText}` }],
95
- details: { status: response.status, url, method: params.method },
103
+ details: { status: response.status, url, method: params.method, requestId },
96
104
  ...(response.status >= 400 ? { isError: true } : {}),
97
105
  };
98
106
  } catch (err) {
package/src/utils/git.ts CHANGED
@@ -1421,3 +1421,13 @@ export const github = {
1421
1421
  return result.stdout;
1422
1422
  },
1423
1423
  };
1424
+
1425
+ // ════════════════════════════════════════════════════════════════════════════
1426
+ // API: gitlab (GitLab CLI — glab)
1427
+ // ════════════════════════════════════════════════════════════════════════════
1428
+
1429
+ export const gitlab = {
1430
+ available(): boolean {
1431
+ return Boolean($which("glab"));
1432
+ },
1433
+ };