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
@@ -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
@@ -300,14 +300,6 @@ function getAgentEvaluationEndpoint(env = process.env) {
300
300
  return deriveFromIngest(env, 'agent-evaluation');
301
301
  }
302
302
 
303
- // Login endpoint — better-auth email sign-in: `POST {hubBase}/auth/sign-in/email` with `{email,password}`.
304
- function getLoginEndpoint(env = process.env) {
305
- const explicit = readEnv(env, 'LOGIN_ENDPOINT');
306
- if (explicit && explicit.trim()) return explicit.trim();
307
- return deriveFromHub(env, 'auth/sign-in/email');
308
- }
309
-
310
- // better-auth JWT endpoint: `GET {hubBase}/auth/token` with the session cookie → `{token}` (ES256/JWKS, ~15-min TTL).
311
303
  function getSignOutEndpoint(env = process.env) {
312
304
  return deriveFromHub(env, 'auth/sign-out');
313
305
  }
@@ -589,12 +581,6 @@ function deriveFromHub(env, relativePath) {
589
581
  return `${base}/${relativePath}`;
590
582
  }
591
583
 
592
- function getSignUpEndpoint(env = process.env) {
593
- const explicit = readEnv(env, 'SIGNUP_ENDPOINT');
594
- if (explicit && explicit.trim()) return explicit.trim();
595
- return deriveFromHub(env, 'auth/sign-up/email');
596
- }
597
-
598
584
  function getImportProfileEndpoint(env = process.env) {
599
585
  const explicit = readEnv(env, 'IMPORT_PROFILE_ENDPOINT');
600
586
  if (explicit && explicit.trim()) return explicit.trim();
@@ -832,7 +818,6 @@ module.exports = {
832
818
  getSynthesisEndpoint,
833
819
  getRoadmapEndpoint,
834
820
  getAgentEvaluationEndpoint,
835
- getLoginEndpoint,
836
821
  getAuthTokenEndpoint,
837
822
  getSignOutEndpoint,
838
823
  getCompleteRegistrationEndpoint,
@@ -884,7 +869,6 @@ module.exports = {
884
869
  getCertsBase,
885
870
  getHubBase,
886
871
  sanitizeStaleBakedEndpoints,
887
- getSignUpEndpoint,
888
872
  getImportProfileEndpoint,
889
873
  getMcpSignupEndpoint,
890
874
  getMyImportStatusEndpoint,
@@ -15,9 +15,12 @@ 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, defaultYes = 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;
22
25
  if (defaultYes && raw === '') return true;
23
26
  if (YES_RE.test(raw)) return true;
@@ -56,6 +59,7 @@ async function runConsentPrompt({
56
59
  sessionEmail = null,
57
60
  drainBeforeAsk = false,
58
61
  profile = 'talent',
62
+ requireExplicitAnswer = false,
59
63
  }) {
60
64
  const c = catalog.consent;
61
65
  // Presentation only: framed by src/legal-notice.js on a
@@ -69,7 +73,8 @@ async function runConsentPrompt({
69
73
  }
70
74
 
71
75
  // defaultNo: a blank Enter means "do not save" (legal requirement).
72
- 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';
73
78
  if (accepted === null) {
74
79
  process.stdout.write(` ${c.notObtained}\n\n`);
75
80
  return null;
@@ -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
  };
@@ -10,15 +10,16 @@ function defaultSleep(ms) {
10
10
  return new Promise((resolve) => { setTimeout(resolve, ms); });
11
11
  }
12
12
 
13
- // The /cli-login page reads `provider` from the query to pick the social button.
14
- function appendProviderParam(url, provider) {
15
- if (!url || !provider) return url;
13
+ function withDeviceParams(url, params = {}) {
14
+ const entries = Object.entries(params).filter(([, v]) => typeof v === 'string' && v);
15
+ if (!url || !entries.length) return url;
16
16
  try {
17
17
  const u = new URL(url);
18
- u.searchParams.set('provider', provider);
18
+ for (const [k, v] of entries) u.searchParams.set(k, v);
19
19
  return u.href;
20
20
  } catch {
21
- return `${url}${url.includes('?') ? '&' : '?'}provider=${encodeURIComponent(provider)}`;
21
+ const query = entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
22
+ return `${url}${url.includes('?') ? '&' : '?'}${query}`;
22
23
  }
23
24
  }
24
25
 
@@ -32,7 +33,7 @@ async function pollDeviceToken({ deviceCode, tokenEndpoint, clientId, timeoutMs
32
33
 
33
34
  // ORDER: a NEW user needs complete-registration BEFORE GET /auth/token (else the Hub 500s: no domain user).
34
35
  async function finalizeDeviceSession(
35
- { sessionToken, isNewUser, authTokenEndpoint, completeRegistrationEndpoint = null, registrationContext = null, claimCode = null, timeoutMs } = {},
36
+ { sessionToken, isNewUser, authTokenEndpoint, completeRegistrationEndpoint = null, registrationContext = null, consent = null, claimCode = null, timeoutMs } = {},
36
37
  {
37
38
  requestCompleteRegistration = require('./signup-client').requestCompleteRegistration,
38
39
  requestApiToken: apiToken = requestApiToken,
@@ -42,7 +43,7 @@ async function finalizeDeviceSession(
42
43
 
43
44
  let claimed = false;
44
45
  if (isNewUser && completeRegistrationEndpoint) {
45
- const done = await requestCompleteRegistration(registrationContext || {}, { endpoint: completeRegistrationEndpoint, bearerToken: sessionToken, claimCode, timeoutMs });
46
+ const done = await requestCompleteRegistration(registrationContext || {}, { endpoint: completeRegistrationEndpoint, bearerToken: sessionToken, claimCode, consent, timeoutMs });
46
47
  if (!done.ok) return { ok: false, reason: done.reason || 'complete-registration-failed' };
47
48
  claimed = done.claimed === true;
48
49
  }
@@ -53,7 +54,7 @@ async function finalizeDeviceSession(
53
54
  }
54
55
 
55
56
  async function runDeviceLogin(
56
- { authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint = null, registrationContext = null, clientId, scope, timeoutMs } = {},
57
+ { authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint = null, registrationContext = null, consent = null, clientId, scope, timeoutMs } = {},
57
58
  {
58
59
  onPrompt = null,
59
60
  onWaiting = null,
@@ -92,6 +93,7 @@ async function runDeviceLogin(
92
93
  authTokenEndpoint,
93
94
  completeRegistrationEndpoint,
94
95
  registrationContext,
96
+ consent,
95
97
  timeoutMs,
96
98
  });
97
99
  if (!fin.ok) return { ok: false, reason: fin.reason };
@@ -124,7 +126,7 @@ module.exports = {
124
126
  pollDeviceToken,
125
127
  finalizeDeviceSession,
126
128
  runDeviceLogin,
127
- appendProviderParam,
129
+ withDeviceParams,
128
130
  defaultSleep,
129
131
  SLOW_DOWN_STEP_SECONDS,
130
132
  DEFAULT_MAX_WAIT_MS,
package/src/env-paths.js CHANGED
@@ -13,6 +13,14 @@ function getHomeDir(env = process.env) {
13
13
  return readEnv(env, 'HOME_DIR') || os.homedir();
14
14
  }
15
15
 
16
+ function codexHome(env = process.env) {
17
+ return env.CODEX_HOME || require('path').join(getHomeDir(env), '.codex');
18
+ }
19
+
20
+ function piAgentDir(env = process.env) {
21
+ return env.PI_CODING_AGENT_DIR ? expandHome(env.PI_CODING_AGENT_DIR, env) : require('path').join(getHomeDir(env), '.pi', 'agent');
22
+ }
23
+
16
24
  // `~/Documents/cv.pdf` -> absolute, so a model can pass the short form it was shown.
17
25
  function expandHome(file, env = process.env) {
18
26
  if (typeof file !== 'string') return file;
@@ -27,4 +35,4 @@ function tildify(file, env = process.env) {
27
35
  return typeof file === 'string' && file.startsWith(`${home}/`) ? `~/${file.slice(home.length + 1)}` : file;
28
36
  }
29
37
 
30
- module.exports = { getHomeDir, readEnv, expandHome, tildify };
38
+ module.exports = { getHomeDir, codexHome, piAgentDir, readEnv, expandHome, tildify };