mitsupi 1.4.0 → 1.6.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.
Files changed (43) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +28 -18
  4. package/analyze-edits.py +232 -0
  5. package/distributions/README.md +8 -0
  6. package/distributions/mitsupi-common/README.md +11 -0
  7. package/distributions/mitsupi-common/package.json +95 -0
  8. package/distributions/mitsupi-loaded/README.md +13 -0
  9. package/distributions/mitsupi-loaded/package.json +38 -0
  10. package/{pi-extensions → extensions}/answer.ts +11 -11
  11. package/extensions/btw.ts +963 -0
  12. package/{pi-extensions → extensions}/context.ts +2 -2
  13. package/{pi-extensions → extensions}/control.ts +20 -13
  14. package/{pi-extensions → extensions}/files.ts +6 -8
  15. package/{pi-extensions → extensions}/go-to-bed.ts +2 -2
  16. package/{pi-extensions → extensions}/loop.ts +18 -11
  17. package/extensions/multi-edit.ts +871 -0
  18. package/{pi-extensions → extensions}/review.ts +254 -82
  19. package/{pi-extensions → extensions}/session-breakdown.ts +551 -74
  20. package/extensions/split-fork.ts +130 -0
  21. package/{pi-extensions → extensions}/todos.ts +41 -34
  22. package/extensions/uv.ts +123 -0
  23. package/intercepted-commands/poetry +8 -1
  24. package/intercepted-commands/python +42 -2
  25. package/intercepted-commands/python3 +42 -2
  26. package/package.json +6 -3
  27. package/skills/google-workspace/SKILL.md +53 -8
  28. package/skills/google-workspace/scripts/auth.js +106 -28
  29. package/skills/google-workspace/scripts/common.js +201 -32
  30. package/skills/google-workspace/scripts/workspace.js +64 -9
  31. package/skills/native-web-search/SKILL.md +2 -0
  32. package/skills/native-web-search/search.mjs +102 -15
  33. package/skills/pi-share/SKILL.md +5 -2
  34. package/skills/pi-share/fetch-session.mjs +46 -15
  35. package/skills/summarize/SKILL.md +4 -3
  36. package/skills/summarize/to-markdown.mjs +3 -1
  37. package/skills/web-browser/SKILL.md +6 -0
  38. package/skills/web-browser/scripts/start.js +75 -27
  39. package/pi-extensions/uv.ts +0 -33
  40. /package/{pi-extensions → extensions}/notify.ts +0 -0
  41. /package/{pi-extensions → extensions}/prompt-editor.ts +0 -0
  42. /package/{pi-extensions → extensions}/whimsical.ts +0 -0
  43. /package/{pi-themes → themes}/nightowl.json +0 -0
@@ -9,6 +9,7 @@ const {
9
9
  formatScopes,
10
10
  getGoogleApis,
11
11
  loadToken,
12
+ normalizeEmail,
12
13
  resolveAuthMode,
13
14
  } = require('./common');
14
15
 
@@ -16,10 +17,10 @@ function printHelp() {
16
17
  console.log(`Google Workspace API helper (exec-only)
17
18
 
18
19
  Usage:
19
- node scripts/workspace.js exec [--script 'return 1'] [--timeout 30000] [--scopes s1,s2]
20
+ node scripts/workspace.js exec --email user@example.com [--script 'return 1'] [--timeout 30000] [--scopes s1,s2]
20
21
 
21
22
  Example:
22
- node scripts/workspace.js exec <<'JS'
23
+ node scripts/workspace.js exec --email user@example.com <<'JS'
23
24
  const me = await workspace.whoAmI();
24
25
  const files = await workspace.call('drive', 'files.list', {
25
26
  pageSize: 3,
@@ -46,6 +47,7 @@ function parseTimeout(raw) {
46
47
  function parseOptions(argv) {
47
48
  const positional = [];
48
49
  const options = {
50
+ email: undefined,
49
51
  scopes: undefined,
50
52
  timeout: undefined,
51
53
  script: undefined,
@@ -54,6 +56,16 @@ function parseOptions(argv) {
54
56
  for (let i = 0; i < argv.length; i += 1) {
55
57
  const arg = argv[i];
56
58
 
59
+ if (arg === '--email') {
60
+ const value = argv[i + 1];
61
+ if (!value) {
62
+ throw new Error('--email requires a value');
63
+ }
64
+ options.email = normalizeEmail(value);
65
+ i += 1;
66
+ continue;
67
+ }
68
+
57
69
  if (arg === '--scopes') {
58
70
  const value = argv[i + 1];
59
71
  if (!value) {
@@ -119,6 +131,7 @@ async function callApi({
119
131
  version,
120
132
  scopes,
121
133
  authClient,
134
+ email,
122
135
  }) {
123
136
  const google = getGoogleApis();
124
137
  const factory = google[service];
@@ -129,6 +142,7 @@ async function callApi({
129
142
  const auth =
130
143
  authClient ||
131
144
  (await authorize({
145
+ email,
132
146
  interactive: true,
133
147
  scopes: scopes && scopes.length > 0 ? scopes : undefined,
134
148
  }));
@@ -155,8 +169,9 @@ function getServiceClient({ google, auth, service, version }) {
155
169
  });
156
170
  }
157
171
 
158
- function createWorkspaceHelper({ auth, google }) {
172
+ function createWorkspaceHelper({ auth, google, email }) {
159
173
  return {
174
+ accountEmail: email,
160
175
  versions: { ...DEFAULT_VERSIONS },
161
176
 
162
177
  async call(service, methodPath, params = {}, options = {}) {
@@ -166,6 +181,7 @@ function createWorkspaceHelper({ auth, google }) {
166
181
  params,
167
182
  version: options.version,
168
183
  authClient: auth,
184
+ email,
169
185
  });
170
186
  },
171
187
 
@@ -302,20 +318,51 @@ function readStdinText() {
302
318
  });
303
319
  }
304
320
 
305
- function errorPayload(error, logs, timeoutMs) {
321
+ function createUnauthorizedHint(message, email) {
322
+ const msg = String(message || '').toLowerCase();
323
+ const looksUnauthorized =
324
+ msg.includes('401') ||
325
+ msg.includes('unauthorized') ||
326
+ msg.includes('invalid credentials') ||
327
+ msg.includes('insufficient permissions') ||
328
+ msg.includes('forbidden');
329
+
330
+ if (!looksUnauthorized) {
331
+ return null;
332
+ }
333
+
334
+ return [
335
+ `Auth/account hint: this request may not be authorized for ${email}.`,
336
+ 'List known accounts with: node scripts/auth.js accounts',
337
+ `Sign in or refresh this account with: node scripts/auth.js login --email ${email}`,
338
+ 'Then retry workspace.js exec with the intended --email.',
339
+ ].join(' ');
340
+ }
341
+
342
+ function errorPayload(error, logs, timeoutMs, email) {
343
+ const baseMessage = error?.message || String(error);
344
+ const hint = createUnauthorizedHint(baseMessage, email);
345
+
306
346
  return {
307
347
  ok: false,
308
348
  timeoutMs,
309
349
  logs,
310
350
  error: {
311
351
  name: error?.name || 'Error',
312
- message: error?.message || String(error),
352
+ message: hint ? `${baseMessage}\n${hint}` : baseMessage,
313
353
  stack: error?.stack || null,
314
354
  },
315
355
  };
316
356
  }
317
357
 
318
358
  async function cmdExec(args, options) {
359
+ if (!options.email) {
360
+ throw new Error(
361
+ 'Missing required --email <account@example.com>. ' +
362
+ 'Use node scripts/auth.js accounts to list known accounts.',
363
+ );
364
+ }
365
+
319
366
  let script = options.script;
320
367
 
321
368
  if (!script && args.length > 0) {
@@ -330,7 +377,7 @@ async function cmdExec(args, options) {
330
377
 
331
378
  if (!script) {
332
379
  throw new Error(
333
- 'Usage: exec [--script "..."] (or pipe script via stdin / heredoc)',
380
+ 'Usage: exec --email user@example.com [--script "..."] (or pipe script via stdin / heredoc)',
334
381
  );
335
382
  }
336
383
 
@@ -339,6 +386,7 @@ async function cmdExec(args, options) {
339
386
 
340
387
  try {
341
388
  const auth = await authorize({
389
+ email: options.email,
342
390
  interactive: true,
343
391
  scopes: options.scopes && options.scopes.length > 0
344
392
  ? options.scopes
@@ -346,7 +394,7 @@ async function cmdExec(args, options) {
346
394
  });
347
395
 
348
396
  const google = getGoogleApis();
349
- const workspace = createWorkspaceHelper({ auth, google });
397
+ const workspace = createWorkspaceHelper({ auth, google, email: options.email });
350
398
 
351
399
  const context = vm.createContext({
352
400
  Buffer,
@@ -381,12 +429,13 @@ ${script}
381
429
  );
382
430
 
383
431
  const result = await withTimeout(resultPromise, timeoutMs);
384
- const token = loadToken();
432
+ const token = loadToken(options.email);
385
433
 
386
434
  console.log(
387
435
  JSON.stringify(
388
436
  {
389
437
  ok: true,
438
+ email: options.email,
390
439
  authMode: resolveAuthMode(token),
391
440
  timeoutMs,
392
441
  logs,
@@ -397,7 +446,13 @@ ${script}
397
446
  ),
398
447
  );
399
448
  } catch (error) {
400
- console.log(JSON.stringify(errorPayload(error, logs, timeoutMs), null, 2));
449
+ console.log(
450
+ JSON.stringify(
451
+ errorPayload(error, logs, timeoutMs, options.email),
452
+ null,
453
+ 2,
454
+ ),
455
+ );
401
456
  process.exitCode = 1;
402
457
  }
403
458
  }
@@ -45,3 +45,5 @@ The script instructs the model to:
45
45
 
46
46
  - No extra npm install is required.
47
47
  - If module resolution fails, set `PI_AI_MODULE_PATH` to `@mariozechner/pi-ai`'s `dist/index.js` path.
48
+ - If OAuth helper resolution fails, set `PI_AI_OAUTH_MODULE_PATH` to `@mariozechner/pi-ai`'s `dist/oauth.js` path.
49
+ - For OAuth providers, the script can fall back to a still-valid cached `access` token from `~/.pi/agent/auth.json`.
@@ -159,7 +159,7 @@ function findPiExecutable() {
159
159
  return first || undefined;
160
160
  }
161
161
 
162
- function collectModuleCandidates() {
162
+ function collectModuleCandidates(fileName = "index.js", envVarName = "PI_AI_MODULE_PATH") {
163
163
  const candidates = new Set();
164
164
 
165
165
  const add = (p) => {
@@ -168,16 +168,16 @@ function collectModuleCandidates() {
168
168
  candidates.add(abs);
169
169
  };
170
170
 
171
- if (process.env.PI_AI_MODULE_PATH) add(process.env.PI_AI_MODULE_PATH);
171
+ if (envVarName && process.env[envVarName]) add(process.env[envVarName]);
172
172
 
173
173
  const cwd = process.cwd();
174
174
  const scriptDir = dirname(fileURLToPath(import.meta.url));
175
175
  for (const start of [cwd, scriptDir]) {
176
176
  let dir = start;
177
177
  for (let i = 0; i < 8; i++) {
178
- add(join(dir, "node_modules", "@mariozechner", "pi-ai", "dist", "index.js"));
179
- add(join(dir, "packages", "ai", "dist", "index.js"));
180
- add(join(dir, "ai", "dist", "index.js"));
178
+ add(join(dir, "node_modules", "@mariozechner", "pi-ai", "dist", fileName));
179
+ add(join(dir, "packages", "ai", "dist", fileName));
180
+ add(join(dir, "ai", "dist", fileName));
181
181
  const parent = dirname(dir);
182
182
  if (parent === dir) break;
183
183
  dir = parent;
@@ -189,16 +189,16 @@ function collectModuleCandidates() {
189
189
  try {
190
190
  const piReal = realpathSync(piExec);
191
191
  const piDir = dirname(piReal);
192
- add(join(piDir, "..", "..", "ai", "dist", "index.js"));
193
- add(join(piDir, "..", "..", "pi-ai", "dist", "index.js"));
194
- add(join(piDir, "..", "node_modules", "@mariozechner", "pi-ai", "dist", "index.js"));
195
- add(join(piDir, "..", "..", "node_modules", "@mariozechner", "pi-ai", "dist", "index.js"));
192
+ add(join(piDir, "..", "..", "ai", "dist", fileName));
193
+ add(join(piDir, "..", "..", "pi-ai", "dist", fileName));
194
+ add(join(piDir, "..", "node_modules", "@mariozechner", "pi-ai", "dist", fileName));
195
+ add(join(piDir, "..", "..", "node_modules", "@mariozechner", "pi-ai", "dist", fileName));
196
196
  } catch {
197
197
  // ignore
198
198
  }
199
199
  }
200
200
 
201
- add(join(homedir(), "Development", "pi-mono", "packages", "ai", "dist", "index.js"));
201
+ add(join(homedir(), "Development", "pi-mono", "packages", "ai", "dist", fileName));
202
202
 
203
203
  return Array.from(candidates);
204
204
  }
@@ -212,7 +212,7 @@ async function loadPiAi() {
212
212
  tried.push(`@mariozechner/pi-ai (${err?.code || err?.message || "not found"})`);
213
213
  }
214
214
 
215
- for (const candidate of collectModuleCandidates()) {
215
+ for (const candidate of collectModuleCandidates("index.js", "PI_AI_MODULE_PATH")) {
216
216
  if (!existsSync(candidate)) continue;
217
217
  try {
218
218
  return await import(pathToFileURL(candidate).href);
@@ -226,6 +226,81 @@ async function loadPiAi() {
226
226
  );
227
227
  }
228
228
 
229
+ async function loadPiAiOAuth(piAi) {
230
+ if (typeof piAi?.getOAuthApiKey === "function") {
231
+ return { getOAuthApiKey: piAi.getOAuthApiKey.bind(piAi) };
232
+ }
233
+
234
+ const tried = [];
235
+
236
+ try {
237
+ const oauth = await import("@mariozechner/pi-ai/oauth");
238
+ if (typeof oauth.getOAuthApiKey === "function") {
239
+ return { getOAuthApiKey: oauth.getOAuthApiKey.bind(oauth) };
240
+ }
241
+ tried.push("@mariozechner/pi-ai/oauth (missing getOAuthApiKey export)");
242
+ } catch (err) {
243
+ tried.push(`@mariozechner/pi-ai/oauth (${err?.code || err?.message || "not found"})`);
244
+ }
245
+
246
+ for (const candidate of collectModuleCandidates("oauth.js", "PI_AI_OAUTH_MODULE_PATH")) {
247
+ if (!existsSync(candidate)) continue;
248
+ try {
249
+ const oauth = await import(pathToFileURL(candidate).href);
250
+ if (typeof oauth.getOAuthApiKey === "function") {
251
+ return { getOAuthApiKey: oauth.getOAuthApiKey.bind(oauth) };
252
+ }
253
+ tried.push(`${candidate} (missing getOAuthApiKey export)`);
254
+ } catch (err) {
255
+ tried.push(`${candidate} (${err?.code || err?.message || "failed"})`);
256
+ }
257
+ }
258
+
259
+ return {
260
+ getOAuthApiKey: undefined,
261
+ error: `Could not load getOAuthApiKey. Set PI_AI_OAUTH_MODULE_PATH to pi-ai dist/oauth.js.\nTried:\n- ${tried.join("\n- ")}`,
262
+ };
263
+ }
264
+
265
+ function parseExpiryTimestamp(expires) {
266
+ if (typeof expires === "number" && Number.isFinite(expires)) {
267
+ if (expires <= 0) return undefined;
268
+ return expires < 1_000_000_000_000 ? expires * 1000 : expires;
269
+ }
270
+
271
+ if (typeof expires === "string") {
272
+ const trimmed = expires.trim();
273
+ if (!trimmed) return undefined;
274
+
275
+ const numeric = Number(trimmed);
276
+ if (Number.isFinite(numeric)) {
277
+ return parseExpiryTimestamp(numeric);
278
+ }
279
+
280
+ const parsed = Date.parse(trimmed);
281
+ if (Number.isFinite(parsed)) return parsed;
282
+ }
283
+
284
+ return undefined;
285
+ }
286
+
287
+ function getCachedOAuthAccess(entry, now = Date.now()) {
288
+ if (!entry || typeof entry !== "object") return undefined;
289
+
290
+ const apiKey = resolveConfigValue(entry.access);
291
+ if (!apiKey) return undefined;
292
+
293
+ const expiresAt = parseExpiryTimestamp(entry.expires);
294
+ if (!expiresAt) return undefined;
295
+
296
+ if (now + 30_000 >= expiresAt) return undefined;
297
+
298
+ return {
299
+ apiKey,
300
+ accountId: entry.accountId,
301
+ };
302
+ }
303
+
229
304
  function pickFastModel(provider, requestedModel, piAi) {
230
305
  const models = typeof piAi.getModels === "function" ? piAi.getModels(provider) : [];
231
306
  if (!Array.isArray(models) || models.length === 0) {
@@ -272,8 +347,12 @@ async function resolveApiKey(provider, auth, authPath, piAi) {
272
347
  throw new Error(`Unsupported credential type for ${provider}: ${String(entry.type || "unknown")}`);
273
348
  }
274
349
 
275
- if (typeof piAi.getOAuthApiKey !== "function") {
276
- throw new Error("Loaded pi-ai module does not export getOAuthApiKey");
350
+ const fallbackToken = getCachedOAuthAccess(entry);
351
+ const oauth = await loadPiAiOAuth(piAi);
352
+
353
+ if (typeof oauth.getOAuthApiKey !== "function") {
354
+ if (fallbackToken) return fallbackToken;
355
+ throw new Error(oauth.error || "Loaded pi-ai module does not export getOAuthApiKey");
277
356
  }
278
357
 
279
358
  const oauthCreds = {};
@@ -283,8 +362,16 @@ async function resolveApiKey(provider, auth, authPath, piAi) {
283
362
  }
284
363
  }
285
364
 
286
- const refreshed = await piAi.getOAuthApiKey(provider, oauthCreds);
287
- if (!refreshed) {
365
+ let refreshed;
366
+ try {
367
+ refreshed = await oauth.getOAuthApiKey(provider, oauthCreds);
368
+ } catch (err) {
369
+ if (fallbackToken) return fallbackToken;
370
+ throw err;
371
+ }
372
+
373
+ if (!refreshed?.apiKey) {
374
+ if (fallbackToken) return fallbackToken;
288
375
  throw new Error(`No OAuth credentials available for provider '${provider}'`);
289
376
  }
290
377
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: pi-share
3
- description: "Load and parse session transcripts from shittycodingagent.ai/buildwithpi.ai/buildwithpi.com (pi-share) URLs. Fetches gists, decodes embedded session data, and extracts conversation history."
3
+ description: "Load and parse session transcripts from shittycodingagent.ai/buildwithpi.ai/buildwithpi.com/pi.dev (pi-share) URLs. Fetches gists, decodes embedded session data, and extracts conversation history."
4
4
  ---
5
5
 
6
6
  # pi-share / buildwithpi Session Loader
7
7
 
8
- Load and parse session transcripts from pi-share URLs (shittycodingagent.ai, buildwithpi.ai, buildwithpi.com).
8
+ Load and parse session transcripts from pi-share URLs (shittycodingagent.ai, buildwithpi.ai, buildwithpi.com, pi.dev).
9
9
 
10
10
  ## When to Use
11
11
 
@@ -13,7 +13,10 @@ Load and parse session transcripts from pi-share URLs (shittycodingagent.ai, bui
13
13
  - `https://shittycodingagent.ai/session/?<gist_id>`
14
14
  - `https://buildwithpi.ai/session/?<gist_id>`
15
15
  - `https://buildwithpi.com/session/?<gist_id>`
16
+ - `https://pi.dev/session/?<gist_id>`
17
+ - `https://pi.dev/session/#<gist_id>`
16
18
  - Or just a gist ID like `46aee35206aefe99257bc5d5e60c6121`
19
+ - Or hash-prefixed shorthand like `#46aee35206aefe99257bc5d5e60c6121`
17
20
 
18
21
  **Human summaries:** Use `--human-summary` when the user asks you to:
19
22
  - Summarize what a human did in a pi/coding agent session
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Fetch and parse pi-share (shittycodingagent.ai/buildwithpi.ai/buildwithpi.com) session exports.
3
+ * Fetch and parse pi-share (shittycodingagent.ai/buildwithpi.ai/buildwithpi.com/pi.dev) session exports.
4
4
  *
5
5
  * Usage:
6
6
  * node fetch-session.mjs <url-or-gist-id> [--header] [--entries] [--system] [--tools] [--human-summary] [--no-cache]
@@ -51,21 +51,52 @@ function writeCache(gistId, data) {
51
51
 
52
52
  // Extract gist ID from URL or use directly
53
53
  function extractGistId(input) {
54
- // Handle full URLs like https://shittycodingagent.ai/session/?<id>
55
- const queryMatch = input.match(/[?&]([a-f0-9]{32})/i);
56
- if (queryMatch) return queryMatch[1];
54
+ const HEX_ID_RE = /^[a-f0-9]{8,40}$/i;
55
+ const normalize = (value = '') => value.trim().replace(/^#/, '');
56
+
57
+ // Handle direct gist ID and #<gist-id>
58
+ const normalizedInput = normalize(input);
59
+ if (HEX_ID_RE.test(normalizedInput)) return normalizedInput;
60
+
61
+ // Parse URL forms (supports shittycodingagent.ai, buildwithpi.ai/.com, pi.dev aliases)
62
+ try {
63
+ const url = new URL(input);
64
+
65
+ // Handle old query style: /session/?<gist-id>
66
+ const bareQuery = normalize(url.search.replace(/^\?/, ''));
67
+ if (HEX_ID_RE.test(bareQuery)) return bareQuery;
68
+
69
+ // Handle keyed query params: ?id=<gist-id>, ?gist=<gist-id>, etc.
70
+ for (const value of url.searchParams.values()) {
71
+ const normalizedValue = normalize(value);
72
+ if (HEX_ID_RE.test(normalizedValue)) return normalizedValue;
73
+ }
74
+
75
+ // Handle hash style: /session/#<gist-id>
76
+ const hashValue = normalize(url.hash);
77
+ if (HEX_ID_RE.test(hashValue)) return hashValue;
78
+
79
+ // Handle path-based URLs like /session/<gist-id>
80
+ const segments = url.pathname.split('/').filter(Boolean);
81
+ for (let i = 0; i < segments.length; i++) {
82
+ if (segments[i] === 'session' && HEX_ID_RE.test(segments[i + 1] || '')) {
83
+ return segments[i + 1];
84
+ }
85
+ }
86
+
87
+ // Handle gist.github.com URLs
88
+ if (url.hostname === 'gist.github.com') {
89
+ const gistCandidate = segments[segments.length - 1];
90
+ if (HEX_ID_RE.test(gistCandidate || '')) return gistCandidate;
91
+ }
92
+ } catch {
93
+ // Not a URL; continue with regex fallback below
94
+ }
95
+
96
+ // Regex fallback for unknown URL formats containing a gist ID
97
+ const fallbackMatch = input.match(/([a-f0-9]{8,40})/i);
98
+ if (fallbackMatch) return fallbackMatch[1];
57
99
 
58
- // Handle path-based URLs like https://buildwithpi.ai/session/<id>
59
- const pathMatch = input.match(/\/session\/?([a-f0-9]{32})/i);
60
- if (pathMatch) return pathMatch[1];
61
-
62
- // Handle direct gist ID
63
- if (/^[a-f0-9]{32}$/i.test(input)) return input;
64
-
65
- // Handle gist.github.com URLs
66
- const gistMatch = input.match(/gist\.github\.com\/[^/]+\/([a-f0-9]+)/i);
67
- if (gistMatch) return gistMatch[1];
68
-
69
100
  throw new Error(`Cannot extract gist ID from: ${input}`);
70
101
  }
71
102
 
@@ -6,6 +6,7 @@ description: "Fetch a URL or convert a local file (PDF/DOCX/HTML/etc.) into Mark
6
6
  Turn “things” (URLs, PDFs, Word docs, PowerPoints, HTML pages, text files, etc.) into **Markdown** so they can be inspected/quoted/processed like normal text.
7
7
 
8
8
  `markitdown` can fetch URLs by itself; this skill mainly wraps it to make saving + summarizing convenient.
9
+ For PDF inputs, use the `markitdown[pdf]` extra (or the wrapper below, which now does this automatically).
9
10
 
10
11
  ## When to use
11
12
 
@@ -21,7 +22,7 @@ Use this skill when you need to:
21
22
  Run from **this skill folder** (the agent should `cd` here first):
22
23
 
23
24
  ```bash
24
- uvx markitdown <url-or-path>
25
+ uvx --from 'markitdown[pdf]' markitdown <url-or-path>
25
26
  ```
26
27
 
27
28
  To write Markdown to a temp file (prints the path) use the wrapper:
@@ -35,7 +36,7 @@ Tip: when summarizing, the script will **always** write the full converted Markd
35
36
  Write Markdown to a specific file:
36
37
 
37
38
  ```bash
38
- uvx markitdown <url-or-path> > /tmp/doc.md
39
+ uvx --from 'markitdown[pdf]' markitdown <url-or-path> > /tmp/doc.md
39
40
  ```
40
41
 
41
42
  ### Convert + summarize with haiku-4-5 (pass context!)
@@ -53,6 +54,6 @@ node to-markdown.mjs <url-or-path> --summary --prompt "Focus on security implica
53
54
  ```
54
55
 
55
56
  This will:
56
- 1) convert to Markdown via `uvx markitdown`
57
+ 1) convert to Markdown via `uvx --from 'markitdown[pdf]' markitdown`
57
58
  2) write the full Markdown to a temp `.md` file and print its path as a "Hint" line
58
59
  3) run `pi --model claude-haiku-4-5` (no-tools, no-session) to summarize using your extra prompt
@@ -132,7 +132,9 @@ for (let i = 0; i < argv.length; i++) {
132
132
  if (!input) usageAndExit(1);
133
133
 
134
134
  function runMarkitdown(arg) {
135
- const result = spawnSync('uvx', ['markitdown', arg], {
135
+ // Include PDF support by default because many document URLs (for example arXiv PDFs)
136
+ // are detected as PDFs only after fetching, so extension-based switching is unreliable.
137
+ const result = spawnSync('uvx', ['--from', 'markitdown[pdf]', 'markitdown', arg], {
136
138
  encoding: 'utf-8',
137
139
  maxBuffer: 50 * 1024 * 1024
138
140
  });
@@ -17,6 +17,12 @@ Minimal CDP tools for collaborative site exploration.
17
17
 
18
18
  Start Chrome on `:9222` with remote debugging.
19
19
 
20
+ If Chrome is installed in a non-standard location, set:
21
+
22
+ ```bash
23
+ BROWSER_BIN=/path/to/chrome ./scripts/start.js
24
+ ```
25
+
20
26
  ## Navigate
21
27
 
22
28
  ```bash
@@ -1,30 +1,40 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn, execSync } from "node:child_process";
4
+ import { existsSync, rmSync } from "node:fs";
4
5
  import { dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
 
7
- const useProfile = process.argv[2] === "--profile";
8
+ const args = new Set(process.argv.slice(2));
9
+ const useProfile = args.has("--profile");
8
10
 
9
- if (process.argv[2] && process.argv[2] !== "--profile") {
10
- console.log("Usage: start.ts [--profile]");
11
+ const unknownArgs = [...args].filter((arg) => arg !== "--profile");
12
+ if (unknownArgs.length > 0) {
13
+ console.log("Usage: start.js [--profile]");
11
14
  console.log("\nOptions:");
12
15
  console.log(
13
16
  " --profile Copy your default Chrome profile (cookies, logins)",
14
17
  );
15
18
  console.log("\nExamples:");
16
- console.log(" start.ts # Start with fresh profile");
17
- console.log(" start.ts --profile # Start with your Chrome profile");
19
+ console.log(" start.js # Start with fresh profile");
20
+ console.log(" start.js --profile # Start with your Chrome profile");
18
21
  process.exit(1);
19
22
  }
20
23
 
21
- // Kill existing Chrome
22
- try {
23
- execSync("killall 'Google Chrome'", { stdio: "ignore" });
24
- } catch {}
24
+ async function isDebugEndpointUp() {
25
+ try {
26
+ const response = await fetch("http://localhost:9222/json/version");
27
+ return response.ok;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
25
32
 
26
- // Wait a bit for processes to fully die
27
- await new Promise((r) => setTimeout(r, 1000));
33
+ // If something is already listening on :9222, reuse it instead of killing Chrome.
34
+ if (await isDebugEndpointUp()) {
35
+ console.log("✓ Chrome already running on :9222 (reusing existing instance)");
36
+ process.exit(0);
37
+ }
28
38
 
29
39
  // Setup profile directory
30
40
  execSync("mkdir -p ~/.cache/scraping", { stdio: "ignore" });
@@ -32,24 +42,60 @@ execSync("mkdir -p ~/.cache/scraping", { stdio: "ignore" });
32
42
  if (useProfile) {
33
43
  // Sync profile with rsync (much faster on subsequent runs)
34
44
  execSync(
35
- `rsync -a --delete "${process.env["HOME"]}/Library/Application Support/Google/Chrome/" ~/.cache/scraping/`,
45
+ `rsync -a --delete --exclude 'Singleton*' --exclude 'DevToolsActivePort*' "${process.env["HOME"]}/Library/Application Support/Google/Chrome/" ~/.cache/scraping/`,
36
46
  { stdio: "pipe" },
37
47
  );
38
48
  }
39
49
 
40
- // Start Chrome in background (detached so Node can exit)
41
- spawn(
42
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
43
- [
44
- "--remote-debugging-port=9222",
45
- `--user-data-dir=${process.env["HOME"]}/.cache/scraping`,
46
- "--profile-directory=Default",
47
- "--disable-search-engine-choice-screen",
48
- "--no-first-run",
49
- "--disable-features=ProfilePicker",
50
- ],
51
- { detached: true, stdio: "ignore" },
52
- ).unref();
50
+ // Remove stale singleton/debug artifacts that can make Chrome forward to an
51
+ // already running browser instance (which drops our debug flags).
52
+ for (const staleFile of [
53
+ "SingletonCookie",
54
+ "SingletonLock",
55
+ "SingletonSocket",
56
+ "DevToolsActivePort",
57
+ "DevToolsActivePort.lock",
58
+ ]) {
59
+ try {
60
+ rmSync(`${process.env["HOME"]}/.cache/scraping/${staleFile}`, { force: true });
61
+ } catch {
62
+ // ignore
63
+ }
64
+ }
65
+
66
+ function resolveChromeBinary() {
67
+ if (process.env.BROWSER_BIN && existsSync(process.env.BROWSER_BIN)) {
68
+ return process.env.BROWSER_BIN;
69
+ }
70
+
71
+ const candidates = [
72
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
73
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
74
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
75
+ ];
76
+
77
+ return candidates.find((path) => existsSync(path)) || null;
78
+ }
79
+
80
+ const chromeBinary = resolveChromeBinary();
81
+
82
+ if (!chromeBinary) {
83
+ console.error("✗ Could not find Chrome/Chromium binary");
84
+ console.error(" Set BROWSER_BIN=/path/to/chrome and retry");
85
+ process.exit(1);
86
+ }
87
+
88
+ const chromeArgs = [
89
+ "--remote-debugging-port=9222",
90
+ `--user-data-dir=${process.env["HOME"]}/.cache/scraping`,
91
+ "--profile-directory=Default",
92
+ "--disable-search-engine-choice-screen",
93
+ "--no-first-run",
94
+ "--disable-features=ProfilePicker",
95
+ ];
96
+
97
+ const chromeProc = spawn(chromeBinary, chromeArgs, { detached: true, stdio: "ignore" });
98
+ chromeProc.unref();
53
99
 
54
100
  // Wait for Chrome to be ready by checking the debugging endpoint
55
101
  let connected = false;
@@ -61,12 +107,14 @@ for (let i = 0; i < 30; i++) {
61
107
  break;
62
108
  }
63
109
  } catch {
64
- await new Promise((r) => setTimeout(r, 500));
110
+ // ignore; wait below
65
111
  }
112
+ await new Promise((r) => setTimeout(r, 500));
66
113
  }
67
114
 
68
115
  if (!connected) {
69
- console.error("✗ Failed to connect to Chrome");
116
+ console.error("✗ Failed to connect to Chrome on :9222");
117
+ console.error(` Attempted binary: ${chromeBinary}`);
70
118
  process.exit(1);
71
119
  }
72
120