shakers-cli-beta 0.10.3 → 0.11.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.
package/bin/shakers.js CHANGED
@@ -46,7 +46,11 @@ const { run: runRegister } = require('./register');
46
46
  const { run: runOnboarding } = require('./onboarding');
47
47
  const { run: runConfig } = require('./config');
48
48
  const { run: runMcp } = require('./mcp');
49
+ const { run: runUpdate } = require('./update');
49
50
  const { checkOnboardingCompleted } = require('../src/onboarding-availability');
51
+ const { emitCommandAudit } = require('../src/command-audit');
52
+ const { readPackageInfo } = require('../src/update-flow');
53
+ const { isSuppressed, maybeNotify, triggerBackgroundCheck } = require('../src/update-notifier');
50
54
 
51
55
  let VERSION = '';
52
56
  try {
@@ -95,13 +99,14 @@ const HANDLERS = {
95
99
  register: runRegister,
96
100
  onboarding: runOnboarding,
97
101
  config: runConfig,
102
+ update: runUpdate,
98
103
  login: runLogin,
99
104
  logout: runLogout,
100
105
  superadmin: runSuperadmin,
101
106
  mcp: runMcp,
102
107
  };
103
108
 
104
- const SESSION_EXEMPT = new Set(['ai-usage', 'register', 'config', 'login', 'logout', 'superadmin', 'mcp', 'share', 'alma']);
109
+ const SESSION_EXEMPT = new Set(['ai-usage', 'register', 'config', 'login', 'logout', 'superadmin', 'mcp', 'share', 'alma', 'update']);
105
110
 
106
111
  // Command FAMILIES — a navigation layer over the flat commands (which all stay
107
112
  // valid as aliases). `shakers <family> <sub>` resolves to the flat handler; every
@@ -119,7 +124,7 @@ const FAMILIES = {
119
124
  invitations: 'invitations', applications: 'applications',
120
125
  },
121
126
  usage: { run: 'ai-usage', report: 'report', share: 'share' },
122
- account: { login: 'login', logout: 'logout', register: 'register', config: 'config' },
127
+ account: { login: 'login', logout: 'logout', register: 'register', config: 'config', update: 'update' },
123
128
  certs: { list: 'certifications', certify: 'certify' },
124
129
  };
125
130
 
@@ -212,12 +217,13 @@ function usageText(lang, { hideOnboarding = false } = {}) {
212
217
  + ` Sesión:\n`
213
218
  + ` login Inicia sesión en tu cuenta de Shakers\n`
214
219
  + ` logout Cierra la sesión\n`
215
- + ` config Lee/escribe la configuración (bases de hub/certs)\n\n`
220
+ + ` config Lee/escribe la configuración (bases de hub/certs)\n`
221
+ + ` update Actualiza el CLI a la última versión publicada (--check solo informa)\n\n`
216
222
  + ` Familias (agrupan los comandos de arriba; los nombres planos siguen valiendo como alias):\n`
217
223
  + ` add <skill|agent|project|role> · role <add|change>\n`
218
224
  + ` profile <show|rate|languages|socials|experiences|portfolios|availability|certifications>\n`
219
225
  + ` projects <find|show|save|unsave|invitations|applications>\n`
220
- + ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config>\n`
226
+ + ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config|update>\n`
221
227
  + ` Ejecuta \`shakers <familia>\` (sin subcomando) para ver sus subcomandos.\n\n`
222
228
  + ` Ejecuta shakers <comando> --help para las opciones de cada comando.\n\n`;
223
229
  }
@@ -255,12 +261,13 @@ function usageText(lang, { hideOnboarding = false } = {}) {
255
261
  + ` Session:\n`
256
262
  + ` login Sign in to your Shakers account\n`
257
263
  + ` logout Sign out\n`
258
- + ` config Read/write configuration (hub/certs bases)\n\n`
264
+ + ` config Read/write configuration (hub/certs bases)\n`
265
+ + ` update Update the CLI to the latest published version (--check only reports)\n\n`
259
266
  + ` Families (group the commands above; the flat names still work as aliases):\n`
260
267
  + ` add <skill|agent|project|role> · role <add|change>\n`
261
268
  + ` profile <show|rate|languages|socials|experiences|portfolios|availability|certifications>\n`
262
269
  + ` projects <find|show|save|unsave|invitations|applications>\n`
263
- + ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config>\n`
270
+ + ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config|update>\n`
264
271
  + ` Run \`shakers <family>\` (no subcommand) to list its subcommands.\n\n`
265
272
  + ` Run \`shakers <command> --help\` for a command's options.\n\n`;
266
273
  }
@@ -295,6 +302,18 @@ async function main() {
295
302
  try { await ensureFreshSession(process.env); } catch { /* never blocks a command */ }
296
303
 
297
304
  const token = argv.find((a) => !a.startsWith('-'));
305
+
306
+ // Single dispatch choke-point (ADR-060): audit EXACTLY once before branching, fire-and-forget (never awaited). MCP tool calls run in-process and never reach here.
307
+ const auditCommand =
308
+ !token || token.toLowerCase() === 'help' ? 'help' : token.toLowerCase();
309
+ emitCommandAudit({ command: auditCommand, argv, env: process.env });
310
+
311
+ // Non-blocking update notice: reads a cached version synchronously, registry check runs in a detached worker; stderr-only, suppressed for --json/non-TTY/CI/update/opt-out.
312
+ if (!isSuppressed({ argv, command: auditCommand, env: process.env })) {
313
+ maybeNotify({ currentVersion: VERSION, lang, env: process.env });
314
+ triggerBackgroundCheck({ name: readPackageInfo().name, env: process.env });
315
+ }
316
+
298
317
  if (!token || token.toLowerCase() === 'help') {
299
318
  let hideOnboarding = false;
300
319
  try {
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Detached worker: fetches the latest version from the PUBLIC, unauthenticated npm registry, writes the `{lastCheck, latest}` cache, fails silent.
5
+
6
+ const https = require('https');
7
+ const { writeCache } = require('../src/update-notifier');
8
+
9
+ const FETCH_TIMEOUT_MS = 10000;
10
+ const MAX_BODY_BYTES = 3 * 1024 * 1024;
11
+
12
+ // Scoped names (`@scope/pkg`) address the registry as `@scope%2fpkg`, `@` literal; install-v1 metadata still carries `dist-tags`.
13
+ function registryUrl(name) {
14
+ const p = name.startsWith('@') ? `@${name.slice(1).replace('/', '%2f')}` : name;
15
+ return `https://registry.npmjs.org/${p}`;
16
+ }
17
+
18
+ function fetchLatest(name, timeoutMs) {
19
+ return new Promise((resolve) => {
20
+ let settled = false;
21
+ const done = (v) => {
22
+ if (!settled) {
23
+ settled = true;
24
+ resolve(v);
25
+ }
26
+ };
27
+ let req;
28
+ try {
29
+ req = https.get(
30
+ registryUrl(name),
31
+ { headers: { Accept: 'application/vnd.npm.install-v1+json' } },
32
+ (res) => {
33
+ if (res.statusCode !== 200) {
34
+ res.resume();
35
+ return done(null);
36
+ }
37
+ let body = '';
38
+ res.setEncoding('utf8');
39
+ res.on('data', (c) => {
40
+ body += c;
41
+ if (body.length > MAX_BODY_BYTES) req.destroy();
42
+ });
43
+ res.on('end', () => {
44
+ try {
45
+ const json = JSON.parse(body);
46
+ const latest = json && json['dist-tags'] && json['dist-tags'].latest;
47
+ done(typeof latest === 'string' && latest ? latest : null);
48
+ } catch {
49
+ done(null);
50
+ }
51
+ });
52
+ },
53
+ );
54
+ } catch {
55
+ return done(null);
56
+ }
57
+ req.setTimeout(timeoutMs, () => req.destroy());
58
+ req.on('error', () => done(null));
59
+ });
60
+ }
61
+
62
+ async function main() {
63
+ const name = process.argv[2];
64
+ if (!name) return;
65
+ const latest = await fetchLatest(name, FETCH_TIMEOUT_MS);
66
+ if (!latest) return;
67
+ writeCache({ lastCheck: Date.now(), latest });
68
+ }
69
+
70
+ main();
package/bin/update.js ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // `update` — update the CLI to the latest published version (CLI-only, never an MCP tool); `--check` reports without installing. Logic in src/update-flow.js.
5
+
6
+ const { detectReportLang, getCatalog } = require('../src/i18n');
7
+ const updateFlow = require('../src/update-flow');
8
+
9
+ function parseUpdateArgs(argv = []) {
10
+ const o = { help: false, check: false, json: false, lang: null };
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a === '--help' || a === '-h') o.help = true;
14
+ else if (a === '--check' || a === '--dry-run') o.check = true;
15
+ else if (a === '--json') o.json = true;
16
+ else if (a === '--lang') {
17
+ const v = argv[++i];
18
+ o.lang = v === 'es' || v === 'en' ? v : null;
19
+ } else if (a.startsWith('--lang=')) {
20
+ const v = a.slice(7);
21
+ o.lang = v === 'es' || v === 'en' ? v : null;
22
+ }
23
+ }
24
+ return o;
25
+ }
26
+
27
+ async function run(
28
+ argv = process.argv.slice(2),
29
+ { out = null, env = process.env, deps = null } = {},
30
+ ) {
31
+ const opts = parseUpdateArgs(argv);
32
+ const lang = opts.lang || detectReportLang();
33
+ const t = getCatalog(lang).cli.update;
34
+ const write = out || ((s) => process.stdout.write(s));
35
+ const flow = deps || updateFlow;
36
+
37
+ if (opts.help) {
38
+ write(t.help + '\n');
39
+ return;
40
+ }
41
+
42
+ const info = flow.readPackageInfo();
43
+ const install = flow.detectInstall(env);
44
+ const current = info.version;
45
+
46
+ if (!opts.json) write(` ${t.checking}\n`);
47
+ const latest = flow.fetchLatestVersion(info);
48
+
49
+ if (opts.json) {
50
+ const updateAvailable =
51
+ latest.ok && current ? flow.compareVersions(current, latest.version) < 0 : null;
52
+ write(
53
+ `${JSON.stringify({
54
+ package: info.name,
55
+ current: current || null,
56
+ latest: latest.ok ? latest.version : null,
57
+ install: install.kind,
58
+ updateAvailable,
59
+ reason: latest.ok ? null : latest.reason,
60
+ })}\n`,
61
+ );
62
+ if (!latest.ok) process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ if (current) write(` ${t.current(current)}\n`);
67
+
68
+ if (!latest.ok) {
69
+ write(` ${t.latestUnknown(latest.reason)}\n`);
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+
74
+ if (!current || flow.compareVersions(current, latest.version) >= 0) {
75
+ write(` ${t.upToDate(latest.version)}\n`);
76
+ return;
77
+ }
78
+
79
+ write(` ${t.available(current, latest.version)}\n`);
80
+
81
+ // `--check`: report only, never install.
82
+ if (opts.check) {
83
+ write(` ${t.checkHint}\n`);
84
+ return;
85
+ }
86
+
87
+ const plan = flow.buildUpdatePlan(install, info, env);
88
+ if (!plan) {
89
+ if (install.kind === 'source') write(` ${t.sourceCheckout}\n`);
90
+ else write(` ${t.unknownInstall(flow.manualCommand(info))}\n`);
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+
95
+ write(` ${t.detected(plan.label)}\n`);
96
+ write(` ${t.updating(info.name, current, latest.version)}\n\n`);
97
+
98
+ const result = flow.runInstall(plan, env);
99
+ if (result.ok) {
100
+ write(`\n ${t.success(latest.version)}\n ${t.reRun}\n`);
101
+ return;
102
+ }
103
+ write(`\n ${t.failInstall(result.reason)}\n ${t.failHint(flow.manualCommand(info))}\n`);
104
+ process.exitCode = 1;
105
+ }
106
+
107
+ module.exports = { run, parseUpdateArgs };
108
+
109
+ if (require.main === module) {
110
+ run();
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -4,6 +4,7 @@
4
4
 
5
5
  const { postJsonWithTimeout } = require('./backend-request');
6
6
  const { reasonForError } = require('./onboarding-client');
7
+ const { toUpperLang } = require('./lang-codes');
7
8
 
8
9
  const DEFAULT_TIMEOUT_MS = 20000;
9
10
 
@@ -153,9 +154,87 @@ async function discoverOfferableDimensions(deps = {}, { accessToken, hubAccessTo
153
154
  return { ok: true, mainRole: mine.mainRole, offerable, unmatched };
154
155
  }
155
156
 
157
+ async function requestCreateCertificationInterview(
158
+ { dimensionKey, languageCode, accessToken } = {},
159
+ { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
160
+ ) {
161
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
162
+ if (!dimensionKey) return { ok: false, reason: 'no-dimension' };
163
+ const body = { dimensionKey };
164
+ if (languageCode) body.languageCode = toUpperLang(languageCode);
165
+ let res;
166
+ try {
167
+ res = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, bearerHeaders(accessToken));
168
+ } catch (e) {
169
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
170
+ }
171
+ if (res.status < 200 || res.status >= 300) {
172
+ const reason = reasonForError(res.status, res.raw);
173
+ if (reason !== 'dimension-on-cooldown') return { ok: false, reason };
174
+ // certs puts the date in the message only: "... available on 2026-10-28T14:50:38.265Z".
175
+ const available = String(res.raw || '').match(/available on (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/);
176
+ return { ok: false, reason, availableOn: available ? available[1] : null };
177
+ }
178
+ const data = payloadOf(res.raw);
179
+ const interviewId = pick(data || {}, ['interviewId', 'id']);
180
+ if (!interviewId) return { ok: false, reason: 'bad-response' };
181
+ return { ok: true, interviewId, language: pick(data || {}, ['language', 'languageCode']) || null };
182
+ }
183
+
184
+ // Poll the verdict; a 2xx/404/425/202 with no band yet resolves `ready:false` (retriable).
185
+ async function requestCertificationReport(
186
+ { interviewId, accessToken } = {},
187
+ { base, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
188
+ ) {
189
+ if (!base) return { ok: false, reason: 'no-endpoint' };
190
+ if (!interviewId) return { ok: false, reason: 'no-interview' };
191
+ const url = `${base.replace(/\/+$/, '')}/${encodeURIComponent(interviewId)}/report`;
192
+ let res;
193
+ try {
194
+ res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(accessToken));
195
+ } catch (e) {
196
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
197
+ }
198
+ if (res.status === 404 || res.status === 425 || res.status === 202) return { ok: true, ready: false, report: null };
199
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
200
+ const data = payloadOf(res.raw);
201
+ if (data === undefined || data === null || typeof data !== 'object') return { ok: true, ready: false, report: null };
202
+ const band = pick(data, ['band', 'level', 'combinedLevel']);
203
+ // A report with too little evidence for a level has band null; it is still finished.
204
+ if (!band && !pick(data, ['evaluationId'])) return { ok: true, ready: false, report: null };
205
+ return { ok: true, ready: true, report: data };
206
+ }
207
+
208
+ // The case the web preview shows before starting (works `get-dimension-case`): the exercise statement, else the
209
+ // version's brief. `case: null` for a template with neither.
210
+ async function requestDimensionCase(
211
+ { dimensionKey, accessToken } = {},
212
+ { base, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
213
+ ) {
214
+ if (!base) return { ok: false, reason: 'no-endpoint' };
215
+ if (!dimensionKey) return { ok: false, reason: 'no-dimension' };
216
+ const path = String(dimensionKey).split('/').map(encodeURIComponent).join('/');
217
+ const url = `${base.replace(/\/+$/, '')}/dimension/${path}/case`;
218
+ let res;
219
+ try {
220
+ res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(accessToken));
221
+ } catch (e) {
222
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
223
+ }
224
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
225
+ const data = payloadOf(res.raw);
226
+ if (!data || typeof data !== 'object') return { ok: false, reason: 'bad-response' };
227
+ const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
228
+ const brief = text(data.statement) || text(data.candidateBrief);
229
+ return { ok: true, case: brief ? { brief, testType: text(data.testType) } : null };
230
+ }
231
+
156
232
  module.exports = {
157
233
  requestMyCertifications,
158
234
  requestTemplatesByDimension,
235
+ requestCreateCertificationInterview,
236
+ requestCertificationReport,
237
+ requestDimensionCase,
159
238
  composeDimensionKey,
160
239
  composeOfferableDimensions,
161
240
  discoverOfferableDimensions,
@@ -1,15 +1,24 @@
1
1
  'use strict';
2
2
 
3
- // `certify` dimension flow: discover -> pick -> hand off to the web interview.
3
+ // `certify` dimension flow: discover -> pick -> create -> LiveKit interview -> poll verdict.
4
4
 
5
5
  const { runInteractiveMultiSelect } = require('./interactive-select');
6
6
 
7
7
  function makeCertifyDimensionDeps(overrides = {}) {
8
8
  const config = require('./config');
9
9
  const client = require('./certify-dimension-client');
10
+ const onboardingFlow = require('./onboarding-flow');
10
11
  return {
11
- getTalentProfileUrl: config.getTalentProfileUrl,
12
+ getCertificationInterviewsEndpoint: config.getCertificationInterviewsEndpoint,
13
+ // `{certsBase}/interviews` — shared base for LiveKit start/complete + the report poll.
14
+ getInterviewsBase: config.getOnboardingInterviewsEndpoint,
12
15
  discoverOfferableDimensions: (opts) => client.discoverOfferableDimensions({}, opts),
16
+ requestCreateCertificationInterview: client.requestCreateCertificationInterview,
17
+ requestCertificationReport: client.requestCertificationReport,
18
+ requestDimensionCase: client.requestDimensionCase,
19
+ conductLivekitInterview: onboardingFlow.conductLivekitInterview,
20
+ onboardingDeps: onboardingFlow.makeDeps(),
21
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
13
22
  ...overrides,
14
23
  };
15
24
  }
@@ -53,19 +62,51 @@ function resolveDimensionArg(arg, items) {
53
62
  return items.find((d) => d.dimensionKey === raw || d.slug === raw) || undefined;
54
63
  }
55
64
 
56
- // Deep link into the talent's Shakers web profile, filtered to the chosen dimension.
57
- // One dimension can publish more than one assessment, so the filtered catalog is the
58
- // robust target rather than a single templateId.
59
- function buildWebLink(profileUrl, dimension) {
60
- if (!profileUrl || !dimension || !dimension.clusterId || !dimension.slug) return null;
61
- let origin;
62
- try {
63
- origin = new URL(profileUrl).origin;
64
- } catch {
65
- return null;
65
+ // The evaluation lands one to two minutes after the interview ends, four when the queue is busy.
66
+ async function pollReport(deps, { interviewId, accessToken }, { base, attempts = 60, intervalMs = 5000 } = {}) {
67
+ for (let i = 0; i < attempts; i++) {
68
+ const res = await deps.requestCertificationReport({ interviewId, accessToken }, { base });
69
+ if (!res.ok) return { ok: false, reason: res.reason };
70
+ if (res.ready) return { ok: true, report: res.report };
71
+ if (i < attempts - 1) await deps.sleep(intervalMs);
66
72
  }
67
- const key = `${encodeURIComponent(dimension.clusterId)}/${encodeURIComponent(dimension.slug)}`;
68
- return `${origin}/certifications?dimension=${key}`;
73
+ return { ok: false, reason: 'timeout' };
74
+ }
75
+
76
+ function formatDay(iso, lang) {
77
+ const d = iso ? new Date(iso) : null;
78
+ if (!d || Number.isNaN(d.getTime())) return null;
79
+ return d.toLocaleDateString(lang === 'en' ? 'en-GB' : 'es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
80
+ }
81
+
82
+ function renderVerdict(report, cd) {
83
+ const lines = [];
84
+ const band = report.band || report.level || report.combinedLevel;
85
+ const dimension = report.dimensionName || report.dimension || report.dimensionKey;
86
+ if (dimension) lines.push(cd.verdictDimension(dimension));
87
+ if (band) lines.push(cd.verdictBand(band));
88
+ if (typeof report.certified === 'boolean') lines.push(report.certified ? cd.verdictCertified : cd.verdictNotCertified);
89
+ const summary = report.summary || report.rationale || report.feedback;
90
+ if (typeof summary === 'string' && summary.trim()) lines.push(summary.trim());
91
+ const areas = Array.isArray(report.areas) ? report.areas : null;
92
+ if (areas && areas.length) {
93
+ for (const a of areas) {
94
+ const name = a && (a.name || a.area || a.tag);
95
+ const note = a && (a.note || a.comment);
96
+ if (name) lines.push(`- ${name}${note ? `: ${note}` : ''}`);
97
+ }
98
+ }
99
+ return lines.join('\n ');
100
+ }
101
+
102
+ // The statement is Markdown; the terminal shows it without the emphasis markers.
103
+ function renderCase(dimensionCase, cd) {
104
+ const brief = dimensionCase.brief.replace(/(\*\*|__)(.+?)\1/g, '$2');
105
+ const expectations = cd.caseExpectations[dimensionCase.testType];
106
+ const lines = brief.split('\n');
107
+ if (expectations) lines.push('', cd.caseExpectationsTitle, ...Object.values(expectations).map((e) => `- ${e}`));
108
+ lines.push('', cd.caseBeforeStart);
109
+ return lines.join('\n ');
69
110
  }
70
111
 
71
112
  async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog, opts = {}, deps = makeCertifyDimensionDeps() }) {
@@ -116,11 +157,52 @@ async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog
116
157
  }
117
158
  }
118
159
 
119
- const webLink = buildWebLink(deps.getTalentProfileUrl(), chosen);
120
- io.notify(cd.webHandoff);
121
- if (webLink) io.success(cd.webHandoffLink(webLink));
122
- else io.notify(cd.webHandoffNoLink);
123
- return { ok: true, handoff: true, dimensionKey: chosen.dimensionKey, webLink };
160
+ const createEndpoint = deps.getCertificationInterviewsEndpoint();
161
+ const created = await io.withProgress(cd.creating, () =>
162
+ deps.requestCreateCertificationInterview(
163
+ { dimensionKey: chosen.dimensionKey, languageCode: lang, accessToken },
164
+ { endpoint: createEndpoint },
165
+ ));
166
+ if (!created.ok) {
167
+ io.error(created.reason === 'dimension-on-cooldown'
168
+ ? cd.onCooldown(formatDay(created.availableOn, lang))
169
+ : cd.createFailed(created.reason));
170
+ return { ok: false, reason: created.reason, step: 'create' };
171
+ }
172
+
173
+ // A failed read leaves the run as it was before the case existed, like the web preview does.
174
+ const caseRead = await deps.requestDimensionCase(
175
+ { dimensionKey: chosen.dimensionKey, accessToken },
176
+ { base: deps.getInterviewsBase() },
177
+ );
178
+ if (caseRead.ok && caseRead.case) {
179
+ io.section(cd.caseTitle);
180
+ io.notify(renderCase(caseRead.case, cd));
181
+ if (stdinIsTTY) await ask(` ${cd.caseReadyPrompt}`);
182
+ }
183
+
184
+ const interview = await deps.conductLivekitInterview(io, deps.onboardingDeps, {
185
+ interviewId: created.interviewId,
186
+ language: lang,
187
+ plain: true,
188
+ });
189
+ if (!interview.ok) {
190
+ io.warn(interview.reason === 'needs-web' ? cd.needsWeb : cd.interviewUnavailable(interview.reason));
191
+ return { ok: false, reason: interview.reason, step: 'interview' };
192
+ }
193
+
194
+ const reportBase = deps.getInterviewsBase();
195
+ const polled = await io.withProgress(cd.reportPolling, () =>
196
+ pollReport(deps, { interviewId: created.interviewId, accessToken }, { base: reportBase }));
197
+ if (!polled.ok) {
198
+ io.warn(polled.reason === 'timeout' ? cd.reportTimeout : cd.reportFailed(polled.reason));
199
+ return { ok: true, completed: 'interview', verdict: null };
200
+ }
201
+
202
+ io.section(cd.verdictTitle);
203
+ io.notify(renderVerdict(polled.report, cd));
204
+ io.success(cd.done);
205
+ return { ok: true, completed: 'interview', verdict: polled.report };
124
206
  }
125
207
 
126
208
  module.exports = {
@@ -128,5 +210,7 @@ module.exports = {
128
210
  runCertifyDimension,
129
211
  chooseDimension,
130
212
  resolveDimensionArg,
131
- buildWebLink,
213
+ pollReport,
214
+ renderVerdict,
215
+ renderCase,
132
216
  };
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { scrubSecrets } = require('./agent-synthesis');
6
+ const { loadAuthSession } = require('./auth-session-store');
7
+ const { getCertsBase } = require('./config');
8
+ const { postJsonWithTimeout } = require('./backend-request');
9
+
10
+ // Fire-and-forget: short timeout, every error swallowed, so a logging failure never blocks or fails a command.
11
+ const AUDIT_TIMEOUT_MS = 1500;
12
+
13
+ // Capped at 200 chars AFTER scrubbing (mirrors MAX_COMMAND_LEN in session-scan.js) so a secret straddling the cut cannot leak.
14
+ const MAX_ARGS_LEN = 200;
15
+
16
+ function cliVersion() {
17
+ try {
18
+ return require(path.join(__dirname, '..', 'package.json')).version || null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // Scrub THEN cap: scrubSecrets can GROW text, so the cap is measured on the scrubbed string that goes on the wire.
25
+ function redactArgs(argv) {
26
+ if (!Array.isArray(argv) || argv.length === 0) return null;
27
+ const scrubbed = scrubSecrets(argv.join(' ')).trim().slice(0, MAX_ARGS_LEN);
28
+ return scrubbed || null;
29
+ }
30
+
31
+ // Anonymous runs carry only a best-effort machine hint; authenticated runs identify via the Bearer instead.
32
+ function machineHint() {
33
+ let osUser = null;
34
+ try {
35
+ const info = os.userInfo();
36
+ osUser = info && typeof info.username === 'string' ? info.username : null;
37
+ } catch {
38
+ osUser = null;
39
+ }
40
+ let hostname = null;
41
+ try {
42
+ hostname = os.hostname() || null;
43
+ } catch {
44
+ hostname = null;
45
+ }
46
+ return { osUser, hostname };
47
+ }
48
+
49
+ // Builds the record + headers; returns null when there is no endpoint to post to.
50
+ function buildAudit(command, argv, env) {
51
+ let base;
52
+ try {
53
+ base = getCertsBase(env);
54
+ } catch {
55
+ base = null;
56
+ }
57
+ if (!base) return null;
58
+
59
+ const session = loadAuthSession(env);
60
+ const token =
61
+ session && (session.accessToken || session.hubAccessToken)
62
+ ? session.accessToken || session.hubAccessToken
63
+ : null;
64
+
65
+ const body = {
66
+ command: String(command || 'unknown').slice(0, 100),
67
+ args: redactArgs(argv),
68
+ cliVersion: cliVersion(),
69
+ clientTimestamp: new Date().toISOString(),
70
+ };
71
+
72
+ // Machine hint only when anonymous; identity comes from the token otherwise.
73
+ if (!token) {
74
+ const { osUser, hostname } = machineHint();
75
+ body.osUser = osUser;
76
+ body.hostname = hostname;
77
+ }
78
+
79
+ const headers = token ? { Authorization: `Bearer ${token}` } : null;
80
+ const endpoint = `${base.replace(/\/+$/, '')}/cli/command-log`;
81
+ return { endpoint, body, headers };
82
+ }
83
+
84
+ // Emits ONE fire-and-forget audit record; never throws or rejects. Returns the `.catch`-guarded promise so tests can await it (production does not).
85
+ function emitCommandAudit({ command, argv = [], env = process.env } = {}) {
86
+ try {
87
+ const audit = buildAudit(command, argv, env);
88
+ if (!audit) return Promise.resolve();
89
+ return postJsonWithTimeout(
90
+ audit.endpoint,
91
+ audit.body,
92
+ AUDIT_TIMEOUT_MS,
93
+ 'POST',
94
+ null,
95
+ audit.headers,
96
+ ).then(
97
+ () => {},
98
+ () => {},
99
+ );
100
+ } catch {
101
+ return Promise.resolve();
102
+ }
103
+ }
104
+
105
+ module.exports = { emitCommandAudit, redactArgs, buildAudit };
package/src/config.js CHANGED
@@ -318,6 +318,14 @@ function getTemplatesByDimensionEndpoint(env = process.env) {
318
318
  return base ? `${base}/templates/by-dimension` : null;
319
319
  }
320
320
 
321
+ // The Talent's assessments with their retry availability (certs, talent Bearer).
322
+ function getMyAssessmentsEndpoint(env = process.env) {
323
+ const explicit = readEnv(env, 'MY_ASSESSMENTS_ENDPOINT');
324
+ if (explicit && explicit.trim()) return explicit.trim();
325
+ const base = getCertsBase(env);
326
+ return base ? `${base}/users/me/assessments` : null;
327
+ }
328
+
321
329
  // Find Positions list (hub `works/positions/find`, talent Bearer). Serves the
322
330
  // All/Recommended/Saved tabs via `criteria.savedByMe`; ordered by match desc.
323
331
  function getFindPositionsEndpoint(env = process.env) {
@@ -749,6 +757,7 @@ module.exports = {
749
757
  getSetMainRoleEndpoint,
750
758
  getAssignedClustersEndpoint,
751
759
  getTemplatesByDimensionEndpoint,
760
+ getMyAssessmentsEndpoint,
752
761
  // Find Positions list (`find-projects` command / `find_projects` MCP tool).
753
762
  getFindPositionsEndpoint,
754
763
  // Marketplace (show-project / save-project / unsave-project / invitations).