shakers-cli 0.16.0 → 0.18.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 (69) hide show
  1. package/README.md +21 -6
  2. package/bin/ai-usage.js +31 -12
  3. package/bin/command-audit-worker.js +19 -0
  4. package/bin/login.js +4 -8
  5. package/bin/mcp.js +32 -21
  6. package/bin/register.js +12 -30
  7. package/bin/shakers-cli.js +15 -10
  8. package/package.json +1 -1
  9. package/src/account-marker.js +40 -0
  10. package/src/account-trace.js +23 -0
  11. package/src/agents-client.js +1 -0
  12. package/src/ai-usage-review.js +103 -0
  13. package/src/auth-client.js +7 -107
  14. package/src/auth-session-store.js +12 -11
  15. package/src/backend-request.js +3 -24
  16. package/src/cli-skill.js +77 -0
  17. package/src/command-audit.js +47 -4
  18. package/src/config.js +2 -16
  19. package/src/consent-flow.js +9 -3
  20. package/src/detect-repos.js +4 -8
  21. package/src/detectors.js +23 -0
  22. package/src/device-auth-store.js +2 -2
  23. package/src/device-login.js +11 -9
  24. package/src/env-paths.js +9 -1
  25. package/src/i18n.js +186 -102
  26. package/src/mcp-ai-usage-result-tool.js +38 -14
  27. package/src/mcp-ai-usage-tool.js +317 -172
  28. package/src/mcp-choice.js +171 -34
  29. package/src/mcp-detector.js +26 -2
  30. package/src/mcp-device-login-tools.js +40 -8
  31. package/src/mcp-install.js +292 -53
  32. package/src/mcp-instructions.js +43 -17
  33. package/src/mcp-onboarding-tools.js +1 -3
  34. package/src/mcp-prompts.js +8 -6
  35. package/src/mcp-register-tools.js +25 -14
  36. package/src/mcp-role-tools.js +59 -18
  37. package/src/mcp-server-name.js +7 -0
  38. package/src/mcp-server.js +35 -7
  39. package/src/mcp-signup-client.js +3 -6
  40. package/src/mcp-signup-flow.js +389 -0
  41. package/src/mcp-signup-tools.js +250 -133
  42. package/src/onboarding-flow.js +72 -146
  43. package/src/onboarding-funnel.js +46 -6
  44. package/src/opencode-sessions.js +92 -0
  45. package/src/profile-agents.js +26 -0
  46. package/src/render-report-text.js +1 -1
  47. package/src/report-gating.js +4 -2
  48. package/src/report-store.js +7 -2
  49. package/src/roles-flow.js +8 -5
  50. package/src/saved-positions-flow.js +3 -3
  51. package/src/scanner.js +104 -12
  52. package/src/session-gate.js +1 -4
  53. package/src/session-normalizers.js +114 -8
  54. package/src/session-refresh.js +5 -16
  55. package/src/session-scan.js +167 -22
  56. package/src/share-consent-state.js +2 -2
  57. package/src/share-payload.js +4 -1
  58. package/src/share.js +2 -2
  59. package/src/signup-client.js +12 -39
  60. package/src/signup-copy.js +545 -58
  61. package/src/skill-install.js +102 -0
  62. package/src/stdin-ask.js +13 -1
  63. package/src/tier-engine.js +14 -2
  64. package/src/trust-narration.js +32 -0
  65. package/src/usage-run-steps.js +68 -6
  66. package/src/usage-scan.js +11 -8
  67. package/src/brand-svg.js +0 -9
  68. package/src/inter-fonts.js +0 -6
  69. package/src/mcp-auth-form.js +0 -480
@@ -2,11 +2,12 @@
2
2
 
3
3
  const { loadAuthSession, sessionStatus, isPlatformTalent } = require('./auth-session-store');
4
4
  const { getProfile } = require('./config');
5
+ const { hasAccountTrace } = require('./account-trace');
5
6
 
6
7
  // THE ONE LAYER THAT DECIDES WHAT THE REPORT SHOWS ACCORDING TO IDENTITY.
7
8
 
8
9
  // Pure: identity in, policy out.
9
- function resolveReportGate({ loggedIn = false, status = null, email = null, userType = null, profile = 'talent' } = {}) {
10
+ function resolveReportGate({ loggedIn = false, accountTrace = false, status = null, email = null, userType = null, profile = 'talent' } = {}) {
10
11
  const showEverything = !loggedIn;
11
12
  const resolvedProfile = profile === 'external' ? 'external' : 'talent';
12
13
  return Object.freeze({
@@ -16,7 +17,7 @@ function resolveReportGate({ loggedIn = false, status = null, email = null, user
16
17
  email: loggedIn && typeof email === 'string' && email ? email : null,
17
18
  // --roadmap AND its server-side computation/egress (see bin/report.js): a
18
19
  // logged-in Talent's roadmap is not merely hidden, it is not requested.
19
- showRoadmap: showEverything,
20
+ showRoadmap: showEverything && !accountTrace,
20
21
  // Per-agent "how to improve this agent" tips in the footprint report.
21
22
  showAgentSuggestions: showEverything,
22
23
  // The copyable remediation prompt in a `certify` verdict.
@@ -35,6 +36,7 @@ function reportGateForIdentity(env = process.env, now = Date.now()) {
35
36
  const status = sessionStatus(session, now);
36
37
  return resolveReportGate({
37
38
  loggedIn: status === 'active',
39
+ accountTrace: hasAccountTrace(env),
38
40
  status,
39
41
  email: session ? session.email : null,
40
42
  userType: session ? session.userType : null,
@@ -54,10 +54,10 @@ function migrateFromV1(parsed) {
54
54
  return { schemaVersion: SCHEMA_VERSION, updatedAt: (parsed && parsed.updatedAt) || null, projects };
55
55
  }
56
56
 
57
- function loadState() {
57
+ function loadState(env = process.env) {
58
58
  let parsed;
59
59
  try {
60
- parsed = JSON.parse(fs.readFileSync(statePath(), 'utf8'));
60
+ parsed = JSON.parse(fs.readFileSync(statePath(env), 'utf8'));
61
61
  } catch {
62
62
  return freshState();
63
63
  }
@@ -154,6 +154,10 @@ function recordBackendAcceptance({ root, kind, backend }) {
154
154
  return stampAndSaveState(state, absRoot);
155
155
  }
156
156
 
157
+ function hasRecordedUpload(env = process.env) {
158
+ return Object.values(loadState(env).projects).some((p) => !!(p && p.backendAcceptance && Object.keys(p.backendAcceptance).length));
159
+ }
160
+
157
161
  function loadFootprint({ root } = {}) {
158
162
  const absRoot = path.resolve(root || process.cwd());
159
163
  const state = loadState();
@@ -220,6 +224,7 @@ module.exports = {
220
224
  loadAgentClassifications,
221
225
  loadFootprint,
222
226
  recordBackendAcceptance,
227
+ hasRecordedUpload,
223
228
  materializeProjectReport,
224
229
  certKey,
225
230
  SCHEMA_VERSION,
package/src/roles-flow.js CHANGED
@@ -88,6 +88,7 @@ async function runAddRole({ io, session, catalog, deps = makeRolesDeps() }) {
88
88
  // deduped, for setting/switching the MAIN role.
89
89
  async function fetchHeldRoles({ deps, hubAccessToken }) {
90
90
  const held = [];
91
+ const assigned = [];
91
92
  let mainClusterId = null;
92
93
  const certEndpoint = deps.getMyCertificationsEndpoint();
93
94
  if (certEndpoint) {
@@ -99,10 +100,11 @@ async function fetchHeldRoles({ deps, hubAccessToken }) {
99
100
  }
100
101
  const assignedEndpoint = deps.getAssignedClustersEndpoint();
101
102
  if (assignedEndpoint) {
102
- const assigned = await deps.requestAssignedClusters({ hubAccessToken }, { endpoint: assignedEndpoint });
103
- if (assigned.ok) for (const c of assigned.clusters || []) held.push(c);
103
+ const res = await deps.requestAssignedClusters({ hubAccessToken }, { endpoint: assignedEndpoint });
104
+ if (res.ok) assigned.push(...(res.clusters || []));
104
105
  }
105
- return { held: dedupeByCluster(held), mainClusterId };
106
+ held.push(...assigned);
107
+ return { held: dedupeByCluster(held), assigned, mainClusterId };
106
108
  }
107
109
 
108
110
  // `change-role`: switch the MAIN role to one of the roles the talent already holds.
@@ -130,12 +132,12 @@ async function runChangeMainRole({ io, session, catalog, deps = makeRolesDeps()
130
132
 
131
133
  // The onboarding final step: pick a MAIN role from the roles resolved for the
132
134
  // talent (interview recommendations + any held). Degrades gracefully to a skip.
133
- async function runOnboardingMainRoleStep({ io, session, catalog, deps = makeRolesDeps() }) {
135
+ async function runOnboardingMainRoleStep({ io, session, catalog, deps = makeRolesDeps(), onRolesShown = () => {} }) {
134
136
  const r = catalog.roles;
135
137
  const hubAccessToken = session ? session.hubAccessToken : null;
136
138
 
137
139
  io.section(r.mainRoleTitle);
138
- const { held, mainClusterId } = await io.withProgress(r.loadingHeld, () => fetchHeldRoles({ deps, hubAccessToken }));
140
+ const { held, assigned, mainClusterId } = await io.withProgress(r.loadingHeld, () => fetchHeldRoles({ deps, hubAccessToken }));
139
141
 
140
142
  // Prefer the talent's own/recommended roles; fall back to the catalog for a
141
143
  // fresh talent so a main role can always be set (picking one adds it first).
@@ -150,6 +152,7 @@ async function runOnboardingMainRoleStep({ io, session, catalog, deps = makeRole
150
152
  io.notify(r.mainRolePending);
151
153
  return { ok: true, chosen: null };
152
154
  }
155
+ onRolesShown({ suggested: assigned.filter((c) => c.type === 'RECOMMENDED').map((c) => c.clusterId), clustersCount: assigned.length });
153
156
  const choice = await chooseRole({
154
157
  io, roles, header: r.mainRolePrompt, hint: r.selectHint, initialClusterId: mainClusterId,
155
158
  });
@@ -27,10 +27,10 @@ async function runToggleSave({ io, rawOut, session, catalog, mode, opts = {}, de
27
27
  }
28
28
  const positionId = resolved.id;
29
29
 
30
- const label = mode === 'save' ? fp.saving : fp.unsaving;
31
- const res = await io.withProgress(label, () => (mode === 'save'
30
+ const toggle = () => (mode === 'save'
32
31
  ? deps.savePosition({ hubAccessToken, positionId })
33
- : deps.unsavePosition({ hubAccessToken, positionId })));
32
+ : deps.unsavePosition({ hubAccessToken, positionId }));
33
+ const res = opts.json ? await toggle() : await io.withProgress(mode === 'save' ? fp.saving : fp.unsaving, toggle);
34
34
  if (!res.ok) {
35
35
  io.error(res.reason === 'not-found' ? fp.notFound : fp.failed(res.reason));
36
36
  return { ok: false, reason: res.reason };
package/src/scanner.js CHANGED
@@ -12,7 +12,8 @@ const { detectMcpServers, buildMcpAggregate } = require('./mcp-detector');
12
12
  const { analyzeMemoryStructure } = require('./memory-structure-detector');
13
13
  const { detectAutomations } = require('./automations-detector');
14
14
  const { detectBrowserTools } = require('./browser-tools-detector');
15
- const { getHomeDir } = require('./env-paths');
15
+ const { getHomeDir, codexHome, piAgentDir } = require('./env-paths');
16
+ const { OWN_SKILL_NAMES } = require('./cli-skill');
16
17
 
17
18
  /* ---------- existence-check utilities (existence only, never content) ---------- */
18
19
 
@@ -85,14 +86,30 @@ function resolveSignalPath(sig, root) {
85
86
 
86
87
  /* ---------- DEPTH probes: return ONLY numbers ---------- */
87
88
 
88
- function countDirEntries(p) {
89
+ function countSkillDirs(p) {
89
90
  try {
90
- return fs.readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
91
+ return fs.readdirSync(p, { withFileTypes: true })
92
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !OWN_SKILL_NAMES.has(e.name)).length;
91
93
  } catch {
92
94
  return 0;
93
95
  }
94
96
  }
95
97
 
98
+ function countFilesDeep(p, ext, depth = 3) {
99
+ let entries;
100
+ try {
101
+ entries = fs.readdirSync(p, { withFileTypes: true });
102
+ } catch {
103
+ return 0;
104
+ }
105
+ let n = 0;
106
+ for (const e of entries) {
107
+ if (e.isFile() && e.name.endsWith(ext)) n += 1;
108
+ else if (e.isDirectory() && depth > 0) n += countFilesDeep(path.join(p, e.name), ext, depth - 1);
109
+ }
110
+ return n;
111
+ }
112
+
96
113
  function countFiles(p, ext) {
97
114
  try {
98
115
  return fs.readdirSync(p).filter((f) => (ext ? f.endsWith(ext) : true)).length;
@@ -113,6 +130,49 @@ function countJsonKeys(file, key) {
113
130
  }
114
131
  }
115
132
 
133
+ function claudeHooks(root) {
134
+ return (
135
+ countJsonKeys(path.join(root, '.claude', 'settings.json'), 'hooks') +
136
+ countJsonKeys(path.join(root, '.claude', 'settings.local.json'), 'hooks') +
137
+ countJsonKeys(path.join(getHomeDir(), '.claude', 'settings.json'), 'hooks')
138
+ );
139
+ }
140
+
141
+ const TOML_HOOK_TABLE = /^\s*\[\[?\s*hooks\.([A-Za-z]+)/;
142
+
143
+ function countTomlHookEvents(file) {
144
+ let raw;
145
+ try {
146
+ raw = fs.readFileSync(file, 'utf8');
147
+ } catch {
148
+ return 0;
149
+ }
150
+ const events = new Set();
151
+ for (const line of raw.split(/\r?\n/)) {
152
+ const m = TOML_HOOK_TABLE.exec(line);
153
+ if (m && m[1] !== 'state') events.add(m[1]);
154
+ }
155
+ return events.size;
156
+ }
157
+
158
+ function codexHooks(root) {
159
+ return [codexHome(), path.join(root, '.codex')].reduce(
160
+ (n, dir) => n + countJsonKeys(path.join(dir, 'hooks.json'), 'hooks') + countTomlHookEvents(path.join(dir, 'config.toml')),
161
+ 0,
162
+ );
163
+ }
164
+
165
+ const CLINE_HOOK_TYPES = new Set(['TaskStart', 'TaskResume', 'TaskCancel', 'TaskComplete', 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Notification', 'PreCompact']);
166
+
167
+ function countClineHooks(dir) {
168
+ try {
169
+ return fs.readdirSync(dir, { withFileTypes: true })
170
+ .filter((e) => e.isFile() && CLINE_HOOK_TYPES.has(path.parse(e.name).name)).length;
171
+ } catch {
172
+ return 0;
173
+ }
174
+ }
175
+
116
176
  /* ---------- config footprint: ONLY size in bytes and file count ---------- */
117
177
  /* No file name or path is ever stored: only numbers are aggregated. */
118
178
 
@@ -273,17 +333,20 @@ const probes = {
273
333
  'claude-code': (root, ctx) => ({
274
334
  mcpServers: mcpCount(ctx, 'claude-code'),
275
335
  skills:
276
- countDirEntries(path.join(root, '.claude', 'skills')) +
277
- countDirEntries(path.join(os.homedir(), '.claude', 'skills')),
336
+ countSkillDirs(path.join(root, '.claude', 'skills')) +
337
+ countSkillDirs(path.join(getHomeDir(), '.claude', 'skills')),
278
338
  commands: countFiles(path.join(root, '.claude', 'commands'), '.md'),
279
339
  instructions: exists(path.join(root, 'CLAUDE.md')) ? 1 : 0,
280
- hooks: countJsonKeys(path.join(root, '.claude', 'settings.json'), 'hooks'),
340
+ hooks: claudeHooks(root),
281
341
  }),
282
342
  cursor: (root, ctx) => ({
283
343
  rules:
284
344
  countFiles(path.join(root, '.cursor', 'rules'), '.mdc') +
285
345
  (exists(path.join(root, '.cursorrules')) ? 1 : 0),
286
346
  mcpServers: mcpCount(ctx, 'cursor'),
347
+ hooks:
348
+ countJsonKeys(path.join(root, '.cursor', 'hooks.json'), 'hooks') +
349
+ countJsonKeys(path.join(getHomeDir(), '.cursor', 'hooks.json'), 'hooks'),
287
350
  }),
288
351
  'github-copilot': (root) => ({
289
352
  instructions: exists(path.join(root, '.github', 'copilot-instructions.md')) ? 1 : 0,
@@ -307,10 +370,41 @@ const probes = {
307
370
  'gemini-cli': (root, ctx) => ({
308
371
  instructions: exists(path.join(root, 'GEMINI.md')) ? 1 : 0,
309
372
  mcpServers: mcpCount(ctx, 'gemini-cli'),
373
+ commands:
374
+ countFilesDeep(path.join(root, '.gemini', 'commands'), '.toml') +
375
+ countFilesDeep(path.join(getHomeDir(), '.gemini', 'commands'), '.toml'),
376
+ hooks:
377
+ countJsonKeys(path.join(root, '.gemini', 'settings.json'), 'hooks') +
378
+ countJsonKeys(path.join(getHomeDir(), '.gemini', 'settings.json'), 'hooks'),
310
379
  }),
311
- 'codex-cli': (root) => ({
380
+ 'codex-cli': (root, ctx) => ({
312
381
  instructions: exists(path.join(root, 'AGENTS.md')) ? 1 : 0,
382
+ mcpServers: mcpCount(ctx, 'codex-cli'),
383
+ skills: countSkillDirs(path.join(codexHome(), 'skills')),
384
+ commands: countFiles(path.join(codexHome(), 'prompts'), '.md'),
385
+ hooks: codexHooks(root),
313
386
  }),
387
+ cline: (root) => ({
388
+ hooks:
389
+ countClineHooks(path.join(root, '.clinerules', 'hooks')) +
390
+ countClineHooks(path.join(getHomeDir(), 'Documents', 'Cline', 'Hooks')),
391
+ }),
392
+ pi: (root, ctx) => ({
393
+ instructions: exists(path.join(root, 'AGENTS.md')) ? 1 : 0,
394
+ mcpServers: mcpCount(ctx, 'pi'),
395
+ skills: countSkillDirs(path.join(root, '.pi', 'skills')) + countSkillDirs(path.join(piAgentDir(), 'skills')),
396
+ commands: countFiles(path.join(root, '.pi', 'prompts'), '.md') + countFiles(path.join(piAgentDir(), 'prompts'), '.md'),
397
+ }),
398
+ opencode: (root, ctx) => {
399
+ const home = path.join(getHomeDir(), '.config', 'opencode');
400
+ const dirs = [path.join(root, '.opencode'), home];
401
+ return {
402
+ instructions: exists(path.join(root, 'AGENTS.md')) ? 1 : 0,
403
+ mcpServers: mcpCount(ctx, 'opencode'),
404
+ skills: dirs.reduce((n, d) => n + countSkillDirs(path.join(d, 'skills')) + countSkillDirs(path.join(d, 'skill')), 0),
405
+ commands: dirs.reduce((n, d) => n + countFiles(path.join(d, 'commands'), '.md') + countFiles(path.join(d, 'command'), '.md'), 0),
406
+ };
407
+ },
314
408
  trae: (root) => ({
315
409
  // Low confidence: `.trae/rules` structure not verified (see detectors.js).
316
410
  rules: countFiles(path.join(root, '.trae', 'rules')),
@@ -325,16 +419,14 @@ function agentOrgChartCounts(root, agentsCount, mcp) {
325
419
  return {
326
420
  agents: agentsCount,
327
421
  skills:
328
- countDirEntries(path.join(root, '.claude', 'skills')) +
329
- countDirEntries(path.join(home, '.claude', 'skills')),
422
+ countSkillDirs(path.join(root, '.claude', 'skills')) +
423
+ countSkillDirs(path.join(home, '.claude', 'skills')),
330
424
  commands:
331
425
  countFiles(path.join(root, '.claude', 'commands'), '.md') +
332
426
  countFiles(path.join(home, '.claude', 'commands'), '.md'),
333
427
  // The third copy of the MCP paths lived here.
334
428
  mcpServers: (mcp && typeof mcp.total === 'number' ? mcp.total : 0),
335
- hooks:
336
- countJsonKeys(path.join(root, '.claude', 'settings.json'), 'hooks') +
337
- countJsonKeys(path.join(home, '.claude', 'settings.json'), 'hooks'),
429
+ hooks: claudeHooks(root),
338
430
  };
339
431
  }
340
432
 
@@ -1,8 +1,5 @@
1
1
  'use strict';
2
2
 
3
- // Whether a non-exempt command may run: an active session, or a durable cookie
4
- // that can mint a fresh hub JWT right now. Reject only when neither holds.
5
-
6
3
  const { loadAuthSession, sessionStatus } = require('./auth-session-store');
7
4
  const { ensureFreshSession } = require('./session-refresh');
8
5
 
@@ -20,7 +17,7 @@ async function hasUsableSession(env = process.env, deps = {}) {
20
17
 
21
18
  const session = load(env);
22
19
  if (sessionStatus(session, now) === 'active') return true;
23
- if (!session || (!session.cookie && !session.sessionToken)) return false;
20
+ if (!session || !session.sessionToken) return false;
24
21
 
25
22
  let refreshed = null;
26
23
  try { refreshed = await refresh(env, { now }); } catch { refreshed = null; }
@@ -26,8 +26,6 @@ function textFromParts(parts) {
26
26
  }
27
27
 
28
28
  // --- Codex (~/.codex/sessions/**/rollout-*.jsonl) ---
29
- // Reads timestamp, cwd (session_meta), user text, and shell commands; a record matching none
30
- // of those passes through only its own real timestamp/cwd, else is dropped.
31
29
  function codexCommand(args) {
32
30
  let a = args;
33
31
  if (typeof a === 'string') {
@@ -60,7 +58,15 @@ function normalizeCodexRecord(raw) {
60
58
  }
61
59
 
62
60
  const type = str(raw.type) || str(item.type);
61
+ if (type === 'turn_context' && item.collaboration_mode && item.collaboration_mode.mode === 'plan') {
62
+ out.planMode = true;
63
+ return out;
64
+ }
63
65
  const name = str(item.name) || (type === 'function_call' ? 'shell' : null);
66
+ if (name === 'update_plan') {
67
+ out.message = { content: [{ type: 'tool_use', name }] };
68
+ return out;
69
+ }
64
70
  if (name && /shell|exec|bash|terminal|command/i.test(name)) {
65
71
  const command = codexCommand(item.arguments ?? item.input ?? item.args);
66
72
  if (command) {
@@ -73,11 +79,16 @@ function normalizeCodexRecord(raw) {
73
79
  }
74
80
 
75
81
  // --- Gemini CLI (~/.gemini/tmp/<project-hash>/chats/logs.json | session-*.jsonl) ---
76
- // logs.json = JSON array of { type:'user'|'model', message, timestamp }; session-*.jsonl =
77
- // per-line { role, parts }. No cwd is recorded (the dir is a one-way SHA-256 hash), so cwd
78
- // stays null — global session/time metrics still count; per-repo scoping can't.
79
82
  function normalizeGeminiRecord(raw) {
80
83
  if (!raw || typeof raw !== 'object') return null;
84
+ if (Array.isArray(raw.messages)) {
85
+ const out = [];
86
+ for (const m of raw.messages) {
87
+ const o = normalizeGeminiRecord(m);
88
+ if (o) out.push(o);
89
+ }
90
+ return out;
91
+ }
81
92
  const out = {};
82
93
  const ts = str(raw.timestamp) || str(raw.time);
83
94
  if (ts) out.timestamp = ts;
@@ -91,6 +102,11 @@ function normalizeGeminiRecord(raw) {
91
102
  return out;
92
103
  }
93
104
  }
105
+ const calls = Array.isArray(raw.toolCalls) ? raw.toolCalls.map((c) => toolUse(c && c.name)).filter(Boolean) : [];
106
+ if (calls.length) {
107
+ out.message = { role: 'assistant', content: calls };
108
+ return out;
109
+ }
94
110
  return out.timestamp ? out : null;
95
111
  }
96
112
 
@@ -111,7 +127,7 @@ function normalizeContinueRecord(raw) {
111
127
  }
112
128
  return null;
113
129
  }
114
- const out = [];
130
+ const out = str(raw.mode) === 'plan' ? [cwd ? { cwd, planMode: true } : { planMode: true }] : [];
115
131
  for (const entry of history) {
116
132
  const msg = entry && entry.message && typeof entry.message === 'object' ? entry.message : entry;
117
133
  if (!msg || typeof msg !== 'object') continue;
@@ -127,15 +143,30 @@ function normalizeContinueRecord(raw) {
127
143
  }
128
144
 
129
145
  // --- Cline / Roo Code: VS Code globalStorage .../tasks/<id>/api_conversation_history.json ---
130
- // One JSON array of Anthropic messages (role/content); no cwd and no timestamp in this file
131
- // (ui_messages.json has ts) → cwd/time attribution absent. Emits only real user turns.
132
146
  function normalizeClineRecord(raw) {
133
147
  if (!raw || typeof raw !== 'object') return null;
148
+ if (str(raw.role) === 'assistant') return clinePlanningTurn(raw.content);
134
149
  if (str(raw.role) !== 'user') return null;
135
150
  const text = typeof raw.content === 'string' ? raw.content.trim() : textFromParts(raw.content);
136
151
  return text ? oneUser(text, null, null) : null;
137
152
  }
138
153
 
154
+ const CLINE_PLANNING_TAG = /<(plan_mode_respond|update_todo_list)>/g;
155
+
156
+ function clinePlanningTurn(content) {
157
+ const blocks = typeof content === 'string' ? [{ type: 'text', text: content }] : (Array.isArray(content) ? content : []);
158
+ const calls = [];
159
+ for (const b of blocks) {
160
+ if (!b || typeof b !== 'object') continue;
161
+ if (b.type === 'tool_use') calls.push(toolUse(b.name));
162
+ else if (b.type === 'text' && typeof b.text === 'string') {
163
+ for (const m of b.text.matchAll(CLINE_PLANNING_TAG)) calls.push(toolUse(m[1]));
164
+ }
165
+ }
166
+ const uses = calls.filter(Boolean);
167
+ return uses.length ? { message: { role: 'assistant', content: uses } } : null;
168
+ }
169
+
139
170
  // --- Aider: per-repo .aider.chat.history.md (markdown) ---
140
171
  // Repo-level source → cwd is the repo toplevel (good attribution). Markdown parse:
141
172
  // '# aider chat started at <ts>' sets the session time; '#### <text>' lines are user
@@ -158,6 +189,78 @@ function parseAiderHistory(text, cwd) {
158
189
  return out;
159
190
  }
160
191
 
192
+ function normalizeClineSessionRecord(raw) {
193
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.messages)) return null;
194
+ const out = [];
195
+ for (const m of raw.messages) {
196
+ if (!m || typeof m !== 'object') continue;
197
+ const ts = Number.isFinite(m.ts) ? new Date(m.ts).toISOString() : null;
198
+ if (str(m.role) === 'user') {
199
+ const text = textFromParts((Array.isArray(m.content) ? m.content : []).filter((b) => b && b.type === 'text'));
200
+ if (text) out.push(oneUser(text, null, ts));
201
+ continue;
202
+ }
203
+ if (str(m.role) !== 'assistant') continue;
204
+ const uses = (Array.isArray(m.content) ? m.content : []).filter((b) => b && b.type === 'tool_use').map((b) => toolUse(b.name)).filter(Boolean);
205
+ const o = ts ? { timestamp: ts } : {};
206
+ if (uses.length) o.message = { role: 'assistant', content: uses };
207
+ if (o.timestamp || o.message) out.push(o);
208
+ }
209
+ return out;
210
+ }
211
+
212
+ const CURSOR_QUERY = /^\s*<user_query>([\s\S]*?)<\/user_query>\s*$/;
213
+
214
+ function normalizeCursorRecord(raw) {
215
+ if (!raw || typeof raw !== 'object' || !raw.message || !Array.isArray(raw.message.content)) return null;
216
+ const role = str(raw.role);
217
+ if (role === 'user') {
218
+ const text = textFromParts(raw.message.content.filter((b) => b && b.type === 'text').map((b) => {
219
+ const m = CURSOR_QUERY.exec(b.text || '');
220
+ return m ? m[1] : b.text;
221
+ }));
222
+ return text ? oneUser(text, null, null) : null;
223
+ }
224
+ if (role !== 'assistant') return null;
225
+ const uses = raw.message.content.filter((b) => b && b.type === 'tool_use' && str(b.name));
226
+ return uses.length ? { message: { role: 'assistant', content: uses } } : null;
227
+ }
228
+
229
+ function normalizePiRecord(raw) {
230
+ if (!raw || typeof raw !== 'object') return null;
231
+ const ts = str(raw.timestamp);
232
+ const type = str(raw.type);
233
+ if (type === 'session') {
234
+ const cwd = str(raw.cwd);
235
+ if (!cwd && !ts) return null;
236
+ const o = {};
237
+ if (cwd) o.cwd = cwd;
238
+ if (ts) o.timestamp = ts;
239
+ return o;
240
+ }
241
+ if (type === 'custom' && raw.customType === 'plan-mode' && raw.data && raw.data.enabled === true) {
242
+ return ts ? { timestamp: ts, planMode: true } : { planMode: true };
243
+ }
244
+ if (type !== 'message' || !raw.message || typeof raw.message !== 'object') return null;
245
+ const role = str(raw.message.role);
246
+ if (role === 'user') {
247
+ const text = textFromParts(raw.message.content);
248
+ return text ? oneUser(text, null, ts) : null;
249
+ }
250
+ if (role !== 'assistant') return ts ? { timestamp: ts } : null;
251
+ const uses = (Array.isArray(raw.message.content) ? raw.message.content : [])
252
+ .filter((b) => b && b.type === 'toolCall')
253
+ .map((b) => toolUse(b.name))
254
+ .filter(Boolean);
255
+ const o = ts ? { timestamp: ts } : {};
256
+ if (uses.length) o.message = { role: 'assistant', content: uses };
257
+ return o.timestamp || o.message ? o : null;
258
+ }
259
+
260
+ function toolUse(name) {
261
+ return typeof name === 'string' && name ? { type: 'tool_use', name } : null;
262
+ }
263
+
161
264
  function oneUser(text, cwd, ts) {
162
265
  const o = { type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } };
163
266
  if (cwd) o.cwd = cwd;
@@ -170,5 +273,8 @@ module.exports = {
170
273
  normalizeGeminiRecord,
171
274
  normalizeContinueRecord,
172
275
  normalizeClineRecord,
276
+ normalizeClineSessionRecord,
277
+ normalizeCursorRecord,
278
+ normalizePiRecord,
173
279
  parseAiderHistory,
174
280
  };
@@ -1,7 +1,5 @@
1
1
  'use strict';
2
2
 
3
- // On-demand hub-JWT minting for an email (better-auth) session.
4
-
5
3
  const REFRESH_MARGIN_MS = 120 * 1000;
6
4
  const REVOKED_STATUSES = new Set([401, 403]);
7
5
 
@@ -24,13 +22,13 @@ async function ensureFreshSession(env = process.env, deps = {}) {
24
22
 
25
23
  const session = loadAuthSession(env);
26
24
  if (!session) return null;
27
- if (!session.cookie && !session.sessionToken) return session;
25
+ if (!session.sessionToken) return session;
28
26
  if (session.accessToken && jwtStillFresh(session.accessTokenExpiresAt, now, marginMs)) return session;
29
27
 
30
28
  const endpoint = getAuthTokenEndpoint(env);
31
29
  if (!endpoint) return session;
32
30
 
33
- const tok = await requestAuthToken({ cookie: session.cookie, sessionToken: session.sessionToken }, { endpoint });
31
+ const tok = await requestAuthToken({ sessionToken: session.sessionToken }, { endpoint });
34
32
  if (!tok.ok && REVOKED_STATUSES.has(tok.status)) {
35
33
  clearAuthSession(env);
36
34
  return null;
@@ -44,7 +42,7 @@ async function ensureFreshSession(env = process.env, deps = {}) {
44
42
  accessToken: tok.accessToken,
45
43
  hubAccessToken: tok.accessToken,
46
44
  accessTokenExpiresAt: tok.expiresAt,
47
- cookieExpiresAt: session.expiresAt,
45
+ sessionExpiresAt: session.expiresAt,
48
46
  },
49
47
  env,
50
48
  );
@@ -54,25 +52,16 @@ async function ensureFreshSession(env = process.env, deps = {}) {
54
52
  return loadAuthSession(env);
55
53
  }
56
54
 
57
- function webOrigin(url) {
58
- try {
59
- return new URL(url).origin;
60
- } catch {
61
- return null;
62
- }
63
- }
64
-
65
55
  async function endSession(env = process.env, deps = {}) {
66
56
  const {
67
57
  loadAuthSession = require('./auth-session-store').loadAuthSession,
68
58
  clearAuthSession = require('./auth-session-store').clearAuthSession,
69
59
  getSignOutEndpoint = require('./config').getSignOutEndpoint,
70
- getTalentProfileUrl = require('./config').getTalentProfileUrl,
71
60
  requestSignOut = require('./auth-client').requestSignOut,
72
61
  } = deps;
73
62
  const session = loadAuthSession(env);
74
- if (session && (session.sessionToken || session.cookie)) {
75
- await requestSignOut({ cookie: session.cookie, sessionToken: session.sessionToken }, { endpoint: getSignOutEndpoint(env), origin: webOrigin(getTalentProfileUrl(env)) });
63
+ if (session && session.sessionToken) {
64
+ await requestSignOut({ sessionToken: session.sessionToken }, { endpoint: getSignOutEndpoint(env) });
76
65
  }
77
66
  clearAuthSession(env);
78
67
  return session !== null;