@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 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.27",
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",
@@ -134,13 +134,21 @@ function mergeDiagnosticsWithWarnings(
134
134
  };
135
135
  }
136
136
 
137
- export class EditTool implements AgentTool<TInput, any, Theme> {
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<TInput, any, Theme>["renderCall"];
143
- readonly renderResult = editToolRenderer.renderResult as unknown as AgentTool<TInput, any, Theme>["renderResult"];
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);
@@ -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 edits) {
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 edits) {
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 edits) {
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 < edits.length; i++) {
682
- const edit = edits[i];
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 = edits.length - 1; i >= 0; i--) {
701
- if (dedupIndices.has(i)) edits.splice(i, 1);
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 = edits.map((edit, idx) => {
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: github, fetch, web_search, search_code
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 github tool for all repository operations — it handles auth, rate limits, and caching
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 github tool for all GitHub API operations:
21
- - `github({ action: "get_file", ... })` for reading remote files
22
- - `github({ action: "get_tree", ... })` for listing directories
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, github get_file for specific files
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. Check history if needed list_commits for evolution context
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
+ }
@@ -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 { type GitHubResponse, githubClient } from "../web/github-client";
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.String({ description: "Repository owner (user or org)" }),
126
- repo: Type.String({ description: "Repository name" }),
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(input: GitHubInput, signal?: AbortSignal): Promise<{ text: string; url?: string }> {
283
- const { action, owner, repo } = input;
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 error(res, "repository");
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 error(res, "repository search");
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 error(issueRes, `issue #${num}`);
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 error(res, "issues");
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 error(prRes, `PR #${num}`);
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 error(res, "pull requests");
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 error(res, "commits");
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 error(res, `commit ${sha}`);
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 input = params;
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: input.action,
540
- owner: input.owner,
541
- repo: input.repo,
451
+ action: params.action,
452
+ owner,
453
+ repo,
542
454
  };
543
455
 
544
- const result = await handleAction(input, signal);
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) {
@@ -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";