shakers-cli-beta 0.8.0-beta.0 → 0.8.0-beta.2

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/config.js CHANGED
@@ -15,24 +15,26 @@ const {
15
15
  const VALID_LANGS = new Set(['es', 'en']);
16
16
  const ENDPOINT_KEYS = new Set(['certsBase', 'hubBase', 'ingestEndpoint', 'profileUrl']);
17
17
  const PROFILE_VALUES = new Set(['external', 'talent']);
18
- const SETTABLE = new Set([...ENDPOINT_KEYS, 'profile']);
18
+ const SETTABLE = new Set([...ENDPOINT_KEYS, 'profile', 'lang']);
19
19
 
20
20
  const COPY = {
21
21
  es: {
22
- help: 'config — lee/escribe la configuracion NO sensible del CLI (bases de hub/certs, perfil). Uso: shakers config <list|get [clave]|set clave valor>\n Claves: certsBase, hubBase, ingestEndpoint, profileUrl, profile.\n Los secretos (tokens/credenciales) NUNCA se guardan aqui: se obtienen con `shakers login` o por entorno.',
22
+ help: 'config — lee/escribe la configuracion NO sensible del CLI (bases de hub/certs, perfil, idioma). Uso: shakers config <list|get [clave]|set clave valor>\n Claves: certsBase, hubBase, ingestEndpoint, profileUrl, profile, lang.\n Los secretos (tokens/credenciales) NUNCA se guardan aqui: se obtienen con `shakers login` o por entorno.',
23
23
  unknownKey: (k) => `clave desconocida: ${k}. Claves validas: ${[...SETTABLE].join(', ')}.`,
24
24
  needValue: 'set necesita <clave> <valor>.',
25
25
  badEndpoint: (k, r) => `valor invalido para ${k} (${r}). Debe ser una URL http de loopback o https.`,
26
26
  badProfile: 'profile debe ser "external" o "talent".',
27
+ badLang: 'lang debe ser "es" o "en".',
27
28
  saved: (k, v) => `guardado ${k} = ${v}`,
28
29
  resolvedHeading: 'Resueltos (env > config.json > default local):',
29
30
  },
30
31
  en: {
31
- help: 'config — read/write the CLI NON-sensitive configuration (hub/certs bases, profile). Usage: shakers config <list|get [key]|set key value>\n Keys: certsBase, hubBase, ingestEndpoint, profileUrl, profile.\n Secrets (tokens/credentials) are NEVER stored here: get them via `shakers login` or the environment.',
32
+ help: 'config — read/write the CLI NON-sensitive configuration (hub/certs bases, profile, language). Usage: shakers config <list|get [key]|set key value>\n Keys: certsBase, hubBase, ingestEndpoint, profileUrl, profile, lang.\n Secrets (tokens/credentials) are NEVER stored here: get them via `shakers login` or the environment.',
32
33
  unknownKey: (k) => `unknown key: ${k}. Valid keys: ${[...SETTABLE].join(', ')}.`,
33
34
  needValue: 'set needs <key> <value>.',
34
35
  badEndpoint: (k, r) => `invalid value for ${k} (${r}). Must be a loopback http URL or https.`,
35
36
  badProfile: 'profile must be "external" or "talent".',
37
+ badLang: 'lang must be "es" or "en".',
36
38
  saved: (k, v) => `saved ${k} = ${v}`,
37
39
  resolvedHeading: 'Resolved (env > config.json > local default):',
38
40
  },
@@ -98,6 +100,8 @@ async function run(argv = process.argv.slice(2), { env = process.env, out = null
98
100
  if (!SETTABLE.has(key)) { line(c.unknownKey(key)); process.exitCode = 1; return; }
99
101
  if (key === 'profile') {
100
102
  if (!PROFILE_VALUES.has(value)) { line(c.badProfile); process.exitCode = 1; return; }
103
+ } else if (key === 'lang') {
104
+ if (!VALID_LANGS.has(value)) { line(c.badLang); process.exitCode = 1; return; }
101
105
  } else if (key !== 'profileUrl') {
102
106
  const v = validateEndpoint(value);
103
107
  if (!v.ok) { line(c.badEndpoint(key, v.reason)); process.exitCode = 1; return; }
package/bin/mcp.js CHANGED
@@ -74,7 +74,7 @@ async function run(argv = process.argv.slice(2)) {
74
74
  return;
75
75
  }
76
76
 
77
- require('../src/config').ensureLocalConfigDefaults(process.env, { force: true });
77
+ require('../src/config').sanitizeStaleBakedEndpoints(process.env);
78
78
 
79
79
  const server = buildServer();
80
80
  const rl = server.start({
package/bin/register.js CHANGED
@@ -8,10 +8,10 @@ const { run: runLogin } = require('./login');
8
8
  const { run: runAiUsage } = require('./ai-usage');
9
9
  const {
10
10
  runOnboarding, makeDeps, CURRENCIES, MONTHLY_HOURS, normalizeAmount,
11
- WORK_SITUATIONS, EMPLOYMENT_PARTICIPATIONS, FREELANCE_OPINIONS, CHANGE_MOTIVATORS,
11
+ WORK_SITUATIONS, EMPLOYMENT_PARTICIPATIONS, FREELANCE_OPINIONS, CHANGE_MOTIVATORS, WORK_MODES,
12
12
  LANGUAGE_CODES, LANGUAGE_LEVELS, showsFreelanceOpinion, showsChangeMotivators,
13
13
  } = require('../src/onboarding-flow');
14
- const { promptSelect } = require('../src/prompt-select');
14
+ const { promptSelect, promptMultiSelect } = require('../src/prompt-select');
15
15
  const { chooseAuthMethod } = require('../src/auth-method-picker');
16
16
  const { methodIdsForFlow } = require('../src/auth-methods');
17
17
  const { palette } = require('../src/ansi');
@@ -123,7 +123,16 @@ function makeIo({ ask, lang, out, acceptDisclaimer, stdinIsTTY = false }) {
123
123
  askAvailability: async (c) => {
124
124
  const available = isYes(await ask(` ${c.availableAsk} `));
125
125
  const monthlyHours = await promptSelect({ ask, stdinIsTTY, out: selectOut, items: MONTHLY_HOURS, header: ` ${c.monthlyHoursAsk}` });
126
- const onlyRemote = isYes(await ask(` ${c.onlyRemoteAsk} `));
126
+ const workModes = await promptMultiSelect({
127
+ ask,
128
+ stdinIsTTY,
129
+ out: selectOut,
130
+ items: WORK_MODES,
131
+ labelFor: (v) => c.workModeLabels[v] || v,
132
+ header: ` ${c.workModesAsk}`,
133
+ hint: c.workModesHint,
134
+ });
135
+ if (workModes.length === 1 && workModes[0] === 'REMOTE') selectOut(c.onlyRemoteNote);
127
136
  let country = '';
128
137
  for (;;) {
129
138
  country = (await ask(` ${c.countryAsk} `)).trim().toUpperCase();
@@ -137,7 +146,7 @@ function makeIo({ ask, lang, out, acceptDisclaimer, stdinIsTTY = false }) {
137
146
  const longFullTimeProjects = isYes(await ask(` ${c.longFullTimeAsk} `));
138
147
  const telephoneCode = (await ask(` ${c.phonePrefixAsk} `)).trim();
139
148
  const telephoneNumber = (await ask(` ${c.phoneNumberAsk} `)).trim();
140
- return { available, monthlyHours, onlyRemote, country, subdivision, timezone, longFullTimeProjects, telephoneCode, telephoneNumber };
149
+ return { available, monthlyHours, workModes, country, subdivision, timezone, longFullTimeProjects, telephoneCode, telephoneNumber };
141
150
  },
142
151
  askSignUp: async (c) => {
143
152
  const name = (await ask(` ${c.askName} `)).trim();
package/bin/shakers.js CHANGED
@@ -14,7 +14,7 @@ const { detectReportLang, getCatalog } = require('../src/i18n');
14
14
  const { promptSelect } = require('../src/prompt-select');
15
15
  const { createStdinAsk } = require('../src/stdin-ask');
16
16
  const { migrateLegacyConfigDir } = require('../src/config-dir');
17
- const { ensureLocalConfigDefaults } = require('../src/config');
17
+ const { sanitizeStaleBakedEndpoints } = require('../src/config');
18
18
  const { ensureFreshSession } = require('../src/session-refresh');
19
19
  const { hasUsableSession } = require('../src/session-gate');
20
20
  const { run: runAiUsage } = require('./ai-usage');
@@ -290,7 +290,7 @@ async function main() {
290
290
  const lang = parseLang(argv) || detectReportLang();
291
291
 
292
292
  migrateLegacyConfigDir(process.env);
293
- ensureLocalConfigDefaults(process.env);
293
+ sanitizeStaleBakedEndpoints(process.env);
294
294
  // Mint a fresh hub JWT from the stored cookie once per command (email sessions only; a no-op for Google/legacy or when no session exists).
295
295
  try { await ensureFreshSession(process.env); } catch { /* never blocks a command */ }
296
296
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.8.0-beta.0",
3
+ "version": "0.8.0-beta.2",
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": {
@@ -5,6 +5,7 @@
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
7
7
  const { reasonForError } = require('./onboarding-client');
8
+ const { WORK_MODES } = require('./onboarding-flow');
8
9
 
9
10
  const DEFAULT_TIMEOUT_MS = 20000;
10
11
 
@@ -36,7 +37,7 @@ function normalizeAvailability(data) {
36
37
  available: bool(it.available),
37
38
  // A numeric hours value (incl. 0) must survive, not be dropped as "absent".
38
39
  monthlyHours: typeof it.monthlyHours === 'number' ? String(it.monthlyHours) : str(it.monthlyHours),
39
- onlyRemote: bool(it.onlyRemote),
40
+ workModes: Array.isArray(it.workModes) ? it.workModes.filter((m) => WORK_MODES.includes(m)) : [],
40
41
  country: str(it.country),
41
42
  subdivision: str(it.subdivision),
42
43
  timezone: str(it.timezone),
@@ -4,6 +4,7 @@
4
4
  // (interactive, explicit confirmation — it writes your profile). Talent Bearer.
5
5
 
6
6
  const { styleLabelPrefix } = require('./ansi');
7
+ const { WORK_MODES, validateAvailability } = require('./onboarding-flow');
7
8
 
8
9
  function makeAvailabilityDeps(overrides = {}) {
9
10
  const client = require('./availability-client');
@@ -11,10 +12,15 @@ function makeAvailabilityDeps(overrides = {}) {
11
12
  fetchAvailability: (opts) => client.fetchAvailability({}, opts),
12
13
  saveAvailability: (opts) => client.saveAvailability({}, opts),
13
14
  promptSelect: (opts) => require('./prompt-select').promptSelect(opts),
15
+ promptMultiSelect: (opts) => require('./prompt-select').promptMultiSelect(opts),
14
16
  ...overrides,
15
17
  };
16
18
  }
17
19
 
20
+ function workModesLine(fp, modes) {
21
+ return modes && modes.length ? modes.map((m) => fp.workModeLabels[m] || m).join(', ') : fp.unknown;
22
+ }
23
+
18
24
  function locationLine(a) {
19
25
  return [a.country, a.subdivision, a.timezone].filter(Boolean).join(' · ') || null;
20
26
  }
@@ -23,7 +29,7 @@ function renderView(io, fp, a) {
23
29
  io.section(fp.title);
24
30
  io.notify(styleLabelPrefix(fp.openToWork(a.available === true ? fp.yes : a.available === false ? fp.no : fp.unknown)));
25
31
  io.notify(styleLabelPrefix(fp.monthlyHours(a.monthlyHours != null ? a.monthlyHours : fp.unknown)));
26
- io.notify(styleLabelPrefix(fp.onlyRemote(a.onlyRemote === true ? fp.yes : a.onlyRemote === false ? fp.no : fp.unknown)));
32
+ io.notify(styleLabelPrefix(fp.workModes(workModesLine(fp, a.workModes))));
27
33
  const loc = locationLine(a);
28
34
  if (loc) io.notify(styleLabelPrefix(fp.location(loc)));
29
35
  if (a.availabilityExpiringDate) io.notify(styleLabelPrefix(fp.expires(a.availabilityExpiringDate)));
@@ -57,18 +63,31 @@ async function runSet({ io, ask, session, catalog, opts, deps }) {
57
63
  const available = openPick.value;
58
64
  const hoursRaw = (await io.ask(fp.askMonthlyHours)).trim();
59
65
  if (hoursRaw && !/^\d{1,3}$/.test(hoursRaw)) { io.error(fp.monthlyHoursInvalid); return { ok: false, reason: 'bad-hours' }; }
60
- const remotePick = await pickYesNo(fp.askOnlyRemote);
61
- if (!remotePick) { io.notify(fp.setCancelled); return { ok: true, cancelled: true }; }
62
- const onlyRemote = remotePick.value;
66
+ const pickedModes = await deps.promptMultiSelect({
67
+ ask,
68
+ stdinIsTTY: opts.stdinIsTTY,
69
+ out: selOut,
70
+ items: WORK_MODES,
71
+ labelFor: (v) => fp.workModeLabels[v] || v,
72
+ header: fp.workModesAsk,
73
+ hint: fp.workModesHint,
74
+ initialMarked: (current && Array.isArray(current.workModes)) ? current.workModes.map((m) => WORK_MODES.indexOf(m)).filter((i) => i >= 0) : [],
75
+ input: opts.input,
76
+ output: opts.output,
77
+ });
78
+ // Reuse the register validation (dedupe/upcase, enum check) — no duplicated logic.
79
+ const workModes = validateAvailability({ workModes: pickedModes }).availability.workModes || [];
80
+ if (workModes.length === 1 && workModes[0] === 'REMOTE') io.notify(fp.onlyRemoteNote);
63
81
 
64
82
  // Only the asked fields ride the partial upsert.
65
- const body = { available, onlyRemote };
83
+ const body = { available };
66
84
  if (hoursRaw) body.monthlyHours = hoursRaw;
85
+ if (workModes.length) body.workModes = workModes;
67
86
 
68
87
  io.notify(fp.setDiff(
69
88
  `${available ? fp.yes : fp.no}`,
70
89
  hoursRaw || (current && current.monthlyHours) || fp.unknown,
71
- `${onlyRemote ? fp.yes : fp.no}`,
90
+ workModesLine(fp, workModes),
72
91
  ));
73
92
  const confirmed = await io.confirm(fp.setConfirm);
74
93
  if (!confirmed) { io.notify(fp.setCancelled); return { ok: true, cancelled: true }; }
@@ -39,6 +39,13 @@ function postJsonWithTimeout(url, body, timeoutMs, method = 'POST', idempotencyK
39
39
  method,
40
40
  headers: {
41
41
  Accept: 'application/json',
42
+ // Lets the backend (and Datadog) tell CLI/MCP traffic apart from the
43
+ // browser without touching auth — same value regardless of which
44
+ // bin/ entrypoint is running, since none of them plumb through here
45
+ // separately today. Set unconditionally so no caller can drop it via
46
+ // extraHeaders (sanitizeExtraHeaders runs after this, so it can only
47
+ // add headers, not remove this one).
48
+ 'X-Shakers-Client': 'cli',
42
49
  // Only declared when there IS a body — see the bodyless note above.
43
50
  ...(data ? { 'Content-Type': 'application/json', 'Content-Length': data.length } : {}),
44
51
  ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
@@ -109,6 +116,8 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
109
116
  method,
110
117
  headers: {
111
118
  Accept: 'application/json',
119
+ // See postJsonWithTimeout's identical header for why.
120
+ 'X-Shakers-Client': 'cli',
112
121
  'Content-Type': `multipart/form-data; boundary=${boundary}`,
113
122
  'Content-Length': data.length,
114
123
  ...sanitizeExtraHeaders(extraHeaders),
package/src/config.js CHANGED
@@ -36,6 +36,18 @@ function isTalentProfile(env = process.env) {
36
36
  return getProfile(env) === 'talent';
37
37
  }
38
38
 
39
+ // Explicit UI-language preference: SHAKERS_CLI_LANG env > config.json `lang`.
40
+ // Returns 'es' | 'en', or null when neither is set (OS detection then fills it, see i18n.js).
41
+ const VALID_UI_LANGS = new Set(['es', 'en']);
42
+
43
+ function getUiLang(env = process.env) {
44
+ const fromEnv = String(readEnv(env, 'LANG') || '').trim().toLowerCase();
45
+ if (VALID_UI_LANGS.has(fromEnv)) return fromEnv;
46
+ const fromFile = String(loadConfigFile(env).lang || '').trim().toLowerCase();
47
+ if (VALID_UI_LANGS.has(fromFile)) return fromFile;
48
+ return null;
49
+ }
50
+
39
51
  // config.json holds the superadmin session token (ADR-027) and consent/ endpoint data, so it must never be briefly world/group-readable.
40
52
  function saveConfigFile(config, env = process.env) {
41
53
  const dir = configDir(env);
@@ -184,7 +196,11 @@ function getCertsBase(env = process.env) {
184
196
  }
185
197
  const ingest = explicitIngest(env);
186
198
  if (ingest) return ingest.replace(/\/usage\/reports\/?$/, '').replace(/\/+$/, '');
187
- return null;
199
+ // No explicit override anywhere -> the baked flavor profile (never null when a
200
+ // profile exists), so switching the published flavor "just works" without a
201
+ // seeded config.json (ADR-065 revised).
202
+ const baked = getEnvProfile(env).certsBase;
203
+ return baked ? baked.replace(/\/+$/, '') : null;
188
204
  }
189
205
 
190
206
  function getIngestEndpoint(env = process.env) {
@@ -495,9 +511,10 @@ function getHubBase(env = process.env) {
495
511
  const fromFile = loadConfigFile(env).hubBase;
496
512
  if (fromFile && String(fromFile).trim()) {
497
513
  const v = validateEndpoint(String(fromFile).trim());
498
- return v.ok ? v.value.replace(/\/+$/, '') : null;
514
+ if (v.ok) return v.value.replace(/\/+$/, '');
499
515
  }
500
- return null;
516
+ const baked = getEnvProfile(env).hubBase;
517
+ return baked ? baked.replace(/\/+$/, '') : null;
501
518
  }
502
519
 
503
520
  function deriveFromHub(env, relativePath) {
@@ -611,20 +628,39 @@ function getTalentProfileUrl(env = process.env) {
611
628
  if (explicit && explicit.trim()) return explicit.trim().replace(/\/+$/, '');
612
629
  const fromFile = loadConfigFile(env).profileUrl;
613
630
  if (fromFile && String(fromFile).trim()) return String(fromFile).trim().replace(/\/+$/, '');
614
- return null;
615
- }
616
-
617
- // First-run local bootstrap: seed config.json with the baked flavor bases (ADR-065)
618
- // so the talent CLI/MCP works with zero exports; the flavor is resolved per-call
619
- // (SHAKERS_CLI_ENV > package name > dev) so env overrides still take precedence.
620
- function ensureLocalConfigDefaults(env = process.env, { force = false } = {}) {
621
- if (!force && !isTalentProfile(env)) return false;
622
- const defaults = getEnvProfile(env);
631
+ const baked = getEnvProfile(env).profileUrl;
632
+ return baked ? baked.replace(/\/+$/, '') : null;
633
+ }
634
+
635
+ // The endpoint keys a flavor profile owns. config.json must hold only EXPLICIT
636
+ // user overrides of these, never the baked profile itself.
637
+ const PROFILE_ENDPOINT_KEYS = Object.keys(ENV_PROFILES[DEFAULT_ENV_PROFILE]);
638
+
639
+ // Boot migration (ADR-065 revised). The CLI used to SEED config.json with the
640
+ // baked bases on first run; the getters now fall back to getEnvProfile() directly,
641
+ // so seeding is unnecessary AND harmful: `npm uninstall` leaves the config dir, so
642
+ // a config.json seeded by a previous flavor (e.g. the private @shakers build → dev)
643
+ // survives into a new flavor (the public beta → staging) and pins the old endpoints
644
+ // forever, since config.json wins over the baked profile. This sweep removes any
645
+ // endpoint key whose value is byte-identical to that SAME field in a KNOWN profile
646
+ // OTHER than the baked one — i.e. a value that could only have gotten there by the
647
+ // old seeding of a different flavor. A value that matches no known profile is a
648
+ // genuine user override (set via `shakers config set`) and is left untouched. We
649
+ // never touch env, auth-session.json or consent.json, and never remove the dir.
650
+ function sanitizeStaleBakedEndpoints(env = process.env) {
651
+ const bakedName = getEnvProfileName(env);
623
652
  const config = loadConfigFile(env);
624
653
  let changed = false;
625
- for (const [key, value] of Object.entries(defaults)) {
626
- if (!(config[key] && String(config[key]).trim())) {
627
- config[key] = value;
654
+ for (const key of PROFILE_ENDPOINT_KEYS) {
655
+ const current = config[key];
656
+ if (typeof current !== 'string' || !current.trim()) continue;
657
+ const normalized = current.trim().replace(/\/+$/, '');
658
+ const matchesForeignProfile = Object.entries(ENV_PROFILES).some(
659
+ ([name, profile]) =>
660
+ name !== bakedName && String(profile[key]).replace(/\/+$/, '') === normalized,
661
+ );
662
+ if (matchesForeignProfile) {
663
+ delete config[key];
628
664
  changed = true;
629
665
  }
630
666
  }
@@ -732,7 +768,7 @@ module.exports = {
732
768
  // Onboarding / register flow (evaluation-mcp, ADR-011..015/024).
733
769
  getCertsBase,
734
770
  getHubBase,
735
- ensureLocalConfigDefaults,
771
+ sanitizeStaleBakedEndpoints,
736
772
  getSignUpEndpoint,
737
773
  getImportProfileEndpoint,
738
774
  getProfessionalDetailsEndpoint,
@@ -771,6 +807,9 @@ module.exports = {
771
807
  VALID_PROFILES,
772
808
  getProfile,
773
809
  isTalentProfile,
810
+ // UI language preference (env SHAKERS_CLI_LANG > config.json `lang`).
811
+ VALID_UI_LANGS,
812
+ getUiLang,
774
813
  // Baked backend target flavor (staging | dev | local).
775
814
  ENV_PROFILES,
776
815
  getEnvProfileName,
package/src/i18n.js CHANGED
@@ -86,7 +86,10 @@ const catalogs = {
86
86
  availabilityAsk: 'Quieres indicar tu disponibilidad y ubicacion ahora? (s/n):',
87
87
  availableAsk: 'Estas disponible para nuevos proyectos? (s/n):',
88
88
  monthlyHoursAsk: 'Cuantas horas al mes?',
89
- onlyRemoteAsk: 'Trabajas solo en remoto? (s/n):',
89
+ workModesAsk: 'En que modalidad quieres trabajar? (marca una o varias)',
90
+ workModeLabels: { REMOTE: 'Remoto', HYBRID: 'Hibrido', IN_PERSON: 'Presencial' },
91
+ workModesHint: 'Espacio para marcar/desmarcar · Enter para confirmar',
92
+ onlyRemoteNote: 'Has marcado solo Remoto: no veras proyectos hibridos ni presenciales.',
90
93
  countryAsk: 'Pais (codigo ISO-2 de 2 letras, p.ej. ES):',
91
94
  countryInvalid: 'Codigo de pais no valido. Usa 2 letras (p.ej. ES, US, GB).',
92
95
  subdivisionAsk: 'Provincia o region (opcional, Enter para omitir):',
@@ -918,7 +921,7 @@ const catalogs = {
918
921
  notFound: 'Aún no has definido tu disponibilidad.',
919
922
  openToWork: (v) => `Abierto a trabajar: ${v}`,
920
923
  monthlyHours: (v) => `Horas al mes: ${v}`,
921
- onlyRemote: (v) => `Solo remoto: ${v}`,
924
+ workModes: (v) => `Modalidad: ${v}`,
922
925
  location: (v) => `Ubicación: ${v}`,
923
926
  expires: (v) => `Caduca: ${v}`,
924
927
  yes: 'sí',
@@ -930,8 +933,11 @@ const catalogs = {
930
933
  askOpenToWork: '¿Estás abierto a trabajar?',
931
934
  askMonthlyHours: 'Horas al mes (número, p. ej. 40; Enter para no cambiar):',
932
935
  monthlyHoursInvalid: 'Horas no válidas. Escribe solo el número (p. ej. 40).',
933
- askOnlyRemote: '¿Solo trabajo remoto?',
934
- setDiff: (open, hours, remote) => `Nuevo: abierto=${open} · ${hours} h/mes · solo remoto=${remote}`,
936
+ workModesAsk: 'En que modalidad quieres trabajar? (marca una o varias)',
937
+ workModeLabels: { REMOTE: 'Remoto', HYBRID: 'Hibrido', IN_PERSON: 'Presencial' },
938
+ workModesHint: 'Espacio para marcar/desmarcar · Enter para confirmar',
939
+ onlyRemoteNote: 'Has marcado solo Remoto: no veras proyectos hibridos ni presenciales.',
940
+ setDiff: (open, hours, modes) => `Nuevo: abierto=${open} · ${hours} h/mes · modalidad=${modes}`,
935
941
  setConfirm: 'Esto actualiza tu perfil. ¿Confirmas? (s/n):',
936
942
  setCancelled: 'Cancelado. No se cambió tu disponibilidad.',
937
943
  setSaving: 'Guardando tu disponibilidad…',
@@ -1696,7 +1702,10 @@ const catalogs = {
1696
1702
  availabilityAsk: 'Do you want to set your availability and location now? (y/n):',
1697
1703
  availableAsk: 'Are you available for new projects? (y/n):',
1698
1704
  monthlyHoursAsk: 'How many hours per month?',
1699
- onlyRemoteAsk: 'Do you work remote-only? (y/n):',
1705
+ workModesAsk: 'Which work modes do you want? (select one or more)',
1706
+ workModeLabels: { REMOTE: 'Remote', HYBRID: 'Hybrid', IN_PERSON: 'On-site' },
1707
+ workModesHint: 'Space to toggle · Enter to confirm',
1708
+ onlyRemoteNote: 'You selected Remote only: you will not see hybrid or on-site projects.',
1700
1709
  countryAsk: 'Country (2-letter ISO-2 code, e.g. ES):',
1701
1710
  countryInvalid: 'Invalid country code. Use 2 letters (e.g. ES, US, GB).',
1702
1711
  subdivisionAsk: 'State or province (optional, Enter to skip):',
@@ -2520,7 +2529,7 @@ const catalogs = {
2520
2529
  notFound: 'You have not set your availability yet.',
2521
2530
  openToWork: (v) => `Open to work: ${v}`,
2522
2531
  monthlyHours: (v) => `Monthly hours: ${v}`,
2523
- onlyRemote: (v) => `Remote only: ${v}`,
2532
+ workModes: (v) => `Work modes: ${v}`,
2524
2533
  location: (v) => `Location: ${v}`,
2525
2534
  expires: (v) => `Expires: ${v}`,
2526
2535
  yes: 'yes',
@@ -2532,8 +2541,11 @@ const catalogs = {
2532
2541
  askOpenToWork: 'Are you open to work?',
2533
2542
  askMonthlyHours: 'Monthly hours (number, e.g. 40; Enter to keep):',
2534
2543
  monthlyHoursInvalid: 'Invalid hours. Type only the number (e.g. 40).',
2535
- askOnlyRemote: 'Remote work only?',
2536
- setDiff: (open, hours, remote) => `New: open=${open} · ${hours} h/month · remote-only=${remote}`,
2544
+ workModesAsk: 'Which work modes do you want? (select one or more)',
2545
+ workModeLabels: { REMOTE: 'Remote', HYBRID: 'Hybrid', IN_PERSON: 'On-site' },
2546
+ workModesHint: 'Space to toggle · Enter to confirm',
2547
+ onlyRemoteNote: 'You selected Remote only: you will not see hybrid or on-site projects.',
2548
+ setDiff: (open, hours, modes) => `New: open=${open} · ${hours} h/month · work modes=${modes}`,
2537
2549
  setConfirm: 'This updates your profile. Confirm? (y/n):',
2538
2550
  setCancelled: 'Cancelled. Your availability was not changed.',
2539
2551
  setSaving: 'Saving your availability…',
@@ -3238,13 +3250,19 @@ function resolveLang(langCode) {
3238
3250
  return langCode && /^es/i.test(langCode) ? 'es' : 'en';
3239
3251
  }
3240
3252
 
3241
- // Single entry point for report callers (bin/report.js): detects the OS language (see locale.js) and already returns the resolved catalog code ('es' or 'en').
3242
- function detectReportLang(env = process.env) {
3243
- return resolveLang(detectLangCode(env));
3253
+ // Explicit user preference (SHAKERS_CLI_LANG env > config.json `lang`) wins over OS detection; null when unset.
3254
+ function preferredUiLang(env = process.env) {
3255
+ return require('./config').getUiLang(env);
3244
3256
  }
3245
3257
 
3246
- function detectFlowLang(env = process.env) {
3247
- return toLowerLang(detectLangCode(env));
3258
+ // Single entry point for report callers (bin/report.js): explicit preference > OS language (see locale.js), resolved to the catalog code ('es' or 'en').
3259
+ // `sys` overrides the macOS/Intl readers (tests inject nulls to exercise the env path deterministically).
3260
+ function detectReportLang(env = process.env, sys) {
3261
+ return resolveLang(preferredUiLang(env) || detectLangCode(env, sys));
3262
+ }
3263
+
3264
+ function detectFlowLang(env = process.env, sys) {
3265
+ return toLowerLang(preferredUiLang(env) || detectLangCode(env, sys));
3248
3266
  }
3249
3267
 
3250
3268
  function getCatalog(lang) {
package/src/locale.js CHANGED
@@ -22,6 +22,22 @@ function langFromEnv(env) {
22
22
  return null;
23
23
  }
24
24
 
25
+ // macOS DISPLAY language (top of "Preferred languages"), e.g. ("en-US", "es-ES").
26
+ // This is the UI-language intent, unlike AppleLocale/LANG which carry the region.
27
+ function langFromAppleLanguages() {
28
+ if (process.platform !== 'darwin') return null;
29
+ try {
30
+ const out = execFileSync('defaults', ['read', '-g', 'AppleLanguages'], {
31
+ timeout: 2000,
32
+ stdio: ['ignore', 'pipe', 'ignore'],
33
+ }).toString('utf8');
34
+ const first = out.replace(/[()]/g, '').split(',')[0].replace(/["\s]/g, '');
35
+ return langFromLocaleString(first);
36
+ } catch {
37
+ return null; // key not set, command missing, or any other failure: ignored
38
+ }
39
+ }
40
+
25
41
  function langFromAppleLocale() {
26
42
  if (process.platform !== 'darwin') return null;
27
43
  try {
@@ -44,8 +60,13 @@ function langFromIntl() {
44
60
  }
45
61
 
46
62
  // Returns the detected language code ('es', 'en', 'fr', ...) or null if no signal could be resolved.
47
- function detectLangCode(env = process.env) {
48
- return langFromEnv(env) || langFromAppleLocale() || langFromIntl() || null;
63
+ // macOS display language (AppleLanguages) wins over env LANG/LC_ALL (region/format), then AppleLocale, then Intl.
64
+ // `sys` overrides the macOS/Intl readers for deterministic tests (they ignore `env` and read the real host).
65
+ function detectLangCode(env = process.env, sys = {}) {
66
+ const appleLanguages = sys.appleLanguages || langFromAppleLanguages;
67
+ const appleLocale = sys.appleLocale || langFromAppleLocale;
68
+ const intl = sys.intl || langFromIntl;
69
+ return appleLanguages() || langFromEnv(env) || appleLocale() || intl() || null;
49
70
  }
50
71
 
51
72
  module.exports = { detectLangCode, langFromLocaleString };
@@ -30,7 +30,7 @@ function makeAvailabilityTools(deps = {}) {
30
30
  return [
31
31
  {
32
32
  name: 'get_availability',
33
- description: "View the talent's availability: open-to-work, monthly hours, remote-only, and location. Read-only. Requires an active session. Any internal ids in the result (e.g. numeric city id) are handles for tools only — never show or mention them to the talent.",
33
+ description: "View the talent's availability: open-to-work, monthly hours, work modes (a combinable array of REMOTE/HYBRID/IN_PERSON), and location. Read-only. Requires an active session. Any internal ids in the result (e.g. numeric city id) are handles for tools only — never show or mention them to the talent.",
34
34
  inputSchema: GET_AVAILABILITY_SCHEMA,
35
35
  handler: getAvailability,
36
36
  },
@@ -2,7 +2,7 @@
2
2
 
3
3
  const {
4
4
  FREELANCE_TYPES, WORK_SITUATIONS, EMPLOYMENT_PARTICIPATIONS, FREELANCE_OPINIONS,
5
- CHANGE_MOTIVATORS, MONTHLY_HOURS, LANGUAGE_CODES, LANGUAGE_LEVELS,
5
+ CHANGE_MOTIVATORS, WORK_MODES, MONTHLY_HOURS, LANGUAGE_CODES, LANGUAGE_LEVELS,
6
6
  } = require('./onboarding-flow');
7
7
  const { getCatalog } = require('./i18n');
8
8
  const { TranscriptBuffer } = require('./interview-transcript');
@@ -55,11 +55,11 @@ function buildSchemas(lang) {
55
55
 
56
56
  const AVAILABILITY_SCHEMA = {
57
57
  type: 'object',
58
- description: `Availability AND location (ADR-028). ALWAYS ask this — it must not be skipped. There is NO separate location field: the talent's LOCATION (country, timezone, region) lives here. Offer the options: available (yes/no); monthlyHours must be one of ${MONTHLY_HOURS_QUOTED}; onlyRemote (yes/no — accepts only remote work); longFullTimeProjects (yes/no — open to long full-time projects). For location, ask the talent their country and timezone (region is optional). All fields optional/nullable, but collect what you can. monthlyHours is a STRICT enum string (not an int). city is not collected here.`,
58
+ description: `Availability AND location (ADR-028). ALWAYS ask this — it must not be skipped. There is NO separate location field: the talent's LOCATION (country, timezone, region) lives here. Offer the options: available (yes/no); monthlyHours must be one of ${MONTHLY_HOURS_QUOTED}; workModes is a MULTI-VALUE array, any combination of ${WORK_MODES.join('/')} (the talent can pick several); longFullTimeProjects (yes/no — open to long full-time projects). For location, ask the talent their country and timezone (region is optional). All fields optional/nullable, but collect what you can. monthlyHours is a STRICT enum string (not an int). city is not collected here.`,
59
59
  properties: {
60
60
  available: { type: 'boolean', description: 'Whether the talent is currently available (yes/no).' },
61
61
  monthlyHours: { type: 'string', enum: MONTHLY_HOURS, description: `Monthly hours the talent can work, exactly one of: ${MONTHLY_HOURS_QUOTED} (send '> 160' verbatim, with the space).` },
62
- onlyRemote: { type: 'boolean', description: 'Whether the talent accepts only remote work (yes/no).' },
62
+ workModes: { type: 'array', items: { type: 'string', enum: WORK_MODES }, description: `Work modes the talent accepts — a combinable array, any subset of ${WORK_MODES.join('/')} (e.g. ['REMOTE','HYBRID']). Only REMOTE means the talent will not see hybrid or on-site projects.` },
63
63
  country: { type: 'string', description: 'LOCATION — ISO-2 country code, 2 letters (e.g. ES, US, GB).' },
64
64
  timezone: { type: 'string', description: 'LOCATION — IANA timezone (e.g. Europe/Madrid, America/New_York). Max 100 chars.' },
65
65
  subdivision: { type: 'string', description: 'LOCATION — optional region/state code (e.g. ES-CT). Max 10 chars.' },
@@ -100,7 +100,7 @@ function buildRegisterDescription(lang) {
100
100
  + labelledOptions(CHANGE_MOTIVATORS, L.changeMotivatorLabels)
101
101
  + " when the talent is or is considering freelance).\n4. Pricing — per-PROJECT price; currency USD, EUR or GBP.\n5. Availability + languages — `availability` (offer monthlyHours "
102
102
  + MONTHLY_HOURS_SLASHED
103
- + ", onlyRemote yes/no, longFullTimeProjects yes/no, and LOCATION: country as ISO-2 e.g. ES, timezone as IANA e.g. Europe/Madrid) and `languages` (offer "
103
+ + ", workModes as a combinable array of " + WORK_MODES.join('/') + ", longFullTimeProjects yes/no, and LOCATION: country as ISO-2 e.g. ES, timezone as IANA e.g. Europe/Madrid) and `languages` (offer "
104
104
  + labelledOptions(LANGUAGE_CODES, L.languageNames)
105
105
  + ", each with a level "
106
106
  + labelledOptions(LANGUAGE_LEVELS, L.languageLevelLabels)
@@ -10,6 +10,8 @@ const WORK_SITUATIONS = ['FREELANCE', 'EMPLOYED', 'BETWEEN_JOBS', 'STUDYING', 'O
10
10
  const EMPLOYMENT_PARTICIPATIONS = ['FULL_TIME', 'PART_TIME'];
11
11
  const FREELANCE_OPINIONS = ['WAS_FREELANCE_BEFORE', 'OPEN_TO_FREELANCE', 'NOT_INTERESTED'];
12
12
  const CHANGE_MOTIVATORS = ['HIGHER_RATE', 'SPECIFIC_PROJECT', 'LEARNING_CERTIFICATION', 'FLEXIBILITY', 'COMMUNITY', 'LIFESTYLE_CHANGE'];
13
+ // Work modes are combinable (multi-value array), mirroring the hub availability contract.
14
+ const WORK_MODES = ['REMOTE', 'HYBRID', 'IN_PERSON'];
13
15
  const LANGUAGE_LEVELS = ['INTERMEDIATE_WRITTEN', 'INTERMEDIATE', 'ADVANCED', 'NATIVE'];
14
16
  const LANGUAGE_CODES = ['es', 'en', 'fr', 'ca', 'de', 'eu', 'it', 'pt'];
15
17
 
@@ -217,7 +219,17 @@ function validateAvailability(input = {}) {
217
219
  if (!mh) return { ok: false, reason: 'bad-monthly-hours' };
218
220
  body.monthlyHours = mh;
219
221
  }
220
- if (typeof input.onlyRemote === 'boolean') body.onlyRemote = input.onlyRemote;
222
+ // workModes is a multi-value array (REMOTE/HYBRID/IN_PERSON); onlyRemote is derived server-side and no longer sent.
223
+ if (input.workModes != null) {
224
+ if (!Array.isArray(input.workModes)) return { ok: false, reason: 'bad-work-modes' };
225
+ const modes = [];
226
+ for (const raw of input.workModes) {
227
+ const mode = typeof raw === 'string' ? raw.trim().toUpperCase() : '';
228
+ if (!WORK_MODES.includes(mode)) return { ok: false, reason: 'bad-work-modes' };
229
+ if (!modes.includes(mode)) modes.push(mode);
230
+ }
231
+ if (modes.length) body.workModes = modes;
232
+ }
221
233
  if (input.country != null && input.country !== '') {
222
234
  const cc = normalizeCountry(input.country);
223
235
  if (!cc) return { ok: false, reason: 'bad-country' };
@@ -1025,6 +1037,7 @@ module.exports = {
1025
1037
  EMPLOYMENT_PARTICIPATIONS,
1026
1038
  FREELANCE_OPINIONS,
1027
1039
  CHANGE_MOTIVATORS,
1040
+ WORK_MODES,
1028
1041
  LANGUAGE_LEVELS,
1029
1042
  LANGUAGE_CODES,
1030
1043
  normalizeFreelanceType,
@@ -26,4 +26,41 @@ async function promptSelect({ ask, stdinIsTTY, out, items, labelFor = (x) => Str
26
26
  return items.find((it) => labelFor(it).toLowerCase() === lowered || String(it).toLowerCase() === lowered) || null;
27
27
  }
28
28
 
29
- module.exports = { promptSelect };
29
+ // Multi-pick variant: SPACE toggles, ENTER confirms; returns the chosen items as
30
+ // an array (possibly empty). Non-TTY fallback accepts space/comma-separated
31
+ // indices or labels.
32
+ async function promptMultiSelect({ ask, stdinIsTTY, out, items, labelFor = (x) => String(x), header = '', hint = '', initialMarked = [], input, output } = {}) {
33
+ if (stdinIsTTY && typeof ask.suspend === 'function') {
34
+ ask.suspend();
35
+ const picked = await runInteractiveMultiSelect({
36
+ items,
37
+ labelFor,
38
+ header,
39
+ hint,
40
+ single: false,
41
+ initialMarked,
42
+ ...(input ? { input } : {}),
43
+ ...(output ? { output } : {}),
44
+ });
45
+ ask.resume();
46
+ return Array.isArray(picked) ? picked : [];
47
+ }
48
+ if (header) out(header);
49
+ items.forEach((it, i) => out(` ${i + 1}) ${labelFor(it)}`));
50
+ const raw = (await ask(' > ')).trim();
51
+ if (!raw) return [];
52
+ const chosen = [];
53
+ for (const tok of raw.split(/[\s,]+/).filter(Boolean)) {
54
+ const n = parseInt(tok, 10);
55
+ let item = null;
56
+ if (Number.isInteger(n) && n >= 1 && n <= items.length) item = items[n - 1];
57
+ else {
58
+ const lowered = tok.toLowerCase();
59
+ item = items.find((it) => labelFor(it).toLowerCase() === lowered || String(it).toLowerCase() === lowered) || null;
60
+ }
61
+ if (item != null && !chosen.includes(item)) chosen.push(item);
62
+ }
63
+ return chosen;
64
+ }
65
+
66
+ module.exports = { promptSelect, promptMultiSelect };
@@ -9,6 +9,9 @@ const { persistFootprint } = require('./report-store');
9
9
  const {
10
10
  loadConsentState,
11
11
  getConsentDecision,
12
+ recordConsent,
13
+ isValidEmail,
14
+ normalizeEmail,
12
15
  autoShare,
13
16
  consentPath,
14
17
  } = require('./share');
@@ -117,9 +120,12 @@ async function resolveAiConsent({ opts, catalog, gate, injectedAsk }) {
117
120
  });
118
121
 
119
122
  let aiConsentGranted;
123
+ // A REUSED grant (skip/--json) carries a stale consent email; a fresh prompt re-anchors itself.
124
+ let reusedStoredConsent = false;
120
125
  if (opts.json) {
121
126
  // `--json` never had an interactive prompt — its whole point is a single parseable stdout document, and it does not gain one now.
122
127
  aiConsentGranted = decision === 'granted' && (!state || state.emailVerified !== false);
128
+ reusedStoredConsent = true;
123
129
  } else {
124
130
  if (consentSkip.message) {
125
131
  process.stdout.write(`\n ${consentSkip.message}\n`);
@@ -128,6 +134,7 @@ async function resolveAiConsent({ opts, catalog, gate, injectedAsk }) {
128
134
  // A TERMINAL decision already exists (a prior run's explicit grant or
129
135
  // denial) — never re-asked, per ADR-007/011.
130
136
  aiConsentGranted = decision === 'granted';
137
+ reusedStoredConsent = true;
131
138
  } else {
132
139
  const ask = injectedAsk || createStdinAsk();
133
140
  let result;
@@ -145,6 +152,11 @@ async function resolveAiConsent({ opts, catalog, gate, injectedAsk }) {
145
152
  process.stdout.write(`\n ${termColors.warning}${catalog.consent.noReportWithoutConsent}${termColors.reset}\n\n`);
146
153
  }
147
154
  }
155
+ // Re-anchor a reused grant to the session's login-proven email (ADR-006): else a stale consent email ingests the report under another account and this session's AI profile never populates.
156
+ if (reusedStoredConsent && aiConsentGranted && gate.loggedIn
157
+ && isValidEmail(gate.email) && state && state.email !== normalizeEmail(gate.email)) {
158
+ recordConsent('granted', gate.email, { verified: true });
159
+ }
148
160
  return aiConsentGranted;
149
161
  }
150
162