shakers-cli 0.17.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 (59) hide show
  1. package/README.md +15 -5
  2. package/bin/command-audit-worker.js +19 -0
  3. package/bin/login.js +4 -8
  4. package/bin/mcp.js +29 -19
  5. package/bin/register.js +10 -29
  6. package/bin/shakers-cli.js +14 -9
  7. package/package.json +1 -1
  8. package/src/agents-client.js +1 -0
  9. package/src/ai-usage-review.js +5 -3
  10. package/src/auth-client.js +7 -107
  11. package/src/auth-session-store.js +6 -12
  12. package/src/backend-request.js +1 -24
  13. package/src/cli-skill.js +77 -0
  14. package/src/command-audit.js +47 -4
  15. package/src/config.js +0 -16
  16. package/src/consent-flow.js +7 -2
  17. package/src/detect-repos.js +4 -8
  18. package/src/detectors.js +23 -0
  19. package/src/device-auth-store.js +2 -2
  20. package/src/device-login.js +11 -9
  21. package/src/env-paths.js +9 -1
  22. package/src/i18n.js +162 -88
  23. package/src/mcp-ai-usage-result-tool.js +6 -4
  24. package/src/mcp-ai-usage-tool.js +72 -46
  25. package/src/mcp-choice.js +171 -34
  26. package/src/mcp-detector.js +26 -2
  27. package/src/mcp-device-login-tools.js +40 -8
  28. package/src/mcp-install.js +15 -11
  29. package/src/mcp-instructions.js +43 -17
  30. package/src/mcp-onboarding-tools.js +1 -3
  31. package/src/mcp-prompts.js +6 -4
  32. package/src/mcp-register-tools.js +25 -14
  33. package/src/mcp-role-tools.js +35 -17
  34. package/src/mcp-server-name.js +7 -0
  35. package/src/mcp-server.js +35 -7
  36. package/src/mcp-signup-client.js +3 -6
  37. package/src/mcp-signup-flow.js +389 -0
  38. package/src/mcp-signup-tools.js +237 -126
  39. package/src/onboarding-flow.js +30 -125
  40. package/src/opencode-sessions.js +92 -0
  41. package/src/profile-agents.js +26 -0
  42. package/src/saved-positions-flow.js +3 -3
  43. package/src/scanner.js +104 -12
  44. package/src/session-gate.js +1 -4
  45. package/src/session-normalizers.js +114 -8
  46. package/src/session-refresh.js +5 -16
  47. package/src/session-scan.js +167 -22
  48. package/src/share-payload.js +4 -1
  49. package/src/share.js +2 -2
  50. package/src/signup-client.js +12 -39
  51. package/src/signup-copy.js +380 -68
  52. package/src/skill-install.js +102 -0
  53. package/src/tier-engine.js +14 -2
  54. package/src/trust-narration.js +32 -0
  55. package/src/usage-run-steps.js +12 -3
  56. package/src/usage-scan.js +3 -0
  57. package/src/brand-svg.js +0 -9
  58. package/src/inter-fonts.js +0 -6
  59. package/src/mcp-auth-form.js +0 -484
@@ -249,7 +249,6 @@ function makeDeps(overrides = {}) {
249
249
  const ai = require('./ai');
250
250
  const inventory = require('./inventory-client');
251
251
  const auth = require('./auth-session-store');
252
- const authClient = require('./auth-client');
253
252
  const signup = require('./signup-client');
254
253
  const livekit = require('./onboarding-livekit-client');
255
254
  return {
@@ -257,17 +256,14 @@ function makeDeps(overrides = {}) {
257
256
  sessionStatus: auth.sessionStatus,
258
257
  saveAuthSession: auth.saveAuthSession,
259
258
  ensureFreshSession: require('./session-refresh').ensureFreshSession,
260
- getSignUpEndpoint: config.getSignUpEndpoint,
261
259
  getCompleteRegistrationEndpoint: config.getCompleteRegistrationEndpoint,
262
- getLoginEndpoint: config.getLoginEndpoint,
263
260
  getAuthTokenEndpoint: config.getAuthTokenEndpoint,
264
261
  getDeviceAuthorizeEndpoint: config.getDeviceAuthorizeEndpoint,
265
262
  getDeviceTokenEndpoint: config.getDeviceTokenEndpoint,
266
263
  runDeviceLogin: require('./device-login').runDeviceLogin,
267
264
  requestCompleteRegistration: signup.requestCompleteRegistration,
268
265
  buildRegistrationContext: signup.buildRegistrationContext,
269
- requestSignUp: signup.requestSignUp,
270
- requestLogin: authClient.requestLogin,
266
+ buildRegistrationConsent: signup.buildRegistrationConsent,
271
267
  getImportProfileEndpoint: config.getImportProfileEndpoint,
272
268
  getProfessionalDetailsEndpoint: config.getProfessionalDetailsEndpoint,
273
269
  getPricingRateEndpoint: config.getPricingRateEndpoint,
@@ -305,7 +301,6 @@ function makeDeps(overrides = {}) {
305
301
  };
306
302
  }
307
303
 
308
- // Refreshes the email session's hub JWT from its stored cookie when the cached one has lapsed (session-refresh.js), then gates on the durable session status.
309
304
  async function requireActiveSession(deps) {
310
305
  const session = typeof deps.ensureFreshSession === 'function'
311
306
  ? await deps.ensureFreshSession(process.env, { loadAuthSession: deps.loadAuthSession, saveAuthSession: deps.saveAuthSession })
@@ -316,79 +311,26 @@ async function requireActiveSession(deps) {
316
311
  return { ok: true, session };
317
312
  }
318
313
 
319
- function isAcceptablePassword(password) {
320
- return typeof password === 'string' && password.length >= 8;
321
- }
322
-
323
- async function signUp(deps, fields = {}) {
324
- // The CLI no longer asks freelanceType at signup → transient default (corrected later from the work situation via professional-details).
325
- if (!isAcceptablePassword(fields.password)) return { ok: false, reason: 'weak-password' };
326
- const signUpEndpoint = deps.getSignUpEndpoint();
327
- if (!signUpEndpoint) return { ok: false, reason: 'no-endpoint' };
328
- const completeRegistrationEndpoint = typeof deps.getCompleteRegistrationEndpoint === 'function'
329
- ? deps.getCompleteRegistrationEndpoint()
330
- : null;
331
- return deps.requestSignUp(
332
- {
333
- name: fields.name,
334
- lastName: fields.lastName,
335
- email: fields.email,
336
- password: fields.password,
337
- preferredLanguage: fields.preferredLanguage,
338
- newsletterConsent: fields.newsletterConsent === true,
339
- freelanceType: normalizeFreelanceType(fields.freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
340
- freelanceIntent: fields.freelanceIntent,
341
- claimCode: fields.claimCode || null,
342
- },
343
- { signUpEndpoint, completeRegistrationEndpoint },
344
- );
345
- }
346
-
347
- async function establishSessionFromCredentials(deps, { email, password } = {}) {
348
- const signInEndpoint = deps.getLoginEndpoint();
349
- if (!signInEndpoint) return { ok: false, reason: 'no-endpoint' };
350
- const tokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
351
- const result = await deps.requestLogin({ email, password }, { signInEndpoint, tokenEndpoint });
352
- if (!result.ok) return { ok: false, reason: result.reason || 'login-failed', status: result.status || null };
353
- try {
354
- // For an email session the durable credential is the cookie; `accessToken` is
355
- // the fresh JWT and `expiresAt` its own exp (stored as the refresh horizon).
356
- deps.saveAuthSession({
357
- accessToken: result.accessToken,
358
- expiresAt: result.expiresAt,
359
- accessTokenExpiresAt: result.expiresAt,
360
- cookie: result.cookie || null,
361
- cookieExpiresAt: result.cookieExpiresAt || null,
362
- email: result.email || email || null,
363
- hubAccessToken: result.hubAccessToken || null,
364
- });
365
- } catch {
366
- return { ok: false, reason: 'session-persist-failed' };
367
- }
368
- return { ok: true };
369
- }
370
-
371
- // Register a talent via a social provider (device flow, RFC 8628); a live session results either way.
372
- async function registerWithSocialAccount(deps, { provider = 'google', freelanceType, language } = {}, { openBrowser, onAuthUrl } = {}) {
373
- const { appendProviderParam, deviceSessionRecord } = require('./device-login');
314
+ async function registerWithDeviceFlow(deps, { provider = 'email', freelanceType, language, newsletterConsent = false } = {}, { openBrowser, onAuthUrl } = {}) {
315
+ const { withDeviceParams, deviceSessionRecord } = require('./device-login');
374
316
  const authorizeEndpoint = deps.getDeviceAuthorizeEndpoint();
375
317
  const tokenEndpoint = deps.getDeviceTokenEndpoint();
376
318
  const authTokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
377
319
  const completeRegistrationEndpoint = typeof deps.getCompleteRegistrationEndpoint === 'function' ? deps.getCompleteRegistrationEndpoint() : null;
378
320
  if (!authorizeEndpoint || !tokenEndpoint || !authTokenEndpoint) return { ok: false, reason: 'no-endpoint' };
379
321
 
380
- // freelanceType is a transient default (corrected later); feeds complete-registration for a new account.
381
322
  const registrationContext = deps.buildRegistrationContext({
382
323
  preferredLanguage: language,
383
- newsletterConsent: false,
324
+ newsletterConsent: newsletterConsent === true,
384
325
  freelanceType: normalizeFreelanceType(freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
385
326
  });
327
+ const consent = deps.buildRegistrationConsent({ newsletterConsent: newsletterConsent === true });
386
328
 
387
329
  const result = await deps.runDeviceLogin(
388
- { authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint, registrationContext },
330
+ { authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint, registrationContext, consent },
389
331
  {
390
332
  onPrompt: ({ verificationUriComplete, userCode }) => {
391
- const url = appendProviderParam(verificationUriComplete, provider);
333
+ const url = withDeviceParams(verificationUriComplete, { provider, mode: 'register', surface: 'cli' });
392
334
  if (typeof onAuthUrl === 'function') onAuthUrl(url, userCode);
393
335
  if (typeof openBrowser === 'function') { try { openBrowser(url); } catch { /* printed URL covers it */ } }
394
336
  },
@@ -402,7 +344,6 @@ async function registerWithSocialAccount(deps, { provider = 'google', freelanceT
402
344
  return { ok: false, reason: 'session-persist-failed' };
403
345
  }
404
346
 
405
- // isNewUser distinguishes a fresh registration from signing an existing account in.
406
347
  return { ok: true, kind: result.isNewUser ? 'registered' : 'account-already-exists', email: result.email || null };
407
348
  }
408
349
 
@@ -697,58 +638,27 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
697
638
  return { ok: false, reason, step: 'signup' };
698
639
  };
699
640
 
700
- if (method === 'google' || method === 'linkedin') {
701
- // The provider supplies name/email; freelanceType is derived later from work situation.
702
- await io.askGoogleSignUp(c);
703
- const reg = await registerWithSocialAccount(
704
- deps,
705
- { provider: method, language: io.lang },
706
- { openBrowser, onAuthUrl },
707
- );
708
- if (!reg.ok) {
709
- io.error(c.signupError);
710
- return accountFailed(reg.reason);
711
- }
712
- funnel.completed('account_creation', { auth_method: method, auth_attempts: 1, is_returning_account: reg.kind === 'account-already-exists' });
713
- funnel.identified({ newAccount: reg.kind !== 'account-already-exists' });
714
- if (reg.kind === 'account-already-exists') {
715
- // Existing account: already signed in — stop, no wizard re-run.
716
- io.warn(c.accountExists);
717
- return { ok: true, alreadyRegistered: true };
718
- }
719
- notifyOk(io, c.accountReady);
720
- } else {
721
- const account = await io.askSignUp(c);
722
- if (!account || !account.email) return accountFailed('missing-fields');
723
- if (!isAcceptablePassword(account.password)) {
724
- io.error(c.weakPassword);
725
- return accountFailed('weak-password');
726
- }
727
-
728
- const su = await signUp(deps, { ...account, preferredLanguage: account.preferredLanguage || io.lang });
729
- if (!su.ok) {
730
- io.error(su.message || c.signupError);
731
- return accountFailed(su.reason);
732
- }
733
-
734
- if (su.accountExists) {
735
- // Existing account: sign them in, then STOP — no wizard re-run.
736
- io.warn(c.accountExists);
737
- const logged = await io.login(account.email);
738
- if (!logged) return accountFailed('login-failed');
739
- funnel.completed('account_creation', { auth_method: 'email', auth_attempts: 1, is_returning_account: true });
740
- funnel.identified({ newAccount: false });
741
- return { ok: true, alreadyRegistered: true };
742
- }
743
- const sess = await establishSessionFromCredentials(deps, { email: account.email, password: account.password });
744
- if (!sess.ok) {
745
- io.error(c.sessionError);
746
- return accountFailed(sess.reason);
747
- }
748
- funnel.completed('account_creation', { auth_method: 'email', auth_attempts: 1, is_returning_account: false });
749
- funnel.identified();
750
- notifyOk(io, c.accountReady);
641
+ const terms = await io.askTerms(c);
642
+ if (!terms.accepted) {
643
+ io.warn(c.termsDeclined);
644
+ return accountFailed('terms-declined');
645
+ }
646
+ const reg = await registerWithDeviceFlow(
647
+ deps,
648
+ { provider: method, language: io.lang, newsletterConsent: terms.newsletterConsent },
649
+ { openBrowser, onAuthUrl },
650
+ );
651
+ if (!reg.ok) {
652
+ io.error(c.signupError);
653
+ return accountFailed(reg.reason);
654
+ }
655
+ funnel.completed('account_creation', { auth_method: method, auth_attempts: 1, is_returning_account: reg.kind === 'account-already-exists' });
656
+ funnel.identified({ newAccount: reg.kind !== 'account-already-exists' });
657
+ if (reg.kind === 'account-already-exists') {
658
+ io.warn(c.accountExists);
659
+ return { ok: true, alreadyRegistered: true };
751
660
  }
661
+ notifyOk(io, c.accountReady);
752
662
 
753
663
  io.section(c.title);
754
664
  funnel.viewed('import_sources', { sources_offered: ['linkedin', 'cv', 'github', 'web_page'] });
@@ -1044,15 +954,13 @@ async function runMainRoleFinalStep(io, deps, { onRolesShown } = {}) {
1044
954
  }
1045
955
  }
1046
956
 
1047
- // Overwrite-repeat: PATCH /interviews/onboarding/restart. Per contract the Bearer is
1048
- // the talent hub JWT (same token roles use); falls back to accessToken when equal.
957
+ // Overwrite-repeat: PATCH /interviews/onboarding/restart. Candidate certs JWT, like its onboarding siblings.
1049
958
  async function restartOnboarding(deps) {
1050
959
  const gate = await requireActiveSession(deps);
1051
960
  if (!gate.ok) return gate;
1052
961
  const endpoint = deps.getOnboardingRestartEndpoint();
1053
962
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
1054
- const token = gate.session.hubAccessToken || gate.session.accessToken;
1055
- return deps.requestRestartOnboardingInterview({ accessToken: token }, { endpoint });
963
+ return deps.requestRestartOnboardingInterview({ accessToken: gate.session.accessToken }, { endpoint });
1056
964
  }
1057
965
 
1058
966
  // `shakers onboarding --repeat`: warn + explicit confirm, then overwrite the prior
@@ -1144,7 +1052,6 @@ module.exports = {
1144
1052
  validatePricing,
1145
1053
  validateAvailability,
1146
1054
  buildPricingUpsert,
1147
- isAcceptablePassword,
1148
1055
  deriveCurrentEmploymentStatus,
1149
1056
  showsFreelanceOpinion,
1150
1057
  deriveFreelanceIntent,
@@ -1153,9 +1060,7 @@ module.exports = {
1153
1060
  validateLanguageRows,
1154
1061
  validatePhone,
1155
1062
  makeDeps,
1156
- signUp,
1157
- establishSessionFromCredentials,
1158
- registerWithSocialAccount,
1063
+ registerWithDeviceFlow,
1159
1064
  startProfileImport,
1160
1065
  reattachConsentIdentity,
1161
1066
  saveWorkSituation,
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getHomeDir } = require('./env-paths');
6
+
7
+ function loadSqlite() {
8
+ const emitWarning = process.emitWarning;
9
+ process.emitWarning = (warning, ...rest) => {
10
+ const type = typeof rest[0] === 'string' ? rest[0] : rest[0] && rest[0].type;
11
+ if (type === 'ExperimentalWarning' && /sqlite/i.test(String(warning))) return;
12
+ emitWarning.call(process, warning, ...rest);
13
+ };
14
+ try {
15
+ return require('node:sqlite');
16
+ } catch {
17
+ return null;
18
+ } finally {
19
+ process.emitWarning = emitWarning;
20
+ }
21
+ }
22
+
23
+ function opencodeDbPath(env = process.env) {
24
+ const dataDir = env.XDG_DATA_HOME
25
+ ? path.join(env.XDG_DATA_HOME, 'opencode')
26
+ : path.join(getHomeDir(env), '.local', 'share', 'opencode');
27
+ if (env.OPENCODE_DB) return path.isAbsolute(env.OPENCODE_DB) ? env.OPENCODE_DB : path.join(dataDir, env.OPENCODE_DB);
28
+ return path.join(dataDir, 'opencode.db');
29
+ }
30
+
31
+ const SESSIONS_SQL = 'SELECT id, directory, parent_id AS parentId, agent FROM session ORDER BY time_created DESC LIMIT ?';
32
+ const MESSAGES_SQL = `SELECT id, time_created AS createdAt, json_extract(data, '$.role') AS role, json_extract(data, '$.agent') AS agent
33
+ FROM message WHERE session_id = ? ORDER BY time_created, id`;
34
+ const PARTS_SQL = `SELECT p.message_id AS messageId, json_extract(p.data, '$.type') AS type, json_extract(p.data, '$.text') AS text,
35
+ json_extract(p.data, '$.tool') AS tool, json_extract(p.data, '$.synthetic') AS synthetic
36
+ FROM part p JOIN message m ON m.id = p.message_id WHERE m.session_id = ? ORDER BY p.id`;
37
+
38
+ function isoOf(ms) {
39
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
40
+ }
41
+
42
+ function sessionObjects(db, session) {
43
+ const parts = new Map();
44
+ for (const p of db.prepare(PARTS_SQL).all(session.id)) {
45
+ if (!parts.has(p.messageId)) parts.set(p.messageId, []);
46
+ parts.get(p.messageId).push(p);
47
+ }
48
+ const head = {};
49
+ if (typeof session.directory === 'string' && session.directory) head.cwd = session.directory;
50
+ if (session.agent === 'plan') head.planMode = true;
51
+ const objects = [head];
52
+ for (const m of db.prepare(MESSAGES_SQL).all(session.id)) {
53
+ const timestamp = isoOf(Number(m.createdAt));
54
+ const own = parts.get(m.id) || [];
55
+ if (m.agent === 'plan') head.planMode = true;
56
+ if (m.role === 'user') {
57
+ const text = own.filter((p) => p.type === 'text' && !p.synthetic && typeof p.text === 'string').map((p) => p.text).join('').trim();
58
+ if (text) {
59
+ objects.push({ type: 'user', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
60
+ continue;
61
+ }
62
+ }
63
+ const uses = own.filter((p) => p.type === 'tool' && typeof p.tool === 'string' && p.tool).map((p) => ({ type: 'tool_use', name: p.tool }));
64
+ const o = timestamp ? { timestamp } : {};
65
+ if (uses.length) o.message = { role: 'assistant', content: uses };
66
+ if (o.timestamp || o.message) objects.push(o);
67
+ }
68
+ return objects;
69
+ }
70
+
71
+ function readOpencodeSessions(env, limit) {
72
+ const file = opencodeDbPath(env);
73
+ if (!fs.existsSync(file)) return [];
74
+ const sqlite = loadSqlite();
75
+ const Database = sqlite && (sqlite.DatabaseSync || sqlite.Database);
76
+ if (!Database) return [];
77
+ let db;
78
+ try {
79
+ db = new Database(file, { readOnly: true });
80
+ return db.prepare(SESSIONS_SQL).all(limit).map((session) => ({
81
+ file: `${file}#${session.id}`,
82
+ objects: sessionObjects(db, session),
83
+ subagent: Boolean(session.parentId),
84
+ }));
85
+ } catch {
86
+ return [];
87
+ } finally {
88
+ if (db) db.close();
89
+ }
90
+ }
91
+
92
+ module.exports = { readOpencodeSessions, opencodeDbPath };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const { requestListAgents } = require('./agents-client');
4
+ const { getAgentsListEndpoint } = require('./config');
5
+ const { ensureFreshSession } = require('./session-refresh');
6
+
7
+ const TALENT_ADDED_SOURCES = new Set(['DECLARED_BY_TALENT', 'CLI_DETECTED']);
8
+
9
+ async function fetchProfileAgentNames(env = process.env, deps = {}) {
10
+ const {
11
+ refresh = ensureFreshSession,
12
+ list = requestListAgents,
13
+ endpoint = getAgentsListEndpoint(env),
14
+ } = deps;
15
+ try {
16
+ const session = await refresh(env);
17
+ if (!session || !session.hubAccessToken) return [];
18
+ const listed = await list({ accessToken: session.accessToken, hubAccessToken: session.hubAccessToken }, { endpoint });
19
+ if (!listed || !listed.ok || !Array.isArray(listed.agents)) return [];
20
+ return listed.agents.filter((a) => a && TALENT_ADDED_SOURCES.has(a.source) && a.name).map((a) => a.name);
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ module.exports = { fetchProfileAgentNames, TALENT_ADDED_SOURCES };
@@ -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; }