santree 0.5.3 → 0.5.5

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 (80) hide show
  1. package/README.md +156 -46
  2. package/dist/commands/dashboard.d.ts +1 -1
  3. package/dist/commands/dashboard.js +22 -18
  4. package/dist/commands/doctor.js +97 -76
  5. package/dist/commands/github/auth.d.ts +2 -0
  6. package/dist/commands/github/auth.js +56 -0
  7. package/dist/commands/github/index.d.ts +1 -0
  8. package/dist/commands/github/index.js +1 -0
  9. package/dist/commands/helpers/english-tutor/index.d.ts +1 -0
  10. package/dist/commands/helpers/english-tutor/index.js +1 -0
  11. package/dist/commands/helpers/english-tutor/install.d.ts +8 -0
  12. package/dist/commands/helpers/english-tutor/install.js +24 -0
  13. package/dist/commands/helpers/english-tutor/prompt.d.ts +2 -0
  14. package/dist/commands/helpers/english-tutor/prompt.js +16 -0
  15. package/dist/commands/helpers/english-tutor/session-start.d.ts +2 -0
  16. package/dist/commands/helpers/english-tutor/session-start.js +34 -0
  17. package/dist/commands/helpers/english-tutor/uninstall.d.ts +2 -0
  18. package/dist/commands/helpers/english-tutor/uninstall.js +15 -0
  19. package/dist/commands/helpers/template.d.ts +1 -0
  20. package/dist/commands/helpers/template.js +13 -10
  21. package/dist/commands/issue/index.d.ts +1 -0
  22. package/dist/commands/issue/index.js +1 -0
  23. package/dist/commands/issue/open.d.ts +2 -0
  24. package/dist/commands/{linear → issue}/open.js +13 -11
  25. package/dist/commands/issue/switch.d.ts +11 -0
  26. package/dist/commands/issue/switch.js +38 -0
  27. package/dist/commands/linear/auth.js +23 -10
  28. package/dist/commands/linear/switch.js +7 -3
  29. package/dist/commands/pr/create.js +7 -5
  30. package/dist/commands/worktree/create.js +4 -6
  31. package/dist/commands/worktree/work.js +1 -1
  32. package/dist/lib/ai.d.ts +8 -6
  33. package/dist/lib/ai.js +29 -15
  34. package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
  35. package/dist/lib/dashboard/DetailPanel.js +6 -3
  36. package/dist/lib/dashboard/data.js +17 -9
  37. package/dist/lib/dashboard/types.d.ts +3 -16
  38. package/dist/lib/english-tutor.d.ts +13 -0
  39. package/dist/lib/english-tutor.js +125 -0
  40. package/dist/lib/git.d.ts +16 -33
  41. package/dist/lib/git.js +20 -74
  42. package/dist/lib/metadata.d.ts +3 -0
  43. package/dist/lib/metadata.js +27 -0
  44. package/dist/lib/multiplexer/cmux.js +1 -1
  45. package/dist/lib/multiplexer/index.js +5 -12
  46. package/dist/lib/multiplexer/types.d.ts +1 -1
  47. package/dist/lib/prompts.d.ts +4 -3
  48. package/dist/lib/prompts.js +4 -3
  49. package/dist/lib/session-signal.d.ts +2 -3
  50. package/dist/lib/session-signal.js +3 -29
  51. package/dist/lib/trackers/auth-store.d.ts +16 -0
  52. package/dist/lib/trackers/auth-store.js +57 -0
  53. package/dist/lib/trackers/config.d.ts +8 -0
  54. package/dist/lib/trackers/config.js +21 -0
  55. package/dist/lib/trackers/github/api.d.ts +3 -0
  56. package/dist/lib/trackers/github/api.js +90 -0
  57. package/dist/lib/trackers/github/auth.d.ts +5 -0
  58. package/dist/lib/trackers/github/auth.js +27 -0
  59. package/dist/lib/trackers/github/images.d.ts +2 -0
  60. package/dist/lib/trackers/github/images.js +42 -0
  61. package/dist/lib/trackers/github/index.d.ts +2 -0
  62. package/dist/lib/trackers/github/index.js +78 -0
  63. package/dist/lib/trackers/index.d.ts +12 -0
  64. package/dist/lib/trackers/index.js +34 -0
  65. package/dist/lib/trackers/linear/api.d.ts +4 -0
  66. package/dist/lib/trackers/linear/api.js +128 -0
  67. package/dist/lib/trackers/linear/auth.d.ts +11 -0
  68. package/dist/lib/trackers/linear/auth.js +206 -0
  69. package/dist/lib/trackers/linear/images.d.ts +2 -0
  70. package/dist/lib/trackers/linear/images.js +44 -0
  71. package/dist/lib/trackers/linear/index.d.ts +3 -0
  72. package/dist/lib/trackers/linear/index.js +100 -0
  73. package/dist/lib/trackers/types.d.ts +52 -0
  74. package/dist/lib/trackers/types.js +1 -0
  75. package/package.json +1 -1
  76. package/prompts/english-tutor-prompt.njk +15 -0
  77. package/prompts/ticket.njk +3 -3
  78. package/dist/commands/linear/open.d.ts +0 -2
  79. package/dist/lib/linear.d.ts +0 -83
  80. package/dist/lib/linear.js +0 -482
@@ -1,482 +0,0 @@
1
- import * as http from "http";
2
- import * as crypto from "crypto";
3
- import * as fs from "fs";
4
- import * as path from "path";
5
- import * as os from "os";
6
- import { exec } from "child_process";
7
- import { getRepoLinearOrg } from "./git.js";
8
- // ── Constants ──────────────────────────────────────────────────────────
9
- const CLIENT_ID = "4be2738749371d7d3401061aabe2d11b";
10
- const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
11
- const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
12
- const LINEAR_REVOKE_URL = "https://api.linear.app/oauth/revoke";
13
- const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
14
- const OAUTH_PORT = 8420;
15
- const REDIRECT_URI = `http://localhost:${OAUTH_PORT}`;
16
- const CONFIG_DIR = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
17
- const AUTH_FILE_PATH = path.join(CONFIG_DIR, "santree", "auth.json");
18
- // ── Auth Store ─────────────────────────────────────────────────────────
19
- export function readAuthStore() {
20
- if (!fs.existsSync(AUTH_FILE_PATH))
21
- return {};
22
- try {
23
- return JSON.parse(fs.readFileSync(AUTH_FILE_PATH, "utf-8"));
24
- }
25
- catch {
26
- return {};
27
- }
28
- }
29
- function writeAuthStore(store) {
30
- const dir = path.dirname(AUTH_FILE_PATH);
31
- if (!fs.existsSync(dir)) {
32
- fs.mkdirSync(dir, { recursive: true });
33
- }
34
- fs.writeFileSync(AUTH_FILE_PATH, JSON.stringify(store, null, 2) + "\n", {
35
- mode: 0o600,
36
- });
37
- }
38
- // ── PKCE Helpers ───────────────────────────────────────────────────────
39
- function generateCodeVerifier() {
40
- return crypto.randomBytes(32).toString("base64url");
41
- }
42
- function generateCodeChallenge(verifier) {
43
- return crypto.createHash("sha256").update(verifier).digest("base64url");
44
- }
45
- // ── OAuth Flow ─────────────────────────────────────────────────────────
46
- /**
47
- * Run the full OAuth PKCE flow:
48
- * 1. Start a temp HTTP server on an ephemeral port
49
- * 2. Open browser to Linear authorize URL
50
- * 3. Wait for callback with auth code
51
- * 4. Exchange code for tokens
52
- * 5. Fetch org info
53
- * 6. Store tokens
54
- * Returns the org slug on success, null on failure.
55
- */
56
- export async function startOAuthFlow() {
57
- const codeVerifier = generateCodeVerifier();
58
- const codeChallenge = generateCodeChallenge(codeVerifier);
59
- const state = crypto.randomBytes(16).toString("hex");
60
- return new Promise((resolve) => {
61
- let handled = false;
62
- const server = http.createServer(async (req, res) => {
63
- const url = new URL(req.url, `http://localhost`);
64
- const code = url.searchParams.get("code");
65
- const returnedState = url.searchParams.get("state");
66
- if (!code || returnedState !== state) {
67
- // Ignore spurious requests (favicon, etc.)
68
- res.writeHead(404);
69
- res.end();
70
- return;
71
- }
72
- if (handled) {
73
- res.writeHead(200);
74
- res.end();
75
- return;
76
- }
77
- handled = true;
78
- // Send success page immediately
79
- res.writeHead(200, { "Content-Type": "text/html" });
80
- res.end("<html><body><h2>Authentication successful!</h2><p>You can close this tab.</p></body></html>");
81
- try {
82
- // Exchange code for tokens
83
- const tokens = await exchangeCode(code, REDIRECT_URI, codeVerifier);
84
- // Fetch org info
85
- const orgInfo = await fetchViewerOrg(tokens.access_token);
86
- if (!orgInfo) {
87
- server.close();
88
- resolve(null);
89
- return;
90
- }
91
- // Store tokens
92
- const store = readAuthStore();
93
- store[orgInfo.urlKey] = {
94
- access_token: tokens.access_token,
95
- refresh_token: tokens.refresh_token,
96
- expires_at: tokens.expires_at,
97
- org_name: orgInfo.name,
98
- };
99
- writeAuthStore(store);
100
- server.close();
101
- resolve({ orgSlug: orgInfo.urlKey, orgName: orgInfo.name });
102
- }
103
- catch {
104
- server.close();
105
- resolve(null);
106
- }
107
- });
108
- server.listen(OAUTH_PORT, () => {
109
- const params = new URLSearchParams({
110
- client_id: CLIENT_ID,
111
- redirect_uri: REDIRECT_URI,
112
- response_type: "code",
113
- scope: "read",
114
- state,
115
- code_challenge: codeChallenge,
116
- code_challenge_method: "S256",
117
- });
118
- const authUrl = `${LINEAR_AUTHORIZE_URL}?${params.toString()}`;
119
- // Try to open browser, fall back to printing URL
120
- const openCmd = process.platform === "darwin"
121
- ? "open"
122
- : process.platform === "win32"
123
- ? "start"
124
- : "xdg-open";
125
- exec(`${openCmd} "${authUrl}"`, (err) => {
126
- if (err) {
127
- console.error(`\nCouldn't open browser automatically. Open this URL manually:\n${authUrl}\n`);
128
- }
129
- });
130
- });
131
- // Timeout after 2 minutes
132
- setTimeout(() => {
133
- server.close();
134
- resolve(null);
135
- }, 120_000);
136
- });
137
- }
138
- async function exchangeCode(code, redirectUri, codeVerifier) {
139
- const res = await fetch(LINEAR_TOKEN_URL, {
140
- method: "POST",
141
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
142
- body: new URLSearchParams({
143
- grant_type: "authorization_code",
144
- client_id: CLIENT_ID,
145
- code,
146
- redirect_uri: redirectUri,
147
- code_verifier: codeVerifier,
148
- }),
149
- });
150
- if (!res.ok) {
151
- throw new Error(`Token exchange failed: ${res.status}`);
152
- }
153
- const data = (await res.json());
154
- return {
155
- access_token: data.access_token,
156
- refresh_token: data.refresh_token,
157
- expires_at: Date.now() + data.expires_in * 1000,
158
- };
159
- }
160
- async function fetchViewerOrg(accessToken) {
161
- const result = await graphqlQuery(`query { viewer { organization { urlKey name } } }`, {}, accessToken);
162
- if (!result?.viewer?.organization)
163
- return null;
164
- return result.viewer.organization;
165
- }
166
- // ── Token Management ───────────────────────────────────────────────────
167
- function isTokenExpired(tokens) {
168
- // 5-minute buffer
169
- return Date.now() >= tokens.expires_at - 5 * 60 * 1000;
170
- }
171
- async function refreshTokens(orgSlug, tokens) {
172
- try {
173
- const res = await fetch(LINEAR_TOKEN_URL, {
174
- method: "POST",
175
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
176
- body: new URLSearchParams({
177
- grant_type: "refresh_token",
178
- client_id: CLIENT_ID,
179
- refresh_token: tokens.refresh_token,
180
- }),
181
- });
182
- if (!res.ok)
183
- return null;
184
- const data = (await res.json());
185
- const updated = {
186
- access_token: data.access_token,
187
- refresh_token: data.refresh_token,
188
- expires_at: Date.now() + data.expires_in * 1000,
189
- org_name: tokens.org_name,
190
- };
191
- // Persist refreshed tokens
192
- const store = readAuthStore();
193
- store[orgSlug] = updated;
194
- writeAuthStore(store);
195
- return updated;
196
- }
197
- catch {
198
- return null;
199
- }
200
- }
201
- export async function revokeTokens(orgSlug) {
202
- const store = readAuthStore();
203
- const tokens = store[orgSlug];
204
- if (!tokens)
205
- return false;
206
- try {
207
- await fetch(LINEAR_REVOKE_URL, {
208
- method: "POST",
209
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
210
- body: new URLSearchParams({
211
- client_id: CLIENT_ID,
212
- token: tokens.access_token,
213
- }),
214
- });
215
- }
216
- catch {
217
- // Best effort revocation
218
- }
219
- delete store[orgSlug];
220
- writeAuthStore(store);
221
- return true;
222
- }
223
- /**
224
- * Get valid tokens for an org, auto-refreshing if expired.
225
- * Returns null if no tokens found or refresh fails.
226
- */
227
- export async function getValidTokens(orgSlug) {
228
- const store = readAuthStore();
229
- const tokens = store[orgSlug];
230
- if (!tokens)
231
- return null;
232
- if (isTokenExpired(tokens)) {
233
- return refreshTokens(orgSlug, tokens);
234
- }
235
- return tokens;
236
- }
237
- // ── GraphQL ────────────────────────────────────────────────────────────
238
- async function graphqlQuery(query, variables, accessToken) {
239
- const res = await fetch(LINEAR_GRAPHQL_URL, {
240
- method: "POST",
241
- headers: {
242
- "Content-Type": "application/json",
243
- Authorization: `Bearer ${accessToken}`,
244
- },
245
- body: JSON.stringify({ query, variables }),
246
- });
247
- if (!res.ok)
248
- return null;
249
- const json = (await res.json());
250
- if (json.errors) {
251
- console.error("Linear GraphQL errors:", JSON.stringify(json.errors, null, 2));
252
- }
253
- return json.data ?? null;
254
- }
255
- const ISSUE_QUERY = `
256
- query GetIssue($id: String!) {
257
- issue(id: $id) {
258
- identifier
259
- title
260
- description
261
- url
262
- state { name }
263
- priority
264
- labels { nodes { name } }
265
- comments {
266
- nodes {
267
- body
268
- createdAt
269
- parent { id }
270
- user { displayName }
271
- children {
272
- nodes {
273
- body
274
- createdAt
275
- user { displayName }
276
- }
277
- }
278
- }
279
- }
280
- }
281
- }
282
- `;
283
- const PRIORITY_MAP = {
284
- 0: "No priority",
285
- 1: "Urgent",
286
- 2: "High",
287
- 3: "Medium",
288
- 4: "Low",
289
- };
290
- async function fetchIssue(ticketId, accessToken) {
291
- const data = await graphqlQuery(ISSUE_QUERY, { id: ticketId }, accessToken);
292
- if (!data?.issue)
293
- return null;
294
- const issue = data.issue;
295
- return {
296
- identifier: issue.identifier,
297
- title: issue.title,
298
- description: issue.description ?? null,
299
- status: issue.state?.name ?? null,
300
- priority: PRIORITY_MAP[issue.priority] ?? null,
301
- labels: (issue.labels?.nodes ?? []).map((l) => l.name),
302
- url: issue.url,
303
- comments: (issue.comments?.nodes ?? [])
304
- .filter((c) => !c.parent)
305
- .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
306
- .map((c) => ({
307
- author: c.user?.displayName ?? "Unknown",
308
- body: c.body,
309
- createdAt: c.createdAt,
310
- children: (c.children?.nodes ?? [])
311
- .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
312
- .map((r) => ({
313
- author: r.user?.displayName ?? "Unknown",
314
- body: r.body,
315
- createdAt: r.createdAt,
316
- children: [],
317
- })),
318
- })),
319
- };
320
- }
321
- // ── Image Handling ─────────────────────────────────────────────────────
322
- function getTempImageDir(ticketId) {
323
- return path.join(os.tmpdir(), `santree-images-${ticketId}`);
324
- }
325
- async function downloadImages(markdown, ticketId, accessToken) {
326
- const imageRegex = /!\[([^\]]*)\]\((https:\/\/uploads\.linear\.app[^)]+)\)/g;
327
- const matches = [...markdown.matchAll(imageRegex)];
328
- if (matches.length === 0)
329
- return markdown;
330
- const tempDir = getTempImageDir(ticketId);
331
- if (!fs.existsSync(tempDir)) {
332
- fs.mkdirSync(tempDir, { recursive: true });
333
- }
334
- let result = markdown;
335
- for (let i = 0; i < matches.length; i++) {
336
- const match = matches[i];
337
- const [fullMatch, altText, url] = match;
338
- try {
339
- const res = await fetch(url, {
340
- headers: { Authorization: `Bearer ${accessToken}` },
341
- });
342
- if (!res.ok)
343
- continue;
344
- const buffer = Buffer.from(await res.arrayBuffer());
345
- const ext = path.extname(new URL(url).pathname) || ".png";
346
- const filename = `image-${i}${ext}`;
347
- const filePath = path.join(tempDir, filename);
348
- fs.writeFileSync(filePath, buffer);
349
- result = result.replace(fullMatch, `![${altText}](${filePath})`);
350
- }
351
- catch {
352
- // Keep original URL on failure
353
- }
354
- }
355
- return result;
356
- }
357
- export function cleanupImages(ticketId) {
358
- const tempDir = getTempImageDir(ticketId);
359
- if (fs.existsSync(tempDir)) {
360
- fs.rmSync(tempDir, { recursive: true, force: true });
361
- }
362
- }
363
- /**
364
- * Get auth status for the current repo's Linear org (or any stored org).
365
- */
366
- export function getAuthStatus(repoRoot) {
367
- const store = readAuthStore();
368
- const orgs = Object.keys(store);
369
- if (orgs.length === 0) {
370
- return { authenticated: false };
371
- }
372
- // Check repo-specific org first
373
- if (repoRoot) {
374
- const repoOrg = getRepoLinearOrg(repoRoot);
375
- if (repoOrg && store[repoOrg]) {
376
- const tokens = store[repoOrg];
377
- return {
378
- authenticated: true,
379
- orgSlug: repoOrg,
380
- orgName: tokens.org_name,
381
- expiresAt: tokens.expires_at,
382
- repoLinked: true,
383
- };
384
- }
385
- }
386
- // Fall back to first stored org
387
- const orgSlug = orgs[0];
388
- const tokens = store[orgSlug];
389
- return {
390
- authenticated: true,
391
- orgSlug,
392
- orgName: tokens.org_name,
393
- expiresAt: tokens.expires_at,
394
- repoLinked: false,
395
- };
396
- }
397
- // ── Assigned Issues Query ──────────────────────────────────────────────
398
- const ASSIGNED_ISSUES_QUERY = `
399
- query AssignedIssues {
400
- viewer {
401
- assignedIssues(
402
- filter: { state: { type: { nin: ["completed", "canceled"] } } }
403
- orderBy: updatedAt
404
- first: 100
405
- ) {
406
- nodes {
407
- identifier
408
- title
409
- description
410
- url
411
- priority
412
- state { name type }
413
- labels { nodes { name } }
414
- project { id name }
415
- }
416
- }
417
- }
418
- }
419
- `;
420
- /**
421
- * Fetch all active issues assigned to the current user.
422
- * Returns null if not authenticated or fetch fails.
423
- */
424
- export async function fetchAssignedIssues(repoRoot) {
425
- const orgSlug = getRepoLinearOrg(repoRoot);
426
- if (!orgSlug)
427
- return null;
428
- const tokens = await getValidTokens(orgSlug);
429
- if (!tokens)
430
- return null;
431
- const data = await graphqlQuery(ASSIGNED_ISSUES_QUERY, {}, tokens.access_token);
432
- if (!data?.viewer?.assignedIssues?.nodes)
433
- return null;
434
- return data.viewer.assignedIssues.nodes.map((issue) => ({
435
- identifier: issue.identifier,
436
- title: issue.title,
437
- description: issue.description ?? null,
438
- url: issue.url,
439
- priority: issue.priority,
440
- priorityLabel: PRIORITY_MAP[issue.priority] ?? "No priority",
441
- state: {
442
- name: issue.state?.name ?? "Unknown",
443
- type: issue.state?.type ?? "unstarted",
444
- },
445
- labels: (issue.labels?.nodes ?? []).map((l) => l.name),
446
- projectId: issue.project?.id ?? null,
447
- projectName: issue.project?.name ?? null,
448
- }));
449
- }
450
- // ── High-Level Entry Point ─────────────────────────────────────────────
451
- /**
452
- * Fetch full ticket content for a given ticket ID.
453
- * Looks up the repo's Linear org, gets valid tokens, fetches issue, downloads images.
454
- * Returns null if not authenticated or fetch fails.
455
- */
456
- export async function getTicketContent(ticketId, repoRoot) {
457
- const orgSlug = getRepoLinearOrg(repoRoot);
458
- if (!orgSlug)
459
- return null;
460
- const tokens = await getValidTokens(orgSlug);
461
- if (!tokens)
462
- return null;
463
- const issue = await fetchIssue(ticketId, tokens.access_token);
464
- if (!issue)
465
- return null;
466
- // Download images from description
467
- if (issue.description) {
468
- issue.description = await downloadImages(issue.description, ticketId, tokens.access_token);
469
- }
470
- // Download images from comments and replies
471
- for (const comment of issue.comments) {
472
- if (comment.body) {
473
- comment.body = await downloadImages(comment.body, ticketId, tokens.access_token);
474
- }
475
- for (const child of comment.children) {
476
- if (child.body) {
477
- child.body = await downloadImages(child.body, ticketId, tokens.access_token);
478
- }
479
- }
480
- }
481
- return issue;
482
- }