paris-immersion 0.1.9 → 0.1.11

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,6 +1,7 @@
1
1
  import { WebSocketServer } from 'ws';
2
2
  import { createRequire } from 'node:module';
3
3
  import { promises as fs, chmodSync, existsSync, statSync, readdirSync, readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { readCredentials } from '../lib/credentials.js';
6
7
  import { ensureWorkspace } from './install.js';
@@ -82,32 +83,50 @@ export function resolveKickoff(cwd) {
82
83
  const override = process.env.PARIS_COACH_KICKOFF;
83
84
  if (override)
84
85
  return override;
85
- try {
86
- const cmdDir = path.join(cwd, '.claude', 'commands');
87
- const files = readdirSync(cmdDir).filter((f) => /^tostudy.*\.md$/i.test(f));
88
- if (files.length === 0)
89
- return '/tostudy';
90
- let chosen = files.sort()[0];
91
- if (files.length > 1) {
92
- try {
93
- const ws = JSON.parse(readFileSync(path.join(cwd, '.tostudy', 'workspace.json'), 'utf8'));
94
- const slug = ws.slug?.toLowerCase();
95
- if (slug) {
96
- const match = files.find((f) => f.toLowerCase() === `tostudy-${slug}.md`) ??
97
- files.find((f) => f.toLowerCase().includes(slug));
98
- if (match)
99
- chosen = match;
100
- }
101
- }
102
- catch {
103
- /* sem workspace.json legível — usa o primeiro */
86
+ // O Claude Code carrega slash-commands de DOIS lugares: o do projeto
87
+ // (workspace) e o global do usuário (~/.claude/commands). O `@tostudy-ai/cli`
88
+ // instala o comando do tutor em qualquer um deles (na prática, no global) —
89
+ // então varremos os DOIS. Sem isto, numa máquina onde o tostudy escreve só no
90
+ // global, o kickoff não acharia o comando e cairia em `/tostudy` (Unknown).
91
+ const dirs = [path.join(cwd, '.claude', 'commands'), path.join(homedir(), '.claude', 'commands')];
92
+ const names = new Set();
93
+ for (const d of dirs) {
94
+ try {
95
+ for (const f of readdirSync(d)) {
96
+ if (/^tostudy.*\.md$/i.test(f))
97
+ names.add(f.replace(/\.md$/i, ''));
104
98
  }
105
99
  }
106
- return '/' + chosen.replace(/\.md$/i, '');
100
+ catch {
101
+ /* diretório não existe — ok */
102
+ }
107
103
  }
108
- catch {
104
+ if (names.size === 0)
109
105
  return '/tostudy';
106
+ const all = [...names];
107
+ // Curso ATIVO deste workspace = fonte da verdade pra escolher entre vários cursos.
108
+ let slug = '';
109
+ try {
110
+ const ws = JSON.parse(readFileSync(path.join(cwd, '.tostudy', 'workspace.json'), 'utf8'));
111
+ slug = (ws.slug ?? '').toLowerCase();
112
+ }
113
+ catch {
114
+ /* sem workspace.json — segue sem slug */
115
+ }
116
+ if (slug) {
117
+ const exact = all.find((n) => n.toLowerCase() === `tostudy-${slug}`);
118
+ if (exact)
119
+ return '/' + exact;
120
+ const partial = all.find((n) => n.toLowerCase().includes(slug));
121
+ if (partial)
122
+ return '/' + partial;
110
123
  }
124
+ // Sem match por slug: prefere um comando ESPECÍFICO de curso (`tostudy-<algo>`)
125
+ // ao genérico `tostudy`. Se só houver o genérico, usa `/tostudy`.
126
+ const courseSpecific = all.filter((n) => /^tostudy-.+/i.test(n)).sort();
127
+ if (courseSpecific.length)
128
+ return '/' + courseSpecific[0];
129
+ return '/tostudy';
111
130
  }
112
131
  /**
113
132
  * Spawn the coached pty (`claude '/tostudy-<curso>'` inside the provisioned
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { daemonCommand } from './commands/daemon.js';
13
13
  const program = new Command()
14
14
  .name('paris')
15
15
  .description('Paris Immersion — CLI do aluno (login, workspace, tarefas)')
16
- .version('0.1.9');
16
+ .version('0.1.11');
17
17
  program
18
18
  .command('login')
19
19
  .description('Autenticar com sua conta Paris (device-code OR --token direto)')
@@ -16,6 +16,7 @@ export function renderHookScript() {
16
16
  import { promises as fs } from 'node:fs'
17
17
  import { homedir } from 'node:os'
18
18
  import path from 'node:path'
19
+ import { execFile } from 'node:child_process'
19
20
 
20
21
  const PHASE = process.argv[2] // session-start | user-prompt-submit | stop
21
22
 
@@ -58,6 +59,33 @@ async function apiPost(apiUrl, pathname, token, body) {
58
59
  return res.json().catch(() => null)
59
60
  }
60
61
 
62
+ function runCmd(cmd, args, cwd) {
63
+ return new Promise((resolve) => {
64
+ try {
65
+ execFile(cmd, args, { cwd, timeout: 8000, windowsHide: true }, (err, stdout) => {
66
+ resolve(err ? null : stdout)
67
+ })
68
+ } catch { resolve(null) }
69
+ })
70
+ }
71
+
72
+ // Sincroniza o progresso REAL do curso da ToStudy pro nosso lado. O aluno faz o
73
+ // curso pela tostudy CLI no terminal embutido, e essa conclusão NÃO dispara o
74
+ // webhook do cursos — então é daqui que a gestão aprende o progresso. A ToStudy
75
+ // só expõe % e contagem concluída; o total do curso é derivado no servidor.
76
+ // Best-effort: se a tostudy não estiver logada / sem curso ativo, vira no-op.
77
+ async function syncTostudyProgress(apiUrl, token, cwd) {
78
+ const out = await runCmd('tostudy', ['progress', '--json'], cwd)
79
+ if (!out) return
80
+ let p
81
+ try { p = JSON.parse(out) } catch { return }
82
+ if (!p || typeof p.completedLessons !== 'number') return
83
+ await apiPost(apiUrl, '/immersion-app/v1/course/tostudy-progress', token, {
84
+ completedLessons: p.completedLessons,
85
+ coursePercent: typeof p.coursePercent === 'number' ? p.coursePercent : 0,
86
+ })
87
+ }
88
+
61
89
  async function main() {
62
90
  const marker = await loadMarker()
63
91
  if (!marker) return // not a paris workspace — silent no-op
@@ -73,6 +101,9 @@ async function main() {
73
101
 
74
102
  if (PHASE === 'stop') {
75
103
  await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'turn_ended' })
104
+ // Logo após cada interação é o melhor momento pra capturar uma lição que o
105
+ // aluno acabou de concluir no curso da ToStudy.
106
+ await syncTostudyProgress(apiUrl, creds.token, marker.dir)
76
107
  return
77
108
  }
78
109
 
@@ -141,6 +172,12 @@ async function main() {
141
172
  if (PHASE === 'session-start') {
142
173
  await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'session_started' })
143
174
  }
175
+
176
+ // Na fase do curso, sincroniza o progresso da ToStudy também ao abrir/retomar
177
+ // a sessão (não só no stop), pra gestão refletir mesmo entre interações.
178
+ if (inCourse) {
179
+ await syncTostudyProgress(apiUrl, creds.token, marker.dir)
180
+ }
144
181
  }
145
182
 
146
183
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paris-immersion",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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": {