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.
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +99 -5
- package/dist/templates/hook.js +33 -15
- package/dist/templates/settings.js +5 -1
- package/package.json +1 -1
- package/templates/mcp/index.mjs +12 -0
package/dist/commands/serve.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/serve.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/templates/hook.js
CHANGED
|
@@ -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 (
|
|
105
|
-
lines.push('##
|
|
106
|
-
lines.push('
|
|
107
|
-
lines.push('
|
|
108
|
-
|
|
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('
|
|
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
|
-
|
|
118
|
-
|
|
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
package/templates/mcp/index.mjs
CHANGED
|
@@ -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.",
|