@nghyane/arcane 0.1.27 → 0.1.29
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/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/src/patch/edit-tool.ts +12 -3
- package/src/patch/hashline.ts +11 -9
- package/src/prompts/agents/librarian.md +7 -12
- package/src/tools/create-tools.ts +3 -0
- package/src/tools/github-fs.ts +195 -0
- package/src/tools/github-utils.ts +35 -0
- package/src/tools/github.ts +35 -123
- package/src/tools/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.1.29] - 2026-03-09
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Split GitHub tool into `github` (issues/PRs/commits) and `github_fs` (file/tree browsing) with auto-detect owner/repo from git remote
|
|
10
|
+
- Restrict `github_fs` to subagents only (librarian); main agent delegates remote code reading via librarian
|
|
11
|
+
|
|
12
|
+
## [0.1.28] - 2026-03-08
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Fix edit tool: hashline delete missing `saveForUndo` causing unrecoverable file deletion
|
|
17
|
+
- Fix `any` types on `EditTool` class — replaced with `EditToolDetails`
|
|
18
|
+
- Fix `applyHashlineEdits` mutating caller's input array via splice
|
|
19
|
+
|
|
5
20
|
## [0.1.27] - 2026-03-08
|
|
6
21
|
|
|
7
22
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@nghyane/arcane",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.29",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/nghyane/arcane",
|
|
7
7
|
"author": "Can Bölük",
|
package/src/patch/edit-tool.ts
CHANGED
|
@@ -134,13 +134,21 @@ function mergeDiagnosticsWithWarnings(
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
export class EditTool implements AgentTool<TInput,
|
|
137
|
+
export class EditTool implements AgentTool<TInput, EditToolDetails, Theme> {
|
|
138
138
|
readonly name = "edit";
|
|
139
139
|
readonly label = "Edit";
|
|
140
140
|
readonly nonAbortable = true;
|
|
141
141
|
readonly concurrency = "exclusive";
|
|
142
|
-
readonly renderCall = editToolRenderer.renderCall as unknown as AgentTool<
|
|
143
|
-
|
|
142
|
+
readonly renderCall = editToolRenderer.renderCall as unknown as AgentTool<
|
|
143
|
+
TInput,
|
|
144
|
+
EditToolDetails,
|
|
145
|
+
Theme
|
|
146
|
+
>["renderCall"];
|
|
147
|
+
readonly renderResult = editToolRenderer.renderResult as unknown as AgentTool<
|
|
148
|
+
TInput,
|
|
149
|
+
EditToolDetails,
|
|
150
|
+
Theme
|
|
151
|
+
>["renderResult"];
|
|
144
152
|
|
|
145
153
|
readonly #allowFuzzy: boolean;
|
|
146
154
|
readonly #fuzzyThreshold: number;
|
|
@@ -252,6 +260,7 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
|
|
|
252
260
|
|
|
253
261
|
if (deleteFile) {
|
|
254
262
|
if (await file.exists()) {
|
|
263
|
+
saveForUndo(absolutePath, await file.text());
|
|
255
264
|
await file.unlink();
|
|
256
265
|
}
|
|
257
266
|
invalidateFsScanAfterDelete(absolutePath);
|
package/src/patch/hashline.ts
CHANGED
|
@@ -592,7 +592,7 @@ function autocorrectEscapedTabs(lines: string[]): string[] {
|
|
|
592
592
|
*/
|
|
593
593
|
export function applyHashlineEdits(
|
|
594
594
|
content: string,
|
|
595
|
-
edits: HashlineEdit[],
|
|
595
|
+
edits: readonly HashlineEdit[],
|
|
596
596
|
): {
|
|
597
597
|
content: string;
|
|
598
598
|
firstChangedLine: number | undefined;
|
|
@@ -603,6 +603,8 @@ export function applyHashlineEdits(
|
|
|
603
603
|
return { content, firstChangedLine: undefined };
|
|
604
604
|
}
|
|
605
605
|
|
|
606
|
+
const mutableEdits: HashlineEdit[] = edits.map(e => ({ ...e, content: [...e.content] }));
|
|
607
|
+
|
|
606
608
|
const fileLines = content.split("\n");
|
|
607
609
|
const hadFinalNewline = content.endsWith("\n");
|
|
608
610
|
const originalFileLines = [...fileLines];
|
|
@@ -612,7 +614,7 @@ export function applyHashlineEdits(
|
|
|
612
614
|
const autocorrect = Bun.env.ARCANE_HL_AUTOCORRECT === "1";
|
|
613
615
|
|
|
614
616
|
const warnings: string[] = [];
|
|
615
|
-
for (const edit of
|
|
617
|
+
for (const edit of mutableEdits) {
|
|
616
618
|
const unicodeWarning = detectUnicodeEscapePlaceholders(edit.content);
|
|
617
619
|
if (unicodeWarning && !warnings.includes(unicodeWarning)) {
|
|
618
620
|
warnings.push(unicodeWarning);
|
|
@@ -622,7 +624,7 @@ export function applyHashlineEdits(
|
|
|
622
624
|
|
|
623
625
|
function collectExplicitlyTouchedLines(): Set<number> {
|
|
624
626
|
const touched = new Set<number>();
|
|
625
|
-
for (const edit of
|
|
627
|
+
for (const edit of mutableEdits) {
|
|
626
628
|
switch (edit.op) {
|
|
627
629
|
case "replace":
|
|
628
630
|
touched.add(edit.target.line);
|
|
@@ -652,7 +654,7 @@ export function applyHashlineEdits(
|
|
|
652
654
|
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
|
653
655
|
return false;
|
|
654
656
|
}
|
|
655
|
-
for (const edit of
|
|
657
|
+
for (const edit of mutableEdits) {
|
|
656
658
|
switch (edit.op) {
|
|
657
659
|
case "replace": {
|
|
658
660
|
if (!validateRef(edit.target)) continue;
|
|
@@ -678,8 +680,8 @@ export function applyHashlineEdits(
|
|
|
678
680
|
}
|
|
679
681
|
const seenEditKeys = new Map<string, number>();
|
|
680
682
|
const dedupIndices = new Set<number>();
|
|
681
|
-
for (let i = 0; i <
|
|
682
|
-
const edit =
|
|
683
|
+
for (let i = 0; i < mutableEdits.length; i++) {
|
|
684
|
+
const edit = mutableEdits[i];
|
|
683
685
|
let lineKey: string;
|
|
684
686
|
switch (edit.op) {
|
|
685
687
|
case "replace":
|
|
@@ -697,13 +699,13 @@ export function applyHashlineEdits(
|
|
|
697
699
|
}
|
|
698
700
|
}
|
|
699
701
|
if (dedupIndices.size > 0) {
|
|
700
|
-
for (let i =
|
|
701
|
-
if (dedupIndices.has(i))
|
|
702
|
+
for (let i = mutableEdits.length - 1; i >= 0; i--) {
|
|
703
|
+
if (dedupIndices.has(i)) mutableEdits.splice(i, 1);
|
|
702
704
|
}
|
|
703
705
|
}
|
|
704
706
|
|
|
705
707
|
// Compute sort key (descending) — bottom-up application
|
|
706
|
-
const annotated =
|
|
708
|
+
const annotated = mutableEdits.map((edit, idx) => {
|
|
707
709
|
let sortLine: number;
|
|
708
710
|
let precedence: number;
|
|
709
711
|
switch (edit.op) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: librarian
|
|
3
3
|
description: "Repository exploration agent for cross-repo codebase understanding"
|
|
4
|
-
tools:
|
|
4
|
+
tools: github_fs, fetch, web_search, search_code
|
|
5
5
|
model: arcane/fast
|
|
6
6
|
thinking-level: minimal
|
|
7
7
|
---
|
|
@@ -9,7 +9,7 @@ thinking-level: minimal
|
|
|
9
9
|
<role>You are the Librarian, a specialized codebase understanding agent that helps answer questions about large, complex codebases across repositories. You are running as a subagent inside an AI coding system — your output goes directly to the main coding agent, not the end user. The main agent invokes you when it needs deep, multi-repository codebase understanding: architecture analysis, cross-repo code tracing, implementation discovery, and history exploration.</role>
|
|
10
10
|
|
|
11
11
|
<directives>
|
|
12
|
-
- Use the
|
|
12
|
+
- Use the github_fs tool for all repository file and directory operations — it handles auth, rate limits, and caching
|
|
13
13
|
- Parallelize tool calls when investigating multiple repos or files
|
|
14
14
|
- Read files thoroughly — skim causes missed context
|
|
15
15
|
- Use web_search or fetch only when GitHub API is insufficient
|
|
@@ -17,12 +17,9 @@ thinking-level: minimal
|
|
|
17
17
|
</directives>
|
|
18
18
|
|
|
19
19
|
<instruction>
|
|
20
|
-
Use the
|
|
21
|
-
- `
|
|
22
|
-
- `
|
|
23
|
-
- `github({ action: "get_issue", ... })` for reading issues with all comments
|
|
24
|
-
- `github({ action: "get_pull", ... })` for PR details and diffs
|
|
25
|
-
- `github({ action: "list_commits", ... })` for commit history
|
|
20
|
+
Use the github_fs tool for all GitHub file/tree operations:
|
|
21
|
+
- `github_fs({ action: "get_file", ... })` for reading remote files
|
|
22
|
+
- `github_fs({ action: "get_tree", ... })` for listing directories
|
|
26
23
|
|
|
27
24
|
Use search_code to find code across public GitHub repositories via grep.app:
|
|
28
25
|
- `search_code({ query: "pattern" })` for broad cross-repo search
|
|
@@ -36,10 +33,9 @@ Use search_code to find code across public GitHub repositories via grep.app:
|
|
|
36
33
|
<procedure>
|
|
37
34
|
1. Identify target repositories
|
|
38
35
|
2. Map structure — get_tree for layout, get_file for README
|
|
39
|
-
3. Locate targets — search_code for patterns across repos,
|
|
36
|
+
3. Locate targets — search_code for patterns across repos, github_fs get_file for specific files
|
|
40
37
|
4. Read relevant code — follow imports, trace call chains
|
|
41
|
-
5.
|
|
42
|
-
6. Synthesize findings into a comprehensive answer
|
|
38
|
+
5. Synthesize findings into a comprehensive answer
|
|
43
39
|
</procedure>
|
|
44
40
|
|
|
45
41
|
<output>
|
|
@@ -48,7 +44,6 @@ Format as markdown. Include:
|
|
|
48
44
|
- Key files and their roles (with paths)
|
|
49
45
|
- Code flow / call chains (if tracing)
|
|
50
46
|
- Relevant code snippets (brief, targeted)
|
|
51
|
-
- Commit history / evolution (if relevant)
|
|
52
47
|
|
|
53
48
|
Be comprehensive and direct. No filler.
|
|
54
49
|
</output>
|
|
@@ -13,6 +13,7 @@ import { FetchTool } from "./fetch";
|
|
|
13
13
|
import { FindTool } from "./find";
|
|
14
14
|
import { FindThreadTool } from "./find-thread";
|
|
15
15
|
import { GitHubTool } from "./github";
|
|
16
|
+
import { GitHubFsTool } from "./github-fs";
|
|
16
17
|
import { GrepTool } from "./grep";
|
|
17
18
|
import type { ToolSession } from "./index";
|
|
18
19
|
import { librarianConfig } from "./librarian";
|
|
@@ -44,6 +45,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
|
|
44
45
|
find: s => new FindTool(s),
|
|
45
46
|
explore: s => new SubagentTool(s, exploreConfig),
|
|
46
47
|
github: s => new GitHubTool(s),
|
|
48
|
+
github_fs: s => new GitHubFsTool(s),
|
|
47
49
|
grep: s => new GrepTool(s),
|
|
48
50
|
librarian: s => new SubagentTool(s, librarianConfig),
|
|
49
51
|
lsp: LspTool.createIf,
|
|
@@ -132,6 +134,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
|
|
132
134
|
if (name === "librarian") return session.settings.get("librarian.enabled");
|
|
133
135
|
if (name === "oracle") return session.settings.get("oracle.enabled");
|
|
134
136
|
if (name === "github") return session.settings.get("github.enabled");
|
|
137
|
+
if (name === "github_fs") return session.isSubagent && session.settings.get("github.enabled");
|
|
135
138
|
if (name === "search_code") return session.isSubagent;
|
|
136
139
|
if (name === "task") {
|
|
137
140
|
return !session.isSubagent;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolResult } from "@nghyane/arcane-agent";
|
|
2
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
3
|
+
import type { Theme } from "../theme/theme";
|
|
4
|
+
import { githubClient } from "../web/github-client";
|
|
5
|
+
import type { ToolSession } from ".";
|
|
6
|
+
import { formatGitHubError, resolveOwnerRepo } from "./github-utils";
|
|
7
|
+
import { type OutputMeta, toolResult } from "./output-meta";
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// GitHub API Types
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
interface GitHubTreeEntry {
|
|
14
|
+
type: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
size?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Schema
|
|
22
|
+
// =============================================================================
|
|
23
|
+
const ActionEnum = Type.Union([Type.Literal("get_file"), Type.Literal("get_tree")]);
|
|
24
|
+
|
|
25
|
+
const schema = Type.Object({
|
|
26
|
+
action: ActionEnum,
|
|
27
|
+
owner: Type.Optional(
|
|
28
|
+
Type.String({ description: "Repository owner (user or org). Auto-detected from git remote if omitted." }),
|
|
29
|
+
),
|
|
30
|
+
repo: Type.Optional(Type.String({ description: "Repository name. Auto-detected from git remote if omitted." })),
|
|
31
|
+
path: Type.Optional(Type.String({ description: "File or directory path within the repo" })),
|
|
32
|
+
ref: Type.Optional(Type.String({ description: "Branch, tag, or commit SHA" })),
|
|
33
|
+
recursive: Type.Optional(Type.Boolean({ description: "Recursively list tree contents" })),
|
|
34
|
+
limit: Type.Optional(Type.Number({ description: "Max number of results" })),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
type GitHubFsInput = Static<typeof schema>;
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Details
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export interface GitHubFsToolDetails {
|
|
44
|
+
action: string;
|
|
45
|
+
owner: string;
|
|
46
|
+
repo: string;
|
|
47
|
+
meta?: OutputMeta;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// Helpers
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
function formatTreeEntry(entry: GitHubTreeEntry): string {
|
|
55
|
+
const icon = entry.type === "dir" || entry.type === "tree" ? "dir" : "file";
|
|
56
|
+
const size = entry.size ? ` (${formatSize(entry.size)})` : "";
|
|
57
|
+
const name = entry.path ?? entry.name;
|
|
58
|
+
return `[${icon}] ${name}${size}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatSize(bytes: number): string {
|
|
62
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
63
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
|
|
64
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Action Handlers
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
const MAX_FILE_LINES = 500;
|
|
72
|
+
|
|
73
|
+
async function handleAction(
|
|
74
|
+
input: GitHubFsInput,
|
|
75
|
+
owner: string,
|
|
76
|
+
repo: string,
|
|
77
|
+
signal?: AbortSignal,
|
|
78
|
+
): Promise<{ text: string; url?: string }> {
|
|
79
|
+
const { action } = input;
|
|
80
|
+
const opts = { signal };
|
|
81
|
+
const base = `/repos/${owner}/${repo}`;
|
|
82
|
+
|
|
83
|
+
switch (action) {
|
|
84
|
+
case "get_file": {
|
|
85
|
+
const filePath = input.path ?? "README.md";
|
|
86
|
+
const ref = input.ref ? `?ref=${input.ref}` : "";
|
|
87
|
+
let res = await githubClient.request<string>(`${base}/contents/${filePath}${ref}`, {
|
|
88
|
+
...opts,
|
|
89
|
+
mediaType: "application/vnd.github.v3.raw",
|
|
90
|
+
});
|
|
91
|
+
// Fallback to Blob API for files >1MB (raw mediaType returns 403)
|
|
92
|
+
if (!res.ok && res.status === 403) {
|
|
93
|
+
const metaRes = await githubClient.request<{ sha: string; size: number }>(
|
|
94
|
+
`${base}/contents/${filePath}${ref}`,
|
|
95
|
+
opts,
|
|
96
|
+
);
|
|
97
|
+
if (metaRes.ok && metaRes.data?.sha) {
|
|
98
|
+
const blobRes = await githubClient.request<{ content: string; encoding: string }>(
|
|
99
|
+
`${base}/git/blobs/${metaRes.data.sha}`,
|
|
100
|
+
opts,
|
|
101
|
+
);
|
|
102
|
+
if (blobRes.ok && blobRes.data?.content) {
|
|
103
|
+
const decoded =
|
|
104
|
+
blobRes.data.encoding === "base64"
|
|
105
|
+
? Buffer.from(blobRes.data.content, "base64").toString("utf-8")
|
|
106
|
+
: blobRes.data.content;
|
|
107
|
+
res = { data: decoded as string, ok: true, status: 200 };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!res.ok) return formatGitHubError(res, `file ${filePath}`);
|
|
112
|
+
const content = String(res.data);
|
|
113
|
+
const lines = content.split("\n");
|
|
114
|
+
const truncated = lines.length > MAX_FILE_LINES;
|
|
115
|
+
const output = truncated ? lines.slice(0, MAX_FILE_LINES).join("\n") : content;
|
|
116
|
+
const note = truncated ? `\n\n[Truncated: showing ${MAX_FILE_LINES}/${lines.length} lines]` : "";
|
|
117
|
+
return {
|
|
118
|
+
text: `# ${owner}/${repo}:${filePath}${input.ref ? ` @${input.ref}` : ""}\n\n${output}${note}`,
|
|
119
|
+
url: `https://github.com/${owner}/${repo}/blob/${input.ref ?? "HEAD"}/${filePath}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case "get_tree": {
|
|
124
|
+
const treePath = input.path ?? "";
|
|
125
|
+
if (input.recursive) {
|
|
126
|
+
const ref = input.ref ?? "HEAD";
|
|
127
|
+
const res = await githubClient.request<{ tree: GitHubTreeEntry[]; truncated: boolean }>(
|
|
128
|
+
`${base}/git/trees/${ref}?recursive=1`,
|
|
129
|
+
opts,
|
|
130
|
+
);
|
|
131
|
+
if (!res.ok) return formatGitHubError(res, "tree");
|
|
132
|
+
const entries = (res.data.tree ?? [])
|
|
133
|
+
.filter(e => !treePath || (e.path ?? "").startsWith(treePath))
|
|
134
|
+
.slice(0, 500);
|
|
135
|
+
return {
|
|
136
|
+
text: `# Tree: ${owner}/${repo}${treePath ? `/${treePath}` : ""} (recursive)\n\n${entries.map(formatTreeEntry).join("\n")}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const ref = input.ref ? `?ref=${input.ref}` : "";
|
|
140
|
+
const endpoint = treePath ? `${base}/contents/${treePath}${ref}` : `${base}/contents${ref}`;
|
|
141
|
+
const res = await githubClient.request<GitHubTreeEntry[]>(endpoint, opts);
|
|
142
|
+
if (!res.ok) return formatGitHubError(res, "directory");
|
|
143
|
+
const entries = Array.isArray(res.data) ? res.data : [res.data];
|
|
144
|
+
return {
|
|
145
|
+
text: `# ${owner}/${repo}/${treePath}\n\n${entries.map(formatTreeEntry).join("\n")}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
default:
|
|
150
|
+
return { text: `Unknown action: ${action}` };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// Tool Class
|
|
156
|
+
// =============================================================================
|
|
157
|
+
|
|
158
|
+
export class GitHubFsTool implements AgentTool<typeof schema, GitHubFsToolDetails, Theme> {
|
|
159
|
+
readonly name = "github_fs";
|
|
160
|
+
readonly label = "GitHub FS";
|
|
161
|
+
readonly parameters = schema;
|
|
162
|
+
description = "Browse remote GitHub repository contents: read files and list directory trees.";
|
|
163
|
+
|
|
164
|
+
constructor(readonly _session: ToolSession) {}
|
|
165
|
+
|
|
166
|
+
async execute(
|
|
167
|
+
_toolCallId: string,
|
|
168
|
+
params: GitHubFsInput,
|
|
169
|
+
signal?: AbortSignal,
|
|
170
|
+
): Promise<AgentToolResult<GitHubFsToolDetails>> {
|
|
171
|
+
const resolved = await resolveOwnerRepo(params, this._session.cwd);
|
|
172
|
+
if (!resolved) {
|
|
173
|
+
return toolResult({ action: params.action, owner: "", repo: "" } as GitHubFsToolDetails)
|
|
174
|
+
.text(
|
|
175
|
+
"Error: owner and repo are required. Provide them explicitly or run from a git repo with a GitHub remote.",
|
|
176
|
+
)
|
|
177
|
+
.done();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { owner, repo } = resolved;
|
|
181
|
+
const details: GitHubFsToolDetails = {
|
|
182
|
+
action: params.action,
|
|
183
|
+
owner,
|
|
184
|
+
repo,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const result = await handleAction(params, owner, repo, signal);
|
|
188
|
+
|
|
189
|
+
const builder = toolResult(details).text(result.text);
|
|
190
|
+
if (result.url) {
|
|
191
|
+
builder.sourceUrl(result.url);
|
|
192
|
+
}
|
|
193
|
+
return builder.done();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import type { GitHubResponse } from "../web/github-client";
|
|
3
|
+
|
|
4
|
+
export async function resolveOwnerRepo(
|
|
5
|
+
input: { owner?: string; repo?: string },
|
|
6
|
+
cwd: string,
|
|
7
|
+
): Promise<{ owner: string; repo: string } | null> {
|
|
8
|
+
if (input.owner && input.repo) return { owner: input.owner, repo: input.repo };
|
|
9
|
+
try {
|
|
10
|
+
const result = await $`git remote get-url origin`.cwd(cwd).quiet().nothrow();
|
|
11
|
+
if (result.exitCode !== 0) return null;
|
|
12
|
+
const url = result.text().trim();
|
|
13
|
+
// https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
|
14
|
+
const match = url.match(/github\.com[/:]([^/]+)\/([^/.]+)/);
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
return { owner: match[1], repo: match[2] };
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatGitHubError(res: GitHubResponse, resource: string): { text: string } {
|
|
23
|
+
const status = res.status;
|
|
24
|
+
if (status === 404) return { text: `Error: ${resource} not found (404)` };
|
|
25
|
+
if (status === 403) {
|
|
26
|
+
const rl = res.rateLimit;
|
|
27
|
+
if (rl && rl.remaining === 0) {
|
|
28
|
+
return { text: `Error: GitHub API rate limit exceeded. Resets at ${new Date(rl.reset * 1000).toISOString()}` };
|
|
29
|
+
}
|
|
30
|
+
return { text: `Error: Access denied to ${resource} (403). Check token permissions.` };
|
|
31
|
+
}
|
|
32
|
+
if (status === 401)
|
|
33
|
+
return { text: `Error: Authentication failed (401). Check GITHUB_TOKEN or run 'gh auth login'.` };
|
|
34
|
+
return { text: `Error: Failed to fetch ${resource} (HTTP ${status})` };
|
|
35
|
+
}
|
package/src/tools/github.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { AgentTool, AgentToolResult } from "@nghyane/arcane-agent";
|
|
2
2
|
import { type Static, Type } from "@sinclair/typebox";
|
|
3
3
|
import type { Theme } from "../theme/theme";
|
|
4
|
-
import {
|
|
4
|
+
import { githubClient } from "../web/github-client";
|
|
5
5
|
import type { ToolSession } from ".";
|
|
6
|
+
import { formatGitHubError, resolveOwnerRepo } from "./github-utils";
|
|
6
7
|
import { type OutputMeta, toolResult } from "./output-meta";
|
|
7
8
|
|
|
8
9
|
// =============================================================================
|
|
@@ -35,13 +36,6 @@ interface GitHubRepo {
|
|
|
35
36
|
homepage: string | null;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
interface GitHubTreeEntry {
|
|
39
|
-
type: string;
|
|
40
|
-
path?: string;
|
|
41
|
-
name?: string;
|
|
42
|
-
size?: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
39
|
interface GitHubIssue {
|
|
46
40
|
number: number;
|
|
47
41
|
title: string;
|
|
@@ -109,8 +103,6 @@ interface GitHubSearchResult<T> {
|
|
|
109
103
|
// =============================================================================
|
|
110
104
|
const ActionEnum = Type.Union([
|
|
111
105
|
Type.Literal("get_repo"),
|
|
112
|
-
Type.Literal("get_file"),
|
|
113
|
-
Type.Literal("get_tree"),
|
|
114
106
|
Type.Literal("search_repos"),
|
|
115
107
|
Type.Literal("get_issue"),
|
|
116
108
|
Type.Literal("list_issues"),
|
|
@@ -122,8 +114,10 @@ const ActionEnum = Type.Union([
|
|
|
122
114
|
|
|
123
115
|
const schema = Type.Object({
|
|
124
116
|
action: ActionEnum,
|
|
125
|
-
owner: Type.
|
|
126
|
-
|
|
117
|
+
owner: Type.Optional(
|
|
118
|
+
Type.String({ description: "Repository owner (user or org). Auto-detected from git remote if omitted." }),
|
|
119
|
+
),
|
|
120
|
+
repo: Type.Optional(Type.String({ description: "Repository name. Auto-detected from git remote if omitted." })),
|
|
127
121
|
path: Type.Optional(Type.String({ description: "File or directory path within the repo" })),
|
|
128
122
|
ref: Type.Optional(Type.String({ description: "Branch, tag, or commit SHA" })),
|
|
129
123
|
number: Type.Optional(Type.Number({ description: "Issue or PR number" })),
|
|
@@ -132,7 +126,6 @@ const schema = Type.Object({
|
|
|
132
126
|
labels: Type.Optional(Type.String({ description: "Comma-separated label filter" })),
|
|
133
127
|
sha: Type.Optional(Type.String({ description: "Commit SHA" })),
|
|
134
128
|
include_diff: Type.Optional(Type.Boolean({ description: "Include diff in commit details" })),
|
|
135
|
-
recursive: Type.Optional(Type.Boolean({ description: "Recursively list tree contents" })),
|
|
136
129
|
limit: Type.Optional(Type.Number({ description: "Max number of results" })),
|
|
137
130
|
});
|
|
138
131
|
|
|
@@ -170,19 +163,6 @@ function formatRepo(data: GitHubRepo): string {
|
|
|
170
163
|
.join("\n");
|
|
171
164
|
}
|
|
172
165
|
|
|
173
|
-
function formatTreeEntry(entry: GitHubTreeEntry): string {
|
|
174
|
-
const icon = entry.type === "dir" || entry.type === "tree" ? "dir" : "file";
|
|
175
|
-
const size = entry.size ? ` (${formatSize(entry.size)})` : "";
|
|
176
|
-
const name = entry.path ?? entry.name;
|
|
177
|
-
return `[${icon}] ${name}${size}`;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function formatSize(bytes: number): string {
|
|
181
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
182
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
|
|
183
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
166
|
function formatIssue(data: GitHubIssue, comments: GitHubComment[] = []): string {
|
|
187
167
|
const lines = [
|
|
188
168
|
`# #${data.number}: ${data.title}`,
|
|
@@ -275,87 +255,26 @@ function formatSearchReposResult(data: GitHubSearchResult<GitHubRepo>): string {
|
|
|
275
255
|
// Action Handlers
|
|
276
256
|
// =============================================================================
|
|
277
257
|
|
|
278
|
-
const MAX_FILE_LINES = 500;
|
|
279
258
|
const MAX_DIFF_CHARS = 50_000;
|
|
280
259
|
const MAX_COMMENTS_PAGES = 5;
|
|
281
260
|
|
|
282
|
-
async function handleAction(
|
|
283
|
-
|
|
261
|
+
async function handleAction(
|
|
262
|
+
input: GitHubInput,
|
|
263
|
+
owner: string,
|
|
264
|
+
repo: string,
|
|
265
|
+
signal?: AbortSignal,
|
|
266
|
+
): Promise<{ text: string; url?: string }> {
|
|
267
|
+
const { action } = input;
|
|
284
268
|
const opts = { signal };
|
|
285
269
|
const base = `/repos/${owner}/${repo}`;
|
|
286
270
|
|
|
287
271
|
switch (action) {
|
|
288
272
|
case "get_repo": {
|
|
289
273
|
const res = await githubClient.request<GitHubRepo>(base, opts);
|
|
290
|
-
if (!res.ok) return
|
|
274
|
+
if (!res.ok) return formatGitHubError(res, "repository");
|
|
291
275
|
return { text: formatRepo(res.data), url: `https://github.com/${owner}/${repo}` };
|
|
292
276
|
}
|
|
293
277
|
|
|
294
|
-
case "get_file": {
|
|
295
|
-
const filePath = input.path ?? "README.md";
|
|
296
|
-
const ref = input.ref ? `?ref=${input.ref}` : "";
|
|
297
|
-
let res = await githubClient.request<string>(`${base}/contents/${filePath}${ref}`, {
|
|
298
|
-
...opts,
|
|
299
|
-
mediaType: "application/vnd.github.v3.raw",
|
|
300
|
-
});
|
|
301
|
-
// Fallback to Blob API for files >1MB (raw mediaType returns 403)
|
|
302
|
-
if (!res.ok && res.status === 403) {
|
|
303
|
-
const metaRes = await githubClient.request<{ sha: string; size: number }>(
|
|
304
|
-
`${base}/contents/${filePath}${ref}`,
|
|
305
|
-
opts,
|
|
306
|
-
);
|
|
307
|
-
if (metaRes.ok && metaRes.data?.sha) {
|
|
308
|
-
const blobRes = await githubClient.request<{ content: string; encoding: string }>(
|
|
309
|
-
`${base}/git/blobs/${metaRes.data.sha}`,
|
|
310
|
-
opts,
|
|
311
|
-
);
|
|
312
|
-
if (blobRes.ok && blobRes.data?.content) {
|
|
313
|
-
const decoded =
|
|
314
|
-
blobRes.data.encoding === "base64"
|
|
315
|
-
? Buffer.from(blobRes.data.content, "base64").toString("utf-8")
|
|
316
|
-
: blobRes.data.content;
|
|
317
|
-
res = { data: decoded as string, ok: true, status: 200 };
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (!res.ok) return error(res, `file ${filePath}`);
|
|
322
|
-
const content = String(res.data);
|
|
323
|
-
const lines = content.split("\n");
|
|
324
|
-
const truncated = lines.length > MAX_FILE_LINES;
|
|
325
|
-
const output = truncated ? lines.slice(0, MAX_FILE_LINES).join("\n") : content;
|
|
326
|
-
const note = truncated ? `\n\n[Truncated: showing ${MAX_FILE_LINES}/${lines.length} lines]` : "";
|
|
327
|
-
return {
|
|
328
|
-
text: `# ${owner}/${repo}:${filePath}${input.ref ? ` @${input.ref}` : ""}\n\n${output}${note}`,
|
|
329
|
-
url: `https://github.com/${owner}/${repo}/blob/${input.ref ?? "HEAD"}/${filePath}`,
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
case "get_tree": {
|
|
334
|
-
const treePath = input.path ?? "";
|
|
335
|
-
if (input.recursive) {
|
|
336
|
-
const ref = input.ref ?? "HEAD";
|
|
337
|
-
const res = await githubClient.request<{ tree: GitHubTreeEntry[]; truncated: boolean }>(
|
|
338
|
-
`${base}/git/trees/${ref}?recursive=1`,
|
|
339
|
-
opts,
|
|
340
|
-
);
|
|
341
|
-
if (!res.ok) return error(res, "tree");
|
|
342
|
-
const entries = (res.data.tree ?? [])
|
|
343
|
-
.filter(e => !treePath || (e.path ?? "").startsWith(treePath))
|
|
344
|
-
.slice(0, 500);
|
|
345
|
-
return {
|
|
346
|
-
text: `# Tree: ${owner}/${repo}${treePath ? `/${treePath}` : ""} (recursive)\n\n${entries.map(formatTreeEntry).join("\n")}`,
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
const ref = input.ref ? `?ref=${input.ref}` : "";
|
|
350
|
-
const endpoint = treePath ? `${base}/contents/${treePath}${ref}` : `${base}/contents${ref}`;
|
|
351
|
-
const res = await githubClient.request<GitHubTreeEntry[]>(endpoint, opts);
|
|
352
|
-
if (!res.ok) return error(res, "directory");
|
|
353
|
-
const entries = Array.isArray(res.data) ? res.data : [res.data];
|
|
354
|
-
return {
|
|
355
|
-
text: `# ${owner}/${repo}/${treePath}\n\n${entries.map(formatTreeEntry).join("\n")}`,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
278
|
case "search_repos": {
|
|
360
279
|
const q = input.query ?? `${owner}/${repo}`;
|
|
361
280
|
const perPage = Math.min(input.limit ?? 30, 100);
|
|
@@ -363,7 +282,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
363
282
|
`/search/repositories?q=${encodeURIComponent(q)}&per_page=${perPage}`,
|
|
364
283
|
opts,
|
|
365
284
|
);
|
|
366
|
-
if (!res.ok) return
|
|
285
|
+
if (!res.ok) return formatGitHubError(res, "repository search");
|
|
367
286
|
return { text: formatSearchReposResult(res.data) };
|
|
368
287
|
}
|
|
369
288
|
|
|
@@ -378,7 +297,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
378
297
|
maxPages: MAX_COMMENTS_PAGES,
|
|
379
298
|
}),
|
|
380
299
|
]);
|
|
381
|
-
if (!issueRes.ok) return
|
|
300
|
+
if (!issueRes.ok) return formatGitHubError(issueRes, `issue #${num}`);
|
|
382
301
|
return {
|
|
383
302
|
text: formatIssue(issueRes.data, commentsRes.ok ? commentsRes.data : []),
|
|
384
303
|
url: `https://github.com/${owner}/${repo}/issues/${num}`,
|
|
@@ -397,7 +316,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
397
316
|
perPage,
|
|
398
317
|
maxPages,
|
|
399
318
|
});
|
|
400
|
-
if (!res.ok) return
|
|
319
|
+
if (!res.ok) return formatGitHubError(res, "issues");
|
|
401
320
|
const issues = (res.data ?? []).filter(i => !i.pull_request).slice(0, limit);
|
|
402
321
|
const header = `${issues.length} issue(s)${issues.length >= limit ? " (limit reached, increase limit for more)" : ""}`;
|
|
403
322
|
return {
|
|
@@ -409,7 +328,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
409
328
|
const num = input.number;
|
|
410
329
|
if (!num) return { text: "Error: 'number' is required for get_pull" };
|
|
411
330
|
const prRes = await githubClient.request<GitHubPR>(`${base}/pulls/${num}`, opts);
|
|
412
|
-
if (!prRes.ok) return
|
|
331
|
+
if (!prRes.ok) return formatGitHubError(prRes, `PR #${num}`);
|
|
413
332
|
|
|
414
333
|
let diff: string | undefined;
|
|
415
334
|
if (input.include_diff) {
|
|
@@ -442,7 +361,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
442
361
|
perPage,
|
|
443
362
|
maxPages,
|
|
444
363
|
});
|
|
445
|
-
if (!res.ok) return
|
|
364
|
+
if (!res.ok) return formatGitHubError(res, "pull requests");
|
|
446
365
|
const pulls = (res.data ?? []).slice(0, limit);
|
|
447
366
|
const header = `${pulls.length} PR(s)${pulls.length >= limit ? " (limit reached, increase limit for more)" : ""}`;
|
|
448
367
|
return {
|
|
@@ -462,7 +381,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
462
381
|
perPage,
|
|
463
382
|
maxPages,
|
|
464
383
|
});
|
|
465
|
-
if (!res.ok) return
|
|
384
|
+
if (!res.ok) return formatGitHubError(res, "commits");
|
|
466
385
|
const commits = (res.data ?? []).slice(0, limit);
|
|
467
386
|
const header = `${commits.length} commit(s)${commits.length >= limit ? " (limit reached, increase limit for more)" : ""}`;
|
|
468
387
|
return {
|
|
@@ -474,7 +393,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
474
393
|
const sha = input.sha;
|
|
475
394
|
if (!sha) return { text: "Error: 'sha' is required for get_commit" };
|
|
476
395
|
const res = await githubClient.request<GitHubCommit>(`${base}/commits/${sha}`, opts);
|
|
477
|
-
if (!res.ok) return
|
|
396
|
+
if (!res.ok) return formatGitHubError(res, `commit ${sha}`);
|
|
478
397
|
|
|
479
398
|
let diff: string | undefined;
|
|
480
399
|
if (input.include_diff) {
|
|
@@ -500,22 +419,6 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
500
419
|
return { text: `Unknown action: ${action}` };
|
|
501
420
|
}
|
|
502
421
|
}
|
|
503
|
-
|
|
504
|
-
function error(res: GitHubResponse, resource: string): { text: string } {
|
|
505
|
-
const status = res.status;
|
|
506
|
-
if (status === 404) return { text: `Error: ${resource} not found (404)` };
|
|
507
|
-
if (status === 403) {
|
|
508
|
-
const rl = res.rateLimit;
|
|
509
|
-
if (rl && rl.remaining === 0) {
|
|
510
|
-
return { text: `Error: GitHub API rate limit exceeded. Resets at ${new Date(rl.reset * 1000).toISOString()}` };
|
|
511
|
-
}
|
|
512
|
-
return { text: `Error: Access denied to ${resource} (403). Check token permissions.` };
|
|
513
|
-
}
|
|
514
|
-
if (status === 401)
|
|
515
|
-
return { text: `Error: Authentication failed (401). Check GITHUB_TOKEN or run 'gh auth login'.` };
|
|
516
|
-
return { text: `Error: Failed to fetch ${resource} (HTTP ${status})` };
|
|
517
|
-
}
|
|
518
|
-
|
|
519
422
|
// =============================================================================
|
|
520
423
|
// Tool Class
|
|
521
424
|
// =============================================================================
|
|
@@ -534,14 +437,23 @@ export class GitHubTool implements AgentTool<typeof schema, GitHubToolDetails, T
|
|
|
534
437
|
params: GitHubInput,
|
|
535
438
|
signal?: AbortSignal,
|
|
536
439
|
): Promise<AgentToolResult<GitHubToolDetails>> {
|
|
537
|
-
const
|
|
440
|
+
const resolved = await resolveOwnerRepo(params, this._session.cwd);
|
|
441
|
+
if (!resolved) {
|
|
442
|
+
return toolResult({ action: params.action, owner: "", repo: "" } as GitHubToolDetails)
|
|
443
|
+
.text(
|
|
444
|
+
"Error: owner and repo are required. Provide them explicitly or run from a git repo with a GitHub remote.",
|
|
445
|
+
)
|
|
446
|
+
.done();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const { owner, repo } = resolved;
|
|
538
450
|
const details: GitHubToolDetails = {
|
|
539
|
-
action:
|
|
540
|
-
owner
|
|
541
|
-
repo
|
|
451
|
+
action: params.action,
|
|
452
|
+
owner,
|
|
453
|
+
repo,
|
|
542
454
|
};
|
|
543
455
|
|
|
544
|
-
const result = await handleAction(
|
|
456
|
+
const result = await handleAction(params, owner, repo, signal);
|
|
545
457
|
|
|
546
458
|
const builder = toolResult(details).text(result.text);
|
|
547
459
|
if (result.url) {
|
package/src/tools/index.ts
CHANGED
|
@@ -72,6 +72,7 @@ export {
|
|
|
72
72
|
} from "./find";
|
|
73
73
|
export { FindThreadTool, type FindThreadToolDetails } from "./find-thread";
|
|
74
74
|
export { GitHubTool, type GitHubToolDetails } from "./github";
|
|
75
|
+
export { GitHubFsTool, type GitHubFsToolDetails } from "./github-fs";
|
|
75
76
|
export { GrepTool, type GrepToolDetails, type GrepToolInput } from "./grep";
|
|
76
77
|
export { librarianConfig } from "./librarian";
|
|
77
78
|
export { NotebookTool, type NotebookToolDetails } from "./notebook";
|