@f5xc-salesdemos/xcsh 18.35.3 → 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.3",
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.3",
52
- "@f5xc-salesdemos/pi-agent-core": "18.35.3",
53
- "@f5xc-salesdemos/pi-ai": "18.35.3",
54
- "@f5xc-salesdemos/pi-natives": "18.35.3",
55
- "@f5xc-salesdemos/pi-tui": "18.35.3",
56
- "@f5xc-salesdemos/pi-utils": "18.35.3",
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.3",
21
- "commit": "32785b70f062fe1930d72a355fc5d72bedb29128",
22
- "shortCommit": "32785b7",
20
+ "version": "18.36.0",
21
+ "commit": "e184ba33c5bf2f52613a85f40b7ebd0d5043deb5",
22
+ "shortCommit": "e184ba3",
23
23
  "branch": "main",
24
- "tag": "v18.35.3",
25
- "commitDate": "2026-05-04T00:13:26Z",
26
- "buildDate": "2026-05-04T00:32:30.792Z",
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/32785b70f062fe1930d72a355fc5d72bedb29128",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.35.3"
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
  };
@@ -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>
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");
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
+ };