paris-immersion 0.1.1 → 0.1.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.
@@ -1,7 +1,17 @@
1
1
  export interface ServeOptions {
2
2
  port: number;
3
3
  allowedOrigins: string[];
4
+ /** The login token from ~/.paris/credentials. Accepted on the WS handshake for
5
+ * backward compatibility (legacy: web sent the login token directly). */
4
6
  token: string;
7
+ /** API base URL — used to verify serve-tokens the browser sends (the CLI has no
8
+ * JWT_SECRET, so it delegates verification to POST /api/onboarding/v2/serve-verify). */
9
+ apiUrl?: string;
10
+ /** When set, a verified serve-token must resolve to this user/participant —
11
+ * read from the workspace marker so one machine's serve can't be driven by a
12
+ * token minted for a different student. */
13
+ expectedUserId?: string;
14
+ expectedParticipantId?: string;
5
15
  /** Working directory the embedded `claude` runs in. Defaults to process.cwd().
6
16
  * serveCommand sets this to the provisioned workspace so the session is coached. */
7
17
  cwd?: string;
@@ -1,13 +1,74 @@
1
1
  import { WebSocketServer } from 'ws';
2
2
  import { createRequire } from 'node:module';
3
+ import { promises as fs, chmodSync, existsSync, statSync } from 'node:fs';
4
+ import path from 'node:path';
3
5
  import { readCredentials } from '../lib/credentials.js';
4
6
  import { ensureWorkspace } from './install.js';
5
7
  import { c } from '../lib/colors.js';
8
+ /**
9
+ * Restore the executable bit on node-pty's `spawn-helper`. Some install paths
10
+ * (pnpm store hardlinks, tarball extraction without same-permissions, certain
11
+ * CI caches) drop the +x bit on the prebuilt helper — and without it node-pty's
12
+ * posix_spawnp fails at runtime with "posix_spawnp failed", killing the embedded
13
+ * terminal on EVERY launch. We can't depend on the package shipping it correctly,
14
+ * so we fix it best-effort at startup. No-op on Windows (no spawn-helper there).
15
+ */
16
+ function ensureSpawnHelperExecutable(req) {
17
+ if (process.platform === 'win32')
18
+ return;
19
+ try {
20
+ const main = req.resolve('node-pty'); // <pkg>/lib/index.js
21
+ const helper = path.resolve(path.dirname(main), '..', 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
22
+ if (existsSync(helper) && !(statSync(helper).mode & 0o111)) {
23
+ chmodSync(helper, 0o755);
24
+ }
25
+ }
26
+ catch {
27
+ /* best-effort — if we can't locate it, the spawn error path still reports cleanly */
28
+ }
29
+ }
30
+ /**
31
+ * Verify a serve-token the browser sent on the WS handshake. The CLI can't check
32
+ * the JWT signature locally (no JWT_SECRET), so it asks the API. Returns true only
33
+ * if the API confirms the token AND it resolves to the workspace's own student.
34
+ */
35
+ async function verifyServeToken(token, opts) {
36
+ if (!token || !opts.apiUrl)
37
+ return false;
38
+ try {
39
+ const res = await fetch(`${opts.apiUrl}/api/onboarding/v2/serve-verify`, {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json' },
42
+ body: JSON.stringify({ token }),
43
+ });
44
+ if (!res.ok)
45
+ return false;
46
+ const data = (await res.json());
47
+ if (!data.valid)
48
+ return false;
49
+ if (opts.expectedUserId && data.userId && data.userId !== opts.expectedUserId)
50
+ return false;
51
+ if (opts.expectedParticipantId && data.participantId && data.participantId !== opts.expectedParticipantId)
52
+ return false;
53
+ return true;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
6
59
  const DEFAULT_ALLOWED_ORIGINS = [
7
60
  'https://app.paris-group.com',
8
61
  'https://paris-immersion.vercel.app',
9
62
  'http://localhost:3010',
10
63
  ];
64
+ // Initial prompt fed to `claude` when the embedded terminal opens, so the course
65
+ // starts immediately instead of sitting at an idle prompt. Invoca a skill do
66
+ // tutor ToStudy (`/tostudy`, do @tostudy-ai/cli) — o aluno chega aqui já logado
67
+ // na conta dele da ToStudy (auto-login no install via cli-auth-code), então o
68
+ // `/tostudy` roda o curso real direto. Override com PARIS_COACH_KICKOFF se uma
69
+ // turma precisar de outro entrypoint (ex.: o tutor-paris `get_course` como
70
+ // fallback quando não há curso ToStudy configurado).
71
+ const COACH_KICKOFF = process.env.PARIS_COACH_KICKOFF || '/tostudy';
11
72
  /**
12
73
  * Start a localhost WebSocket server that brokers a pty (running `claude`) to a
13
74
  * connected browser-side xterm.js. Two gates protect the channel:
@@ -33,11 +94,18 @@ export async function startServeServer(opts) {
33
94
  return;
34
95
  }
35
96
  const url = new URL(info.req.url ?? '/', 'http://localhost');
36
- if (url.searchParams.get('token') !== opts.token) {
37
- cb(false, 401, 'invalid token');
97
+ const qToken = url.searchParams.get('token') ?? '';
98
+ // Fast path: the login token (creds.token) — backward compatible with
99
+ // older web clients that sent it directly.
100
+ if (qToken && qToken === opts.token) {
101
+ cb(true);
38
102
  return;
39
103
  }
40
- cb(true);
104
+ // Otherwise treat it as a serve-token (purpose 'onboarding-serve', minted by
105
+ // GET /api/onboarding/v2/serve-token) and let the API verify it.
106
+ void verifyServeToken(qToken, opts).then((ok) => {
107
+ cb(ok, ok ? undefined : 401, ok ? undefined : 'invalid token');
108
+ });
41
109
  },
42
110
  });
43
111
  // Wait for the listening event so handle.port is final (random port if 0).
@@ -56,9 +124,17 @@ export async function startServeServer(opts) {
56
124
  // Lazy require — keeps `paris serve --help` working even if the native
57
125
  // node-pty binary failed to compile/install on this platform.
58
126
  const req = createRequire(import.meta.url);
127
+ ensureSpawnHelperExecutable(req);
59
128
  const ptyMod = req('node-pty');
60
129
  const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : 'bash');
61
- const shellArgs = process.platform === 'win32' ? ['-NoLogo'] : ['-l', '-c', 'claude'];
130
+ // Auto-start the coach: launch `claude` with an initial prompt so the
131
+ // student opens the terminal to Claude ALREADY running the course (it
132
+ // speaks first), instead of an idle prompt. The SessionStart hook has
133
+ // already injected the immersion/day/task context; the kickoff just tells
134
+ // Claude to begin now without waiting for input.
135
+ const shellArgs = process.platform === 'win32'
136
+ ? ['-NoLogo', '-Command', `claude "${COACH_KICKOFF.replace(/"/g, '`"')}"`]
137
+ : ['-l', '-c', `claude '${COACH_KICKOFF.replace(/'/g, `'\\''`)}'`];
62
138
  pty = ptyMod.spawn(shell, shellArgs, {
63
139
  name: 'xterm-color',
64
140
  cols: 80,
@@ -173,9 +249,27 @@ export async function serveCommand(opts) {
173
249
  const msg = err instanceof Error ? err.message : String(err);
174
250
  console.error(c.yellow(` ! sem workspace coachado (${msg}). Abrindo Claude em ${cwd}.`));
175
251
  }
252
+ // Read api_url + the workspace's owner so we can verify serve-tokens (and bind
253
+ // them to this student). Best-effort — falls back to login-token-only auth.
254
+ let apiUrl = '';
255
+ let expectedUserId;
256
+ let expectedParticipantId;
257
+ try {
258
+ const creds = await readCredentials();
259
+ apiUrl = creds?.api_url ?? '';
260
+ const markerRaw = await fs.readFile(path.join(cwd, '.paris', 'workspace.json'), 'utf8');
261
+ const marker = JSON.parse(markerRaw);
262
+ expectedUserId = marker.student_user_id;
263
+ expectedParticipantId = marker.participant_id;
264
+ if (!apiUrl && marker.api_url)
265
+ apiUrl = marker.api_url;
266
+ }
267
+ catch {
268
+ /* best-effort — verifyServeToken just no-ops without apiUrl */
269
+ }
176
270
  let handle;
177
271
  try {
178
- handle = await startServeServer({ port: portNum, allowedOrigins, token, cwd });
272
+ handle = await startServeServer({ port: portNum, allowedOrigins, token, cwd, apiUrl, expectedUserId, expectedParticipantId });
179
273
  }
180
274
  catch (err) {
181
275
  const msg = err instanceof Error ? err.message : String(err);
@@ -76,9 +76,10 @@ async function main() {
76
76
  return
77
77
  }
78
78
 
79
- const [workspace, current] = await Promise.all([
79
+ const [workspace, current, course] = await Promise.all([
80
80
  apiGet(apiUrl, '/immersion-app/v1/workspace', creds.token),
81
81
  apiGet(apiUrl, '/immersion-app/v1/missions/current', creds.token),
82
+ apiGet(apiUrl, '/immersion-app/v1/course', creds.token),
82
83
  ])
83
84
 
84
85
  if (!workspace) {
@@ -87,6 +88,11 @@ async function main() {
87
88
  }
88
89
  if (workspace.status === 'graduated') return
89
90
 
91
+ // Onboarding: enquanto o curso não termina, o terminal roda o tutor do curso
92
+ // (/tostudy) — isso tem prioridade sobre as missões da imersão (que nem existem
93
+ // na fase pre).
94
+ const inCourse = !!(course && course.courseCompleted === false)
95
+
90
96
  const lines = []
91
97
  if (PHASE === 'session-start') {
92
98
  lines.push('# Paris Immersion — contexto da sessão')
@@ -94,28 +100,40 @@ async function main() {
94
100
  lines.push('Imersão: **' + workspace.immersion.name + '** (\\'' + workspace.immersion.slug + '\\')')
95
101
  lines.push('Dia: **' + workspace.day_number + '** · fase: **' + workspace.phase + '**')
96
102
  lines.push('')
97
- lines.push('Você (Claude) é o coach técnico desse aluno. Siga as regras do CLAUDE.md e da skill paris-coach. **Comece falando primeiro** com um resumo do dia e a tarefa atual — o aluno NÃO digitou nada ainda.')
98
- lines.push('')
99
103
  } else {
100
104
  lines.push('# Paris — refresh de contexto')
101
105
  lines.push('')
102
106
  }
103
107
 
104
- if (current && current.current) {
105
- lines.push('## Tarefa atual')
106
- lines.push('- **id:** \\'' + current.current.id + '\\'')
107
- lines.push('- **título:** ' + current.current.title)
108
- lines.push('- **tipo:** ' + current.current.deliverable_type)
109
- lines.push('- **status:** ' + current.current.status)
110
- if (current.current.description) lines.push('- **descrição:** ' + current.current.description)
111
- if (current.current.socio_prompt) {
108
+ if (inCourse) {
109
+ lines.push('## FASE DO CURSO DE ONBOARDING')
110
+ lines.push('O aluno está na etapa do curso **' + course.title + '** (progresso: **' + course.lessonsCompleted + '/' + course.totalLessons + '**).')
111
+ lines.push('Rode a skill **/tostudy** (o tutor da ToStudy) pra conduzir o curso — o aluno já chega logado na conta dele da ToStudy (auto-login no install). NÃO trabalhe em missões da imersão enquanto o curso não terminar. (Fallback: se a ToStudy não estiver disponível, use as ferramentas MCP **get_course**/**complete_lesson** do paris.)')
112
+ if (PHASE === 'session-start') {
112
113
  lines.push('')
113
- lines.push('### Sócio prompt')
114
- lines.push(current.current.socio_prompt)
114
+ lines.push('**Comece falando primeiro**: rode /tostudy e dê as boas-vindas. O aluno ainda não digitou nada.')
115
115
  }
116
116
  } else {
117
- lines.push('## Sem tarefa em foco')
118
- lines.push('Pergunte ao aluno em que ele quer trabalhar.')
117
+ if (PHASE === 'session-start') {
118
+ lines.push('Você (Claude) é o coach técnico desse aluno. Siga as regras do CLAUDE.md e da skill paris-coach. **Comece falando primeiro** com um resumo do dia e a tarefa atual — o aluno NÃO digitou nada ainda.')
119
+ lines.push('')
120
+ }
121
+ if (current && current.current) {
122
+ lines.push('## Tarefa atual')
123
+ lines.push('- **id:** \\'' + current.current.id + '\\'')
124
+ lines.push('- **título:** ' + current.current.title)
125
+ lines.push('- **tipo:** ' + current.current.deliverable_type)
126
+ lines.push('- **status:** ' + current.current.status)
127
+ if (current.current.description) lines.push('- **descrição:** ' + current.current.description)
128
+ if (current.current.socio_prompt) {
129
+ lines.push('')
130
+ lines.push('### Sócio prompt')
131
+ lines.push(current.current.socio_prompt)
132
+ }
133
+ } else {
134
+ lines.push('## Sem tarefa em foco')
135
+ lines.push('Pergunte ao aluno em que ele quer trabalhar.')
136
+ }
119
137
  }
120
138
 
121
139
  process.stdout.write(lines.join('\\n') + '\\n')
@@ -9,7 +9,11 @@
9
9
  */
10
10
  export function renderSettingsJson(opts) {
11
11
  return {
12
- $schema: 'https://json.schemastore.org/claude-code-settings',
12
+ $schema: 'https://json.schemastore.org/claude-code-settings.json',
13
+ // Auto-confia no MCP do projeto (paris-immersion) — sem isso o Claude Code
14
+ // pode não conectar o server numa sessão não-interativa (terminal embutido),
15
+ // e o tutor abre sem as tools get_course/complete_lesson.
16
+ enableAllProjectMcpServers: true,
13
17
  mcpServers: {
14
18
  'paris-immersion': {
15
19
  command: 'node',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paris-immersion",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Paris Immersion CLI — login, install workspace, status, task control. Para alunos das imersões da Paris Group.",
5
5
  "homepage": "https://parisgroup.ai",
6
6
  "repository": {
@@ -18024,6 +18024,18 @@ function buildTools() {
18024
18024
  transcript_excerpt: input.transcript_excerpt
18025
18025
  })
18026
18026
  },
18027
+ {
18028
+ name: "get_course",
18029
+ description: "Retorna o curso de onboarding (Terminal + Claude Code): li\xE7\xF5es, objetivos e o progresso do aluno. Use NO IN\xCDCIO pra saber o que ensinar e de onde retomar.",
18030
+ inputSchema: external_exports.object({}),
18031
+ handler: async (_, { client }) => client.get("/immersion-app/v1/course")
18032
+ },
18033
+ {
18034
+ name: "complete_lesson",
18035
+ description: "Marca uma li\xE7\xE3o do curso de onboarding como conclu\xEDda. Progresso \xE9 MONOT\xD4NICO (nunca regride) e reconcilia com a conta ToStudy do aluno pelo e-mail. S\xD3 chame quando o aluno realmente cumprir o objetivo da li\xE7\xE3o. Quando a \xFAltima fecha, o curso conclui e a fase avan\xE7a.",
18036
+ inputSchema: external_exports.object({ lesson_id: external_exports.string().min(1) }),
18037
+ handler: async (input, { client }) => client.post(`/immersion-app/v1/course/lessons/${input.lesson_id}/complete`, {})
18038
+ },
18027
18039
  {
18028
18040
  name: "reopen_task",
18029
18041
  description: "Reabrir uma tarefa marcada como conclu\xEDda. O aluno (voc\xEA) precisa de uma raz\xE3o.",