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
@@ -1,15 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const { postJsonWithTimeout, extractSetCookie } = require('./backend-request');
4
-
5
- // Login / token acquisition against the Hub's better-auth email engine.
3
+ const { postJsonWithTimeout } = require('./backend-request');
6
4
 
7
5
  const DEFAULT_TIMEOUT_MS = 20000;
8
6
  const SIGN_OUT_TIMEOUT_MS = 3000;
9
- const SESSION_COOKIE_NAME = 'better-auth.session_token';
10
- // Refresh the cookie session's own validity from the Set-Cookie Max-Age; fall
11
- // back to 7 days (better-auth's default) when the attribute is absent.
12
- const DEFAULT_COOKIE_TTL_SECONDS = 7 * 24 * 60 * 60;
13
7
 
14
8
  function parseJson(raw) {
15
9
  try { return raw ? JSON.parse(raw) : null; } catch { return null; }
@@ -28,71 +22,10 @@ function expiresAtFromJwt(token) {
28
22
  }
29
23
  }
30
24
 
31
- // ISO expiry for the session cookie, read from the Set-Cookie `Max-Age` (or
32
- // `Expires`) of the named cookie; defaults to +7 days when neither is present.
33
- function cookieExpiresAtFromHeaders(headers, name = SESSION_COOKIE_NAME) {
34
- const raw = headers && headers['set-cookie'];
35
- const list = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw ? [raw] : []);
36
- // Match the base name with or without better-auth's HTTPS `__Secure-`/`__Host-` prefixes.
37
- const names = [name, `__Secure-${name}`, `__Host-${name}`];
38
- const entry = list.find((e) => typeof e === 'string' && names.some((n) => e.startsWith(`${n}=`)));
39
- if (entry) {
40
- const maxAge = /(?:^|;\s*)max-age=(\d+)/i.exec(entry);
41
- if (maxAge) return new Date(Date.now() + Number(maxAge[1]) * 1000).toISOString();
42
- const expires = /(?:^|;\s*)expires=([^;]+)/i.exec(entry);
43
- if (expires) {
44
- const t = Date.parse(expires[1]);
45
- if (Number.isFinite(t)) return new Date(t).toISOString();
46
- }
47
- }
48
- return new Date(Date.now() + DEFAULT_COOKIE_TTL_SECONDS * 1000).toISOString();
49
- }
50
-
51
- function normalizeResponse(status, raw) {
52
- if (status >= 200 && status < 300) {
53
- let json = null;
54
- try { json = raw ? JSON.parse(raw) : null; } catch { json = null; }
55
- const payload = json && typeof json.data === 'object' && json.data !== null ? json.data : json;
56
- const accessToken = payload && typeof payload.accessToken === 'string' && payload.accessToken ? payload.accessToken : null;
57
- if (!accessToken) return { ok: false, reason: 'bad-response', status };
58
- const expiresAt = payload && typeof payload.expiresAt === 'string' && payload.expiresAt ? payload.expiresAt : null;
59
- const emailRaw = payload && (payload.userEmail || payload.email);
60
- const email = typeof emailRaw === 'string' && emailRaw ? emailRaw : null;
61
- const hubAccessToken = payload && typeof payload.hubAccessToken === 'string' && payload.hubAccessToken ? payload.hubAccessToken : null;
62
- return { ok: true, accessToken, expiresAt, email, hubAccessToken };
63
- }
64
- if (status === 401) return { ok: false, reason: 'invalid-credentials', status };
65
- if (status === 409 || status === 422) return { ok: false, reason: 'no-email-password', status };
66
- if (status === 502 || status === 503) return { ok: false, reason: 'upstream', status };
67
- return { ok: false, reason: 'http-error', status };
68
- }
69
-
70
- // Maps the sign-in POST outcome to a captured cookie + email, or a named reason.
71
- function normalizeSignIn(status, raw, headers) {
72
- if (status >= 200 && status < 300) {
73
- const cookie = extractSetCookie(headers, SESSION_COOKIE_NAME);
74
- if (!cookie) return { ok: false, reason: 'bad-response', status };
75
- const body = parseJson(raw);
76
- const user = body && typeof body === 'object' ? body.user : null;
77
- const emailRaw = user && typeof user.email === 'string' ? user.email : null;
78
- return {
79
- ok: true,
80
- cookie,
81
- cookieExpiresAt: cookieExpiresAtFromHeaders(headers),
82
- email: emailRaw || null,
83
- };
84
- }
85
- if (status === 401) return { ok: false, reason: 'invalid-credentials', status };
86
- if (status === 409 || status === 422) return { ok: false, reason: 'no-email-password', status };
87
- if (status === 502 || status === 503) return { ok: false, reason: 'upstream', status };
88
- return { ok: false, reason: 'http-error', status };
89
- }
90
-
91
- // Mints a hub JWT from a better-auth session cookie: `GET {tokenEndpoint}` with the `Cookie` header.
92
- async function requestAuthToken({ cookie, sessionToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
25
+ async function requestAuthToken({ sessionToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
93
26
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
94
- if (!cookie && !sessionToken) return { ok: false, reason: 'no-cookie' };
95
- const credential = cookie ? { Cookie: cookie } : { Authorization: `Bearer ${sessionToken}` };
27
+ if (!sessionToken) return { ok: false, reason: 'no-session-token' };
28
+ const credential = { Authorization: `Bearer ${sessionToken}` };
96
29
  let res;
97
30
  try {
98
31
  res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, credential);
@@ -106,38 +39,9 @@ async function requestAuthToken({ cookie, sessionToken } = {}, { endpoint, timeo
106
39
  return { ok: true, accessToken: token, expiresAt: expiresAtFromJwt(token) };
107
40
  }
108
41
 
109
- // The full email login: sign-in (capture cookie) -> mint JWT.
110
- async function requestLogin(
111
- { email, password },
112
- { signInEndpoint, tokenEndpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
113
- ) {
114
- if (!signInEndpoint) return { ok: false, reason: 'no-endpoint' };
115
- let signIn;
116
- try {
117
- const { status, raw, headers } = await postJsonWithTimeout(signInEndpoint, { email, password }, timeoutMs);
118
- signIn = normalizeSignIn(status, raw, headers);
119
- } catch (e) {
120
- return { ok: false, reason: (e && e.kind) || 'local-error' };
121
- }
122
- if (!signIn.ok) return signIn;
123
-
124
- const tok = await requestAuthToken({ cookie: signIn.cookie }, { endpoint: tokenEndpoint, timeoutMs });
125
- if (!tok.ok) return { ok: false, reason: 'token-fetch-failed', status: tok.status || null };
126
-
127
- return {
128
- ok: true,
129
- accessToken: tok.accessToken,
130
- hubAccessToken: tok.accessToken,
131
- expiresAt: tok.expiresAt,
132
- email: signIn.email || (typeof email === 'string' ? email : null),
133
- cookie: signIn.cookie,
134
- cookieExpiresAt: signIn.cookieExpiresAt,
135
- };
136
- }
137
-
138
- async function requestSignOut({ cookie, sessionToken } = {}, { endpoint, origin = null, timeoutMs = SIGN_OUT_TIMEOUT_MS } = {}) {
139
- if (!endpoint || (!cookie && !sessionToken)) return { ok: false, reason: 'no-credential' };
140
- const credential = cookie ? { Cookie: cookie, ...(origin ? { Origin: origin } : {}) } : { Authorization: `Bearer ${sessionToken}` };
42
+ async function requestSignOut({ sessionToken } = {}, { endpoint, timeoutMs = SIGN_OUT_TIMEOUT_MS } = {}) {
43
+ if (!endpoint || !sessionToken) return { ok: false, reason: 'no-credential' };
44
+ const credential = { Authorization: `Bearer ${sessionToken}` };
141
45
  try {
142
46
  const res = await postJsonWithTimeout(endpoint, {}, timeoutMs, 'POST', null, credential);
143
47
  return { ok: res.status >= 200 && res.status < 300, status: res.status };
@@ -148,11 +52,7 @@ async function requestSignOut({ cookie, sessionToken } = {}, { endpoint, origin
148
52
 
149
53
  module.exports = {
150
54
  requestSignOut,
151
- requestLogin,
152
55
  requestAuthToken,
153
- normalizeSignIn,
154
- normalizeResponse,
155
56
  expiresAtFromJwt,
156
- SESSION_COOKIE_NAME,
157
57
  DEFAULT_TIMEOUT_MS,
158
58
  };
@@ -4,6 +4,7 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { getConfigDir } = require('./config-dir');
7
+ const { recordAccountMarker } = require('./account-marker');
7
8
 
8
9
  // `~/.config/shakers/auth-session.json` — the logged-in Talent's session.
9
10
 
@@ -88,12 +89,11 @@ function saveAuthSession(session, env = process.env, { fs: fsImpl = fs } = {}) {
88
89
  const explicitHubToken = typeof session.hubAccessToken === 'string' && session.hubAccessToken ? session.hubAccessToken : null;
89
90
  const hubAccessToken = explicitHubToken || (typeof session.accessToken === 'string' && session.accessToken ? session.accessToken : null);
90
91
 
91
- const cookie = typeof session.cookie === 'string' && session.cookie ? session.cookie : null;
92
92
  const sessionToken = typeof session.sessionToken === 'string' && session.sessionToken ? session.sessionToken : null;
93
93
  let expiresAt;
94
94
  let accessTokenExpiresAt;
95
- if (cookie || sessionToken) {
96
- expiresAt = [session.cookieExpiresAt, session.sessionExpiresAt, session.expiresAt]
95
+ if (sessionToken) {
96
+ expiresAt = [session.sessionExpiresAt, session.expiresAt]
97
97
  .find((v) => typeof v === 'string' && v) || expiresAtFromToken(session.accessToken);
98
98
  accessTokenExpiresAt = (typeof session.accessTokenExpiresAt === 'string' && session.accessTokenExpiresAt)
99
99
  ? session.accessTokenExpiresAt
@@ -108,7 +108,6 @@ function saveAuthSession(session, env = process.env, { fs: fsImpl = fs } = {}) {
108
108
  accessToken: typeof session.accessToken === 'string' && session.accessToken ? session.accessToken : null,
109
109
  expiresAt: expiresAt ?? null,
110
110
  accessTokenExpiresAt: accessTokenExpiresAt ?? null,
111
- cookie,
112
111
  sessionToken,
113
112
  email: (typeof session.email === 'string' && session.email ? session.email : null)
114
113
  || emailFromHubToken(hubAccessToken),
@@ -141,6 +140,7 @@ function saveAuthSession(session, env = process.env, { fs: fsImpl = fs } = {}) {
141
140
  try { fsImpl.unlinkSync(tmp); } catch { /* never existed */ }
142
141
  throw e;
143
142
  }
143
+ recordAccountMarker(sessionToken ? 'device' : 'token', env, { fs: fsImpl });
144
144
  return target;
145
145
  }
146
146
 
@@ -154,19 +154,16 @@ function loadAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
154
154
  }
155
155
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
156
156
  if (parsed.version !== SESSION_FILE_VERSION) return null;
157
- // A usable session needs at least ONE credential: the durable cookie (email/
158
- // better-auth) or a JWT (Google/legacy, or an email session whose JWT is cached).
159
- const hasCookie = typeof parsed.cookie === 'string' && parsed.cookie;
160
157
  const hasToken = typeof parsed.accessToken === 'string' && parsed.accessToken;
161
158
  const sessionToken = typeof parsed.sessionToken === 'string' && parsed.sessionToken ? parsed.sessionToken : null;
162
- if (!hasCookie && !hasToken && !sessionToken) return null;
159
+ if (typeof parsed.cookie === 'string' && parsed.cookie && !sessionToken) return null;
160
+ if (!hasToken && !sessionToken) return null;
163
161
  const hubAccessToken = typeof parsed.hubAccessToken === 'string' && parsed.hubAccessToken ? parsed.hubAccessToken : null;
164
162
  const email = (typeof parsed.email === 'string' && parsed.email ? parsed.email : null) || emailFromHubToken(hubAccessToken);
165
163
  return {
166
164
  accessToken: hasToken ? parsed.accessToken : null,
167
165
  expiresAt: typeof parsed.expiresAt === 'string' ? parsed.expiresAt : null,
168
166
  accessTokenExpiresAt: typeof parsed.accessTokenExpiresAt === 'string' ? parsed.accessTokenExpiresAt : null,
169
- cookie: hasCookie ? parsed.cookie : null,
170
167
  sessionToken,
171
168
  email,
172
169
  hubAccessToken,
@@ -174,6 +171,10 @@ function loadAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
174
171
  };
175
172
  }
176
173
 
174
+ function hasAuthSessionFile(env = process.env, { fs: fsImpl = fs } = {}) {
175
+ return fsImpl.existsSync(authSessionPath(env));
176
+ }
177
+
177
178
  // Removes the file. Idempotent and never throws — `logout` on no session is a
178
179
  // no-op, not a failure.
179
180
  function clearAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
@@ -187,8 +188,7 @@ function clearAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
187
188
 
188
189
  // `none | active | expired`, the three states the caller needs to give the Talent a true message.
189
190
  function sessionStatus(session, now = Date.now()) {
190
- // A credential is a durable cookie or session token, or a JWT (Google/legacy).
191
- const hasCred = !!(session && [session.accessToken, session.cookie, session.sessionToken].some((v) => typeof v === 'string' && v));
191
+ const hasCred = !!(session && [session.accessToken, session.sessionToken].some((v) => typeof v === 'string' && v));
192
192
  if (!hasCred) return 'none';
193
193
  const expiry = Date.parse(String(session.expiresAt || ''));
194
194
  if (!Number.isFinite(expiry)) return 'expired';
@@ -207,6 +207,7 @@ module.exports = {
207
207
  authSessionPath,
208
208
  saveAuthSession,
209
209
  loadAuthSession,
210
+ hasAuthSessionFile,
210
211
  clearAuthSession,
211
212
  sessionStatus,
212
213
  isLoggedIn,
@@ -71,6 +71,7 @@ function postJsonWithTimeout(url, body, timeoutMs, method = 'POST', idempotencyK
71
71
  ...sanitizeExtraHeaders(extraHeaders),
72
72
  },
73
73
  timeout: timeoutMs,
74
+ agent: false,
74
75
  },
75
76
  (res) => {
76
77
  let raw = '';
@@ -79,8 +80,7 @@ function postJsonWithTimeout(url, body, timeoutMs, method = 'POST', idempotencyK
79
80
  raw += chunk;
80
81
  if (raw.length > MAX_RESPONSE_BYTES) req.destroy();
81
82
  });
82
- // `headers` is exposed so the better-auth email flow can read `Set-Cookie` (the session cookie it captures and replays); every other caller reads only `{status, raw}` and ignores it.
83
- res.on('end', () => resolve({ status: res.statusCode, raw, headers: res.headers }));
83
+ res.on('end', () => resolve({ status: res.statusCode, raw }));
84
84
  },
85
85
  );
86
86
  req.on('timeout', () => req.destroy(Object.assign(new Error('request timed out'), { kind: 'timeout' })));
@@ -90,27 +90,6 @@ function postJsonWithTimeout(url, body, timeoutMs, method = 'POST', idempotencyK
90
90
  });
91
91
  }
92
92
 
93
- // Pulls one `name=value` cookie pair out of a response's `Set-Cookie` header (Node exposes it as an array, or a single string).
94
- // Accepts the base cookie name with or without the RFC6265 `__Secure-`/`__Host-`
95
- // prefixes that better-auth applies over HTTPS, and returns the EXACT server-set
96
- // name (prefix included) so it can be forwarded back verbatim in a `Cookie:` header.
97
- function extractSetCookie(headers, name) {
98
- if (!headers) return null;
99
- const raw = headers['set-cookie'];
100
- const list = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw ? [raw] : []);
101
- const candidates = [name, `__Secure-${name}`, `__Host-${name}`];
102
- for (const candidate of candidates) {
103
- const prefix = `${candidate}=`;
104
- for (const entry of list) {
105
- if (typeof entry === 'string' && entry.startsWith(prefix)) {
106
- const value = entry.slice(prefix.length).split(';')[0];
107
- if (value) return `${candidate}=${value}`;
108
- }
109
- }
110
- }
111
- return null;
112
- }
113
-
114
93
  function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extraHeaders = null) {
115
94
  return new Promise((resolve, reject) => {
116
95
  let u;
@@ -151,6 +130,7 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
151
130
  ...sanitizeExtraHeaders(extraHeaders),
152
131
  },
153
132
  timeout: timeoutMs,
133
+ agent: false,
154
134
  },
155
135
  (res) => {
156
136
  let raw = '';
@@ -202,7 +182,6 @@ module.exports = {
202
182
  MAX_RESPONSE_BYTES,
203
183
  postJsonWithTimeout,
204
184
  postMultipartWithTimeout,
205
- extractSetCookie,
206
185
  requestBackend,
207
186
  setClientIdentity,
208
187
  };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const { SERVER_NAME } = require('./mcp-server-name');
4
+
5
+ const CLI_SKILL_NAME = 'shakers-cli';
6
+
7
+ const OWN_SKILL_NAMES = new Set([CLI_SKILL_NAME, SERVER_NAME]);
8
+
9
+ const CLI_SKILL_DESCRIPTION = 'Use Shakers, the freelance talent marketplace, from the terminal through the `shakers` CLI: find, view and save projects, read the talent profile, certifications, applications and invitations, and get the link to certify a role dimension. Use when the talent wants to work with Shakers from the terminal or mentions the `shakers` command.';
10
+
11
+ const BODY = `# Shakers CLI
12
+
13
+ Shakers is a freelance talent marketplace. The \`shakers\` CLI acts on the talent's own account. You run it for the talent, with their permission.
14
+
15
+ ## Rules
16
+
17
+ - Before running any command, show the talent the exact command and ask them first.
18
+ - Run only the \`shakers\` commands listed in this skill. If \`shakers\` is not on PATH, run the same command as \`npx -y shakers-cli <command>\`.
19
+ - Install only the official npm package \`shakers-cli\`. Never install a package with a similar name.
20
+ - Never run \`shakers update\` without \`--check\` unless the talent explicitly asks to update.
21
+ - Put the command name first (\`shakers me --json\`, not \`shakers --json me\`).
22
+ - Prefer \`--json\` and read ids from the JSON. Never pass the \`N)\` numbers of a printed list: a number refers to the last human listing, not to your JSON.
23
+ - If the Shakers MCP server tools are available in this session and the talent did not ask for the terminal, use those tools instead of this skill.
24
+
25
+ ## Not logged in
26
+
27
+ A command that needs a session exits 1 and prints on stderr that it requires an active session. Do not try to log in for the talent. Tell them to run \`shakers login\` in their own terminal, then retry the command.
28
+
29
+ Without \`--json\`, each command first prints on stderr one line saying what Shakers does on this computer and in the account (what it reads, changes or sends). Relay that line to the talent word for word.
30
+
31
+ ## Commands you may run
32
+
33
+ Projects:
34
+ - \`shakers find-projects --json [--page N] [--limit N] [--tab all|saved] [--attendance remote|hybrid|in-person] [--country ES] [--recommended]\`: projects available to the talent. The id of each item is \`items[].positionId\`.
35
+ - \`shakers show-project <positionId> --json\`: detail of one project.
36
+ - \`shakers save-project <positionId> --json\` and \`shakers unsave-project <positionId> --json\`: add or remove a saved project.
37
+ - \`shakers applications --json\`: projects the talent applied to and their status (\`items[].positionId\`).
38
+ - \`shakers invitations --json\`: the talent's unread project invitations.
39
+
40
+ Profile (read only):
41
+ - \`shakers me --json\`: profile summary.
42
+ - \`shakers rate --json\`, \`shakers availability --json\`, \`shakers lang --json\`, \`shakers socials --json\`.
43
+ - \`shakers experiences --json\`, \`shakers portfolios --json\`.
44
+
45
+ Certifications:
46
+ - \`shakers certifications --json\`: certified, uncertified and expired dimensions.
47
+ - \`shakers certify --dimension <key>\`: only when the talent named the dimension. It prints a link; the interview happens on the web. Show the talent the link. It has no \`--json\`.
48
+
49
+ Account:
50
+ - \`shakers config list\` and \`shakers config get <key>\`: read the CLI configuration.
51
+ - \`shakers update --check --json\`: tells whether a newer version exists. Installs nothing.
52
+ - \`shakers logout\`: only when the talent asks to sign out.
53
+
54
+ ## Hand these to the talent
55
+
56
+ Never run these yourself. They need the talent at their own keyboard: they ask for consent, reviews or choices, and \`login\` and \`register\` open a Shakers page on shakersworks.com with a code the talent checks and approves (the password is typed only there, never in a terminal). Tell the talent the exact command to run in their own terminal, or point them to the web (https://www.shakersworks.com) or the Shakers MCP server.
57
+
58
+ - \`shakers register\` and \`shakers onboarding\`: sign-up and the onboarding interview.
59
+ - \`shakers login\`.
60
+ - \`shakers ai-usage\`: the AI-usage analysis and upload. The talent gives consent and reviews what is sent.
61
+ - Profile changes: \`shakers rate --set\`, \`shakers availability --set\`, \`shakers lang --set\`, \`shakers socials --set\`.
62
+ - \`shakers add-agent\`, \`shakers add-skill\`, \`shakers add-project\`, \`shakers add-role\`, \`shakers change-role\`.
63
+ - \`shakers config set\`.
64
+ `;
65
+
66
+ function cliSkill() {
67
+ return [
68
+ '---',
69
+ `name: ${CLI_SKILL_NAME}`,
70
+ `description: ${JSON.stringify(CLI_SKILL_DESCRIPTION)}`,
71
+ '---',
72
+ '',
73
+ BODY,
74
+ ].join('\n');
75
+ }
76
+
77
+ module.exports = { cliSkill, CLI_SKILL_NAME, OWN_SKILL_NAMES };
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const os = require('os');
4
5
  const path = require('path');
6
+ const { spawn } = require('child_process');
5
7
  const { scrubSecrets } = require('./agent-synthesis');
6
8
  const { loadAuthSession, decodeJwtPayload } = require('./auth-session-store');
7
9
  const { getCertsBase } = require('./config');
@@ -13,6 +15,13 @@ const AUDIT_TIMEOUT_MS = 1500;
13
15
  // Capped at 200 chars AFTER scrubbing (mirrors MAX_COMMAND_LEN in session-scan.js) so a secret straddling the cut cannot leak.
14
16
  const MAX_ARGS_LEN = 200;
15
17
 
18
+ const WORKER = path.join(__dirname, '..', 'bin', 'command-audit-worker.js');
19
+ const PAYLOAD_VAR = 'SHAKERS_CLI_AUDIT_PAYLOAD';
20
+
21
+ function auditDisabled(env) {
22
+ return !!env && env.SHAKERS_CLI_NO_AUDIT === '1';
23
+ }
24
+
16
25
  function cliVersion() {
17
26
  try {
18
27
  return require(path.join(__dirname, '..', 'package.json')).version || null;
@@ -52,7 +61,7 @@ function jwtExpired(token, now) {
52
61
  }
53
62
 
54
63
  // Builds the record + headers; returns null when there is no endpoint to post to.
55
- function buildAudit(command, argv, env) {
64
+ function buildAudit(command, argv, env, id = null) {
56
65
  let base;
57
66
  try {
58
67
  base = getCertsBase(env);
@@ -66,6 +75,7 @@ function buildAudit(command, argv, env) {
66
75
  const token = stored && !jwtExpired(stored, Date.now()) ? stored : null;
67
76
 
68
77
  const body = {
78
+ ...(id ? { id } : {}),
69
79
  command: String(command || 'unknown').slice(0, 100),
70
80
  args: redactArgs(argv),
71
81
  cliVersion: cliVersion(),
@@ -85,9 +95,10 @@ function buildAudit(command, argv, env) {
85
95
  }
86
96
 
87
97
  // Emits ONE fire-and-forget audit record; never throws or rejects. Returns the `.catch`-guarded promise so tests can await it (production does not).
88
- function emitCommandAudit({ command, argv = [], env = process.env } = {}) {
98
+ function emitCommandAudit({ command, argv = [], env = process.env, id = null } = {}) {
89
99
  try {
90
- const audit = buildAudit(command, argv, env);
100
+ if (auditDisabled(env)) return Promise.resolve();
101
+ const audit = buildAudit(command, argv, env, id);
91
102
  if (!audit) return Promise.resolve();
92
103
  return postJsonWithTimeout(
93
104
  audit.endpoint,
@@ -105,4 +116,36 @@ function emitCommandAudit({ command, argv = [], env = process.env } = {}) {
105
116
  }
106
117
  }
107
118
 
108
- module.exports = { emitCommandAudit, redactArgs, buildAudit };
119
+ async function logCommandRun({ command, argv = [], env = process.env } = {}) {
120
+ if (auditDisabled(env)) return null;
121
+ const id = crypto.randomUUID();
122
+ await emitCommandAudit({ command, argv, env, id });
123
+ return id;
124
+ }
125
+
126
+ let runId = null;
127
+
128
+ function startCommandAudit({ command, argv = [], env = process.env } = {}) {
129
+ if (auditDisabled(env)) return null;
130
+ const id = crypto.randomUUID();
131
+ try {
132
+ const child = spawn(process.execPath, [WORKER], {
133
+ detached: true,
134
+ stdio: 'ignore',
135
+ windowsHide: true,
136
+ env: { ...env, [PAYLOAD_VAR]: JSON.stringify({ id, command, args: redactArgs(argv) }) },
137
+ });
138
+ child.on('error', () => {});
139
+ child.unref();
140
+ } catch {
141
+ return null;
142
+ }
143
+ runId = id;
144
+ return id;
145
+ }
146
+
147
+ function currentRunId() {
148
+ return runId;
149
+ }
150
+
151
+ module.exports = { emitCommandAudit, startCommandAudit, logCommandRun, currentRunId, redactArgs, buildAudit, PAYLOAD_VAR };
package/src/config.js CHANGED
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { readEnv } = require('./env-paths');
6
6
  const { getConfigDir } = require('./config-dir');
7
+ const { recordAccountMarker } = require('./account-marker');
7
8
 
8
9
  // Persistent CLI config file.
9
10
  function configDir(env = process.env) {
@@ -299,14 +300,6 @@ function getAgentEvaluationEndpoint(env = process.env) {
299
300
  return deriveFromIngest(env, 'agent-evaluation');
300
301
  }
301
302
 
302
- // Login endpoint — better-auth email sign-in: `POST {hubBase}/auth/sign-in/email` with `{email,password}`.
303
- function getLoginEndpoint(env = process.env) {
304
- const explicit = readEnv(env, 'LOGIN_ENDPOINT');
305
- if (explicit && explicit.trim()) return explicit.trim();
306
- return deriveFromHub(env, 'auth/sign-in/email');
307
- }
308
-
309
- // better-auth JWT endpoint: `GET {hubBase}/auth/token` with the session cookie → `{token}` (ES256/JWKS, ~15-min TTL).
310
303
  function getSignOutEndpoint(env = process.env) {
311
304
  return deriveFromHub(env, 'auth/sign-out');
312
305
  }
@@ -588,12 +581,6 @@ function deriveFromHub(env, relativePath) {
588
581
  return `${base}/${relativePath}`;
589
582
  }
590
583
 
591
- function getSignUpEndpoint(env = process.env) {
592
- const explicit = readEnv(env, 'SIGNUP_ENDPOINT');
593
- if (explicit && explicit.trim()) return explicit.trim();
594
- return deriveFromHub(env, 'auth/sign-up/email');
595
- }
596
-
597
584
  function getImportProfileEndpoint(env = process.env) {
598
585
  const explicit = readEnv(env, 'IMPORT_PROFILE_ENDPOINT');
599
586
  if (explicit && explicit.trim()) return explicit.trim();
@@ -815,6 +802,7 @@ function saveSuperadminSession(session, env = process.env) {
815
802
  expiresAt: session.expiresAt || null,
816
803
  };
817
804
  saveConfigFile(config, env);
805
+ recordAccountMarker('superadmin', env);
818
806
  }
819
807
 
820
808
  function clearSuperadminSession(env = process.env) {
@@ -830,7 +818,6 @@ module.exports = {
830
818
  getSynthesisEndpoint,
831
819
  getRoadmapEndpoint,
832
820
  getAgentEvaluationEndpoint,
833
- getLoginEndpoint,
834
821
  getAuthTokenEndpoint,
835
822
  getSignOutEndpoint,
836
823
  getCompleteRegistrationEndpoint,
@@ -882,7 +869,6 @@ module.exports = {
882
869
  getCertsBase,
883
870
  getHubBase,
884
871
  sanitizeStaleBakedEndpoints,
885
- getSignUpEndpoint,
886
872
  getImportProfileEndpoint,
887
873
  getMcpSignupEndpoint,
888
874
  getMyImportStatusEndpoint,
@@ -15,10 +15,14 @@ const MAX_ATTEMPTS = 5;
15
15
  const YES_RE = /^(y|yes|s|si|sí)$/i;
16
16
  const NO_RE = /^(n|no)$/i;
17
17
 
18
- async function askYesNo(ask, question, catalog, { defaultNo = false } = {}) {
18
+ const UNANSWERED = Symbol('unanswered');
19
+
20
+ async function askYesNo(ask, question, catalog, { defaultNo = false, defaultYes = false, blankUnanswered = false } = {}) {
19
21
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
20
22
  const raw = String(await ask(question)).trim();
23
+ if (blankUnanswered && raw === '') return UNANSWERED;
21
24
  if (defaultNo && raw === '') return false;
25
+ if (defaultYes && raw === '') return true;
22
26
  if (YES_RE.test(raw)) return true;
23
27
  if (NO_RE.test(raw)) return false;
24
28
  process.stdout.write(` ${catalog.consent.invalidAnswer}\n`);
@@ -55,6 +59,7 @@ async function runConsentPrompt({
55
59
  sessionEmail = null,
56
60
  drainBeforeAsk = false,
57
61
  profile = 'talent',
62
+ requireExplicitAnswer = false,
58
63
  }) {
59
64
  const c = catalog.consent;
60
65
  // Presentation only: framed by src/legal-notice.js on a
@@ -68,7 +73,8 @@ async function runConsentPrompt({
68
73
  }
69
74
 
70
75
  // defaultNo: a blank Enter means "do not save" (legal requirement).
71
- const accepted = await askYesNo(ask, c.persistQuestion, catalog, { defaultNo: true });
76
+ const accepted = await askYesNo(ask, c.persistQuestion, catalog, { defaultNo: !requireExplicitAnswer, blankUnanswered: requireExplicitAnswer });
77
+ if (accepted === UNANSWERED) return 'unanswered';
72
78
  if (accepted === null) {
73
79
  process.stdout.write(` ${c.notObtained}\n\n`);
74
80
  return null;
@@ -112,4 +118,4 @@ async function runConsentPrompt({
112
118
  return null;
113
119
  }
114
120
 
115
- module.exports = { runConsentPrompt, defaultVerifyEmail };
121
+ module.exports = { runConsentPrompt, defaultVerifyEmail, askYesNo };
@@ -2,12 +2,7 @@
2
2
 
3
3
  const { execFileSync } = require('child_process');
4
4
  const path = require('path');
5
- const {
6
- toolSources,
7
- listSessionFiles,
8
- readSessionObjects,
9
- resolveFileCwd,
10
- } = require('./session-scan');
5
+ const { toolSources, sourceSessions, resolveFileCwd } = require('./session-scan');
11
6
  const { normalizeRemote } = require('./authorship');
12
7
 
13
8
  const GIT_TIMEOUT_MS = 15000;
@@ -37,8 +32,9 @@ function sessionCwdCounts(env = process.env) {
37
32
  const counts = new Map();
38
33
  let unassigned = 0;
39
34
  for (const source of toolSources(env)) {
40
- for (const file of listSessionFiles(source.dir)) {
41
- const cwd = resolveFileCwd(readSessionObjects(file));
35
+ if (source.repoLevel) continue;
36
+ for (const { objects } of sourceSessions(source)) {
37
+ const cwd = resolveFileCwd(objects);
42
38
  if (cwd === null) {
43
39
  unassigned += 1;
44
40
  continue;
package/src/detectors.js CHANGED
@@ -115,6 +115,29 @@ const detectors = [
115
115
  { type: 'bin', name: 'codex' },
116
116
  ],
117
117
  },
118
+ {
119
+ id: 'pi',
120
+ name: 'Pi',
121
+ vendor: 'Earendil Works',
122
+ category: CATEGORIES.AGENTIC_CLI,
123
+ signals: [
124
+ { type: 'projectPath', path: '.pi' },
125
+ { type: 'homePath', path: '.pi/agent' },
126
+ ],
127
+ },
128
+ {
129
+ id: 'opencode',
130
+ name: 'OpenCode',
131
+ vendor: 'OpenCode',
132
+ category: CATEGORIES.AGENTIC_CLI,
133
+ signals: [
134
+ { type: 'projectPath', path: 'opencode.json' },
135
+ { type: 'projectPath', path: '.opencode' },
136
+ { type: 'homePath', path: '.config/opencode' },
137
+ { type: 'homePath', path: '.local/share/opencode' },
138
+ { type: 'bin', name: 'opencode' },
139
+ ],
140
+ },
118
141
  {
119
142
  id: 'cody',
120
143
  name: 'Cody',
@@ -26,7 +26,7 @@ function savePendingDevice(pending, env = process.env, { fs: fsImpl = fs } = {})
26
26
  interval: typeof pending.interval === 'number' && pending.interval > 0 ? Math.floor(pending.interval) : 5,
27
27
  expiresAt: typeof pending.expiresAt === 'string' && pending.expiresAt ? pending.expiresAt : null,
28
28
  mode: pending.mode === 'register' ? 'register' : 'login',
29
- provider: pending.provider === 'linkedin' ? 'linkedin' : 'google',
29
+ provider: pending.provider === 'linkedin' || pending.provider === 'email' ? pending.provider : 'google',
30
30
  registerContext: pending.registerContext && typeof pending.registerContext === 'object' ? pending.registerContext : null,
31
31
  email: typeof pending.email === 'string' && pending.email ? pending.email : null,
32
32
  version: DEVICE_FILE_VERSION,
@@ -75,7 +75,7 @@ function loadPendingDevice(env = process.env, { fs: fsImpl = fs } = {}) {
75
75
  interval: typeof parsed.interval === 'number' && parsed.interval > 0 ? Math.floor(parsed.interval) : 5,
76
76
  expiresAt: typeof parsed.expiresAt === 'string' && parsed.expiresAt ? parsed.expiresAt : null,
77
77
  mode: parsed.mode === 'register' ? 'register' : 'login',
78
- provider: parsed.provider === 'linkedin' ? 'linkedin' : 'google',
78
+ provider: parsed.provider === 'linkedin' || parsed.provider === 'email' ? parsed.provider : 'google',
79
79
  registerContext: parsed.registerContext && typeof parsed.registerContext === 'object' ? parsed.registerContext : null,
80
80
  email: typeof parsed.email === 'string' && parsed.email ? parsed.email : null,
81
81
  };