@weavetab/plugin-github 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,95 +1,197 @@
1
1
  /**
2
2
  * @weavetab/plugin-github
3
- * Official Weavetab GitHub plugin — repository analysis, raw file reads, issue
4
- * search/inspection and pull request inspection over the GitHub REST API.
3
+ * Official Weavetab GitHub plugin — modular GitHub tools with zero-API options.
5
4
  *
6
- * All GitHub data is fetched over HTTP, so no browser turns are consumed and the
7
- * active tab is never navigated or mutated.
5
+ * All GitHub data is fetched over HTTP or directly via active tab DOM evaluation,
6
+ * so browser turns and rate-limited API quotas are minimized.
8
7
  *
9
8
  * Author: fy2ne
10
9
  * License: MIT
11
10
  */
12
11
  import { definePlugin, defineTool, z } from "@weavetab/sdk";
13
- import { runGithub } from "./github.js";
12
+ import { githubAnalyze, githubRead, githubIssues, githubPullRequest, githubDiff, githubActiveTab, } from "./github.js";
14
13
  export * from "./types.js";
15
- export { runGithub } from "./github.js";
14
+ export * from "./github.js";
16
15
  const DEFAULT_CACHE_TTL_MS = 30 * 60 * 1000;
17
16
  const DEFAULT_MAX_READ_LINES = 200;
18
17
  const DEFAULT_ISSUE_LIMIT = 5;
19
- const TOOL_DESCRIPTION = "Interact with GitHub repositories without burning browser turns. " +
20
- "Analyze a repo's file tree and README, read raw source files, list/search/inspect issues, " +
21
- "and examine pull request details, changed files and comments — all fetched over the GitHub REST API.";
22
- /** Coerce a possibly-string config value into a positive integer. */
23
18
  function positiveInt(value, fallback) {
24
19
  const n = typeof value === "string" ? parseInt(value, 10) : value;
25
20
  return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
26
21
  }
22
+ function buildRunContext(ctx, session) {
23
+ return {
24
+ session: session,
25
+ store: ctx.storage,
26
+ cacheTtlMs: positiveInt(ctx.config?.cacheTtlMs, DEFAULT_CACHE_TTL_MS),
27
+ maxReadLines: positiveInt(ctx.config?.maxReadLines, DEFAULT_MAX_READ_LINES),
28
+ defaultIssueLimit: positiveInt(ctx.config?.defaultIssueLimit, DEFAULT_ISSUE_LIMIT),
29
+ token: ctx.config?.token || process.env.GITHUB_TOKEN,
30
+ };
31
+ }
27
32
  export const githubPlugin = definePlugin({
28
33
  name: "@weavetab/plugin-github",
29
- version: "1.0.0",
30
- description: "Official Weavetab GitHub plugin — analyze repositories, read raw source, inspect issues and pull requests over the GitHub API.",
34
+ version: "1.1.0",
35
+ description: "Official Weavetab GitHub plugin — modular repository analysis, raw source reads, issues, pull requests, raw diffs, and active-tab DOM inspection.",
31
36
  defaultConfig: {
32
37
  cacheTtlMs: DEFAULT_CACHE_TTL_MS,
33
38
  maxReadLines: DEFAULT_MAX_READ_LINES,
34
39
  defaultIssueLimit: DEFAULT_ISSUE_LIMIT,
35
40
  },
36
41
  async onLoad(ctx) {
37
- const cacheTtlMs = positiveInt(ctx.config?.cacheTtlMs, DEFAULT_CACHE_TTL_MS);
38
- const maxReadLines = positiveInt(ctx.config?.maxReadLines, DEFAULT_MAX_READ_LINES);
39
- const defaultIssueLimit = positiveInt(ctx.config?.defaultIssueLimit, DEFAULT_ISSUE_LIMIT);
42
+ // 1. github_analyze
40
43
  ctx.mcp.registerTool(defineTool({
41
- name: "github",
42
- description: TOOL_DESCRIPTION,
44
+ name: "github_analyze",
45
+ description: "Analyze a GitHub repository's metadata, default branch, recursive file tree, and README snippet over the GitHub REST API without burning browser turns.",
43
46
  requiresSession: false,
44
47
  schema: {
45
- action: z
46
- .enum(["analyze", "read", "issues", "pr"])
47
- .describe("The GitHub operation to perform. 'analyze' = repo metadata, file tree & README; 'read' = raw file contents (needs 'paths'); 'issues' = list/search/inspect issues; 'pr' = list/inspect pull requests."),
48
48
  repo: z
49
49
  .string()
50
50
  .optional()
51
- .describe("Target repository as 'owner/repo' or a full github.com URL. Optional — when omitted, it is inferred from the active browser tab."),
51
+ .describe("Target repository as 'owner/repo' or full github.com URL. When omitted, inferred from the active browser tab."),
52
+ },
53
+ handler: async (args, session) => {
54
+ const runCtx = buildRunContext(ctx, session);
55
+ const result = await githubAnalyze(runCtx, args.repo);
56
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
57
+ },
58
+ }));
59
+ // 2. github_read
60
+ ctx.mcp.registerTool(defineTool({
61
+ name: "github_read",
62
+ description: "Fetch raw source files from a GitHub repository directly over HTTP without burning browser turns or API search quota.",
63
+ requiresSession: false,
64
+ schema: {
52
65
  paths: z
53
66
  .array(z.string())
54
67
  .optional()
55
- .describe("READ: file paths relative to the repo root, e.g. ['src/index.ts', 'README.md']."),
68
+ .describe("File paths relative to the repository root, e.g. ['src/index.ts', 'README.md']. When omitted, inferred from active tab if viewing a file blob."),
69
+ repo: z
70
+ .string()
71
+ .optional()
72
+ .describe("Target repository as 'owner/repo' or full github.com URL. Optional — inferred from active tab if omitted."),
73
+ branch: z
74
+ .string()
75
+ .optional()
76
+ .describe("Target branch or tag (defaults to 'main' or cached default branch). Avoids API discovery calls."),
56
77
  line_range: z
57
78
  .array(z.number())
58
79
  .length(2)
59
80
  .optional()
60
- .describe("READ: optional [start, end] 1-indexed line range per file."),
81
+ .describe("Optional [start, end] 1-indexed line range to return per file."),
82
+ },
83
+ handler: async (args, session) => {
84
+ const runCtx = buildRunContext(ctx, session);
85
+ const result = await githubRead(runCtx, args);
86
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
87
+ },
88
+ }));
89
+ // 3. github_issues
90
+ ctx.mcp.registerTool(defineTool({
91
+ name: "github_issues",
92
+ description: "List, search, or inspect issues and comments in a GitHub repository without burning browser turns.",
93
+ requiresSession: false,
94
+ schema: {
95
+ repo: z
96
+ .string()
97
+ .optional()
98
+ .describe("Target repository as 'owner/repo' or full github.com URL."),
61
99
  number: z
62
100
  .number()
63
101
  .optional()
64
- .describe("ISSUES / PR: issue or pull request number to fetch full detail and comments."),
102
+ .describe("Specific issue number to fetch full details and timeline comments for."),
65
103
  search: z
66
104
  .string()
67
105
  .optional()
68
- .describe("ISSUES: free-text query to search issues in the target repository."),
69
- labels: z.array(z.string()).optional().describe("ISSUES: restrict results to these labels."),
106
+ .describe("Free-text query to search issues in the repository."),
107
+ labels: z.array(z.string()).optional().describe("Restrict results to these labels."),
108
+ state: z
109
+ .enum(["open", "closed", "all"])
110
+ .optional()
111
+ .describe("Filter by issue state (default 'open')."),
112
+ limit: z
113
+ .number()
114
+ .optional()
115
+ .describe("Maximum number of issues to return (default 5)."),
116
+ },
117
+ handler: async (args, session) => {
118
+ const runCtx = buildRunContext(ctx, session);
119
+ const result = await githubIssues(runCtx, args);
120
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
121
+ },
122
+ }));
123
+ // 4. github_pull_request
124
+ ctx.mcp.registerTool(defineTool({
125
+ name: "github_pull_request",
126
+ description: "Examine pull request details, changed files, diff stats, and comments, or list recent pull requests in a GitHub repository without burning browser turns.",
127
+ requiresSession: false,
128
+ schema: {
129
+ repo: z
130
+ .string()
131
+ .optional()
132
+ .describe("Target repository as 'owner/repo' or full github.com URL."),
133
+ number: z
134
+ .number()
135
+ .optional()
136
+ .describe("Pull request number to fetch full detail, changed files, and comments for."),
70
137
  state: z
71
138
  .enum(["open", "closed", "all"])
72
139
  .optional()
73
- .describe("ISSUES / PR: filter by state."),
140
+ .describe("Filter pull requests by state (default 'all' when listing)."),
74
141
  limit: z
75
142
  .number()
76
143
  .optional()
77
- .describe(`ISSUES / PR: max items to return when listing/searching (default ${DEFAULT_ISSUE_LIMIT} for issues, 10 for pull requests).`),
144
+ .describe("Maximum pull requests to return when listing (default 10)."),
145
+ },
146
+ handler: async (args, session) => {
147
+ const runCtx = buildRunContext(ctx, session);
148
+ const result = await githubPullRequest(runCtx, args);
149
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
150
+ },
151
+ }));
152
+ // 5. github_diff (Zero API Calls)
153
+ ctx.mcp.registerTool(defineTool({
154
+ name: "github_diff",
155
+ description: "Fetch raw git diffs or patches for pull requests, commits, or branch comparisons directly from github.com without consuming REST API rate limits.",
156
+ requiresSession: false,
157
+ schema: {
158
+ repo: z
159
+ .string()
160
+ .optional()
161
+ .describe("Target repository as 'owner/repo' or full github.com URL."),
162
+ pull_number: z.number().optional().describe("Pull request number to fetch the diff or patch for."),
163
+ commit_sha: z.string().optional().describe("Commit SHA to fetch the diff or patch for."),
164
+ base: z.string().optional().describe("Base branch/commit for compare diff (requires 'head')."),
165
+ head: z.string().optional().describe("Head branch/commit for compare diff (requires 'base')."),
166
+ format: z
167
+ .enum(["diff", "patch"])
168
+ .optional()
169
+ .describe("Output format: 'diff' (unified diff) or 'patch' (git patch with metadata). Default 'diff'."),
170
+ },
171
+ handler: async (args, session) => {
172
+ const runCtx = buildRunContext(ctx, session);
173
+ const result = await githubDiff(runCtx, args);
174
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
175
+ },
176
+ }));
177
+ // 6. github_active_tab (Zero API Calls, works on private repos via CDP)
178
+ ctx.mcp.registerTool(defineTool({
179
+ name: "github_active_tab",
180
+ description: "Inspect the active browser tab on github.com via local CDP evaluation. Extracts repository overview, open PR/issue discussion, or file content directly from the browser DOM with ZERO API calls. Works on private repositories you are logged into.",
181
+ requiresSession: true,
182
+ schema: {
183
+ scope: z
184
+ .enum(["auto", "summary", "files", "comments", "raw"])
185
+ .optional()
186
+ .describe("Scope of data extraction from the page DOM (default 'auto')."),
78
187
  },
79
188
  handler: async (args, session) => {
80
- const result = await runGithub({
81
- session: session,
82
- store: ctx.storage,
83
- cacheTtlMs,
84
- maxReadLines,
85
- defaultIssueLimit,
86
- }, args);
87
- return {
88
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
89
- };
189
+ const runCtx = buildRunContext(ctx, session);
190
+ const result = await githubActiveTab(runCtx, args);
191
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
90
192
  },
91
193
  }));
92
- ctx.logger?.info(`@weavetab/plugin-github ready — tool "github" registered (cacheTtlMs=${cacheTtlMs}, maxReadLines=${maxReadLines}, defaultIssueLimit=${defaultIssueLimit}).`);
194
+ ctx.logger?.info(`@weavetab/plugin-github ready — registered 6 tools: github_analyze, github_read, github_issues, github_pull_request, github_diff, github_active_tab.`);
93
195
  },
94
196
  async onUnload(ctx) {
95
197
  ctx.logger?.info("@weavetab/plugin-github unloaded.");
package/dist/issues.d.ts CHANGED
@@ -1,15 +1,6 @@
1
- import type { GithubRunContext } from "./types.js";
2
- interface IssueArgs {
3
- repo?: string;
4
- search?: string;
5
- number?: number;
6
- labels?: string[];
7
- state?: "open" | "closed" | "all";
8
- limit?: number;
9
- }
1
+ import type { GithubRunContext, GithubIssuesArgs } from "./types.js";
10
2
  /**
11
3
  * `issues` action — inspect a single issue (with comments) when `number` is
12
- * given, otherwise list or search issues in the target repository.
4
+ * given or open in the active tab, otherwise list or search issues.
13
5
  */
14
- export declare function githubIssues(ctx: GithubRunContext, args: IssueArgs): Promise<any>;
15
- export {};
6
+ export declare function githubIssues(ctx: GithubRunContext, args?: GithubIssuesArgs): Promise<any>;
package/dist/issues.js CHANGED
@@ -1,25 +1,23 @@
1
- import { getRepoContext, isGithubError } from "./repoContext.js";
2
- import { canMakeApiCall, handleApiResponse } from "./rateLimiter.js";
3
- const API_HEADERS = {
4
- Accept: "application/vnd.github.v3+json",
5
- "User-Agent": "Weavetab-GitHub-Plugin",
6
- };
1
+ import { getDetailedRepoContext, isGithubError } from "./repoContext.js";
2
+ import { canMakeApiCall, handleApiResponse, getApiHeaders } from "./rateLimiter.js";
7
3
  /**
8
4
  * `issues` action — inspect a single issue (with comments) when `number` is
9
- * given, otherwise list or search issues in the target repository.
5
+ * given or open in the active tab, otherwise list or search issues.
10
6
  */
11
- export async function githubIssues(ctx, args) {
12
- const context = await getRepoContext(ctx.session, args.repo);
7
+ export async function githubIssues(ctx, args = {}) {
8
+ const context = await getDetailedRepoContext(ctx.session, args.repo);
13
9
  if (isGithubError(context))
14
10
  return context;
15
11
  const { owner, repo } = context;
12
+ const targetNumber = typeof args.number === "number" ? args.number : context.issueNumber;
16
13
  const limit = args.limit || ctx.defaultIssueLimit;
14
+ const apiHeaders = getApiHeaders(ctx.token);
17
15
  const rateLimitCheck = canMakeApiCall();
18
16
  if (isGithubError(rateLimitCheck))
19
17
  return rateLimitCheck;
20
18
  // Single issue + comments
21
- if (args.number) {
22
- const issueRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${args.number}`, { headers: API_HEADERS });
19
+ if (targetNumber) {
20
+ const issueRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${targetNumber}`, { headers: apiHeaders });
23
21
  const handledIssueRes = handleApiResponse(issueRes);
24
22
  if (isGithubError(handledIssueRes))
25
23
  return handledIssueRes;
@@ -27,7 +25,7 @@ export async function githubIssues(ctx, args) {
27
25
  return {
28
26
  error: "FETCH_FAILED",
29
27
  status: issueRes.status,
30
- message: `Failed to fetch issue #${args.number}`,
28
+ message: `Failed to fetch issue #${targetNumber}`,
31
29
  };
32
30
  }
33
31
  const issueData = await issueRes.json();
@@ -35,7 +33,7 @@ export async function githubIssues(ctx, args) {
35
33
  if (issueData.comments > 0) {
36
34
  const rateLimitCheck2 = canMakeApiCall();
37
35
  if (!isGithubError(rateLimitCheck2)) {
38
- const commentsRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${args.number}/comments`, { headers: API_HEADERS });
36
+ const commentsRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${targetNumber}/comments`, { headers: apiHeaders });
39
37
  const handledCommentsRes = handleApiResponse(commentsRes);
40
38
  if (!isGithubError(handledCommentsRes) && commentsRes.ok) {
41
39
  const commentsData = await commentsRes.json();
@@ -73,7 +71,7 @@ export async function githubIssues(ctx, args) {
73
71
  args.labels.forEach((l) => (q += ` label:"${l}"`));
74
72
  urlStr = `https://api.github.com/search/issues?q=${encodeURIComponent(q)}&per_page=${limit}`;
75
73
  }
76
- const listRes = await fetch(urlStr, { headers: API_HEADERS });
74
+ const listRes = await fetch(urlStr, { headers: apiHeaders });
77
75
  const handledListRes = handleApiResponse(listRes);
78
76
  if (isGithubError(handledListRes))
79
77
  return handledListRes;
package/dist/pr.d.ts CHANGED
@@ -1,13 +1,7 @@
1
- import type { GithubRunContext } from "./types.js";
2
- interface PrArgs {
3
- repo?: string;
4
- number?: number;
5
- state?: "open" | "closed" | "all";
6
- limit?: number;
7
- }
1
+ import type { GithubRunContext, GithubPullRequestArgs } from "./types.js";
8
2
  /**
9
- * `pr` action — inspect a single pull request (changed files, diff stats and
10
- * comments) when `number` is given, otherwise list recent pull requests.
3
+ * `pull_request` action — inspect a single pull request (changed files, diff stats and
4
+ * comments) when `number` is given or active in tab, otherwise list recent pull requests.
11
5
  */
12
- export declare function githubGetPr(ctx: GithubRunContext, args: PrArgs): Promise<any>;
13
- export {};
6
+ export declare function githubPullRequest(ctx: GithubRunContext, args?: GithubPullRequestArgs): Promise<any>;
7
+ export declare const githubGetPr: typeof githubPullRequest;
package/dist/pr.js CHANGED
@@ -1,25 +1,23 @@
1
- import { getRepoContext, isGithubError } from "./repoContext.js";
2
- import { canMakeApiCall, handleApiResponse } from "./rateLimiter.js";
1
+ import { getDetailedRepoContext, isGithubError } from "./repoContext.js";
2
+ import { canMakeApiCall, handleApiResponse, getApiHeaders } from "./rateLimiter.js";
3
3
  const DEFAULT_PR_LIMIT = 10;
4
- const API_HEADERS = {
5
- Accept: "application/vnd.github.v3+json",
6
- "User-Agent": "Weavetab-GitHub-Plugin",
7
- };
8
4
  /**
9
- * `pr` action — inspect a single pull request (changed files, diff stats and
10
- * comments) when `number` is given, otherwise list recent pull requests.
5
+ * `pull_request` action — inspect a single pull request (changed files, diff stats and
6
+ * comments) when `number` is given or active in tab, otherwise list recent pull requests.
11
7
  */
12
- export async function githubGetPr(ctx, args) {
13
- const context = await getRepoContext(ctx.session, args.repo);
8
+ export async function githubPullRequest(ctx, args = {}) {
9
+ const context = await getDetailedRepoContext(ctx.session, args.repo);
14
10
  if (isGithubError(context))
15
11
  return context;
16
12
  const { owner, repo } = context;
13
+ const targetNumber = typeof args.number === "number" ? args.number : context.pullNumber;
14
+ const apiHeaders = getApiHeaders(ctx.token);
17
15
  const rateLimitCheck = canMakeApiCall();
18
16
  if (isGithubError(rateLimitCheck))
19
17
  return rateLimitCheck;
20
- if (args.number) {
21
- const prRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${args.number}`, {
22
- headers: API_HEADERS,
18
+ if (targetNumber) {
19
+ const prRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${targetNumber}`, {
20
+ headers: apiHeaders,
23
21
  });
24
22
  const handledPrRes = handleApiResponse(prRes);
25
23
  if (isGithubError(handledPrRes))
@@ -28,14 +26,14 @@ export async function githubGetPr(ctx, args) {
28
26
  return {
29
27
  error: "FETCH_FAILED",
30
28
  status: prRes.status,
31
- message: `Failed to fetch PR #${args.number}`,
29
+ message: `Failed to fetch PR #${targetNumber}`,
32
30
  };
33
31
  }
34
32
  const prData = await prRes.json();
35
33
  let files_changed = [];
36
34
  const rateLimitCheck2 = canMakeApiCall();
37
35
  if (!isGithubError(rateLimitCheck2)) {
38
- const filesRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${args.number}/files`, { headers: API_HEADERS });
36
+ const filesRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${targetNumber}/files`, { headers: apiHeaders });
39
37
  const handledFilesRes = handleApiResponse(filesRes);
40
38
  if (!isGithubError(handledFilesRes) && filesRes.ok) {
41
39
  const filesData = await filesRes.json();
@@ -46,7 +44,7 @@ export async function githubGetPr(ctx, args) {
46
44
  if (prData.comments > 0) {
47
45
  const rateLimitCheck3 = canMakeApiCall();
48
46
  if (!isGithubError(rateLimitCheck3)) {
49
- const commentsRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${args.number}/comments`, { headers: API_HEADERS });
47
+ const commentsRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${targetNumber}/comments`, { headers: apiHeaders });
50
48
  const handledCommentsRes = handleApiResponse(commentsRes);
51
49
  if (!isGithubError(handledCommentsRes) && commentsRes.ok) {
52
50
  const commentsData = await commentsRes.json();
@@ -78,7 +76,7 @@ export async function githubGetPr(ctx, args) {
78
76
  // List recent pull requests
79
77
  const limit = args.limit && args.limit > 0 ? Math.min(args.limit, 100) : DEFAULT_PR_LIMIT;
80
78
  const state = args.state || "all";
81
- const listRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?state=${state}&per_page=${limit}`, { headers: API_HEADERS });
79
+ const listRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?state=${state}&per_page=${limit}`, { headers: apiHeaders });
82
80
  const handledListRes = handleApiResponse(listRes);
83
81
  if (isGithubError(handledListRes))
84
82
  return handledListRes;
@@ -95,3 +93,4 @@ export async function githubGetPr(ctx, args) {
95
93
  updated_at: pr.updated_at,
96
94
  }));
97
95
  }
96
+ export const githubGetPr = githubPullRequest;
@@ -1,8 +1,14 @@
1
1
  import type { GithubError } from "./types.js";
2
+ export declare function getApiHeaders(token?: string): Record<string, string>;
2
3
  /**
3
4
  * Guard that refuses new API calls once the remaining quota drops below a safety
4
5
  * threshold, so we never trip GitHub's hard rate limit mid-flow.
5
6
  */
6
7
  export declare function canMakeApiCall(): true | GithubError;
7
8
  export declare function recordApiCall(headers: Headers): void;
9
+ export declare function getRateLimitStatus(): {
10
+ remaining: number;
11
+ resetAt: number | null;
12
+ limit: number;
13
+ };
8
14
  export declare function handleApiResponse(response: Response): Response | GithubError;
@@ -1,5 +1,16 @@
1
1
  const SAFETY_THRESHOLD = 5;
2
2
  const state = { remaining: 60, resetAt: null };
3
+ export function getApiHeaders(token) {
4
+ const headers = {
5
+ Accept: "application/vnd.github.v3+json",
6
+ "User-Agent": "Weavetab-GitHub-Plugin",
7
+ };
8
+ const tok = token || process.env.GITHUB_TOKEN;
9
+ if (tok) {
10
+ headers.Authorization = `Bearer ${tok.trim()}`;
11
+ }
12
+ return headers;
13
+ }
3
14
  /**
4
15
  * Guard that refuses new API calls once the remaining quota drops below a safety
5
16
  * threshold, so we never trip GitHub's hard rate limit mid-flow.
@@ -7,8 +18,8 @@ const state = { remaining: 60, resetAt: null };
7
18
  export function canMakeApiCall() {
8
19
  const nowSec = Math.floor(Date.now() / 1000);
9
20
  if (state.resetAt && nowSec >= state.resetAt) {
10
- // We've passed the reset window, assume quota is restored.
11
- state.remaining = 60;
21
+ const hasToken = Boolean(process.env.GITHUB_TOKEN);
22
+ state.remaining = hasToken ? 5000 : 60;
12
23
  state.resetAt = null;
13
24
  }
14
25
  if (state.remaining < SAFETY_THRESHOLD) {
@@ -32,6 +43,14 @@ export function recordApiCall(headers) {
32
43
  state.resetAt = parseInt(resetStr, 10) || state.resetAt;
33
44
  }
34
45
  }
46
+ export function getRateLimitStatus() {
47
+ const hasToken = Boolean(process.env.GITHUB_TOKEN);
48
+ return {
49
+ remaining: state.remaining,
50
+ resetAt: state.resetAt,
51
+ limit: hasToken ? 5000 : 60,
52
+ };
53
+ }
35
54
  export function handleApiResponse(response) {
36
55
  recordApiCall(response.headers);
37
56
  if (response.status === 403 || response.status === 429) {
package/dist/read.d.ts CHANGED
@@ -1,14 +1,8 @@
1
- import type { GithubRunContext } from "./types.js";
2
- interface ReadArgs {
3
- repo?: string;
4
- paths: string[];
5
- line_range?: number[];
6
- }
1
+ import type { GithubRunContext, GithubReadArgs } from "./types.js";
7
2
  /**
8
- * `read` action — fetch raw file contents from the default branch.
3
+ * `read` action — fetch raw file contents from raw.githubusercontent.com.
9
4
  *
10
- * Ensures an analysis (and thus `default_branch` + `file_tree`) exists first,
11
- * then fetches each requested path in parallel over raw.githubusercontent.com.
5
+ * If a branch is explicitly provided, cached, or inferred from the active tab,
6
+ * reads directly over HTTP without consuming any GitHub API rate-limited quota.
12
7
  */
13
- export declare function githubRead(ctx: GithubRunContext, args: ReadArgs): Promise<any>;
14
- export {};
8
+ export declare function githubRead(ctx: GithubRunContext, args: GithubReadArgs): Promise<any>;
package/dist/read.js CHANGED
@@ -1,60 +1,104 @@
1
- import { getRepoContext, isGithubError } from "./repoContext.js";
1
+ import { getDetailedRepoContext, isGithubError } from "./repoContext.js";
2
2
  import { loadRepoCache } from "./cache.js";
3
- import { githubAnalyze } from "./analyze.js";
3
+ const BINARY_EXTENSIONS = new Set([
4
+ "png", "jpg", "jpeg", "gif", "webp", "ico", "bmp", "tiff",
5
+ "pdf", "zip", "tar", "gz", "tgz", "7z", "rar",
6
+ "wasm", "exe", "dll", "so", "dylib",
7
+ "mp3", "mp4", "wav", "avi", "mov", "webm",
8
+ "woff", "woff2", "ttf", "eot", "otf",
9
+ ]);
10
+ function isBinaryPath(filePath) {
11
+ const ext = filePath.split(".").pop()?.toLowerCase();
12
+ return ext ? BINARY_EXTENSIONS.has(ext) : false;
13
+ }
4
14
  /**
5
- * `read` action — fetch raw file contents from the default branch.
15
+ * `read` action — fetch raw file contents from raw.githubusercontent.com.
6
16
  *
7
- * Ensures an analysis (and thus `default_branch` + `file_tree`) exists first,
8
- * then fetches each requested path in parallel over raw.githubusercontent.com.
17
+ * If a branch is explicitly provided, cached, or inferred from the active tab,
18
+ * reads directly over HTTP without consuming any GitHub API rate-limited quota.
9
19
  */
10
20
  export async function githubRead(ctx, args) {
11
- const context = await getRepoContext(ctx.session, args.repo);
21
+ const context = await getDetailedRepoContext(ctx.session, args.repo);
12
22
  if (isGithubError(context))
13
23
  return context;
14
24
  const { owner, repo } = context;
15
- let cached = await loadRepoCache(owner, repo, ctx.store, ctx.cacheTtlMs);
16
- if (!cached) {
17
- const analyzed = await githubAnalyze(ctx, args.repo);
18
- if (isGithubError(analyzed))
19
- return analyzed;
20
- cached = analyzed;
25
+ // Resolve target paths (explicit paths win, fallback to active tab file)
26
+ let rawPaths = Array.isArray(args.paths) ? args.paths.filter((p) => typeof p === "string" && p.trim()) : [];
27
+ if (rawPaths.length === 0 && context.filePath) {
28
+ rawPaths = [context.filePath];
29
+ }
30
+ if (rawPaths.length === 0) {
31
+ return {
32
+ error: "MISSING_PATHS",
33
+ message: "github_read requires a 'paths' array (or open a source file blob in the active browser tab).",
34
+ };
35
+ }
36
+ let branch = args.branch || context.branch;
37
+ let file_tree = null;
38
+ if (!branch) {
39
+ const cached = await loadRepoCache(owner, repo, ctx.store, ctx.cacheTtlMs);
40
+ if (cached) {
41
+ branch = cached.default_branch;
42
+ file_tree = cached.file_tree;
43
+ }
44
+ else {
45
+ branch = "main";
46
+ }
21
47
  }
22
- const default_branch = cached.default_branch;
23
- const file_tree = cached.file_tree;
24
48
  const results = [];
25
49
  const warnings = [];
26
- const fetchPromises = args.paths.map(async (rawPath) => {
50
+ const fetchPromises = rawPaths.map(async (rawPath) => {
27
51
  const path = rawPath.replace(/\\/g, "/").replace(/^\/+/, "");
28
52
  if (path.includes("..") || path.includes("\0")) {
29
53
  return { path: rawPath, error: "INVALID_PATH", message: "Path traversal or null bytes detected." };
30
54
  }
31
- if (!file_tree.some((entry) => entry.path === path)) {
55
+ if (file_tree && !file_tree.some((entry) => entry.path === path)) {
32
56
  warnings.push(`Path '${path}' was not found in the cached file tree. Branch contents may differ.`);
33
57
  }
34
- const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${default_branch}/${path}`;
58
+ if (isBinaryPath(path)) {
59
+ return {
60
+ path,
61
+ branch: branch,
62
+ binary: true,
63
+ message: "Binary file omitted from text read.",
64
+ };
65
+ }
66
+ let targetBranch = branch;
67
+ let rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${targetBranch}/${path}`;
35
68
  try {
36
- const controller = new AbortController();
37
- const timeout = setTimeout(() => controller.abort(), 10_000);
38
- const res = await fetch(rawUrl, { signal: controller.signal });
39
- clearTimeout(timeout);
69
+ let res = await fetchWithTimeout(rawUrl, 10_000);
70
+ // If branch wasn't pinned and "main" failed, try "master"
71
+ if (!res.ok && res.status === 404 && !args.branch && !context.branch && branch === "main") {
72
+ const altUrl = `https://raw.githubusercontent.com/${owner}/${repo}/master/${path}`;
73
+ const altRes = await fetchWithTimeout(altUrl, 10_000);
74
+ if (altRes.ok) {
75
+ res = altRes;
76
+ targetBranch = "master";
77
+ }
78
+ }
40
79
  if (!res.ok) {
41
80
  return { path, error: "FETCH_FAILED", status: res.status, message: res.statusText };
42
81
  }
43
- const lines = (await res.text()).split("\n");
82
+ const text = await res.text();
83
+ const lines = text.split("\n");
44
84
  const total_lines = lines.length;
45
- let returnedContent = lines.join("\n");
85
+ let returnedContent = text;
46
86
  let truncated = false;
47
87
  if (Array.isArray(args.line_range) && args.line_range.length === 2) {
48
- const start = Math.max(0, args.line_range[0] - 1); // 1-indexed -> 0-indexed
49
- const end = Math.min(total_lines, args.line_range[1]);
50
- returnedContent = lines.slice(start, end).join("\n");
51
- truncated = start > 0 || end < total_lines;
88
+ let start = Math.floor(args.line_range[0]);
89
+ let end = Math.floor(args.line_range[1]);
90
+ if (start > end)
91
+ [start, end] = [end, start];
92
+ start = Math.max(1, start);
93
+ end = Math.min(total_lines, end);
94
+ returnedContent = lines.slice(start - 1, end).join("\n");
95
+ truncated = start > 1 || end < total_lines;
52
96
  }
53
97
  else if (total_lines > ctx.maxReadLines) {
54
98
  returnedContent = lines.slice(0, ctx.maxReadLines).join("\n");
55
99
  truncated = true;
56
100
  }
57
- return { path, total_lines, truncated, content: returnedContent };
101
+ return { path, branch: targetBranch, total_lines, truncated, content: returnedContent };
58
102
  }
59
103
  catch (err) {
60
104
  return { path, error: "FETCH_ERROR", message: err?.message };
@@ -67,3 +111,13 @@ export async function githubRead(ctx, args) {
67
111
  files: results,
68
112
  };
69
113
  }
114
+ async function fetchWithTimeout(url, timeoutMs) {
115
+ const controller = new AbortController();
116
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
117
+ try {
118
+ return await fetch(url, { signal: controller.signal });
119
+ }
120
+ finally {
121
+ clearTimeout(timer);
122
+ }
123
+ }