shakers-cli-beta 0.8.0-beta.1 → 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/mcp.js +1 -1
- package/bin/register.js +13 -4
- package/bin/shakers.js +2 -2
- package/package.json +1 -1
- package/src/availability-client.js +2 -1
- package/src/availability-flow.js +25 -6
- package/src/backend-request.js +9 -0
- package/src/config.js +40 -16
- package/src/i18n.js +20 -8
- package/src/mcp-availability-tools.js +1 -1
- package/src/mcp-register-tools.js +4 -4
- package/src/onboarding-flow.js +14 -1
- package/src/prompt-select.js +38 -1
- package/src/usage-run-steps.js +12 -0
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').
|
|
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
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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),
|
package/src/availability-flow.js
CHANGED
|
@@ -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.
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
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 }; }
|
package/src/backend-request.js
CHANGED
|
@@ -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
|
@@ -196,7 +196,11 @@ function getCertsBase(env = process.env) {
|
|
|
196
196
|
}
|
|
197
197
|
const ingest = explicitIngest(env);
|
|
198
198
|
if (ingest) return ingest.replace(/\/usage\/reports\/?$/, '').replace(/\/+$/, '');
|
|
199
|
-
|
|
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;
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
function getIngestEndpoint(env = process.env) {
|
|
@@ -507,9 +511,10 @@ function getHubBase(env = process.env) {
|
|
|
507
511
|
const fromFile = loadConfigFile(env).hubBase;
|
|
508
512
|
if (fromFile && String(fromFile).trim()) {
|
|
509
513
|
const v = validateEndpoint(String(fromFile).trim());
|
|
510
|
-
|
|
514
|
+
if (v.ok) return v.value.replace(/\/+$/, '');
|
|
511
515
|
}
|
|
512
|
-
|
|
516
|
+
const baked = getEnvProfile(env).hubBase;
|
|
517
|
+
return baked ? baked.replace(/\/+$/, '') : null;
|
|
513
518
|
}
|
|
514
519
|
|
|
515
520
|
function deriveFromHub(env, relativePath) {
|
|
@@ -623,20 +628,39 @@ function getTalentProfileUrl(env = process.env) {
|
|
|
623
628
|
if (explicit && explicit.trim()) return explicit.trim().replace(/\/+$/, '');
|
|
624
629
|
const fromFile = loadConfigFile(env).profileUrl;
|
|
625
630
|
if (fromFile && String(fromFile).trim()) return String(fromFile).trim().replace(/\/+$/, '');
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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);
|
|
635
652
|
const config = loadConfigFile(env);
|
|
636
653
|
let changed = false;
|
|
637
|
-
for (const
|
|
638
|
-
|
|
639
|
-
|
|
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];
|
|
640
664
|
changed = true;
|
|
641
665
|
}
|
|
642
666
|
}
|
|
@@ -744,7 +768,7 @@ module.exports = {
|
|
|
744
768
|
// Onboarding / register flow (evaluation-mcp, ADR-011..015/024).
|
|
745
769
|
getCertsBase,
|
|
746
770
|
getHubBase,
|
|
747
|
-
|
|
771
|
+
sanitizeStaleBakedEndpoints,
|
|
748
772
|
getSignUpEndpoint,
|
|
749
773
|
getImportProfileEndpoint,
|
|
750
774
|
getProfessionalDetailsEndpoint,
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2536
|
-
|
|
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…',
|
|
@@ -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,
|
|
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};
|
|
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
|
-
|
|
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
|
-
+ ",
|
|
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)
|
package/src/onboarding-flow.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/prompt-select.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/usage-run-steps.js
CHANGED
|
@@ -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
|
|