shakers-cli 0.0.0-stage → 0.14.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 (237) hide show
  1. package/LICENSE +59 -0
  2. package/README.md +1233 -2
  3. package/bin/add-agent.js +88 -0
  4. package/bin/add-project.js +88 -0
  5. package/bin/add-role.js +61 -0
  6. package/bin/add-skill.js +88 -0
  7. package/bin/ai-usage.js +99 -0
  8. package/bin/alma.js +99 -0
  9. package/bin/applications.js +69 -0
  10. package/bin/availability.js +70 -0
  11. package/bin/certifications.js +69 -0
  12. package/bin/certify.js +73 -0
  13. package/bin/change-role.js +61 -0
  14. package/bin/config.js +124 -0
  15. package/bin/experiences.js +69 -0
  16. package/bin/find-projects.js +89 -0
  17. package/bin/invitations.js +69 -0
  18. package/bin/lang.js +70 -0
  19. package/bin/login.js +216 -0
  20. package/bin/mcp.js +124 -0
  21. package/bin/mcpb.js +5 -0
  22. package/bin/me.js +69 -0
  23. package/bin/onboarding.js +134 -0
  24. package/bin/portfolios.js +69 -0
  25. package/bin/rate.js +70 -0
  26. package/bin/register.js +337 -0
  27. package/bin/report-html.js +134 -0
  28. package/bin/save.js +77 -0
  29. package/bin/shakers.js +363 -0
  30. package/bin/share.js +72 -0
  31. package/bin/show-project.js +72 -0
  32. package/bin/socials.js +70 -0
  33. package/bin/superadmin.js +351 -0
  34. package/bin/update-check-worker.js +70 -0
  35. package/bin/update.js +111 -0
  36. package/package.json +40 -4
  37. package/src/agent-category.js +13 -0
  38. package/src/agent-evaluation.js +177 -0
  39. package/src/agent-org-chart.js +355 -0
  40. package/src/agent-synthesis.js +132 -0
  41. package/src/agent-usage.js +118 -0
  42. package/src/agents-client.js +165 -0
  43. package/src/ai-fluency-cell.js +72 -0
  44. package/src/ai-profile-client.js +45 -0
  45. package/src/ai-profile-preview.js +154 -0
  46. package/src/ai.js +66 -0
  47. package/src/alma-client.js +123 -0
  48. package/src/alma-flow.js +32 -0
  49. package/src/alma-repo-context.js +47 -0
  50. package/src/alma-shell.js +161 -0
  51. package/src/ansi.js +62 -0
  52. package/src/applications-client.js +79 -0
  53. package/src/applications-flow.js +58 -0
  54. package/src/auth-client.js +144 -0
  55. package/src/auth-device-client.js +118 -0
  56. package/src/auth-method-picker.js +44 -0
  57. package/src/auth-methods.js +35 -0
  58. package/src/auth-session-store.js +215 -0
  59. package/src/authorship-mismatch.js +62 -0
  60. package/src/authorship.js +204 -0
  61. package/src/automations-detector.js +170 -0
  62. package/src/availability-client.js +93 -0
  63. package/src/availability-flow.js +124 -0
  64. package/src/backend-request.js +191 -0
  65. package/src/blind-approval-extract.js +48 -0
  66. package/src/brand-ansi.js +14 -0
  67. package/src/brand-svg.js +9 -0
  68. package/src/browser-tools-detector.js +21 -0
  69. package/src/build-next-level.js +56 -0
  70. package/src/certifications-client.js +89 -0
  71. package/src/certifications-flow.js +75 -0
  72. package/src/certify-dimension-client.js +163 -0
  73. package/src/certify-dimension-flow.js +135 -0
  74. package/src/certify-disclaimer.js +51 -0
  75. package/src/certify-remediation-prompt.js +23 -0
  76. package/src/cli-args.js +83 -0
  77. package/src/command-audit.js +105 -0
  78. package/src/config-dir.js +120 -0
  79. package/src/config.js +877 -0
  80. package/src/consent-flow.js +115 -0
  81. package/src/consent-skip.js +27 -0
  82. package/src/cv-file.js +62 -0
  83. package/src/cv-text.js +92 -0
  84. package/src/decisions-extract.js +43 -0
  85. package/src/detect-repos.js +145 -0
  86. package/src/detected-tools.js +15 -0
  87. package/src/detection-evidence.js +105 -0
  88. package/src/detection-noise.js +132 -0
  89. package/src/detectors.js +221 -0
  90. package/src/device-auth-store.js +100 -0
  91. package/src/device-login.js +120 -0
  92. package/src/email-verification.js +152 -0
  93. package/src/env-paths.js +30 -0
  94. package/src/find-projects-cache.js +89 -0
  95. package/src/find-projects-client.js +148 -0
  96. package/src/find-projects-flow.js +164 -0
  97. package/src/git-activity.js +124 -0
  98. package/src/git-log-prefetch.js +53 -0
  99. package/src/graph-certs.js +58 -0
  100. package/src/graph-generator.js +230 -0
  101. package/src/graph-scan.js +239 -0
  102. package/src/i18n.js +3341 -0
  103. package/src/inter-fonts.js +6 -0
  104. package/src/interactive-select.js +183 -0
  105. package/src/interview-client.js +226 -0
  106. package/src/interview-transcript.js +56 -0
  107. package/src/inventory-client.js +90 -0
  108. package/src/invitations-client.js +66 -0
  109. package/src/invitations-flow.js +50 -0
  110. package/src/lang-client.js +147 -0
  111. package/src/lang-codes.js +14 -0
  112. package/src/lang-flow.js +85 -0
  113. package/src/legal-notice.js +53 -0
  114. package/src/locale.js +72 -0
  115. package/src/markdown-ansi.js +50 -0
  116. package/src/maturity.js +111 -0
  117. package/src/mcp-ai-usage-result-tool.js +111 -0
  118. package/src/mcp-ai-usage-tool.js +401 -0
  119. package/src/mcp-alma-tools.js +95 -0
  120. package/src/mcp-applications-tools.js +44 -0
  121. package/src/mcp-auth-form.js +480 -0
  122. package/src/mcp-availability-tools.js +45 -0
  123. package/src/mcp-certifications-tools.js +44 -0
  124. package/src/mcp-certify-dimension-tools.js +66 -0
  125. package/src/mcp-choice.js +95 -0
  126. package/src/mcp-detector.js +223 -0
  127. package/src/mcp-device-login-tools.js +177 -0
  128. package/src/mcp-find-projects-tools.js +69 -0
  129. package/src/mcp-install.js +275 -0
  130. package/src/mcp-instructions.js +27 -0
  131. package/src/mcp-invitations-tools.js +44 -0
  132. package/src/mcp-lang-tools.js +41 -0
  133. package/src/mcp-onboarding-tools.js +379 -0
  134. package/src/mcp-portfolios-tools.js +50 -0
  135. package/src/mcp-profile-tools.js +48 -0
  136. package/src/mcp-prompts.js +73 -0
  137. package/src/mcp-rate-tools.js +44 -0
  138. package/src/mcp-register-tools.js +441 -0
  139. package/src/mcp-report-tools.js +105 -0
  140. package/src/mcp-role-tools.js +372 -0
  141. package/src/mcp-saved-positions-tools.js +59 -0
  142. package/src/mcp-server.js +290 -0
  143. package/src/mcp-show-project-tools.js +52 -0
  144. package/src/mcp-signup-client.js +118 -0
  145. package/src/mcp-signup-tools.js +1171 -0
  146. package/src/mcp-socials-tools.js +41 -0
  147. package/src/memory-structure-detector.js +108 -0
  148. package/src/model-call-record.js +198 -0
  149. package/src/onboarding-availability.js +26 -0
  150. package/src/onboarding-client.js +374 -0
  151. package/src/onboarding-flow.js +1162 -0
  152. package/src/onboarding-funnel.js +272 -0
  153. package/src/onboarding-livekit-client.js +102 -0
  154. package/src/onboarding-status-client.js +57 -0
  155. package/src/open-file.js +32 -0
  156. package/src/osc-link.js +16 -0
  157. package/src/portfolio-client.js +164 -0
  158. package/src/portfolio-git-info.js +61 -0
  159. package/src/portfolios-client.js +85 -0
  160. package/src/portfolios-flow.js +79 -0
  161. package/src/profile-client.js +133 -0
  162. package/src/profile-flow.js +115 -0
  163. package/src/profile-switch.js +46 -0
  164. package/src/prompt-select.js +66 -0
  165. package/src/rate-client.js +81 -0
  166. package/src/rate-flow.js +137 -0
  167. package/src/register-context.js +145 -0
  168. package/src/render-ai-fluency.js +266 -0
  169. package/src/render-certification.js +177 -0
  170. package/src/render-html.js +140 -0
  171. package/src/render-report-text.js +27 -0
  172. package/src/render-sheet.js +261 -0
  173. package/src/render-terminal.js +109 -0
  174. package/src/render-usage-report-text.js +155 -0
  175. package/src/repo-context.js +215 -0
  176. package/src/report-gating.js +49 -0
  177. package/src/report-store.js +226 -0
  178. package/src/report-theme.js +177 -0
  179. package/src/report-view-client.js +50 -0
  180. package/src/reveal-output.js +29 -0
  181. package/src/roadmap-content.js +453 -0
  182. package/src/roadmap-personalization.js +132 -0
  183. package/src/roadmap-prompt.js +154 -0
  184. package/src/roles-client.js +112 -0
  185. package/src/roles-flow.js +192 -0
  186. package/src/sanitize-network-text.js +36 -0
  187. package/src/saved-positions-client.js +71 -0
  188. package/src/saved-positions-flow.js +50 -0
  189. package/src/scan-exclusions.js +50 -0
  190. package/src/scanner.js +624 -0
  191. package/src/scope-signals.js +148 -0
  192. package/src/secret-prompt.js +72 -0
  193. package/src/session-gate.js +30 -0
  194. package/src/session-normalizers.js +174 -0
  195. package/src/session-refresh.js +51 -0
  196. package/src/session-scan.js +475 -0
  197. package/src/session-signals.js +36 -0
  198. package/src/shakers-wordmark.js +17 -0
  199. package/src/share-card.js +362 -0
  200. package/src/share-certification.js +111 -0
  201. package/src/share-consent-state.js +157 -0
  202. package/src/share-payload.js +300 -0
  203. package/src/share.js +185 -0
  204. package/src/show-project-client.js +58 -0
  205. package/src/show-project-flow.js +119 -0
  206. package/src/signup-client.js +103 -0
  207. package/src/signup-copy.js +450 -0
  208. package/src/signup-language.js +21 -0
  209. package/src/skills-client.js +102 -0
  210. package/src/socials-client.js +91 -0
  211. package/src/socials-flow.js +83 -0
  212. package/src/specificity-extract.js +41 -0
  213. package/src/start-add-agents.js +306 -0
  214. package/src/start-add-portfolio.js +388 -0
  215. package/src/start-add-skills.js +260 -0
  216. package/src/stdin-ask.js +129 -0
  217. package/src/steering-extract.js +43 -0
  218. package/src/strip-internal-comments.js +45 -0
  219. package/src/tech-detector.js +295 -0
  220. package/src/templates/report-sheet.html +572 -0
  221. package/src/terminal-agents.js +207 -0
  222. package/src/terminal-format.js +85 -0
  223. package/src/terminal-progress.js +106 -0
  224. package/src/terminal-sections.js +219 -0
  225. package/src/tier-analysis.js +126 -0
  226. package/src/tier-engine.js +114 -0
  227. package/src/tier-projection.js +117 -0
  228. package/src/transcript-turns.js +49 -0
  229. package/src/translations-client.js +84 -0
  230. package/src/typewriter.js +154 -0
  231. package/src/update-flow.js +223 -0
  232. package/src/update-notifier.js +113 -0
  233. package/src/usage-model-calls.js +119 -0
  234. package/src/usage-oneshot-commands.js +180 -0
  235. package/src/usage-repo-scope.js +162 -0
  236. package/src/usage-run-steps.js +354 -0
  237. package/src/work-streams.js +79 -0
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const { requestBackend } = require('./backend-request');
4
+
5
+ // Email-verification client + wait-mode loop (skill-code-certification, ADR-006).
6
+
7
+ const DEFAULT_TIMEOUT_MS = 15000;
8
+ // Bounded so a Talent who can never produce a matching code can't loop forever — after this many CODE-ENTRY attempts we give up (nothing persisted, asked again next run).
9
+ const MAX_CODE_ATTEMPTS = 5;
10
+
11
+ // POST {email} to the request endpoint.
12
+ async function requestCode({ email }, { url, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
13
+ if (!url) return { ok: false, reason: 'no-endpoint' };
14
+
15
+ let res;
16
+ try {
17
+ res = await requestBackend({ endpoint: url, body: { email }, timeoutMs });
18
+ } catch (e) {
19
+ if (e && e.kind === 'timeout') return { ok: false, reason: 'timeout' };
20
+ return { ok: false, reason: 'network-error', detail: e && e.message };
21
+ }
22
+
23
+ if (res.status < 200 || res.status >= 300) {
24
+ return { ok: false, reason: `http-${res.status}` };
25
+ }
26
+ return { ok: true, backend: res.backend };
27
+ }
28
+
29
+ // Maps a server "not verified" outcome to a soft reason the loop can retry on.
30
+ function mapNotVerifiedReason(bodyReason) {
31
+ return bodyReason === 'expired' ? 'expired' : 'invalid-code';
32
+ }
33
+
34
+ // POST {email, code} to the verify endpoint.
35
+ async function verifyCode({ email, code }, { url, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
36
+ if (!url) return { ok: false, reason: 'no-endpoint' };
37
+
38
+ let res;
39
+ try {
40
+ res = await requestBackend({ endpoint: url, body: { email, code }, timeoutMs });
41
+ } catch (e) {
42
+ if (e && e.kind === 'timeout') return { ok: false, reason: 'timeout' };
43
+ return { ok: false, reason: 'network-error', detail: e && e.message };
44
+ }
45
+
46
+ let parsed = null;
47
+ try {
48
+ parsed = res.raw ? JSON.parse(res.raw) : null;
49
+ } catch {
50
+ // fall through: unparseable body handled per status below
51
+ }
52
+
53
+ if (res.status >= 200 && res.status < 300) {
54
+ if (parsed && parsed.verified === true) return { ok: true, verified: true, backend: res.backend };
55
+ if (parsed && typeof parsed === 'object') return { ok: false, reason: mapNotVerifiedReason(parsed.reason) };
56
+ return { ok: false, reason: 'invalid-json' };
57
+ }
58
+
59
+ // Non-2xx: a bad/expired code the server rejects at the HTTP layer is still
60
+ // a SOFT failure (retryable). 5xx and anything else is technical.
61
+ if (res.status === 400 || res.status === 401 || res.status === 410 || res.status === 422) {
62
+ return { ok: false, reason: mapNotVerifiedReason(parsed && parsed.reason) };
63
+ }
64
+ return { ok: false, reason: `http-${res.status}` };
65
+ }
66
+
67
+ // Categorizes a verifyCode reason for the wait-mode loop: 'soft' -> invalid/expired code: show a message, keep waiting (retry).
68
+ function classifyVerifyReason(reason) {
69
+ if (reason === 'invalid-code' || reason === 'expired') return 'soft';
70
+ return 'technical';
71
+ }
72
+
73
+ // The interactive "modo espera": send a code, then loop reading the pasted code until it verifies, the Talent cancels, or attempts run out.
74
+ // talents-ai-score, ADR-042: `requestUrl`/`verifyUrl` address the ONE backend (the certifications service).
75
+ async function runEmailVerification({
76
+ email,
77
+ ask,
78
+ catalog,
79
+ requestUrl,
80
+ verifyUrl,
81
+ deps = {},
82
+ }) {
83
+ const v = catalog.verify;
84
+ const doRequest = deps.requestCode || requestCode;
85
+ const doVerify = deps.verifyCode || verifyCode;
86
+ const write = deps.write || ((s) => process.stdout.write(s));
87
+
88
+ // No endpoint could be derived (ingest unset): nothing to verify against,
89
+ // and nowhere to persist either. Quiet, non-error outcome.
90
+ if (!requestUrl || !verifyUrl) {
91
+ write(` ${v.unavailable}\n`);
92
+ return { verified: false, reason: 'unavailable' };
93
+ }
94
+
95
+ const sent = await doRequest({ email }, { url: requestUrl });
96
+ if (!sent.ok) {
97
+ write(` ${v.requestFailed}\n`);
98
+ return { verified: false, reason: 'request-failed' };
99
+ }
100
+
101
+ // Entered wait mode.
102
+ write(`\n ${v.sent(email)}\n`);
103
+ write(` ${v.waitHint}\n`);
104
+
105
+ for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; ) {
106
+ // The OTP is a secret too (issue 102): same reader, same echo, and the fix is the same call.
107
+ const readCode = typeof ask.secret === 'function' ? ask.secret.bind(ask) : ask;
108
+ const raw = String(await readCode(v.codePrompt)).trim();
109
+
110
+ // Empty line / EOF (piped, closed stdin) / Ctrl-C-then-Enter -> cancel.
111
+ if (raw === '') {
112
+ write(` ${v.cancelled}\n`);
113
+ return { verified: false, reason: 'cancelled' };
114
+ }
115
+
116
+ // Resend, without consuming an attempt.
117
+ if (/^r$/i.test(raw)) {
118
+ const resent = await doRequest({ email }, { url: requestUrl });
119
+ write(resent.ok ? ` ${v.resent(email)}\n` : ` ${v.resendFailed}\n`);
120
+ continue;
121
+ }
122
+
123
+ attempt++;
124
+ const result = await doVerify({ email, code: raw }, { url: verifyUrl });
125
+ if (result.ok && result.verified) {
126
+ write(` ${v.verified}\n`);
127
+ return { verified: true, backend: result.backend };
128
+ }
129
+
130
+ if (classifyVerifyReason(result.reason) === 'soft') {
131
+ write(result.reason === 'expired' ? ` ${v.expired}\n` : ` ${v.invalidCode}\n`);
132
+ continue;
133
+ }
134
+
135
+ // Technical: the Hub couldn't be reached or answered unexpectedly. Legible
136
+ // error, bail out (nothing persisted); the report was already shown.
137
+ write(` ${v.technicalError}\n`);
138
+ return { verified: false, reason: 'technical' };
139
+ }
140
+
141
+ write(` ${v.tooManyAttempts}\n`);
142
+ return { verified: false, reason: 'exhausted' };
143
+ }
144
+
145
+ module.exports = {
146
+ requestCode,
147
+ verifyCode,
148
+ classifyVerifyReason,
149
+ runEmailVerification,
150
+ DEFAULT_TIMEOUT_MS,
151
+ MAX_CODE_ATTEMPTS,
152
+ };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+
5
+ // Shared home-directory + env resolution (talents-ai-score, ADR-014 — "Alcance del tier: Talento = proyecto ∪ home", applied uniformly to every category).
6
+ function readEnv(env, suffix) {
7
+ const next = env[`SHAKERS_CLI_${suffix}`];
8
+ if (next !== undefined && next !== '') return next;
9
+ return env[`AI_FOOTPRINT_${suffix}`];
10
+ }
11
+
12
+ function getHomeDir(env = process.env) {
13
+ return readEnv(env, 'HOME_DIR') || os.homedir();
14
+ }
15
+
16
+ // `~/Documents/cv.pdf` -> absolute, so a model can pass the short form it was shown.
17
+ function expandHome(file, env = process.env) {
18
+ if (typeof file !== 'string') return file;
19
+ if (file === '~') return getHomeDir(env);
20
+ if (file.startsWith('~/')) return require('path').join(getHomeDir(env), file.slice(2));
21
+ return file;
22
+ }
23
+
24
+ // The short `~/...` form of a path under home, for showing (and copying) without typos.
25
+ function tildify(file, env = process.env) {
26
+ const home = getHomeDir(env);
27
+ return typeof file === 'string' && file.startsWith(`${home}/`) ? `~/${file.slice(home.length + 1)}` : file;
28
+ }
29
+
30
+ module.exports = { getHomeDir, readEnv, expandHome, tildify };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ // Ordinal -> positionId cache for the LAST human `find-projects` listing, so the
4
+ // terminal user can reference a project by the `N)` number they saw (the
5
+ // positionId is hidden from the human render). Written only on a human list,
6
+ // never on --json. Atomic 0600, same dir/style as the session store.
7
+
8
+ const crypto = require('crypto');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { getConfigDir } = require('./config-dir');
12
+
13
+ const CACHE_FILE_NAME = 'find-projects-cache.json';
14
+ const CACHE_FILE_VERSION = 1;
15
+
16
+ function cachePath(env = process.env) {
17
+ // Containment guard: the file name is a constant with no separators, so this
18
+ // can never escape — but resolving + checking closes the path-traversal gate
19
+ // cheaply and makes the invariant explicit. Throws on any escape; callers that
20
+ // read swallow it (return null), callers that write propagate (best-effort).
21
+ const base = path.resolve(getConfigDir(env));
22
+ const target = path.resolve(base, CACHE_FILE_NAME);
23
+ const rel = path.relative(base, target);
24
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
25
+ throw new Error('find-projects cache path escapes the config dir');
26
+ }
27
+ return target;
28
+ }
29
+
30
+ // Persists the display-ordered positionIds (index i -> ordinal i+1).
31
+ function writePositionCache(ids, env = process.env, { fs: fsImpl = fs } = {}) {
32
+ const clean = (Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string' && id);
33
+ const payload = `${JSON.stringify({ version: CACHE_FILE_VERSION, listedAt: new Date().toISOString(), ids: clean }, null, 2)}\n`;
34
+ const target = cachePath(env);
35
+ const dir = path.dirname(target);
36
+ fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ const tmp = path.join(dir, `.${CACHE_FILE_NAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
38
+ let fd = null;
39
+ try {
40
+ fd = fsImpl.openSync(tmp, 'wx', 0o600);
41
+ fsImpl.writeFileSync(fd, payload);
42
+ try { fsImpl.fsyncSync(fd); } catch { /* e.g. EINVAL on some filesystems */ }
43
+ fsImpl.closeSync(fd);
44
+ fd = null;
45
+ fsImpl.renameSync(tmp, target);
46
+ } catch (e) {
47
+ if (fd !== null) { try { fsImpl.closeSync(fd); } catch { /* already closed */ } }
48
+ try { fsImpl.unlinkSync(tmp); } catch { /* never existed */ }
49
+ throw e;
50
+ }
51
+ return target;
52
+ }
53
+
54
+ // Returns the stored id list, or null when there is no usable cache.
55
+ function readPositionCache(env = process.env, { fs: fsImpl = fs } = {}) {
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(fsImpl.readFileSync(cachePath(env), 'utf8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ if (!parsed || typeof parsed !== 'object' || parsed.version !== CACHE_FILE_VERSION || !Array.isArray(parsed.ids)) return null;
63
+ const ids = parsed.ids.filter((id) => typeof id === 'string' && id);
64
+ return { ids };
65
+ }
66
+
67
+ // Resolves a raw positional argument to a positionId. A PURE integer is an
68
+ // ordinal into the last listing's cache; anything else (UUID / legacy Mongo id)
69
+ // is passed through unchanged. Never resolves to the wrong project silently.
70
+ function resolvePositionRef(raw, env = process.env, deps = {}) {
71
+ const read = deps.readPositionCache || readPositionCache;
72
+ const arg = typeof raw === 'string' ? raw.trim() : '';
73
+ if (!arg) return { ok: false, reason: 'no-id' };
74
+ if (!/^\d+$/.test(arg)) return { ok: true, id: arg }; // UUID / Mongo id passthrough
75
+ const n = Number.parseInt(arg, 10);
76
+ const cache = read(env);
77
+ if (!cache || cache.ids.length === 0) return { ok: false, reason: 'no-cache' };
78
+ if (n < 1 || n > cache.ids.length) return { ok: false, reason: 'out-of-range', count: cache.ids.length };
79
+ return { ok: true, id: cache.ids[n - 1] };
80
+ }
81
+
82
+ module.exports = {
83
+ CACHE_FILE_NAME,
84
+ CACHE_FILE_VERSION,
85
+ cachePath,
86
+ writePositionCache,
87
+ readPositionCache,
88
+ resolvePositionRef,
89
+ };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // Find Positions list client. Each
4
+ // item carries the parent Project's id/name — a "project" here is the Position's
5
+ // parent, not a separate list. Pagination (limit/offset) and filters map to the
6
+ // endpoint's `criteria`; the server orders by match desc and returns the total.
7
+
8
+ const { postJsonWithTimeout } = require('./backend-request');
9
+ const { reasonForError } = require('./onboarding-client');
10
+
11
+ const DEFAULT_TIMEOUT_MS = 20000;
12
+ const DEFAULT_LIMIT = 15;
13
+ // Only tabs the endpoint distinguishes server-side: All (default order) and
14
+ // Saved (savedByMe). "Recommended" was a client illusion — dropped under
15
+ // pagination, where it would only cover the fetched page and mislead.
16
+ const TABS = ['all', 'saved'];
17
+ const ATTENDANCE = { remote: 'REMOTE', hybrid: 'HYBRID', 'in-person': 'IN_PERSON' };
18
+
19
+ function parseJson(raw) {
20
+ try {
21
+ return raw ? JSON.parse(raw) : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function bearerHeaders(accessToken) {
28
+ const headers = {};
29
+ if (typeof accessToken === 'string' && accessToken) headers.Authorization = `Bearer ${accessToken}`;
30
+ return headers;
31
+ }
32
+
33
+ function pick(obj, keys) {
34
+ for (const k of keys) {
35
+ const v = obj && obj[k];
36
+ if (v !== undefined && v !== null && v !== '') return v;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function numOrNull(v) {
42
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
43
+ }
44
+
45
+ function strArray(v) {
46
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : [];
47
+ }
48
+
49
+ // Only fields the readview's criteria map supports server-side: savedByMe,
50
+ // attendance, country, clusterIds (confirmed in FindPositionsService + its prisma readview).
51
+ // languages and skills are output-only (no filter field) → not built here.
52
+ function buildCriteria(tab, { limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null, clusterId = null } = {}) {
53
+ const criteria = { limit, offset };
54
+ const conditions = [];
55
+ if (tab === 'saved') conditions.push({ field: 'savedByMe', operator: 'EQUALS', value: true });
56
+ if (clusterId) conditions.push({ field: 'clusterIds', operator: 'IN', value: [clusterId] });
57
+ if (attendance) conditions.push({ field: 'attendance', operator: 'IN', value: [attendance] });
58
+ if (country) conditions.push({ field: 'country', operator: 'IN', value: [country] });
59
+ if (conditions.length) criteria.filter = { conjunction: 'AND', conditions };
60
+ return criteria;
61
+ }
62
+
63
+ function normalizeItem(raw) {
64
+ const it = raw && typeof raw === 'object' ? raw : {};
65
+ const company = it.company && typeof it.company === 'object' ? it.company : {};
66
+ const budget = it.budget && typeof it.budget === 'object' ? it.budget : null;
67
+ return {
68
+ positionId: pick(it, ['positionId', 'id']),
69
+ title: pick(it, ['title']),
70
+ projectId: pick(it, ['projectId']),
71
+ projectName: pick(it, ['projectName']),
72
+ description: pick(it, ['description']),
73
+ country: pick(it, ['country']),
74
+ requiredMonthlyHours: numOrNull(it.requiredMonthlyHours),
75
+ attendance: pick(it, ['attendance']),
76
+ match: numOrNull(it.match),
77
+ isSaved: it.isSaved === true,
78
+ company: {
79
+ name: pick(company, ['name']),
80
+ restricted: company.restricted === true,
81
+ },
82
+ budget: budget
83
+ ? {
84
+ from: numOrNull(budget.from),
85
+ to: numOrNull(budget.to),
86
+ unit: pick(budget, ['unit']),
87
+ display: pick(budget, ['display']),
88
+ }
89
+ : null,
90
+ skillNames: strArray(it.skillNames),
91
+ languages: strArray(it.languages),
92
+ };
93
+ }
94
+
95
+ async function requestFindProjects(
96
+ { hubAccessToken } = {},
97
+ { tab = 'all', endpoint, timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null, clusterId = null } = {},
98
+ ) {
99
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
100
+ if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
101
+
102
+ const criteria = buildCriteria(tab, { limit, offset, attendance, country, clusterId });
103
+ const url = `${endpoint}${endpoint.includes('?') ? '&' : '?'}criteria=${encodeURIComponent(JSON.stringify(criteria))}`;
104
+ let res;
105
+ try {
106
+ res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(hubAccessToken));
107
+ } catch (e) {
108
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
109
+ }
110
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
111
+
112
+ const env = parseJson(res.raw);
113
+ if (!env || typeof env !== 'object') return { ok: false, reason: 'bad-response' };
114
+ const data = env.status === 'OK' ? env.data : env;
115
+ const rows = Array.isArray(data) ? data : (Array.isArray(data && data.items) ? data.items : null);
116
+ if (!rows) return { ok: false, reason: 'bad-response' };
117
+ return { ok: true, items: rows.map(normalizeItem), meta: (env && typeof env.meta === 'object' ? env.meta : null) };
118
+ }
119
+
120
+ // CLI+MCP entry: resolve the endpoint from config, then fetch one page.
121
+ async function fetchFindProjects(deps = {}, { hubAccessToken, tab = 'all', timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null, clusterId = null } = {}) {
122
+ const {
123
+ getFindPositionsEndpoint = require('./config').getFindPositionsEndpoint,
124
+ requestFindProjects: request = requestFindProjects,
125
+ } = deps;
126
+ const endpoint = getFindPositionsEndpoint();
127
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
128
+ return request({ hubAccessToken }, { tab, endpoint, timeoutMs, limit, offset, attendance, country, clusterId });
129
+ }
130
+
131
+ // Open positions visible to the talent that look for one role (cluster): the list's own total, one row fetched.
132
+ async function countOpenPositions(deps = {}, { hubAccessToken, clusterId, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
133
+ const res = await fetchFindProjects(deps, { hubAccessToken, timeoutMs, limit: 1, clusterId });
134
+ if (!res.ok) return res;
135
+ const total = res.meta && Number.isInteger(res.meta.total) ? res.meta.total : null;
136
+ return total === null ? { ok: false, reason: 'no-total' } : { ok: true, total };
137
+ }
138
+
139
+ module.exports = {
140
+ TABS,
141
+ countOpenPositions,
142
+ ATTENDANCE,
143
+ DEFAULT_LIMIT,
144
+ buildCriteria,
145
+ normalizeItem,
146
+ requestFindProjects,
147
+ fetchFindProjects,
148
+ };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ // `find-projects` flow: list the Positions/Projects available to the talent,
4
+ // paginated and filtered server-side via the endpoint's `criteria`. Read-only.
5
+
6
+ const { TABS, ATTENDANCE, DEFAULT_LIMIT } = require('./find-projects-client');
7
+ const { styleLabelPrefix } = require('./ansi');
8
+
9
+ function makeFindProjectsDeps(overrides = {}) {
10
+ const client = require('./find-projects-client');
11
+ return {
12
+ fetchFindProjects: (opts) => client.fetchFindProjects({}, opts),
13
+ writePositionCache: (ids) => require('./find-projects-cache').writePositionCache(ids),
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ // Normalize CLI/agent inputs to the shapes the endpoint's criteria expects.
19
+ function resolveQuery(opts = {}) {
20
+ const tab = TABS.includes(opts.tab) ? opts.tab : 'all';
21
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? Math.min(opts.limit, 50) : DEFAULT_LIMIT;
22
+ const page = Number.isInteger(opts.page) && opts.page > 0 ? opts.page : 1;
23
+ const offset = (page - 1) * limit;
24
+ const attRaw = typeof opts.attendance === 'string' ? opts.attendance.trim().toLowerCase() : null;
25
+ const attendance = attRaw && ATTENDANCE[attRaw] ? ATTENDANCE[attRaw] : null;
26
+ const attendanceInvalid = !!(attRaw && !ATTENDANCE[attRaw]);
27
+ const countryRaw = typeof opts.country === 'string' ? opts.country.trim().toUpperCase() : null;
28
+ const country = countryRaw && /^[A-Z]{2}$/.test(countryRaw) ? countryRaw : null;
29
+ const countryInvalid = !!(countryRaw && !/^[A-Z]{2}$/.test(countryRaw));
30
+ return { tab, limit, page, offset, attendance, country, attendanceInvalid, countryInvalid, recommended: !!opts.recommended };
31
+ }
32
+
33
+ // `--recommended` is a CLIENT-SIDE filter over the fetched page (the hub has no
34
+ // server-side "recommended" criterion — `match` is output-only). Recommended =
35
+ // items that have a match. If none on this page, fall back to the available
36
+ // items so the user is never left with an empty screen.
37
+ function splitRecommended(items, recommended) {
38
+ if (!recommended) return { shown: items, mode: null };
39
+ const rec = items.filter((it) => it.match != null);
40
+ if (rec.length > 0) return { shown: rec, mode: 'recommended' };
41
+ return { shown: items, mode: 'fallback' };
42
+ }
43
+
44
+ function budgetLine(item, fp) {
45
+ if (item.budget && item.budget.display) return item.budget.display;
46
+ if (item.budget && (item.budget.from != null || item.budget.to != null)) {
47
+ return `${item.budget.from ?? '·'}–${item.budget.to ?? '·'}${item.budget.unit ? ` ${item.budget.unit}` : ''}`;
48
+ }
49
+ return fp.budgetUnknown;
50
+ }
51
+
52
+ function formatItem(item, index, fp) {
53
+ const headline = item.title || item.projectName || fp.untitled;
54
+ const company = item.company && item.company.name
55
+ ? item.company.name
56
+ : (item.company && item.company.restricted ? fp.companyRestricted : null);
57
+ // No type segment (positions-only, always "position" → redundant).
58
+ const metaBits = [
59
+ item.attendance || null,
60
+ item.country || null,
61
+ item.requiredMonthlyHours != null ? fp.hoursPerMonth(item.requiredMonthlyHours) : null,
62
+ budgetLine(item, fp),
63
+ item.match != null ? fp.matchScore(item.match) : null,
64
+ item.isSaved ? fp.savedBadge : null,
65
+ ].filter(Boolean);
66
+ // Human render never prints positionId/projectId (they stay in --json only).
67
+ const lines = [`${index}) ${headline}${company ? ` — ${company}` : ''}`];
68
+ if (item.projectName && item.projectName !== headline) lines.push(` ${styleLabelPrefix(fp.projectLabel(item.projectName))}`);
69
+ lines.push(` ${metaBits.join(' · ')}`);
70
+ return lines.join('\n ');
71
+ }
72
+
73
+ // "Showing X–Y of TOTAL" (+ a next-page hint while more pages remain).
74
+ function paginationFooter(fp, meta, q, shown) {
75
+ if (!meta || typeof meta.total !== 'number') return null;
76
+ if (meta.total === 0) return null;
77
+ const from = q.offset + 1;
78
+ const to = q.offset + shown;
79
+ let line = fp.showing(from, to, meta.total);
80
+ if (meta.totalPages && q.page < meta.totalPages) line += ` · ${fp.morePages(q.page + 1)}`;
81
+ return line;
82
+ }
83
+
84
+ // Honest footer while showing the client-side recommended SUBSET of a page:
85
+ // never "X of TOTAL" (TOTAL is all available, not recommended). Pagination still
86
+ // works to pull more pages (which may carry more recommended).
87
+ function recommendedFooter(fp, count, meta, q) {
88
+ let line = fp.recommendedOnPage(count);
89
+ if (meta && meta.totalPages && q.page < meta.totalPages) line += ` · ${fp.morePages(q.page + 1)}`;
90
+ return line;
91
+ }
92
+
93
+ async function runFindProjects({ io, rawOut, session, lang, catalog, opts = {}, deps = makeFindProjectsDeps() }) {
94
+ const fp = catalog.findProjects;
95
+ const hubAccessToken = session ? session.hubAccessToken : null;
96
+ const q = resolveQuery(opts);
97
+
98
+ if (!opts.json) {
99
+ if (q.attendanceInvalid) io.warn(fp.attendanceIgnored);
100
+ if (q.countryInvalid) io.warn(fp.countryIgnored);
101
+ }
102
+
103
+ // In --json the loading label must not print (it would corrupt the JSON on a
104
+ // non-TTY stdout); only decorate the human path with the progress spinner.
105
+ const fetchPage = () => deps.fetchFindProjects({
106
+ hubAccessToken, tab: q.tab, limit: q.limit, offset: q.offset, attendance: q.attendance, country: q.country,
107
+ });
108
+ const res = opts.json ? await fetchPage() : await io.withProgress(fp.loading, fetchPage);
109
+ if (!res.ok) {
110
+ io.error(fp.fetchFailed(res.reason));
111
+ return { ok: false, reason: res.reason };
112
+ }
113
+
114
+ const items = Array.isArray(res.items) ? res.items : [];
115
+ // Client-side recommended filter (+ fallback to available), applied to both
116
+ // the human render and --json so an agent sees the same view (json keeps match).
117
+ const { shown, mode } = splitRecommended(items, q.recommended);
118
+
119
+ if (opts.json) {
120
+ (rawOut || ((s) => process.stdout.write(s)))(`${JSON.stringify({
121
+ ok: true,
122
+ tab: q.tab,
123
+ page: q.page,
124
+ limit: q.limit,
125
+ filters: { attendance: q.attendance, country: q.country, recommended: q.recommended },
126
+ recommended: mode,
127
+ meta: res.meta || null,
128
+ items: shown,
129
+ }, null, 2)}\n`);
130
+ return { ok: true, items: shown, meta: res.meta || null, recommended: mode };
131
+ }
132
+
133
+ // Persist the ordinal -> positionId map of what is SHOWN so `show-project N`
134
+ // matches the render. Human path only; a write failure never breaks the listing.
135
+ try { deps.writePositionCache(shown.map((it) => it.positionId)); } catch { /* cache is best-effort */ }
136
+
137
+ io.section(q.tab === 'saved' ? fp.titleSaved : fp.title);
138
+ if (items.length === 0) {
139
+ io.notify(q.page > 1 ? fp.noneOnPage : fp.noneAvailable);
140
+ return { ok: true, items: shown, meta: res.meta || null, recommended: mode };
141
+ }
142
+ // No recommended on this page -> we show the available ones, with a clear notice.
143
+ if (mode === 'fallback') io.notify(fp.recommendedNoneFallback);
144
+ shown.forEach((item, i) => {
145
+ if (i > 0) io.notify('');
146
+ io.notify(formatItem(item, i + 1, fp));
147
+ });
148
+ const footer = mode === 'recommended'
149
+ ? recommendedFooter(fp, shown.length, res.meta, q)
150
+ : paginationFooter(fp, res.meta, q, shown.length);
151
+ if (footer) io.notify(footer);
152
+ io.success(fp.done);
153
+ return { ok: true, items: shown, meta: res.meta || null, recommended: mode };
154
+ }
155
+
156
+ module.exports = {
157
+ makeFindProjectsDeps,
158
+ runFindProjects,
159
+ resolveQuery,
160
+ splitRecommended,
161
+ formatItem,
162
+ paginationFooter,
163
+ recommendedFooter,
164
+ };