@nghyane/arcane 0.1.13 → 0.1.15

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.
Files changed (303) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +21 -70
  3. package/scripts/format-prompts.ts +1 -3
  4. package/src/cli/args.ts +2 -7
  5. package/src/cli/config-cli.ts +1 -1
  6. package/src/cli/plugin-cli.ts +1 -1
  7. package/src/cli/setup-cli.ts +1 -1
  8. package/src/cli/update-cli.ts +1 -1
  9. package/src/cli/web-search-cli.ts +1 -1
  10. package/src/cli.ts +0 -1
  11. package/src/commands/config.ts +1 -1
  12. package/src/commands/grep.ts +1 -1
  13. package/src/commands/jupyter.ts +1 -1
  14. package/src/commands/plugin.ts +1 -1
  15. package/src/commands/setup.ts +1 -1
  16. package/src/commands/shell.ts +1 -1
  17. package/src/commands/ssh.ts +1 -1
  18. package/src/commands/stats.ts +1 -1
  19. package/src/commands/update.ts +1 -1
  20. package/src/config/model-registry.ts +3 -4
  21. package/src/config/model-resolver.ts +36 -9
  22. package/src/config/prompt-templates.ts +1 -9
  23. package/src/config/settings-schema.ts +32 -88
  24. package/src/config/settings.ts +3 -4
  25. package/src/debug/index.ts +1 -1
  26. package/src/debug/log-formatting.ts +1 -1
  27. package/src/debug/log-viewer.ts +2 -2
  28. package/src/discovery/helpers.ts +13 -3
  29. package/src/exa/index.ts +1 -35
  30. package/src/exa/render.ts +30 -190
  31. package/src/export/html/index.ts +1 -1
  32. package/src/extensibility/custom-tools/loader.ts +1 -1
  33. package/src/extensibility/custom-tools/types.ts +5 -1
  34. package/src/extensibility/custom-tools/wrapper.ts +1 -1
  35. package/src/extensibility/extensions/runner.ts +1 -1
  36. package/src/extensibility/extensions/types.ts +1 -1
  37. package/src/extensibility/extensions/wrapper.ts +7 -15
  38. package/src/extensibility/hooks/runner.ts +1 -1
  39. package/src/extensibility/hooks/types.ts +1 -1
  40. package/src/extensibility/plugins/doctor.ts +1 -1
  41. package/src/index.ts +13 -13
  42. package/src/lsp/index.ts +77 -24
  43. package/src/lsp/render.ts +34 -583
  44. package/src/lsp/types.ts +3 -3
  45. package/src/lsp/utils.ts +1 -1
  46. package/src/main.ts +1 -1
  47. package/src/mcp/tool-bridge.ts +1 -24
  48. package/src/modes/components/assistant-message.ts +7 -7
  49. package/src/modes/components/bash-execution.ts +50 -112
  50. package/src/modes/components/bordered-loader.ts +1 -1
  51. package/src/modes/components/branch-summary-message.ts +16 -10
  52. package/src/modes/components/compaction-summary-message.ts +20 -12
  53. package/src/modes/components/context-group.ts +106 -0
  54. package/src/modes/components/custom-message.ts +4 -5
  55. package/src/modes/components/diff.ts +2 -2
  56. package/src/modes/components/dynamic-border.ts +1 -1
  57. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  58. package/src/modes/components/extensions/extension-list.ts +1 -1
  59. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  60. package/src/modes/components/footer.ts +2 -2
  61. package/src/modes/components/history-search.ts +1 -1
  62. package/src/modes/components/hook-editor.ts +1 -1
  63. package/src/modes/components/hook-input.ts +1 -1
  64. package/src/modes/components/hook-message.ts +4 -5
  65. package/src/modes/components/hook-selector.ts +1 -1
  66. package/src/modes/components/index.ts +0 -2
  67. package/src/modes/components/keybinding-hints.ts +1 -1
  68. package/src/modes/components/login-dialog.ts +1 -1
  69. package/src/modes/components/mcp-add-wizard.ts +1 -1
  70. package/src/modes/components/model-selector.ts +1 -1
  71. package/src/modes/components/oauth-selector.ts +1 -1
  72. package/src/modes/components/plugin-settings.ts +1 -1
  73. package/src/modes/components/python-execution.ts +51 -91
  74. package/src/modes/components/queue-mode-selector.ts +1 -1
  75. package/src/modes/components/session-selector.ts +1 -1
  76. package/src/modes/components/settings-defs.ts +5 -10
  77. package/src/modes/components/settings-selector.ts +1 -1
  78. package/src/modes/components/show-images-selector.ts +1 -1
  79. package/src/modes/components/skill-message.ts +4 -4
  80. package/src/modes/components/status-line/segments.ts +2 -2
  81. package/src/modes/components/status-line/separators.ts +1 -1
  82. package/src/modes/components/status-line-segment-editor.ts +1 -1
  83. package/src/modes/components/status-line.ts +1 -1
  84. package/src/modes/components/theme-selector.ts +1 -1
  85. package/src/modes/components/thinking-selector.ts +1 -1
  86. package/src/modes/components/todo-display.ts +2 -4
  87. package/src/modes/components/todo-reminder.ts +4 -4
  88. package/src/modes/components/tool-execution.ts +118 -440
  89. package/src/modes/components/tool-image-display.ts +107 -0
  90. package/src/modes/components/tree-selector.ts +2 -2
  91. package/src/modes/components/ttsr-notification.ts +4 -17
  92. package/src/modes/components/user-message-selector.ts +1 -1
  93. package/src/modes/components/user-message.ts +9 -10
  94. package/src/modes/components/welcome.ts +1 -1
  95. package/src/modes/controllers/command-controller.ts +1 -1
  96. package/src/modes/controllers/event-controller.ts +58 -187
  97. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  98. package/src/modes/controllers/input-controller.ts +3 -1
  99. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  100. package/src/modes/controllers/selector-controller.ts +3 -26
  101. package/src/modes/controllers/ssh-command-controller.ts +1 -1
  102. package/src/modes/interactive-mode.ts +3 -7
  103. package/src/modes/print-mode.ts +5 -5
  104. package/src/modes/rpc/rpc-mode.ts +1 -1
  105. package/src/modes/types.ts +1 -2
  106. package/src/modes/utils/ui-helpers.ts +34 -32
  107. package/src/patch/edit-tool.ts +742 -0
  108. package/src/patch/index.ts +32 -898
  109. package/src/patch/schemas.ts +208 -0
  110. package/src/patch/shared.ts +83 -151
  111. package/src/prompts/agents/explore.md +22 -37
  112. package/src/prompts/agents/init.md +1 -1
  113. package/src/prompts/agents/librarian.md +29 -20
  114. package/src/prompts/agents/oracle.md +9 -2
  115. package/src/prompts/agents/reviewer.md +14 -48
  116. package/src/prompts/agents/task.md +16 -8
  117. package/src/prompts/compaction/branch-summary.md +4 -1
  118. package/src/prompts/compaction/compaction-summary.md +4 -1
  119. package/src/prompts/system/subagent-system-prompt.md +1 -1
  120. package/src/prompts/system/system-prompt.md +162 -178
  121. package/src/prompts/system/verification-reminder.md +6 -0
  122. package/src/sdk.ts +0 -9
  123. package/src/session/agent-session.ts +244 -1459
  124. package/src/session/model-controller.ts +406 -0
  125. package/src/session/retry-utils.ts +71 -0
  126. package/src/session/session-manager.ts +22 -186
  127. package/src/session/session-types.ts +312 -0
  128. package/src/session/stats.ts +387 -0
  129. package/src/session/streaming-edit.ts +258 -0
  130. package/src/session/ttsr.ts +213 -0
  131. package/src/slash-commands/builtin-registry.ts +0 -8
  132. package/src/stt/recorder.ts +2 -2
  133. package/src/system-prompt.ts +1 -14
  134. package/src/task/agents.ts +7 -33
  135. package/src/task/executor.ts +50 -438
  136. package/src/task/index.ts +104 -71
  137. package/src/task/progress-tracker.ts +390 -0
  138. package/src/task/render.ts +371 -187
  139. package/src/task/subprocess-tool-registry.ts +1 -1
  140. package/src/task/types.ts +14 -47
  141. package/src/tools/ask.ts +31 -42
  142. package/src/tools/bash-interactive.ts +2 -2
  143. package/src/tools/bash-interceptor.ts +2 -2
  144. package/src/tools/bash-normalize.ts +1 -1
  145. package/src/tools/bash-skill-urls.ts +2 -2
  146. package/src/tools/bash.ts +87 -136
  147. package/src/tools/browser.ts +54 -84
  148. package/src/tools/create-tools.ts +186 -0
  149. package/src/tools/default-renderer.ts +104 -0
  150. package/src/tools/explore.ts +11 -10
  151. package/src/tools/fetch.ts +24 -114
  152. package/src/tools/find.ts +48 -132
  153. package/src/tools/gemini-image.ts +5 -15
  154. package/src/tools/github.ts +450 -0
  155. package/src/tools/grep.ts +43 -179
  156. package/src/tools/index.ts +35 -198
  157. package/src/tools/json-tree.ts +3 -3
  158. package/src/tools/librarian.ts +18 -18
  159. package/src/tools/list-limit.ts +2 -2
  160. package/src/tools/notebook.ts +35 -87
  161. package/src/tools/oracle.ts +25 -25
  162. package/src/tools/output-meta.ts +89 -4
  163. package/src/tools/output-utils.ts +2 -2
  164. package/src/tools/python.ts +86 -637
  165. package/src/tools/read.ts +36 -119
  166. package/src/tools/reviewer-tool.ts +19 -21
  167. package/src/tools/search-code.ts +128 -0
  168. package/src/tools/ssh.ts +67 -126
  169. package/src/tools/subagent-tool.ts +197 -123
  170. package/src/tools/todo-write.ts +15 -31
  171. package/src/tools/tool-errors.ts +0 -30
  172. package/src/tools/undo-edit.ts +30 -67
  173. package/src/tools/write.ts +78 -127
  174. package/src/tui/code-cell.ts +4 -4
  175. package/src/tui/file-list.ts +2 -2
  176. package/src/tui/output-block.ts +1 -1
  177. package/src/tui/status-line.ts +1 -1
  178. package/src/tui/tree-list.ts +2 -2
  179. package/src/tui/types.ts +1 -1
  180. package/src/tui/utils.ts +1 -1
  181. package/src/{tools → ui}/render-utils.ts +87 -126
  182. package/src/utils/external-editor.ts +4 -4
  183. package/src/utils/file-mentions.ts +1 -1
  184. package/src/utils/index.ts +30 -0
  185. package/src/utils/tools-manager.ts +9 -19
  186. package/src/web/github-client.ts +290 -0
  187. package/src/web/scrapers/github.ts +11 -62
  188. package/src/web/search/auth.ts +1 -3
  189. package/src/web/search/index.ts +82 -46
  190. package/src/web/search/provider.ts +11 -16
  191. package/src/web/search/providers/grep.ts +160 -0
  192. package/src/web/search/render.ts +48 -235
  193. package/src/web/search/types.ts +1 -1
  194. package/src/commands/commit.ts +0 -36
  195. package/src/commit/agentic/agent.ts +0 -311
  196. package/src/commit/agentic/fallback.ts +0 -96
  197. package/src/commit/agentic/index.ts +0 -359
  198. package/src/commit/agentic/prompts/analyze-file.md +0 -22
  199. package/src/commit/agentic/prompts/session-user.md +0 -25
  200. package/src/commit/agentic/prompts/split-confirm.md +0 -1
  201. package/src/commit/agentic/prompts/system.md +0 -38
  202. package/src/commit/agentic/state.ts +0 -69
  203. package/src/commit/agentic/tools/analyze-file.ts +0 -118
  204. package/src/commit/agentic/tools/git-file-diff.ts +0 -194
  205. package/src/commit/agentic/tools/git-hunk.ts +0 -50
  206. package/src/commit/agentic/tools/git-overview.ts +0 -84
  207. package/src/commit/agentic/tools/index.ts +0 -56
  208. package/src/commit/agentic/tools/propose-changelog.ts +0 -128
  209. package/src/commit/agentic/tools/propose-commit.ts +0 -154
  210. package/src/commit/agentic/tools/recent-commits.ts +0 -81
  211. package/src/commit/agentic/tools/split-commit.ts +0 -280
  212. package/src/commit/agentic/topo-sort.ts +0 -44
  213. package/src/commit/agentic/trivial.ts +0 -51
  214. package/src/commit/agentic/validation.ts +0 -200
  215. package/src/commit/analysis/conventional.ts +0 -165
  216. package/src/commit/analysis/index.ts +0 -4
  217. package/src/commit/analysis/scope.ts +0 -242
  218. package/src/commit/analysis/summary.ts +0 -112
  219. package/src/commit/analysis/validation.ts +0 -66
  220. package/src/commit/changelog/detect.ts +0 -37
  221. package/src/commit/changelog/generate.ts +0 -110
  222. package/src/commit/changelog/index.ts +0 -234
  223. package/src/commit/changelog/parse.ts +0 -44
  224. package/src/commit/cli.ts +0 -93
  225. package/src/commit/git/diff.ts +0 -148
  226. package/src/commit/git/errors.ts +0 -9
  227. package/src/commit/git/index.ts +0 -211
  228. package/src/commit/git/operations.ts +0 -54
  229. package/src/commit/index.ts +0 -5
  230. package/src/commit/map-reduce/index.ts +0 -64
  231. package/src/commit/map-reduce/map-phase.ts +0 -178
  232. package/src/commit/map-reduce/reduce-phase.ts +0 -145
  233. package/src/commit/map-reduce/utils.ts +0 -9
  234. package/src/commit/message.ts +0 -11
  235. package/src/commit/model-selection.ts +0 -69
  236. package/src/commit/pipeline.ts +0 -243
  237. package/src/commit/prompts/analysis-system.md +0 -148
  238. package/src/commit/prompts/analysis-user.md +0 -38
  239. package/src/commit/prompts/changelog-system.md +0 -50
  240. package/src/commit/prompts/changelog-user.md +0 -18
  241. package/src/commit/prompts/file-observer-system.md +0 -24
  242. package/src/commit/prompts/file-observer-user.md +0 -8
  243. package/src/commit/prompts/reduce-system.md +0 -50
  244. package/src/commit/prompts/reduce-user.md +0 -17
  245. package/src/commit/prompts/summary-retry.md +0 -3
  246. package/src/commit/prompts/summary-system.md +0 -38
  247. package/src/commit/prompts/summary-user.md +0 -13
  248. package/src/commit/prompts/types-description.md +0 -2
  249. package/src/commit/types.ts +0 -109
  250. package/src/commit/utils/exclusions.ts +0 -42
  251. package/src/mcp/render.ts +0 -123
  252. package/src/modes/components/agent-dashboard.ts +0 -1130
  253. package/src/modes/components/codemode-group.ts +0 -369
  254. package/src/modes/components/read-tool-group.ts +0 -119
  255. package/src/modes/components/visual-truncate.ts +0 -63
  256. package/src/prompts/system/subagent-user-prompt.md +0 -8
  257. package/src/prompts/tools/ask.md +0 -44
  258. package/src/prompts/tools/bash.md +0 -24
  259. package/src/prompts/tools/browser.md +0 -33
  260. package/src/prompts/tools/calculator.md +0 -12
  261. package/src/prompts/tools/explore.md +0 -29
  262. package/src/prompts/tools/fetch.md +0 -16
  263. package/src/prompts/tools/find.md +0 -18
  264. package/src/prompts/tools/gemini-image.md +0 -23
  265. package/src/prompts/tools/grep.md +0 -28
  266. package/src/prompts/tools/hashline.md +0 -232
  267. package/src/prompts/tools/librarian.md +0 -24
  268. package/src/prompts/tools/lsp.md +0 -28
  269. package/src/prompts/tools/oracle.md +0 -26
  270. package/src/prompts/tools/patch.md +0 -74
  271. package/src/prompts/tools/python.md +0 -66
  272. package/src/prompts/tools/read.md +0 -36
  273. package/src/prompts/tools/replace.md +0 -38
  274. package/src/prompts/tools/reviewer.md +0 -41
  275. package/src/prompts/tools/ssh.md +0 -51
  276. package/src/prompts/tools/task-summary.md +0 -28
  277. package/src/prompts/tools/task.md +0 -146
  278. package/src/prompts/tools/todo-write.md +0 -65
  279. package/src/prompts/tools/undo-edit.md +0 -7
  280. package/src/prompts/tools/web-search.md +0 -19
  281. package/src/prompts/tools/write.md +0 -18
  282. package/src/task/batch.ts +0 -102
  283. package/src/task/discovery.ts +0 -126
  284. package/src/task/parallel.ts +0 -84
  285. package/src/task/template.ts +0 -32
  286. package/src/tools/calculator.ts +0 -537
  287. package/src/tools/jtd-to-typescript.ts +0 -198
  288. package/src/tools/renderers.ts +0 -60
  289. package/src/tools/tool-result.ts +0 -86
  290. /package/src/{modes/theme → theme}/dark.json +0 -0
  291. /package/src/{modes/theme → theme}/defaults/dark-catppuccin.json +0 -0
  292. /package/src/{modes/theme → theme}/defaults/dark-dracula.json +0 -0
  293. /package/src/{modes/theme → theme}/defaults/dark-gruvbox.json +0 -0
  294. /package/src/{modes/theme → theme}/defaults/dark-solarized.json +0 -0
  295. /package/src/{modes/theme → theme}/defaults/dark-tokyo-night.json +0 -0
  296. /package/src/{modes/theme → theme}/defaults/index.ts +0 -0
  297. /package/src/{modes/theme → theme}/defaults/light-catppuccin.json +0 -0
  298. /package/src/{modes/theme → theme}/defaults/light-github.json +0 -0
  299. /package/src/{modes/theme → theme}/defaults/light-solarized.json +0 -0
  300. /package/src/{modes/theme → theme}/light.json +0 -0
  301. /package/src/{modes/theme → theme}/mermaid-cache.ts +0 -0
  302. /package/src/{modes/theme → theme}/theme-schema.json +0 -0
  303. /package/src/{modes/theme → theme}/theme.ts +0 -0
@@ -0,0 +1,30 @@
1
+ export { EventBus } from "./event-bus";
2
+ export { getEditorCommand, type OpenInEditorOptions, openInEditor } from "./external-editor";
3
+ export {
4
+ type FileDisplayMode,
5
+ type FileDisplayModeSession,
6
+ resolveFileDisplayMode,
7
+ } from "./file-display-mode";
8
+ export { FrontmatterError, type FrontmatterOptions, parseFrontmatter } from "./frontmatter";
9
+ export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy";
10
+ export {
11
+ addIgnoreRules,
12
+ addIgnoreRulesSync,
13
+ createIgnoreMatcher,
14
+ IGNORE_FILE_NAMES,
15
+ type IgnoreMatcher,
16
+ prefixIgnorePattern,
17
+ shouldIgnore,
18
+ toPosixPath,
19
+ } from "./ignore-files";
20
+ export { convertToPng } from "./image-convert";
21
+ export {
22
+ formatDimensionNote,
23
+ type ImageResizeOptions,
24
+ type ResizedImage,
25
+ resizeImage,
26
+ } from "./image-resize";
27
+ export { detectSupportedImageMimeTypeFromFile } from "./mime";
28
+ export { openPath } from "./open";
29
+ export { getOrCreateSnapshot, getSnapshotSourceCommand } from "./shell-snapshot";
30
+ export { printTimings, time } from "./timings";
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { logger, ptree, TempDir } from "@nghyane/arcane-utils";
5
- import { APP_NAME, getToolsDir } from "@nghyane/arcane-utils/dirs";
5
+ import { getToolsDir } from "@nghyane/arcane-utils/dirs";
6
+ import { githubClient } from "../web/github-client";
6
7
 
7
8
  const TOOLS_DIR = getToolsDir();
8
9
  const TOOL_DOWNLOAD_TIMEOUT_MS = 15000;
@@ -120,25 +121,14 @@ export function getToolPath(tool: ToolName): string | null {
120
121
 
121
122
  // Fetch latest release version from GitHub
122
123
  async function getLatestVersion(repo: string, signal?: AbortSignal): Promise<string> {
123
- let response: Response;
124
- try {
125
- response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
126
- headers: { "User-Agent": `${APP_NAME}-coding-agent` },
127
- signal: ptree.combineSignals(signal, TOOL_METADATA_TIMEOUT_MS),
128
- });
129
- } catch (err) {
130
- if (err instanceof Error && err.name === "AbortError") {
131
- throw new Error("GitHub API request timed out");
132
- }
133
- throw err;
124
+ const result = await githubClient.request<{ tag_name: string }>(`/repos/${repo}/releases/latest`, {
125
+ timeout: Math.ceil(TOOL_METADATA_TIMEOUT_MS / 1000),
126
+ signal,
127
+ });
128
+ if (!result.ok) {
129
+ throw new Error(`GitHub API error: ${result.status}`);
134
130
  }
135
-
136
- if (!response.ok) {
137
- throw new Error(`GitHub API error: ${response.status}`);
138
- }
139
-
140
- const data = (await response.json()) as { tag_name: string };
141
- return data.tag_name.replace(/^v/, "");
131
+ return (result.data as { tag_name: string }).tag_name.replace(/^v/, "");
142
132
  }
143
133
 
144
134
  // Download a file from URL
@@ -0,0 +1,290 @@
1
+ import { $env, logger } from "@nghyane/arcane-utils";
2
+ import { $ } from "bun";
3
+
4
+ // =============================================================================
5
+ // Types
6
+ // =============================================================================
7
+
8
+ interface GitHubResponse<T = unknown> {
9
+ data: T;
10
+ ok: boolean;
11
+ status: number;
12
+ rateLimit?: RateLimitInfo;
13
+ }
14
+
15
+ interface RateLimitInfo {
16
+ remaining: number;
17
+ limit: number;
18
+ reset: number;
19
+ }
20
+
21
+ interface RequestOptions {
22
+ timeout?: number;
23
+ signal?: AbortSignal;
24
+ accept?: string;
25
+ mediaType?: string;
26
+ }
27
+
28
+ interface CacheEntry {
29
+ etag: string;
30
+ data: unknown;
31
+ timestamp: number;
32
+ }
33
+
34
+ // =============================================================================
35
+ // Auth
36
+ // =============================================================================
37
+
38
+ let cachedToken: string | undefined;
39
+ let tokenResolved = false;
40
+
41
+ async function resolveToken(): Promise<string | undefined> {
42
+ if (tokenResolved) return cachedToken;
43
+ tokenResolved = true;
44
+
45
+ // Env vars first
46
+ const envToken = $env.GITHUB_TOKEN || $env.GH_TOKEN;
47
+ if (envToken) {
48
+ cachedToken = envToken;
49
+ return cachedToken;
50
+ }
51
+
52
+ // Fallback: gh auth token
53
+ if (Bun.which("gh")) {
54
+ try {
55
+ const result = await $`gh auth token`.quiet().nothrow();
56
+ if (result.exitCode === 0) {
57
+ const token = result.text().trim();
58
+ if (token) {
59
+ cachedToken = token;
60
+ return cachedToken;
61
+ }
62
+ }
63
+ } catch {
64
+ // gh not authenticated
65
+ }
66
+ }
67
+
68
+ return undefined;
69
+ }
70
+
71
+ // =============================================================================
72
+ // ETag Cache
73
+ // =============================================================================
74
+
75
+ const etagCache = new Map<string, CacheEntry>();
76
+ const CACHE_MAX_SIZE = 200;
77
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
78
+
79
+ function getCacheKey(endpoint: string): string {
80
+ return endpoint;
81
+ }
82
+
83
+ function pruneCache(): void {
84
+ if (etagCache.size <= CACHE_MAX_SIZE) return;
85
+ const now = Date.now();
86
+ for (const [key, entry] of etagCache) {
87
+ if (now - entry.timestamp > CACHE_TTL_MS) {
88
+ etagCache.delete(key);
89
+ }
90
+ }
91
+ // If still too large, remove oldest
92
+ if (etagCache.size > CACHE_MAX_SIZE) {
93
+ const entries = [...etagCache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
94
+ const toRemove = entries.slice(0, etagCache.size - CACHE_MAX_SIZE);
95
+ for (const [key] of toRemove) {
96
+ etagCache.delete(key);
97
+ }
98
+ }
99
+ }
100
+
101
+ // =============================================================================
102
+ // Core Request
103
+ // =============================================================================
104
+
105
+ const RETRY_STATUS_CODES = new Set([429, 502, 503]);
106
+ const MAX_RETRIES = 3;
107
+
108
+ async function request<T = unknown>(endpoint: string, options: RequestOptions = {}): Promise<GitHubResponse<T>> {
109
+ const { timeout = 30, signal } = options;
110
+ const token = await resolveToken();
111
+
112
+ const headers: Record<string, string> = {
113
+ Accept: options.accept ?? "application/vnd.github.v3+json",
114
+ "User-Agent": "arcane-github-tool/1.0",
115
+ };
116
+
117
+ if (token) {
118
+ headers.Authorization = `Bearer ${token}`;
119
+ }
120
+
121
+ if (options.mediaType) {
122
+ headers.Accept = options.mediaType;
123
+ }
124
+
125
+ // ETag conditional request
126
+ const cacheKey = getCacheKey(endpoint);
127
+ const cached = etagCache.get(cacheKey);
128
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
129
+ headers["If-None-Match"] = cached.etag;
130
+ }
131
+
132
+ const url = endpoint.startsWith("https://") ? endpoint : `https://api.github.com${endpoint}`;
133
+
134
+ let lastError: Error | undefined;
135
+
136
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
137
+ try {
138
+ const controller = new AbortController();
139
+ const timeoutId = setTimeout(() => controller.abort(), timeout * 1000);
140
+
141
+ // Combine with external signal
142
+ if (signal?.aborted) {
143
+ clearTimeout(timeoutId);
144
+ return { data: null as T, ok: false, status: 0 };
145
+ }
146
+
147
+ const abortHandler = () => controller.abort();
148
+ signal?.addEventListener("abort", abortHandler, { once: true });
149
+
150
+ try {
151
+ const response = await fetch(url, {
152
+ headers,
153
+ signal: controller.signal,
154
+ });
155
+
156
+ clearTimeout(timeoutId);
157
+ signal?.removeEventListener("abort", abortHandler);
158
+
159
+ // Parse rate limit
160
+ const rateLimit: RateLimitInfo = {
161
+ remaining: parseInt(response.headers.get("x-ratelimit-remaining") ?? "-1", 10),
162
+ limit: parseInt(response.headers.get("x-ratelimit-limit") ?? "-1", 10),
163
+ reset: parseInt(response.headers.get("x-ratelimit-reset") ?? "0", 10),
164
+ };
165
+
166
+ if (rateLimit.remaining >= 0 && rateLimit.remaining < 10) {
167
+ logger.warn("GitHub API rate limit low", {
168
+ remaining: rateLimit.remaining,
169
+ reset: new Date(rateLimit.reset * 1000).toISOString(),
170
+ });
171
+ }
172
+
173
+ // 304 Not Modified — return cached data
174
+ if (response.status === 304 && cached) {
175
+ cached.timestamp = Date.now();
176
+ return { data: cached.data as T, ok: true, status: 304, rateLimit };
177
+ }
178
+
179
+ // Retry on transient errors
180
+ if (RETRY_STATUS_CODES.has(response.status) && attempt < MAX_RETRIES) {
181
+ const retryAfter = response.headers.get("retry-after");
182
+ const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.min(1000 * 2 ** attempt, 10_000);
183
+
184
+ if (response.status === 429 && rateLimit.remaining === 0) {
185
+ const resetMs = rateLimit.reset * 1000 - Date.now();
186
+ if (resetMs > 30_000) {
187
+ return { data: null as T, ok: false, status: response.status, rateLimit };
188
+ }
189
+ await Bun.sleep(Math.max(resetMs, 1000));
190
+ } else {
191
+ await Bun.sleep(waitMs);
192
+ }
193
+ continue;
194
+ }
195
+
196
+ if (!response.ok) {
197
+ return { data: null as T, ok: false, status: response.status, rateLimit };
198
+ }
199
+
200
+ const data = (await response.json()) as T;
201
+
202
+ // Cache with ETag
203
+ const etag = response.headers.get("etag");
204
+ if (etag) {
205
+ etagCache.set(cacheKey, { etag, data, timestamp: Date.now() });
206
+ pruneCache();
207
+ }
208
+
209
+ return { data, ok: true, status: response.status, rateLimit };
210
+ } catch (err) {
211
+ clearTimeout(timeoutId);
212
+ signal?.removeEventListener("abort", abortHandler);
213
+ throw err;
214
+ }
215
+ } catch (err) {
216
+ lastError = err instanceof Error ? err : new Error(String(err));
217
+ if (attempt < MAX_RETRIES) {
218
+ await Bun.sleep(1000 * 2 ** attempt);
219
+ }
220
+ }
221
+ }
222
+
223
+ logger.error("GitHub API request failed", { endpoint, error: lastError?.message });
224
+ return { data: null as T, ok: false, status: 0 };
225
+ }
226
+
227
+ // =============================================================================
228
+ // Paginated Request
229
+ // =============================================================================
230
+
231
+ async function requestPaginated<T>(
232
+ endpoint: string,
233
+ options: RequestOptions & { perPage?: number; maxPages?: number } = {},
234
+ ): Promise<GitHubResponse<T[]>> {
235
+ const perPage = options.perPage ?? 30;
236
+ const maxPages = options.maxPages ?? 3;
237
+ const separator = endpoint.includes("?") ? "&" : "?";
238
+ const allData: T[] = [];
239
+
240
+ for (let page = 1; page <= maxPages; page++) {
241
+ const paginatedEndpoint = `${endpoint}${separator}per_page=${perPage}&page=${page}`;
242
+ const response = await request<T[]>(paginatedEndpoint, options);
243
+
244
+ if (!response.ok) {
245
+ if (allData.length > 0) {
246
+ return { data: allData, ok: true, status: response.status, rateLimit: response.rateLimit };
247
+ }
248
+ return response;
249
+ }
250
+
251
+ allData.push(...response.data);
252
+
253
+ // No more pages
254
+ if (response.data.length < perPage) break;
255
+ }
256
+
257
+ return { data: allData, ok: true, status: 200 };
258
+ }
259
+
260
+ // =============================================================================
261
+ // Raw content request (for file contents)
262
+ // =============================================================================
263
+
264
+ async function requestRaw(endpoint: string, options: RequestOptions = {}): Promise<GitHubResponse<string>> {
265
+ return request<string>(endpoint, {
266
+ ...options,
267
+ mediaType: "application/vnd.github.v3.raw",
268
+ });
269
+ }
270
+
271
+ // =============================================================================
272
+ // Public API
273
+ // =============================================================================
274
+
275
+ export const githubClient = {
276
+ request,
277
+ requestPaginated,
278
+ requestRaw,
279
+ resolveToken,
280
+
281
+ clearCache(): void {
282
+ etagCache.clear();
283
+ },
284
+
285
+ isAuthenticated(): Promise<boolean> {
286
+ return resolveToken().then(t => t !== undefined);
287
+ },
288
+ } as const;
289
+
290
+ export type { GitHubResponse, RateLimitInfo, RequestOptions };
@@ -1,4 +1,4 @@
1
- import { $env, ptree } from "@nghyane/arcane-utils";
1
+ import { githubClient } from "../github-client";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
3
  import { finalizeOutput, loadPage } from "./types";
4
4
 
@@ -11,12 +11,6 @@ interface GitHubUrl {
11
11
  number?: number;
12
12
  }
13
13
 
14
- interface GitHubIssueComment {
15
- user: { login: string };
16
- created_at: string;
17
- body: string;
18
- }
19
-
20
14
  /**
21
15
  * Parse GitHub URL into components
22
16
  */
@@ -75,40 +69,15 @@ function toRawGitHubUrl(gh: GitHubUrl): string {
75
69
  }
76
70
 
77
71
  /**
78
- * Fetch from GitHub API
72
+ * Fetch from GitHub API — delegates to shared github-client.
79
73
  */
80
74
  export async function fetchGitHubApi(
81
75
  endpoint: string,
82
76
  timeout: number,
83
77
  signal?: AbortSignal,
84
78
  ): Promise<{ data: unknown; ok: boolean }> {
85
- try {
86
- const requestSignal = ptree.combineSignals(signal, timeout * 1000);
87
-
88
- const headers: Record<string, string> = {
89
- Accept: "application/vnd.github.v3+json",
90
- "User-Agent": "arc-web-fetch/1.0",
91
- };
92
-
93
- // Use GITHUB_TOKEN if available
94
- const token = $env.GITHUB_TOKEN || $env.GH_TOKEN;
95
- if (token) {
96
- headers.Authorization = `Bearer ${token}`;
97
- }
98
-
99
- const response = await fetch(`https://api.github.com${endpoint}`, {
100
- signal: requestSignal,
101
- headers,
102
- });
103
-
104
- if (!response.ok) {
105
- return { data: null, ok: false };
106
- }
107
-
108
- return { data: await response.json(), ok: true };
109
- } catch {
110
- return { data: null, ok: false };
111
- }
79
+ const result = await githubClient.request(endpoint, { timeout, signal });
80
+ return { data: result.data, ok: result.ok };
112
81
  }
113
82
 
114
83
  /**
@@ -118,35 +87,15 @@ async function fetchGitHubIssueComments(
118
87
  owner: string,
119
88
  repo: string,
120
89
  issueNumber: number,
121
- expectedCount: number,
90
+ _expectedCount: number,
122
91
  timeout: number,
123
92
  signal?: AbortSignal,
124
- ): Promise<GitHubIssueComment[]> {
125
- const perPage = 100;
126
- const comments: GitHubIssueComment[] = [];
127
-
128
- for (let page = 1; comments.length < expectedCount; page++) {
129
- const result = await fetchGitHubApi(
130
- `/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`,
131
- timeout,
132
- signal,
133
- );
134
- if (!result.ok || !Array.isArray(result.data)) {
135
- break;
136
- }
137
-
138
- const pageComments = result.data as GitHubIssueComment[];
139
- if (pageComments.length === 0) {
140
- break;
141
- }
142
-
143
- comments.push(...pageComments);
144
- if (pageComments.length < perPage) {
145
- break;
146
- }
147
- }
148
-
149
- return comments;
93
+ ): Promise<Array<{ user: { login: string }; created_at: string; body: string }>> {
94
+ const result = await githubClient.requestPaginated<{ user: { login: string }; created_at: string; body: string }>(
95
+ `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
96
+ { timeout, signal, perPage: 100, maxPages: 5 },
97
+ );
98
+ return result.ok ? result.data : [];
150
99
  }
151
100
 
152
101
  /**
@@ -23,9 +23,7 @@ const DEFAULT_BASE_URL = "https://api.anthropic.com";
23
23
  */
24
24
  async function readJson<T>(filePath: string): Promise<T | null> {
25
25
  try {
26
- const file = Bun.file(filePath);
27
- if (!(await file.exists())) return null;
28
- const content = await file.text();
26
+ const content = await Bun.file(filePath).text();
29
27
  return JSON.parse(content) as T;
30
28
  } catch (error) {
31
29
  logger.warn("Failed to parse JSON file", { path: filePath, error: String(error) });