paris-immersion 0.1.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.
Files changed (39) hide show
  1. package/README.md +50 -0
  2. package/dist/commands/doctor.d.ts +5 -0
  3. package/dist/commands/doctor.js +104 -0
  4. package/dist/commands/graduate.d.ts +1 -0
  5. package/dist/commands/graduate.js +13 -0
  6. package/dist/commands/install.d.ts +4 -0
  7. package/dist/commands/install.js +88 -0
  8. package/dist/commands/login.d.ts +4 -0
  9. package/dist/commands/login.js +101 -0
  10. package/dist/commands/logout.d.ts +1 -0
  11. package/dist/commands/logout.js +20 -0
  12. package/dist/commands/reopen.d.ts +3 -0
  13. package/dist/commands/reopen.js +9 -0
  14. package/dist/commands/serve.d.ts +31 -0
  15. package/dist/commands/serve.js +180 -0
  16. package/dist/commands/status.d.ts +1 -0
  17. package/dist/commands/status.js +25 -0
  18. package/dist/commands/tasks.d.ts +1 -0
  19. package/dist/commands/tasks.js +32 -0
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.js +62 -0
  22. package/dist/lib/api.d.ts +16 -0
  23. package/dist/lib/api.js +45 -0
  24. package/dist/lib/colors.d.ts +10 -0
  25. package/dist/lib/colors.js +18 -0
  26. package/dist/lib/credentials.d.ts +16 -0
  27. package/dist/lib/credentials.js +45 -0
  28. package/dist/lib/marker.d.ts +17 -0
  29. package/dist/lib/marker.js +27 -0
  30. package/dist/templates/claude-md.d.ts +5 -0
  31. package/dist/templates/claude-md.js +47 -0
  32. package/dist/templates/hook.d.ts +12 -0
  33. package/dist/templates/hook.js +132 -0
  34. package/dist/templates/settings.d.ts +12 -0
  35. package/dist/templates/settings.js +58 -0
  36. package/dist/templates/skill.d.ts +1 -0
  37. package/dist/templates/skill.js +61 -0
  38. package/package.json +55 -0
  39. package/templates/mcp/index.mjs +18171 -0
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # paris-immersion
2
+
3
+ CLI dos alunos da **Paris Immersion** — login, instalação do workspace da imersão, controle de tarefas e diagnóstico de ambiente.
4
+
5
+ ```bash
6
+ npm i -g paris-immersion
7
+ # ou
8
+ pnpm add -g paris-immersion
9
+ # ou
10
+ yarn global add paris-immersion
11
+ ```
12
+
13
+ ## Como começar
14
+
15
+ Depois de instalar, você terá o comando `paris` disponível no terminal.
16
+
17
+ ```bash
18
+ paris login # Autentica via device-code flow (abre o browser pra confirmar)
19
+ paris install # Cria o workspace da sua imersão na pasta atual
20
+ paris status # Vê em qual fase você está + tarefa em foco
21
+ paris tasks # Lista as tarefas do dia
22
+ paris doctor # Diagnostica credentials, marker, hooks, conectividade
23
+ ```
24
+
25
+ ## Fluxo do aluno
26
+
27
+ 1. **Login** — `paris login` abre uma URL com um código tipo `ABCD-1234`. Você acessa, confirma no painel Paris Immersion (já logado com sua conta) e o CLI fica autenticado.
28
+ 2. **Install** — `paris install` baixa templates e configura o Claude Code pra te guiar pela imersão usando o **Sócio** (IA da Paris).
29
+ 3. **Durante a imersão** — Cada tarefa do dia tem um Sócio dedicado que te ajuda. Marque conclusões com `paris tasks`.
30
+ 4. **Reabrir tarefa** — `paris reopen <mission-id>` se precisar retomar algo.
31
+ 5. **Graduate** — `paris graduate` quando concluir a imersão.
32
+
33
+ ## Comandos
34
+
35
+ | Comando | O que faz |
36
+ |---|---|
37
+ | `paris login` | Autentica com sua conta Paris (device-code flow) |
38
+ | `paris install` | Instala o workspace da imersão na pasta atual |
39
+ | `paris doctor` | Diagnostica credentials, marker, hooks, conectividade |
40
+ | `paris tasks` | Lista tarefas do dia (CLI-only, não abre Claude) |
41
+ | `paris status` | Resumo da fase atual + tarefa em foco |
42
+ | `paris reopen <id>` | Reabre uma tarefa marcada como concluída |
43
+ | `paris graduate` | Marca a imersão como concluída |
44
+ | `paris logout` | Remove credenciais locais |
45
+
46
+ ## Suporte
47
+
48
+ Em caso de problemas, rode `paris doctor` e mande o output pro time da Paris.
49
+
50
+ — Paris Group · [parisgroup.ai](https://parisgroup.ai)
@@ -0,0 +1,5 @@
1
+ export type DoctorOptions = {
2
+ /** When set, posts a summary of the run to /api/onboarding/v2/install-status for the onboarding wizard. */
3
+ report?: boolean;
4
+ };
5
+ export declare function doctorCommand(opts?: DoctorOptions): Promise<void>;
@@ -0,0 +1,104 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { apiAuthFetch, apiFetch, defaultApiUrl, ApiError } from '../lib/api.js';
4
+ import { readCredentials, credentialsPath } from '../lib/credentials.js';
5
+ import { findMarker } from '../lib/marker.js';
6
+ import { c } from '../lib/colors.js';
7
+ export async function doctorCommand(opts = {}) {
8
+ const checks = [];
9
+ // 1. Credentials file
10
+ const creds = await readCredentials().catch(() => null);
11
+ if (!creds) {
12
+ checks.push({ name: 'credentials', ok: false, detail: `not found at ${credentialsPath()} — run \`paris login\`` });
13
+ }
14
+ else {
15
+ checks.push({ name: 'credentials', ok: true, detail: `logged in as ${creds.email}` });
16
+ }
17
+ // 2. API reachable + token still valid
18
+ if (creds) {
19
+ try {
20
+ const workspace = await apiAuthFetch('/immersion-app/v1/workspace');
21
+ checks.push({ name: 'api / token', ok: true, detail: `${workspace.immersion.name} · ${workspace.phase} · day ${workspace.day_number} (${workspace.status})` });
22
+ }
23
+ catch (err) {
24
+ checks.push({
25
+ name: 'api / token',
26
+ ok: false,
27
+ detail: err instanceof ApiError ? `${err.status} ${err.message}` : String(err),
28
+ });
29
+ }
30
+ }
31
+ // 3. Workspace marker
32
+ const found = await findMarker();
33
+ if (!found) {
34
+ checks.push({ name: 'workspace marker', ok: false, detail: 'no .paris/workspace.json walking up from cwd — run `paris install` here' });
35
+ }
36
+ else {
37
+ checks.push({ name: 'workspace marker', ok: true, detail: `${path.join(found.dir, '.paris/workspace.json')}` });
38
+ // 4. Hooks present
39
+ const hookPath = path.join(found.dir, '.paris', 'hooks', 'paris-hook.mjs');
40
+ const hookExists = await fs.access(hookPath).then(() => true).catch(() => false);
41
+ checks.push({ name: 'paris-hook.mjs', ok: hookExists, detail: hookExists ? hookPath : 'missing — re-run `paris install --dir <here>`' });
42
+ // 5. .claude/settings.json
43
+ const settingsPath = path.join(found.dir, '.claude', 'settings.json');
44
+ const settingsExists = await fs.access(settingsPath).then(() => true).catch(() => false);
45
+ checks.push({ name: '.claude/settings.json', ok: settingsExists, detail: settingsExists ? settingsPath : 'missing — re-run `paris install`' });
46
+ }
47
+ // Report
48
+ console.log();
49
+ console.log(c.bold('paris doctor'));
50
+ console.log(c.dim(`API: ${creds?.api_url ?? defaultApiUrl()}`));
51
+ console.log();
52
+ let failures = 0;
53
+ for (const ch of checks) {
54
+ const icon = ch.ok ? c.green('✓') : c.red('✗');
55
+ console.log(` ${icon} ${ch.name}${ch.detail ? c.dim(' — ' + ch.detail) : ''}`);
56
+ if (!ch.ok)
57
+ failures++;
58
+ }
59
+ console.log();
60
+ if (failures === 0) {
61
+ console.log(c.green('Tudo certo.'));
62
+ }
63
+ else {
64
+ console.log(c.yellow(`${failures} problema(s) encontrados.`));
65
+ process.exitCode = 1;
66
+ }
67
+ if (opts.report) {
68
+ await reportInstallStatus(checks, creds?.token, creds?.api_url);
69
+ }
70
+ }
71
+ /**
72
+ * POST /api/onboarding/v2/install-status — used by Phase 3 of the onboarding
73
+ * wizard to flip install_completed_at when every check passes, or stash a
74
+ * blocked-signal when something failed. Best-effort: a failed report is logged
75
+ * to stderr but never raises (the doctor output is the real signal to the user).
76
+ */
77
+ async function reportInstallStatus(checks, token, apiUrl) {
78
+ if (!token) {
79
+ console.error(c.yellow(' ! --report ignorado: sem credenciais (rode `paris login`).'));
80
+ return;
81
+ }
82
+ const allOk = checks.every((c) => c.ok);
83
+ const failedNames = checks.filter((c) => !c.ok).map((c) => c.name);
84
+ const body = {
85
+ step: 'all',
86
+ status: allOk ? 'ok' : 'failed',
87
+ os: process.platform,
88
+ arch: process.arch,
89
+ detail: failedNames.length ? `failed: ${failedNames.join(',')}` : undefined,
90
+ };
91
+ try {
92
+ await apiFetch('/api/onboarding/v2/install-status', {
93
+ method: 'POST',
94
+ body,
95
+ token,
96
+ apiUrl: apiUrl ?? defaultApiUrl(),
97
+ });
98
+ console.log(c.dim(` → reported install-status=${body.status} to api`));
99
+ }
100
+ catch (err) {
101
+ const msg = err instanceof ApiError ? `${err.status} ${err.message}` : (err instanceof Error ? err.message : String(err));
102
+ console.error(c.yellow(` ! falha ao enviar install-status: ${msg}`));
103
+ }
104
+ }
@@ -0,0 +1 @@
1
+ export declare function graduateCommand(): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import { apiAuthFetch } from '../lib/api.js';
2
+ import { c } from '../lib/colors.js';
3
+ export async function graduateCommand() {
4
+ const res = await apiAuthFetch('/immersion-app/v1/graduate', {
5
+ method: 'POST',
6
+ });
7
+ if (res.status === 'already_graduated') {
8
+ console.log(c.yellow(`Já estava graduado em ${res.graduated_at.slice(0, 10)}.`));
9
+ }
10
+ else {
11
+ console.log(c.green(`✓ Graduado em ${res.graduated_at.slice(0, 10)}. Hooks ficam silenciosos a partir da próxima sessão.`));
12
+ }
13
+ }
@@ -0,0 +1,4 @@
1
+ export declare function installCommand(opts: {
2
+ dir?: string;
3
+ immersion?: string;
4
+ }): Promise<void>;
@@ -0,0 +1,88 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { apiAuthFetch } from '../lib/api.js';
5
+ import { findMarker, writeMarker } from '../lib/marker.js';
6
+ import { readCredentials } from '../lib/credentials.js';
7
+ import { c } from '../lib/colors.js';
8
+ import { renderClaudeMd } from '../templates/claude-md.js';
9
+ import { renderSettingsJson } from '../templates/settings.js';
10
+ import { renderParisCoachSkill } from '../templates/skill.js';
11
+ import { renderHookScript } from '../templates/hook.js';
12
+ export async function installCommand(opts) {
13
+ const creds = await readCredentials();
14
+ if (!creds) {
15
+ throw new Error('Você precisa rodar `paris login` antes.');
16
+ }
17
+ // Refuse to re-install on top of an existing marker without -d <new path>.
18
+ const existing = await findMarker();
19
+ if (existing && !opts.dir) {
20
+ console.log(c.yellow(`Marker já existe em ${existing.dir}.`));
21
+ console.log(c.dim('Use `paris doctor` pra validar, ou passe --dir <novo-path> pra criar outro workspace.'));
22
+ return;
23
+ }
24
+ process.stdout.write(c.dim('Buscando contexto da imersão...\n'));
25
+ const slugQuery = opts.immersion ? `?immersion=${encodeURIComponent(opts.immersion)}` : '';
26
+ const boot = await apiAuthFetch(`/immersion-app/v1/workspace/bootstrap${slugQuery}`, {
27
+ method: 'POST',
28
+ });
29
+ const targetDir = path.resolve(opts.dir ?? `paris-${boot.immersion.slug}`);
30
+ await fs.mkdir(targetDir, { recursive: true });
31
+ // .paris/workspace.json — the marker.
32
+ await writeMarker(targetDir, { ...boot.marker, api_url: creds.api_url });
33
+ // .paris/hooks/paris-hook.mjs — installed Claude Code hook.
34
+ const hooksDir = path.join(targetDir, '.paris', 'hooks');
35
+ await fs.mkdir(hooksDir, { recursive: true });
36
+ await fs.writeFile(path.join(hooksDir, 'paris-hook.mjs'), renderHookScript(), { mode: 0o755 });
37
+ // .paris/mcp/index.mjs — vendored, self-contained MCP server bundle. Shipped
38
+ // inside this package (templates/mcp/index.mjs) and copied verbatim so the
39
+ // workspace needs no npm install and no npx fetch at session start.
40
+ const mcpDir = path.join(targetDir, '.paris', 'mcp');
41
+ await fs.mkdir(mcpDir, { recursive: true });
42
+ await fs.writeFile(path.join(mcpDir, 'index.mjs'), await readVendoredMcpBundle(), { mode: 0o755 });
43
+ // .claude/settings.json — wires hooks + MCP server.
44
+ const claudeDir = path.join(targetDir, '.claude');
45
+ await fs.mkdir(claudeDir, { recursive: true });
46
+ // Write both settings.json (so the regular interactive `claude` picks it up)
47
+ // and settings.local.json (so `claude -p` does too — in -p mode, local
48
+ // settings are the reliably-loaded source for hooks).
49
+ const settingsPayload = renderSettingsJson({ apiUrl: creds.api_url });
50
+ await fs.writeFile(path.join(claudeDir, 'settings.json'), JSON.stringify(settingsPayload, null, 2));
51
+ await fs.writeFile(path.join(claudeDir, 'settings.local.json'), JSON.stringify(settingsPayload, null, 2));
52
+ // .claude/skills/paris-coach/SKILL.md
53
+ const skillDir = path.join(claudeDir, 'skills', 'paris-coach');
54
+ await fs.mkdir(skillDir, { recursive: true });
55
+ await fs.writeFile(path.join(skillDir, 'SKILL.md'), renderParisCoachSkill());
56
+ // CLAUDE.md — workspace-level rules.
57
+ await fs.writeFile(path.join(targetDir, 'CLAUDE.md'), renderClaudeMd({
58
+ immersionName: boot.immersion.name,
59
+ immersionSlug: boot.immersion.slug,
60
+ participantEmail: creds.email,
61
+ }));
62
+ console.log();
63
+ console.log(c.green(`✓ Workspace pronto em ${targetDir}`));
64
+ console.log();
65
+ console.log(c.bold('Próximo passo:'));
66
+ console.log(` cd ${path.relative(process.cwd(), targetDir) || '.'}`);
67
+ console.log(' claude');
68
+ console.log();
69
+ console.log(c.dim(`Imersão: ${boot.immersion.name} · fase ${boot.phase} · dia ${boot.day_number}`));
70
+ }
71
+ /**
72
+ * Reads the bundled MCP server shipped with this package. Lives at
73
+ * `<pkg>/templates/mcp/index.mjs` — same relative location from both the
74
+ * compiled `dist/commands/install.js` and the dev `src/commands/install.ts`.
75
+ * Built by `scripts/build-mcp-bundle.mjs` (runs in the `build` script before
76
+ * publish). If it's missing the build is broken — fail loud rather than ship a
77
+ * workspace whose Claude session has no coach tools.
78
+ */
79
+ async function readVendoredMcpBundle() {
80
+ const bundlePath = fileURLToPath(new URL('../../templates/mcp/index.mjs', import.meta.url));
81
+ try {
82
+ return await fs.readFile(bundlePath, 'utf-8');
83
+ }
84
+ catch {
85
+ throw new Error(`MCP bundle não encontrado em ${bundlePath}. ` +
86
+ 'Build incompleto — rode `pnpm --filter paris-immersion build` (que gera o bundle) antes de publicar.');
87
+ }
88
+ }
@@ -0,0 +1,4 @@
1
+ export declare function loginCommand(opts: {
2
+ apiUrl?: string;
3
+ token?: string;
4
+ }): Promise<void>;
@@ -0,0 +1,101 @@
1
+ import { hostname, platform } from 'node:os';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { apiFetch, ApiError, defaultApiUrl } from '../lib/api.js';
4
+ import { writeCredentials } from '../lib/credentials.js';
5
+ import { c } from '../lib/colors.js';
6
+ export async function loginCommand(opts) {
7
+ const apiUrl = opts.apiUrl ?? defaultApiUrl();
8
+ // --token: skip device-code flow. Usado pelo installer (PARIS_ONBOARDING_TOKEN
9
+ // env mintado pelo wizard /api/onboarding/v2/installer-token).
10
+ // Valida o token contra /auth/me antes de persistir.
11
+ if (opts.token) {
12
+ process.stdout.write(c.dim(`Validando token via ${apiUrl}...\n`));
13
+ try {
14
+ const me = await apiFetch('/auth/me', {
15
+ method: 'GET',
16
+ apiUrl,
17
+ token: opts.token,
18
+ });
19
+ await writeCredentials({
20
+ token: opts.token,
21
+ user_id: me.id,
22
+ email: me.email,
23
+ api_url: apiUrl,
24
+ obtained_at: new Date().toISOString(),
25
+ });
26
+ console.log(c.green(`✓ Autenticado como ${me.email}.`));
27
+ console.log(c.dim(' Token salvo em ~/.paris/credentials.'));
28
+ return;
29
+ }
30
+ catch (err) {
31
+ const msg = err instanceof ApiError ? `${err.status} ${err.message}` : String(err);
32
+ throw new Error(`Login falhou (token inválido ou expirado): ${msg}`);
33
+ }
34
+ }
35
+ process.stdout.write(c.dim(`Conectando a ${apiUrl}...\n`));
36
+ const start = await apiFetch('/auth/cli/start', {
37
+ method: 'POST',
38
+ apiUrl,
39
+ body: {
40
+ client_info: {
41
+ hostname: safeHostname(),
42
+ platform: platform(),
43
+ cli_version: '0.1.0',
44
+ },
45
+ },
46
+ });
47
+ console.log();
48
+ console.log(c.bold('Para autorizar o CLI, abra:'));
49
+ console.log(' ' + c.cyan(start.verification_uri));
50
+ console.log();
51
+ console.log(c.bold('Confirme que o código abaixo aparece na página:'));
52
+ console.log(' ' + c.green(c.bold(start.user_code)));
53
+ console.log();
54
+ console.log(c.dim(`Esse código expira em ${Math.floor(start.expires_in / 60)} minutos.`));
55
+ console.log(c.dim('Aguardando aprovação...'));
56
+ console.log();
57
+ const deadline = Date.now() + start.expires_in * 1000;
58
+ let intervalMs = start.interval * 1000;
59
+ while (Date.now() < deadline) {
60
+ await delay(intervalMs);
61
+ try {
62
+ const poll = await apiFetch('/auth/cli/poll', {
63
+ method: 'POST',
64
+ apiUrl,
65
+ body: { device_code: start.device_code },
66
+ });
67
+ if (poll.status === 'approved') {
68
+ await writeCredentials({
69
+ token: poll.token,
70
+ user_id: poll.user.id,
71
+ email: poll.user.email,
72
+ api_url: apiUrl,
73
+ obtained_at: new Date().toISOString(),
74
+ });
75
+ console.log(c.green(`✓ Autenticado como ${poll.user.email}.`));
76
+ console.log(c.dim(` Token salvo em ~/.paris/credentials.`));
77
+ return;
78
+ }
79
+ if (poll.status === 'authorization_pending') {
80
+ // Honor server-suggested cadence.
81
+ intervalMs = poll.interval * 1000;
82
+ continue;
83
+ }
84
+ }
85
+ catch (err) {
86
+ if (err instanceof ApiError && (err.status === 410 || err.status === 404)) {
87
+ throw new Error(`Login falhou: ${err.message}`);
88
+ }
89
+ throw err;
90
+ }
91
+ }
92
+ throw new Error('Login expirou. Rode `paris login` de novo.');
93
+ }
94
+ function safeHostname() {
95
+ try {
96
+ return hostname();
97
+ }
98
+ catch {
99
+ return 'unknown';
100
+ }
101
+ }
@@ -0,0 +1 @@
1
+ export declare function logoutCommand(): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { apiAuthFetch } from '../lib/api.js';
2
+ import { deleteCredentials, readCredentials } from '../lib/credentials.js';
3
+ import { c } from '../lib/colors.js';
4
+ export async function logoutCommand() {
5
+ const creds = await readCredentials();
6
+ if (!creds) {
7
+ console.log(c.dim('Nenhuma sessão local pra encerrar.'));
8
+ return;
9
+ }
10
+ // Best-effort server-side revocation. If it fails (offline, server down) we
11
+ // still wipe the local credentials so the user isn't stranded.
12
+ try {
13
+ await apiAuthFetch('/auth/logout', { method: 'POST', body: {} });
14
+ }
15
+ catch {
16
+ // ignore
17
+ }
18
+ await deleteCredentials();
19
+ console.log(c.green('✓ Logout completo.'));
20
+ }
@@ -0,0 +1,3 @@
1
+ export declare function reopenCommand(missionId: string, opts: {
2
+ reason?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,9 @@
1
+ import { apiAuthFetch } from '../lib/api.js';
2
+ import { c } from '../lib/colors.js';
3
+ export async function reopenCommand(missionId, opts) {
4
+ const res = await apiAuthFetch(`/immersion-app/v1/missions/${missionId}/reopen`, {
5
+ method: 'POST',
6
+ body: { reason: opts.reason ?? 'student reopen' },
7
+ });
8
+ console.log(c.green(`✓ Reaberta: ${res.mission_id}`));
9
+ }
@@ -0,0 +1,31 @@
1
+ export interface ServeOptions {
2
+ port: number;
3
+ allowedOrigins: string[];
4
+ token: string;
5
+ /** When true, accept connections without spawning a pty — used by tests + dry-run handshake checks. */
6
+ dryRun?: boolean;
7
+ }
8
+ export interface ServeHandle {
9
+ port: number;
10
+ close: () => void;
11
+ }
12
+ /**
13
+ * Start a localhost WebSocket server that brokers a pty (running `claude`) to a
14
+ * connected browser-side xterm.js. Two gates protect the channel:
15
+ *
16
+ * 1. Origin header must be in `allowedOrigins`. Browsers always set Origin on
17
+ * WS handshakes, so a same-origin web page can connect but a script from
18
+ * evil.com can't.
19
+ * 2. `?token=...` query param must match `opts.token`. The token is the
20
+ * value persisted in ~/.paris/credentials at `paris login` time.
21
+ *
22
+ * The server binds to 127.0.0.1 only — never 0.0.0.0 — so it's invisible on
23
+ * the LAN. xterm.js still reaches it because `ws://localhost` is a secure
24
+ * context per W3C Secure Contexts spec, even from an https:// page.
25
+ */
26
+ export declare function startServeServer(opts: ServeOptions): Promise<ServeHandle>;
27
+ /** Read the bearer token used by the web client to authenticate against `paris serve`. */
28
+ export declare function readServeToken(): Promise<string>;
29
+ export declare function serveCommand(opts: {
30
+ port?: string | number;
31
+ }): Promise<void>;
@@ -0,0 +1,180 @@
1
+ import { WebSocketServer } from 'ws';
2
+ import { createRequire } from 'node:module';
3
+ import { readCredentials } from '../lib/credentials.js';
4
+ import { c } from '../lib/colors.js';
5
+ const DEFAULT_ALLOWED_ORIGINS = [
6
+ 'https://app.paris-group.com',
7
+ 'https://paris-immersion.vercel.app',
8
+ 'http://localhost:3010',
9
+ ];
10
+ /**
11
+ * Start a localhost WebSocket server that brokers a pty (running `claude`) to a
12
+ * connected browser-side xterm.js. Two gates protect the channel:
13
+ *
14
+ * 1. Origin header must be in `allowedOrigins`. Browsers always set Origin on
15
+ * WS handshakes, so a same-origin web page can connect but a script from
16
+ * evil.com can't.
17
+ * 2. `?token=...` query param must match `opts.token`. The token is the
18
+ * value persisted in ~/.paris/credentials at `paris login` time.
19
+ *
20
+ * The server binds to 127.0.0.1 only — never 0.0.0.0 — so it's invisible on
21
+ * the LAN. xterm.js still reaches it because `ws://localhost` is a secure
22
+ * context per W3C Secure Contexts spec, even from an https:// page.
23
+ */
24
+ export async function startServeServer(opts) {
25
+ const wss = new WebSocketServer({
26
+ port: opts.port,
27
+ host: '127.0.0.1',
28
+ verifyClient: (info, cb) => {
29
+ const origin = info.origin ?? '';
30
+ if (!opts.allowedOrigins.includes(origin)) {
31
+ cb(false, 403, 'origin not allowed');
32
+ return;
33
+ }
34
+ const url = new URL(info.req.url ?? '/', 'http://localhost');
35
+ if (url.searchParams.get('token') !== opts.token) {
36
+ cb(false, 401, 'invalid token');
37
+ return;
38
+ }
39
+ cb(true);
40
+ },
41
+ });
42
+ // Wait for the listening event so handle.port is final (random port if 0).
43
+ await new Promise((resolve, reject) => {
44
+ wss.once('listening', () => resolve());
45
+ wss.once('error', reject);
46
+ });
47
+ wss.on('connection', (ws) => {
48
+ if (opts.dryRun) {
49
+ // Tests use this branch — we just confirm the handshake passed.
50
+ ws.send('READY\n');
51
+ return;
52
+ }
53
+ let pty;
54
+ try {
55
+ // Lazy require — keeps `paris serve --help` working even if the native
56
+ // node-pty binary failed to compile/install on this platform.
57
+ const req = createRequire(import.meta.url);
58
+ const ptyMod = req('node-pty');
59
+ const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : 'bash');
60
+ const shellArgs = process.platform === 'win32' ? ['-NoLogo'] : ['-l', '-c', 'claude'];
61
+ pty = ptyMod.spawn(shell, shellArgs, {
62
+ name: 'xterm-color',
63
+ cols: 80,
64
+ rows: 24,
65
+ cwd: process.cwd(),
66
+ env: {
67
+ ...process.env,
68
+ TOSTUDY_MCP_ENABLED: '1',
69
+ PARIS_MCP_ENABLED: '1',
70
+ },
71
+ });
72
+ }
73
+ catch (err) {
74
+ const msg = err instanceof Error ? err.message : String(err);
75
+ ws.send(`ERROR: pty spawn failed: ${msg}\n`);
76
+ ws.close(1011, 'pty_unavailable');
77
+ return;
78
+ }
79
+ const p = pty;
80
+ p.onData((d) => {
81
+ try {
82
+ ws.send(d);
83
+ }
84
+ catch {
85
+ // ws may already be closed mid-write — pty.kill on close cleans up
86
+ }
87
+ });
88
+ if (p.onExit) {
89
+ p.onExit(() => {
90
+ try {
91
+ ws.close(1000, 'pty_exited');
92
+ }
93
+ catch {
94
+ /* noop */
95
+ }
96
+ });
97
+ }
98
+ ws.on('message', (m) => {
99
+ const s = m.toString();
100
+ if (s.startsWith('RESIZE:')) {
101
+ const [cols, rows] = s.slice('RESIZE:'.length).split(',').map(Number);
102
+ if (Number.isFinite(cols) && Number.isFinite(rows)) {
103
+ try {
104
+ p.resize(cols, rows);
105
+ }
106
+ catch {
107
+ /* ignore resize race */
108
+ }
109
+ }
110
+ return;
111
+ }
112
+ p.write(s);
113
+ });
114
+ ws.on('close', () => {
115
+ try {
116
+ p.kill();
117
+ }
118
+ catch {
119
+ /* noop */
120
+ }
121
+ });
122
+ });
123
+ const addr = wss.address();
124
+ const port = typeof addr === 'object' && addr ? addr.port : opts.port;
125
+ return {
126
+ port,
127
+ close: () => {
128
+ for (const client of wss.clients) {
129
+ try {
130
+ client.terminate();
131
+ }
132
+ catch {
133
+ /* noop */
134
+ }
135
+ }
136
+ wss.close();
137
+ },
138
+ };
139
+ }
140
+ /** Read the bearer token used by the web client to authenticate against `paris serve`. */
141
+ export async function readServeToken() {
142
+ const creds = await readCredentials().catch(() => null);
143
+ if (!creds)
144
+ return '';
145
+ return creds.token ?? '';
146
+ }
147
+ export async function serveCommand(opts) {
148
+ const token = await readServeToken();
149
+ if (!token) {
150
+ console.error(c.red('Não logado.') + ' Rode: ' + c.bold('paris login'));
151
+ process.exit(1);
152
+ }
153
+ const portNum = opts.port == null ? 31000 : Number(opts.port);
154
+ if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) {
155
+ console.error(c.red(`Porta inválida: ${opts.port}`));
156
+ process.exit(1);
157
+ }
158
+ const allowedOrigins = (process.env.PARIS_SERVE_ORIGINS?.split(',').map((s) => s.trim()).filter(Boolean)) ?? DEFAULT_ALLOWED_ORIGINS;
159
+ let handle;
160
+ try {
161
+ handle = await startServeServer({ port: portNum, allowedOrigins, token });
162
+ }
163
+ catch (err) {
164
+ const msg = err instanceof Error ? err.message : String(err);
165
+ console.error(c.red(`Falha ao iniciar paris serve: ${msg}`));
166
+ process.exit(1);
167
+ }
168
+ console.log();
169
+ console.log(c.bold('paris serve'));
170
+ console.log(` ${c.green('✓')} WebSocket em ${c.bold(`ws://localhost:${handle.port}`)}`);
171
+ console.log(` ${c.dim('origens permitidas:')} ${allowedOrigins.join(', ')}`);
172
+ console.log(` ${c.dim('Ctrl+C pra encerrar.')}`);
173
+ console.log();
174
+ const shutdown = () => {
175
+ handle.close();
176
+ process.exit(0);
177
+ };
178
+ process.on('SIGINT', shutdown);
179
+ process.on('SIGTERM', shutdown);
180
+ }
@@ -0,0 +1 @@
1
+ export declare function statusCommand(): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import { apiAuthFetch } from '../lib/api.js';
2
+ import { c } from '../lib/colors.js';
3
+ export async function statusCommand() {
4
+ const [workspace, current] = await Promise.all([
5
+ apiAuthFetch('/immersion-app/v1/workspace'),
6
+ apiAuthFetch('/immersion-app/v1/missions/current'),
7
+ ]);
8
+ console.log();
9
+ console.log(c.bold(`${workspace.immersion.name} (${workspace.immersion.slug})`));
10
+ console.log(` ${c.dim('status')}: ${workspace.status === 'active' ? c.green('active') : c.yellow('graduated')}`);
11
+ console.log(` ${c.dim('fase')}: ${workspace.phase} · dia ${workspace.day_number}`);
12
+ console.log(` ${c.dim('janela')}: ${workspace.immersion.starts_at.slice(0, 10)} → ${workspace.immersion.ends_at.slice(0, 10)}`);
13
+ if (workspace.graduated_at) {
14
+ console.log(` ${c.dim('graduado em')}: ${workspace.graduated_at.slice(0, 10)}`);
15
+ }
16
+ console.log();
17
+ if (current.current) {
18
+ console.log(c.bold('Tarefa em foco:'));
19
+ console.log(` ${c.cyan(current.current.title)} ${c.dim('(' + current.current.deliverable_type + ', ' + current.current.status + ')')}`);
20
+ console.log(` ${c.dim(current.current.id)}`);
21
+ }
22
+ else {
23
+ console.log(c.dim('Sem tarefa em foco.'));
24
+ }
25
+ }
@@ -0,0 +1 @@
1
+ export declare function tasksCommand(): Promise<void>;