@nghyane/arcane 0.1.28 → 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,13 @@
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
+
5
12
  ## [0.1.28] - 2026-03-08
6
13
 
7
14
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@nghyane/arcane",
4
- "version": "0.1.28",
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",
@@ -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";