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.
@@ -21,14 +21,14 @@ const LEGAL = {
21
21
  interviewGoalDuration: 'Goal: learn your role, your dedication and how you use AI. Duration: ~5 minutes.',
22
22
  },
23
23
  it: {
24
- signupNotice: 'Prima di creare il tuo account: conserviamo i dati che mi dai (il tuo LinkedIn, il tuo CV e quello che so di te) e possiamo contattarti. Completo il tuo profilo in automatico e puoi cambiare qualsiasi campo in seguito sul sito. Il colloquio di onboarding puoi farlo dopo qui nella chat o sul sito.',
24
+ signupNotice: 'Prima di creare il tuo account: conserviamo i dati che mi dai (il tuo LinkedIn, il tuo CV e quello che so di te) e possiamo contattarti. Completo il tuo profilo in automatico e puoi cambiare qualsiasi campo in seguito sul sito. Il colloquio di onboarding puoi farlo dopo sul sito.',
25
25
  usageInfoAccessed: 'Accesso a: la struttura dei tuoi strumenti di IA ed estratti anonimizzati della tua attività su questo computer. Il tuo codice e i tuoi prompt originali NON escono MAI dalla tua macchina.',
26
26
  usageGoalDuration: "Obiettivo: misurare come lavori con l'IA per il tuo profilo. Durata: ~1-2 minuti.",
27
27
  interviewInfoAccessed: 'Accesso a: le tue risposte in questo colloquio e il tuo profilo importato. Lo conduce il server di Shakers.',
28
28
  interviewGoalDuration: "Obiettivo: conoscere il tuo ruolo, la tua disponibilità e come usi l'IA. Durata: ~5 minuti.",
29
29
  },
30
30
  pt: {
31
- signupNotice: 'Antes de criar a sua conta: guardamos os dados que me der (o seu LinkedIn, o seu CV e o que sei sobre si) e podemos contactá-lo. Preencho o seu perfil automaticamente e pode alterar qualquer campo depois na web. A entrevista de onboarding pode fazê-la depois aqui mesmo no chat ou na web.',
31
+ signupNotice: 'Antes de criar a sua conta: guardamos os dados que me der (o seu LinkedIn, o seu CV e o que sei sobre si) e podemos contactá-lo. Preencho o seu perfil automaticamente e pode alterar qualquer campo depois na web. A entrevista de onboarding pode fazê-la depois na web.',
32
32
  usageInfoAccessed: 'Acede-se a: a estrutura das suas ferramentas de IA e excertos anonimizados da sua atividade neste computador. O seu código e os seus prompts em bruto NUNCA saem da sua máquina.',
33
33
  usageGoalDuration: 'Objetivo: medir como trabalha com IA para o seu perfil. Duração: ~1-2 minutos.',
34
34
  interviewInfoAccessed: 'Acede-se a: as suas respostas nesta entrevista e o seu perfil importado. É conduzida pelo servidor da Shakers.',
@@ -65,11 +65,11 @@ const COPY = {
65
65
  draftTitle: 'Esto es lo que he preparado para tu perfil:',
66
66
  years: (n) => `${n} años de experiencia`,
67
67
  hours: (h) => `${h} h/mes`,
68
- rates: (hourly, annual) => `Tarifa estimada: ${joinParts([hourly != null ? `${amount('es', hourly)} €/h` : null, annual != null ? `${amount('es', annual)} €/año` : null])} (estimación mía, ajústala)`,
68
+ rates: (hourly, annual, estimated = true) => (estimated ? `Tarifa estimada: ${joinParts([hourly != null ? `${amount('es', hourly)} €/h` : null, annual != null ? `${amount('es', annual)} €/año` : null])} (estimación mía, ajústala)` : `Tarifa: ${joinParts([hourly != null ? `${amount('es', hourly)} €/h` : null, annual != null ? `${amount('es', annual)} €/año` : null])}`),
69
69
  draftQuestion: '¿Está bien o cambio algo?',
70
70
  draftOk: 'Todo correcto',
71
71
  draftChange: 'Quiero cambiar algo',
72
- windowOpening: 'Perfecto. Ahora solo falta crear tu cuenta para guardar todo esto y que puedas entrar en Shakers cuando quieras.\n\nTe abro una ventana en el navegador, elige cómo entrar:\n· Con Google\n· Con email y contraseña\n\nLa contraseña la escribes allí directamente, nunca pasa por este chat. En esa misma pantalla aceptas los términos y la política de privacidad.\n\n👉 Mira tu navegador. Cuando termines, vuelve aquí y seguimos.',
72
+ windowNotice: 'Cuando confirmes, te abro una ventana en el navegador para crear tu cuenta y guardar todo esto. Allí eliges cómo entrar:\n· Con Google\n· Con email y contraseña\n\nLa contraseña la escribes allí directamente, nunca pasa por este chat. En esa misma pantalla aceptas los términos y la política de privacidad. Cuando termines, vuelve aquí y seguimos.',
73
73
  windowLink: (link) => `Si no se te ha abierto la ventana, entra desde aquí: ${link}`,
74
74
  existingTitle: 'He visto que ya tienes cuenta. ¿Actualizo tu perfil con esto?',
75
75
  existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
@@ -80,10 +80,14 @@ const COPY = {
80
80
  existingYes: 'Sí, actualízalo',
81
81
  existingNo: 'No, déjalo como está',
82
82
  existingKept: 'Perfecto, dejo tu perfil como está.',
83
+ keptLabels: { workSituation: 'Situación laboral', availability: 'Disponibilidad', languages: 'Idiomas', phone: 'Teléfono' },
84
+ existingUnchanged: (items) => `Estos datos de tu perfil no los he cambiado, se quedan como estaban: ${items.join(' · ')}. Si quieres otros valores, cámbialos en la web.`,
83
85
  aiUsageQuestion: '¿Quieres que analice cómo trabajas con IA? Es opcional y tarda 1-2 min. Solo veo qué herramientas usas, tu código y tus prompts no salen de tu máquina. Te ayuda a destacar en proyectos de IA.',
84
86
  aiUsageYes: 'Sí, analízalo',
85
87
  aiUsageSkip: 'Saltar este paso',
86
88
  aiUsageSkipped: "Sin problema, seguimos sin él. Te aviso de que es lo que más pesa para que te lleguen proyectos: lo puedes lanzar cuando quieras diciéndome 'analiza mi uso de IA'.",
89
+ importSources: { linkedin: 'tu LinkedIn', cv: 'tu CV', github: 'tu GitHub', web: 'tu web' },
90
+ importFailed: (names) => `No he podido importar ${names.join(' y ')} ahora mismo. El resto de tu perfil está guardado; lo que falte lo puedes añadir en la web.`,
87
91
  interviewWhereQuestion: '¿Hacemos la entrevista de onboarding aquí ahora o luego en la web?',
88
92
  interviewHere: 'Aquí ahora',
89
93
  interviewLater: 'Luego en la web',
@@ -91,6 +95,7 @@ const COPY = {
91
95
  answerModeDrafted: 'Propónmelas tú',
92
96
  answerModeOwn: 'Las contesto yo',
93
97
  answerModeFull: 'Hazla entera',
98
+ interviewOnWeb: 'La entrevista de onboarding la puedes hacer en la web: al terminar te abro Shakers con tu sesión iniciada.',
94
99
  livekitUnavailable: 'La entrevista no puede arrancar en este equipo. Puedes hacerla en la web: te abro Shakers con tu sesión iniciada.',
95
100
  rolesIntro: 'Con todo lo que me has contado, encajas en estos perfiles:',
96
101
  roleRecommended: (name) => `**${name}** (recomendado)`,
@@ -100,11 +105,14 @@ const COPY = {
100
105
  rolesOutro: 'Tu perfil principal es el que ven primero las empresas y el que usamos para recomendarte proyectos. Puedes añadir más y cambiarlo cuando quieras.',
101
106
  mainRoleQuestion: '¿Cuál es tu perfil principal?',
102
107
  mainRoleOther: 'Otro rol del catálogo',
108
+ mainRoleAsk: '¿A qué te dedicas? Dime tu rol con tus palabras y lo busco en el catálogo.',
109
+ mainRoleNotListed: 'Otro rol',
110
+ catalogueReason: { role: (words) => `Encaja con tu rol (${words})`, stack: (words) => `Encaja con tu stack (${words})` },
103
111
  hourlyRateLabel: 'Tarifa por hora',
104
112
  annualRateLabel: 'Tarifa anual',
105
113
  perHour: '€/h',
106
114
  perYear: '€/año',
107
- ratesSaved: (rates) => `${rates.join(' · ')}. Es una estimación: la puedes cambiar en la web.`,
115
+ ratesSaved: (rates, estimated = true) => `${rates.join(' · ')}. ${estimated ? 'Es una estimación: la puedes cambiar en la web.' : 'La puedes cambiar en la web.'}`,
108
116
  saved: { workSituation: 'Situación laboral guardada.', availability: 'Disponibilidad guardada.', languages: 'Idiomas guardados.', phone: 'Teléfono guardado.' },
109
117
  yes: 'Sí',
110
118
  no: 'No',
@@ -127,11 +135,11 @@ const COPY = {
127
135
  draftTitle: 'Here is what I have prepared for your profile:',
128
136
  years: (n) => `${n} years of experience`,
129
137
  hours: (h) => `${h} h/month`,
130
- rates: (hourly, annual) => `Estimated rate: ${joinParts([hourly != null ? `${amount('en', hourly)} €/h` : null, annual != null ? `${amount('en', annual)} €/year` : null])} (my estimate, adjust it)`,
138
+ rates: (hourly, annual, estimated = true) => (estimated ? `Estimated rate: ${joinParts([hourly != null ? `${amount('en', hourly)} €/h` : null, annual != null ? `${amount('en', annual)} €/year` : null])} (my estimate, adjust it)` : `Rate: ${joinParts([hourly != null ? `${amount('en', hourly)} €/h` : null, annual != null ? `${amount('en', annual)} €/year` : null])}`),
131
139
  draftQuestion: 'Does it look right, or shall I change something?',
132
140
  draftOk: 'All correct',
133
141
  draftChange: 'I want to change something',
134
- windowOpening: "Great. Now all that's left is to create your account, to save all this and so you can sign in to Shakers whenever you like.\n\nI'm opening a window in your browser; choose how to sign in:\n· With Google\n· With email and password\n\nYou type the password there directly; it never goes through this chat. On that same screen you accept the terms and the privacy policy.\n\n👉 Check your browser. When you're done, come back here and we'll carry on.",
142
+ windowNotice: "When you confirm, I'll open a window in your browser to create your account and save all this. There you choose how to sign in:\n· With Google\n· With email and password\n\nYou type the password there directly; it never goes through this chat. On that same screen you accept the terms and the privacy policy. When you're done, come back here and we'll carry on.",
135
143
  windowLink: (link) => `If the window did not open, use this link: ${link}`,
136
144
  existingTitle: 'I see you already have an account. Shall I update your profile with this?',
137
145
  existingRate: (from, to) => `Hourly rate: ${from} → ${to}`,
@@ -142,10 +150,14 @@ const COPY = {
142
150
  existingYes: 'Yes, update it',
143
151
  existingNo: 'No, leave it as it is',
144
152
  existingKept: 'Sure, I am leaving your profile as it is.',
153
+ keptLabels: { workSituation: 'Work situation', availability: 'Availability', languages: 'Languages', phone: 'Phone' },
154
+ existingUnchanged: (items) => `I did not change these details of your profile; they stay as they were: ${items.join(' · ')}. If you want other values, change them on the web.`,
145
155
  aiUsageQuestion: 'Do you want me to analyse how you work with AI? It is optional and takes 1-2 min. I only see which tools you use; your code and your prompts do not leave your machine. It helps you stand out for AI projects.',
146
156
  aiUsageYes: 'Yes, analyse it',
147
157
  aiUsageSkip: 'Skip this step',
148
158
  aiUsageSkipped: "No problem, we carry on without it. Heads-up: it is what weighs most for projects to reach you. You can run it whenever you like by telling me 'analyse my AI usage'.",
159
+ importSources: { linkedin: 'your LinkedIn', cv: 'your CV', github: 'your GitHub', web: 'your website' },
160
+ importFailed: (names) => `I couldn't import ${names.join(' and ')} right now. The rest of your profile is saved; you can add what is missing on the web.`,
149
161
  interviewWhereQuestion: 'Shall we do the onboarding interview here now or later on the web?',
150
162
  interviewHere: 'Here now',
151
163
  interviewLater: 'Later on the web',
@@ -153,6 +165,7 @@ const COPY = {
153
165
  answerModeDrafted: 'Draft them for me',
154
166
  answerModeOwn: "I'll answer myself",
155
167
  answerModeFull: 'Do it all for me',
168
+ interviewOnWeb: 'You can do the onboarding interview on the web: when we finish I will open Shakers for you, already signed in.',
156
169
  livekitUnavailable: 'The interview cannot start on this machine. You can do it on the web: I will open Shakers with you signed in.',
157
170
  rolesIntro: 'With everything you have told me, you fit these profiles:',
158
171
  roleRecommended: (name) => `**${name}** (recommended)`,
@@ -162,17 +175,20 @@ const COPY = {
162
175
  rolesOutro: 'Your main profile is the one companies see first and the one we use to recommend projects to you. You can add more and change it whenever you like.',
163
176
  mainRoleQuestion: 'Which is your main profile?',
164
177
  mainRoleOther: 'Another role from the catalogue',
178
+ mainRoleAsk: 'What do you do? Tell me your role in your own words and I will look it up in the catalogue.',
179
+ mainRoleNotListed: 'Another role',
180
+ catalogueReason: { role: (words) => `Matches your role (${words})`, stack: (words) => `Matches your stack (${words})` },
165
181
  hourlyRateLabel: 'Hourly rate',
166
182
  annualRateLabel: 'Annual rate',
167
183
  perHour: '€/hour',
168
184
  perYear: '€/year',
169
- ratesSaved: (rates) => `${rates.join(' · ')}. It is an estimate: you can change it on the web.`,
185
+ ratesSaved: (rates, estimated = true) => `${rates.join(' · ')}. ${estimated ? 'It is an estimate: you can change it on the web.' : 'You can change it on the web.'}`,
170
186
  saved: { workSituation: 'Work situation saved.', availability: 'Availability saved.', languages: 'Languages saved.', phone: 'Phone saved.' },
171
187
  yes: 'Yes',
172
188
  no: 'No',
173
189
  },
174
190
  it: {
175
- welcome: (name) => `Ciao${name ? `, ${name}` : ''}! Grazie per voler entrare in Shakers 🙌\n\nSiamo una community di professionisti tech freelance. Il nostro obiettivo è avere sempre la tua versione più aggiornata come professionista (cosa sai fare oggi, come lavori con l'IA e cosa cerchi) per metterti in contatto con progetti che fanno davvero per te.\n\nL'iscrizione richiede tre passaggi e la maggior parte la faccio io per te:\n1. Il tuo profilo (2 min). Lo compilo a partire dal tuo CV e da LinkedIn, tu devi solo rivederlo e confermarlo.\n2. Come lavori con l'IA (1-2 min, facoltativo). Analizzo quali strumenti usi su questo computer. Il tuo codice e i tuoi prompt non escono mai dalla tua macchina.\n3. Un breve colloquio (facoltativo). Per definire meglio il tuo ruolo e cosa cerchi. Puoi farlo ora o più tardi.\n\nPuoi modificare tutto in seguito dal sito.`,
191
+ welcome: (name) => `Ciao${name ? `, ${name}` : ''}! Grazie per voler entrare in Shakers 🙌\n\nSiamo una community di professionisti tech freelance. Il nostro obiettivo è avere sempre la tua versione più aggiornata come professionista (cosa sai fare oggi, come lavori con l'IA e cosa cerchi) per metterti in contatto con progetti che fanno davvero per te.\n\nL'iscrizione richiede tre passaggi e la maggior parte la faccio io per te:\n1. Il tuo profilo (2 min). Lo compilo a partire dal tuo CV e da LinkedIn, tu devi solo rivederlo e confermarlo.\n2. Come lavori con l'IA (1-2 min, facoltativo). Analizzo quali strumenti usi su questo computer. Il tuo codice e i tuoi prompt non escono mai dalla tua macchina.\n3. Un breve colloquio (facoltativo). Per definire meglio il tuo ruolo e cosa cerchi. Lo fai sul sito, quando vuoi.\n\nPuoi modificare tutto in seguito dal sito.`,
176
192
  welcomeQuestion: 'Iniziamo? Se mi dai il permesso, cerco il tuo CV su questo computer e ti preparo una bozza da confermare e basta.',
177
193
  cvSearch: 'Sì, cercalo',
178
194
  cvAttach: 'Te lo allego io',
@@ -189,11 +205,11 @@ const COPY = {
189
205
  draftTitle: 'Ecco cosa ho preparato per il tuo profilo:',
190
206
  years: (n) => `${n} anni di esperienza`,
191
207
  hours: (h) => `${h} h/mese`,
192
- rates: (hourly, annual) => `Tariffa stimata: ${joinParts([hourly != null ? `${amount('it', hourly)} €/h` : null, annual != null ? `${amount('it', annual)} €/anno` : null])} (una mia stima, modificala pure)`,
208
+ rates: (hourly, annual, estimated = true) => (estimated ? `Tariffa stimata: ${joinParts([hourly != null ? `${amount('it', hourly)} €/h` : null, annual != null ? `${amount('it', annual)} €/anno` : null])} (una mia stima, modificala pure)` : `Tariffa: ${joinParts([hourly != null ? `${amount('it', hourly)} €/h` : null, annual != null ? `${amount('it', annual)} €/anno` : null])}`),
193
209
  draftQuestion: 'Va bene o cambio qualcosa?',
194
210
  draftOk: 'Tutto corretto',
195
211
  draftChange: 'Voglio cambiare qualcosa',
196
- windowOpening: "Perfetto. Ora manca solo creare il tuo account per salvare tutto questo e poter entrare in Shakers quando vuoi.\n\nTi apro una finestra nel browser, scegli come entrare:\n· Con Google\n· Con email e password\n\nLa password la scrivi direttamente lì, non passa mai da questa chat. Nella stessa schermata accetti i termini e l'informativa sulla privacy.\n\n👉 Guarda il tuo browser. Quando hai finito, torna qui e continuiamo.",
212
+ windowNotice: "Quando confermi, ti apro una finestra nel browser per creare il tuo account e salvare tutto questo. Lì scegli come entrare:\n· Con Google\n· Con email e password\n\nLa password la scrivi direttamente lì, non passa mai da questa chat. Nella stessa schermata accetti i termini e l'informativa sulla privacy. Quando hai finito, torna qui e continuiamo.",
197
213
  windowLink: (link) => `Se la finestra non si è aperta, entra da qui: ${link}`,
198
214
  existingTitle: 'Ho visto che hai già un account. Aggiorno il tuo profilo con questi dati?',
199
215
  existingRate: (from, to) => `Tariffa oraria: ${from} → ${to}`,
@@ -204,10 +220,14 @@ const COPY = {
204
220
  existingYes: 'Sì, aggiornalo',
205
221
  existingNo: 'No, lascialo così',
206
222
  existingKept: 'Perfetto, lascio il tuo profilo così com’è.',
223
+ keptLabels: { workSituation: 'Situazione lavorativa', availability: 'Disponibilità', languages: 'Lingue', phone: 'Telefono' },
224
+ existingUnchanged: (items) => `Questi dati del tuo profilo non li ho cambiati, restano com'erano: ${items.join(' · ')}. Se vuoi altri valori, cambiali sul sito.`,
207
225
  aiUsageQuestion: "Vuoi che analizzi come lavori con l'IA? È facoltativo e richiede 1-2 min. Vedo solo quali strumenti usi, il tuo codice e i tuoi prompt non escono dalla tua macchina. Ti aiuta a distinguerti nei progetti di IA.",
208
226
  aiUsageYes: 'Sì, analizzalo',
209
227
  aiUsageSkip: 'Salta questo passaggio',
210
228
  aiUsageSkipped: "Nessun problema, andiamo avanti senza. Ti avviso che è ciò che pesa di più perché ti arrivino progetti: puoi lanciarlo quando vuoi dicendomi 'analizza il mio uso dell'IA'.",
229
+ importSources: { linkedin: 'il tuo LinkedIn', cv: 'il tuo CV', github: 'il tuo GitHub', web: 'il tuo sito' },
230
+ importFailed: (names) => `Non sono riuscito a importare ${names.join(' e ')} in questo momento. Il resto del tuo profilo è salvato; quello che manca puoi aggiungerlo sul sito.`,
211
231
  interviewWhereQuestion: 'Facciamo il colloquio di onboarding qui adesso o più tardi sul sito?',
212
232
  interviewHere: 'Qui adesso',
213
233
  interviewLater: 'Più tardi sul sito',
@@ -215,6 +235,7 @@ const COPY = {
215
235
  answerModeDrafted: 'Proponile tu',
216
236
  answerModeOwn: 'Rispondo io',
217
237
  answerModeFull: 'Fallo tutto tu',
238
+ interviewOnWeb: 'Il colloquio di onboarding puoi farlo sul sito: alla fine ti apro Shakers con la sessione già avviata.',
218
239
  livekitUnavailable: 'Il colloquio non può partire su questo computer. Puoi farlo sul sito: ti apro Shakers con la sessione già avviata.',
219
240
  rolesIntro: 'Con tutto quello che mi hai raccontato, sei adatto a questi profili:',
220
241
  roleRecommended: (name) => `**${name}** (consigliato)`,
@@ -224,17 +245,20 @@ const COPY = {
224
245
  rolesOutro: 'Il tuo profilo principale è quello che le aziende vedono per primo e quello che usiamo per consigliarti progetti. Puoi aggiungerne altri e cambiarlo quando vuoi.',
225
246
  mainRoleQuestion: 'Qual è il tuo profilo principale?',
226
247
  mainRoleOther: 'Un altro ruolo del catalogo',
248
+ mainRoleAsk: 'Di cosa ti occupi? Dimmi il tuo ruolo con parole tue e lo cerco nel catalogo.',
249
+ mainRoleNotListed: 'Un altro ruolo',
250
+ catalogueReason: { role: (words) => `Corrisponde al tuo ruolo (${words})`, stack: (words) => `Corrisponde al tuo stack (${words})` },
227
251
  hourlyRateLabel: 'Tariffa oraria',
228
252
  annualRateLabel: 'Tariffa annuale',
229
253
  perHour: '€/h',
230
254
  perYear: '€/anno',
231
- ratesSaved: (rates) => `${rates.join(' · ')}. È una stima: puoi cambiarla sul sito.`,
255
+ ratesSaved: (rates, estimated = true) => `${rates.join(' · ')}. ${estimated ? 'È una stima: puoi cambiarla sul sito.' : 'Puoi cambiarla sul sito.'}`,
232
256
  saved: { workSituation: 'Situazione lavorativa salvata.', availability: 'Disponibilità salvata.', languages: 'Lingue salvate.', phone: 'Telefono salvato.' },
233
257
  yes: 'Sì',
234
258
  no: 'No',
235
259
  },
236
260
  pt: {
237
- welcome: (name) => `Olá${name ? `, ${name}` : ''}! Obrigado por querer juntar-se à Shakers 🙌\n\nSomos uma comunidade de profissionais tech freelance. O nosso objetivo é ter sempre a sua versão mais atual como profissional (o que sabe fazer hoje, como trabalha com IA e o que procura) para o pôr em contacto com projetos que encaixem mesmo consigo.\n\nO registo tem três passos e a maior parte faço eu por si:\n1. O seu perfil (2 min). Preencho-o a partir do seu CV e do LinkedIn; só tem de rever e confirmar.\n2. Como trabalha com IA (1-2 min, opcional). Analiso que ferramentas usa neste computador. O seu código e os seus prompts nunca saem da sua máquina.\n3. Uma breve entrevista (opcional). Para afinar o seu perfil e o que procura. Pode fazê-la agora ou mais tarde.\n\nPode editar tudo depois na web.`,
261
+ welcome: (name) => `Olá${name ? `, ${name}` : ''}! Obrigado por querer juntar-se à Shakers 🙌\n\nSomos uma comunidade de profissionais tech freelance. O nosso objetivo é ter sempre a sua versão mais atual como profissional (o que sabe fazer hoje, como trabalha com IA e o que procura) para o pôr em contacto com projetos que encaixem mesmo consigo.\n\nO registo tem três passos e a maior parte faço eu por si:\n1. O seu perfil (2 min). Preencho-o a partir do seu CV e do LinkedIn; só tem de rever e confirmar.\n2. Como trabalha com IA (1-2 min, opcional). Analiso que ferramentas usa neste computador. O seu código e os seus prompts nunca saem da sua máquina.\n3. Uma breve entrevista (opcional). Para afinar o seu perfil e o que procura. Faz-se na web, quando quiser.\n\nPode editar tudo depois na web.`,
238
262
  welcomeQuestion: 'Começamos? Se me der autorização, procuro o seu CV neste computador e preparo um rascunho para que só tenha de o confirmar.',
239
263
  cvSearch: 'Sim, procure-o',
240
264
  cvAttach: 'Eu anexo-o',
@@ -251,11 +275,11 @@ const COPY = {
251
275
  draftTitle: 'Isto é o que preparei para o seu perfil:',
252
276
  years: (n) => `${n} anos de experiência`,
253
277
  hours: (h) => `${h} h/mês`,
254
- rates: (hourly, annual) => `Tarifa estimada: ${joinParts([hourly != null ? `${amount('pt', hourly)} €/h` : null, annual != null ? `${amount('pt', annual)} €/ano` : null])} (estimativa minha, ajuste-a)`,
278
+ rates: (hourly, annual, estimated = true) => (estimated ? `Tarifa estimada: ${joinParts([hourly != null ? `${amount('pt', hourly)} €/h` : null, annual != null ? `${amount('pt', annual)} €/ano` : null])} (estimativa minha, ajuste-a)` : `Tarifa: ${joinParts([hourly != null ? `${amount('pt', hourly)} €/h` : null, annual != null ? `${amount('pt', annual)} €/ano` : null])}`),
255
279
  draftQuestion: 'Está bem ou mudo alguma coisa?',
256
280
  draftOk: 'Está tudo certo',
257
281
  draftChange: 'Quero mudar algo',
258
- windowOpening: 'Perfeito. Agora só falta criar a sua conta para guardar tudo isto e poder entrar na Shakers quando quiser.\n\nVou abrir uma janela no seu navegador; escolha como entrar:\n· Com Google\n· Com email e palavra-passe\n\nEscreve a palavra-passe diretamente lá; nunca passa por este chat. Nesse mesmo ecrã aceita os termos e a política de privacidade.\n\n👉 Veja o seu navegador. Quando terminar, volte aqui e continuamos.',
282
+ windowNotice: 'Quando confirmar, abro uma janela no seu navegador para criar a sua conta e guardar tudo isto. Lá escolhe como entrar:\n· Com Google\n· Com email e palavra-passe\n\nEscreve a palavra-passe diretamente lá; nunca passa por este chat. Nesse mesmo ecrã aceita os termos e a política de privacidade. Quando terminar, volte aqui e continuamos.',
259
283
  windowLink: (link) => `Se a janela não abriu, entre por aqui: ${link}`,
260
284
  existingTitle: 'Vi que já tem conta. Atualizo o seu perfil com isto?',
261
285
  existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
@@ -266,10 +290,14 @@ const COPY = {
266
290
  existingYes: 'Sim, atualize',
267
291
  existingNo: 'Não, deixe como está',
268
292
  existingKept: 'Perfeito, deixo o seu perfil como está.',
293
+ keptLabels: { workSituation: 'Situação profissional', availability: 'Disponibilidade', languages: 'Idiomas', phone: 'Telefone' },
294
+ existingUnchanged: (items) => `Estes dados do seu perfil não os alterei, ficam como estavam: ${items.join(' · ')}. Se quiser outros valores, altere-os na web.`,
269
295
  aiUsageQuestion: 'Quer que analise como trabalha com IA? É opcional e demora 1-2 min. Só vejo que ferramentas usa; o seu código e os seus prompts não saem da sua máquina. Ajuda-o a destacar-se em projetos de IA.',
270
296
  aiUsageYes: 'Sim, analise',
271
297
  aiUsageSkip: 'Saltar este passo',
272
298
  aiUsageSkipped: "Sem problema, seguimos sem ele. Aviso-o de que é o que mais pesa para lhe chegarem projetos: pode lançá-lo quando quiser dizendo-me 'analisa o meu uso de IA'.",
299
+ importSources: { linkedin: 'o seu LinkedIn', cv: 'o seu CV', github: 'o seu GitHub', web: 'o seu site' },
300
+ importFailed: (names) => `Não consegui importar ${names.join(' e ')} neste momento. O resto do seu perfil está guardado; o que faltar pode acrescentá-lo na web.`,
273
301
  interviewWhereQuestion: 'Fazemos a entrevista de onboarding aqui agora ou mais tarde na web?',
274
302
  interviewHere: 'Aqui agora',
275
303
  interviewLater: 'Mais tarde na web',
@@ -277,6 +305,7 @@ const COPY = {
277
305
  answerModeDrafted: 'Proponha-as você',
278
306
  answerModeOwn: 'Respondo eu',
279
307
  answerModeFull: 'Faça-a toda',
308
+ interviewOnWeb: 'Pode fazer a entrevista de onboarding na web: no fim abro-lhe o Shakers com a sessão iniciada.',
280
309
  livekitUnavailable: 'A entrevista não pode começar neste computador. Pode fazê-la na web: abro-lhe a Shakers com a sessão iniciada.',
281
310
  rolesIntro: 'Com tudo o que me contou, encaixa nestes perfis:',
282
311
  roleRecommended: (name) => `**${name}** (recomendado)`,
@@ -286,11 +315,14 @@ const COPY = {
286
315
  rolesOutro: 'O seu perfil principal é o que as empresas veem primeiro e o que usamos para lhe recomendar projetos. Pode adicionar mais e alterá-lo quando quiser.',
287
316
  mainRoleQuestion: 'Qual é o seu perfil principal?',
288
317
  mainRoleOther: 'Outro perfil do catálogo',
318
+ mainRoleAsk: 'A que se dedica? Diga-me o seu perfil por palavras suas e eu procuro-o no catálogo.',
319
+ mainRoleNotListed: 'Outro perfil',
320
+ catalogueReason: { role: (words) => `Corresponde ao seu perfil (${words})`, stack: (words) => `Corresponde ao seu stack (${words})` },
289
321
  hourlyRateLabel: 'Tarifa por hora',
290
322
  annualRateLabel: 'Tarifa anual',
291
323
  perHour: '€/h',
292
324
  perYear: '€/ano',
293
- ratesSaved: (rates) => `${rates.join(' · ')}. É uma estimativa: pode alterá-la na web.`,
325
+ ratesSaved: (rates, estimated = true) => `${rates.join(' · ')}. ${estimated ? 'É uma estimativa: pode alterá-la na web.' : 'Pode alterá-la na web.'}`,
294
326
  saved: { workSituation: 'Situação profissional guardada.', availability: 'Disponibilidade guardada.', languages: 'Idiomas guardados.', phone: 'Telefone guardado.' },
295
327
  yes: 'Sim',
296
328
  no: 'Não',
@@ -318,7 +350,7 @@ function renderDraft(lang, d = {}) {
318
350
  joinParts([d.name, d.role, d.city]),
319
351
  joinParts([has(d.yearsOfExperience) ? c.years(d.yearsOfExperience) : null, d.stack]),
320
352
  joinParts([d.languages, d.workMode, has(d.monthlyHours) ? c.hours(d.monthlyHours) : null]),
321
- has(d.hourlyRate) || has(d.annualRate) ? c.rates(has(d.hourlyRate) ? d.hourlyRate : null, has(d.annualRate) ? d.annualRate : null) : '',
353
+ has(d.hourlyRate) || has(d.annualRate) ? c.rates(has(d.hourlyRate) ? d.hourlyRate : null, has(d.annualRate) ? d.annualRate : null, d.ratesFromTalent !== true) : '',
322
354
  ].filter(Boolean);
323
355
  return [c.draftTitle, ...lines].join('\n');
324
356
  }
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ // `update` is explicit and user-invoked (never a hot path), so these timeouts can be generous.
9
+ const VIEW_TIMEOUT_MS = 20000;
10
+ const INSTALL_TIMEOUT_MS = 180000;
11
+
12
+ // Documented installer one-liner (README `curl | bash`, `release` branch); overridable for a non-default deployment.
13
+ const DEFAULT_INSTALL_URL =
14
+ 'https://raw.githubusercontent.com/devShakers/ai-usage-evaluator/release/install.sh';
15
+
16
+ // Name/version/registry come from THIS package.json, so a renamed flavor adapts with no code change here.
17
+ function readPackageInfo() {
18
+ try {
19
+ const pkg = require(path.join(__dirname, '..', 'package.json'));
20
+ const registry =
21
+ pkg.publishConfig && typeof pkg.publishConfig.registry === 'string'
22
+ ? pkg.publishConfig.registry
23
+ : null;
24
+ return {
25
+ name: typeof pkg.name === 'string' ? pkg.name : null,
26
+ version: typeof pkg.version === 'string' ? pkg.version : null,
27
+ registry,
28
+ };
29
+ } catch {
30
+ return { name: null, version: null, registry: null };
31
+ }
32
+ }
33
+
34
+ function realPathOrSelf(p) {
35
+ try {
36
+ return fs.realpathSync(p);
37
+ } catch {
38
+ return p;
39
+ }
40
+ }
41
+
42
+ // Root of the installed package = the parent of this src/ directory.
43
+ function packageRoot() {
44
+ return realPathOrSelf(path.resolve(__dirname, '..'));
45
+ }
46
+
47
+ function npmGlobalRoot() {
48
+ try {
49
+ const out = execFileSync('npm', ['root', '-g'], {
50
+ encoding: 'utf8',
51
+ timeout: VIEW_TIMEOUT_MS,
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ });
54
+ return out ? realPathOrSelf(out.trim()) : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // Detects how this CLI was installed (prefix | npm-global | npm-local | source | unknown) so the update lands where the CLI lives.
61
+ function detectInstall(env = process.env) {
62
+ const root = packageRoot();
63
+ const nodeModulesSeg = `${path.sep}node_modules${path.sep}`;
64
+
65
+ if (!root.includes(nodeModulesSeg)) {
66
+ const home = realPathOrSelf(
67
+ env.SHAKERS_CLI_HOME ||
68
+ env.AI_FOOTPRINT_HOME ||
69
+ path.join(os.homedir(), '.shakers'),
70
+ );
71
+ if (root === home) return { kind: 'prefix', root };
72
+ if (fs.existsSync(path.join(root, '.git'))) return { kind: 'source', root };
73
+ return { kind: 'unknown', root };
74
+ }
75
+
76
+ const info = readPackageInfo();
77
+ const gRoot = npmGlobalRoot();
78
+ const globalPkgPath =
79
+ gRoot && info.name
80
+ ? realPathOrSelf(path.join(gRoot, ...info.name.split('/')))
81
+ : null;
82
+ if (globalPkgPath && root === globalPkgPath) {
83
+ return { kind: 'npm-global', root, global: true, projectDir: null };
84
+ }
85
+ // `.../project/node_modules/<@scope/name>` → the project dir owning the local node_modules is `up` segments up.
86
+ const up = info.name && info.name.includes('/') ? 3 : 2;
87
+ const projectDir = path.resolve(root, ...Array(up).fill('..'));
88
+ return { kind: 'npm-local', root, global: false, projectDir };
89
+ }
90
+
91
+ function classifyError(error) {
92
+ const code = error && error.code;
93
+ const stderr =
94
+ (error && error.stderr && error.stderr.toString()) ||
95
+ (error && error.message) ||
96
+ '';
97
+ if (code === 'ENOENT') return 'npm-not-found';
98
+ if (code === 'ETIMEDOUT' || /ETIMEDOUT|timed out/i.test(stderr))
99
+ return 'timeout';
100
+ if (/EACCES|permission denied|EPERM/i.test(stderr)) return 'permission';
101
+ if (/ENOTFOUND|ECONNREFUSED|network|ECONNRESET|getaddrinfo/i.test(stderr))
102
+ return 'network';
103
+ if (/\b401\b|\b403\b|unauthorized|forbidden|authentication/i.test(stderr))
104
+ return 'auth';
105
+ if (/E404|not found/i.test(stderr)) return 'not-found';
106
+ return 'unknown';
107
+ }
108
+
109
+ // `npm view <name>@latest version` prints only the version string (last line).
110
+ function fetchLatestVersion({ name, registry } = {}) {
111
+ if (!name) return { ok: false, reason: 'no-package-name' };
112
+ const args = ['view', `${name}@latest`, 'version'];
113
+ if (registry) args.push('--registry', registry);
114
+ try {
115
+ const out = execFileSync('npm', args, {
116
+ encoding: 'utf8',
117
+ timeout: VIEW_TIMEOUT_MS,
118
+ stdio: ['ignore', 'pipe', 'pipe'],
119
+ });
120
+ const version = (out || '')
121
+ .trim()
122
+ .split('\n')
123
+ .map((l) => l.trim())
124
+ .filter(Boolean)
125
+ .pop();
126
+ return version ? { ok: true, version } : { ok: false, reason: 'empty-response' };
127
+ } catch (error) {
128
+ return { ok: false, reason: classifyError(error) };
129
+ }
130
+ }
131
+
132
+ function parseSemver(v) {
133
+ const core = String(v).trim().replace(/^v/, '').split('-')[0].split('+')[0];
134
+ return core.split('.').map((n) => parseInt(n, 10) || 0);
135
+ }
136
+
137
+ // -1/0/1 on the semver core; prerelease suffixes ignored (fine for an "is a newer release available" check).
138
+ function compareVersions(a, b) {
139
+ const pa = parseSemver(a);
140
+ const pb = parseSemver(b);
141
+ for (let i = 0; i < 3; i++) {
142
+ const x = pa[i] || 0;
143
+ const y = pb[i] || 0;
144
+ if (x !== y) return x < y ? -1 : 1;
145
+ }
146
+ return 0;
147
+ }
148
+
149
+ function manualCommand({ name, registry } = {}) {
150
+ if (!name) return 'npm install -g <package>@latest';
151
+ const reg = registry ? ` --registry ${registry}` : '';
152
+ return `npm install -g ${name}@latest${reg}`;
153
+ }
154
+
155
+ // Resolves the exact command this install model needs to pull `@latest`.
156
+ function buildUpdatePlan(install, info, env = process.env) {
157
+ const { name, registry } = info;
158
+ if (!name) return null;
159
+ if (install.kind === 'npm-global') {
160
+ const args = ['install', '-g', `${name}@latest`];
161
+ if (registry) args.push('--registry', registry);
162
+ return { type: 'npm', cmd: 'npm', args, cwd: undefined, label: 'npm (global)' };
163
+ }
164
+ if (install.kind === 'npm-local') {
165
+ const args = ['install', `${name}@latest`];
166
+ if (registry) args.push('--registry', registry);
167
+ return {
168
+ type: 'npm',
169
+ cmd: 'npm',
170
+ args,
171
+ cwd: install.projectDir || undefined,
172
+ label: 'npm (local project)',
173
+ };
174
+ }
175
+ if (install.kind === 'prefix') {
176
+ const url = env.SHAKERS_CLI_INSTALL_URL || DEFAULT_INSTALL_URL;
177
+ return {
178
+ type: 'installer',
179
+ url,
180
+ label: 'installer (~/.shakers)',
181
+ command: `curl -fsSL "${url}" | bash`,
182
+ };
183
+ }
184
+ return null;
185
+ }
186
+
187
+ // Inherited stdio so the user sees npm's progress; the installer path keeps SHAKERS_CLI_HOME/BIN so a re-run lands in the same prefix.
188
+ function runInstall(plan, env = process.env) {
189
+ if (!plan) return { ok: false, reason: 'no-plan' };
190
+ try {
191
+ if (plan.type === 'npm') {
192
+ execFileSync(plan.cmd, plan.args, {
193
+ stdio: 'inherit',
194
+ timeout: INSTALL_TIMEOUT_MS,
195
+ cwd: plan.cwd,
196
+ env,
197
+ });
198
+ return { ok: true };
199
+ }
200
+ if (plan.type === 'installer') {
201
+ execFileSync('bash', ['-c', plan.command], {
202
+ stdio: 'inherit',
203
+ timeout: INSTALL_TIMEOUT_MS,
204
+ env,
205
+ });
206
+ return { ok: true };
207
+ }
208
+ return { ok: false, reason: 'no-plan' };
209
+ } catch (error) {
210
+ return { ok: false, reason: classifyError(error) };
211
+ }
212
+ }
213
+
214
+ module.exports = {
215
+ readPackageInfo,
216
+ detectInstall,
217
+ fetchLatestVersion,
218
+ compareVersions,
219
+ buildUpdatePlan,
220
+ runInstall,
221
+ manualCommand,
222
+ DEFAULT_INSTALL_URL,
223
+ };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { spawn } = require('child_process');
7
+ const { getConfigDir } = require('./config-dir');
8
+ const { getCatalog } = require('./i18n');
9
+ const { compareVersions } = require('./update-flow');
10
+
11
+ // Non-blocking update notifier: the hot path only reads the cache synchronously; a detached unref'd worker does the network check.
12
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
+
14
+ function cacheFilePath(env = process.env) {
15
+ return path.join(getConfigDir(env), 'update-check.json');
16
+ }
17
+
18
+ // `{ lastCheck, latest }` or null. Never throws — a missing/corrupt cache is "no data".
19
+ function readCache(env = process.env) {
20
+ try {
21
+ const parsed = JSON.parse(fs.readFileSync(cacheFilePath(env), 'utf8'));
22
+ if (!parsed || typeof parsed !== 'object') return null;
23
+ return parsed;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // Written by the background worker (and nowhere else on the hot path). Best-effort, atomic + 0600 like every other home-dir write (temp-in-same-dir + fsync + rename).
30
+ function writeCache(data, env = process.env) {
31
+ const target = cacheFilePath(env);
32
+ const dir = path.dirname(target);
33
+ const tmp = path.join(dir, `.update-check.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
34
+ let fd = null;
35
+ try {
36
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ const payload = JSON.stringify({ lastCheck: data.lastCheck, latest: data.latest });
38
+ fd = fs.openSync(tmp, 'wx', 0o600);
39
+ fs.writeFileSync(fd, payload);
40
+ try { fs.fsyncSync(fd); } catch { /* e.g. EINVAL on some filesystems */ }
41
+ fs.closeSync(fd);
42
+ fd = null;
43
+ fs.renameSync(tmp, target);
44
+ return true;
45
+ } catch {
46
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
47
+ try { fs.unlinkSync(tmp); } catch { /* never existed */ }
48
+ return false;
49
+ }
50
+ }
51
+
52
+ // `stdoutIsTTY` is injectable for tests; defaults to the real tty bit so piped/redirected/mcp-stdio stdout stays quiet.
53
+ function isSuppressed({
54
+ argv = [],
55
+ command = null,
56
+ env = process.env,
57
+ stdoutIsTTY = process.stdout.isTTY,
58
+ } = {}) {
59
+ if (env.SHAKERS_CLI_NO_UPDATE_NOTIFIER === '1') return true;
60
+ if (env.CI && env.CI !== 'false' && env.CI !== '0') return true;
61
+ if (!stdoutIsTTY) return true;
62
+ if (Array.isArray(argv) && argv.includes('--json')) return true;
63
+ if (command === 'update') return true;
64
+ return false;
65
+ }
66
+
67
+ // Synchronous: prints to stderr when the cached latest beats the installed version, never polluting stdout. Returns the notice (or null).
68
+ function maybeNotify({ currentVersion, lang, env = process.env, out = null } = {}) {
69
+ if (!currentVersion) return null;
70
+ const cache = readCache(env);
71
+ if (!cache || typeof cache.latest !== 'string' || !cache.latest) return null;
72
+ if (compareVersions(currentVersion, cache.latest) >= 0) return null;
73
+ const t = getCatalog(lang).cli.updateNotifier;
74
+ const write = out || ((s) => process.stderr.write(s));
75
+ write(`\n ${t.available(currentVersion, cache.latest)}\n ${t.howTo}\n\n`);
76
+ return t.available(currentVersion, cache.latest);
77
+ }
78
+
79
+ // Spawns the detached, unref'd worker to refresh the cache, but only when it is missing or older than CHECK_INTERVAL_MS. Failures swallowed.
80
+ function triggerBackgroundCheck({ name, env = process.env } = {}) {
81
+ if (!name) return false;
82
+ const cache = readCache(env);
83
+ const now = Date.now();
84
+ if (
85
+ cache &&
86
+ typeof cache.lastCheck === 'number' &&
87
+ now - cache.lastCheck < CHECK_INTERVAL_MS
88
+ ) {
89
+ return false;
90
+ }
91
+ try {
92
+ const worker = path.join(__dirname, '..', 'bin', 'update-check-worker.js');
93
+ const child = spawn(process.execPath, [worker, name], {
94
+ detached: true,
95
+ stdio: 'ignore',
96
+ });
97
+ child.on('error', () => {});
98
+ child.unref();
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ module.exports = {
106
+ cacheFilePath,
107
+ readCache,
108
+ writeCache,
109
+ isSuppressed,
110
+ maybeNotify,
111
+ triggerBackgroundCheck,
112
+ CHECK_INTERVAL_MS,
113
+ };