diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index cc52f2a..6c0fc3c 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -11,6 +11,10 @@ export interface Config { /** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */ allowedOrigins: string[]; printToken: boolean; + /** racine effective de l'install Claude (~/.claude par défaut, --claude-home, ou réglage claude_home). */ + claudeHome: string; + /** true si --claude-home a été passé explicitement → a priorité sur le réglage claude_home. */ + claudeHomeFromFlag: boolean; /** ~/.claude/projects (transcripts JSONL) — surchargeable via --claude-home (tests). */ claudeProjectsDir: string; /** ~/.claude/sessions (registre des sessions CLI vivantes). */ @@ -61,7 +65,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config { } catch { /* FS sans permissions POSIX : ignoré, cf. avertissement dans db.openDb */ } - const claudeHome = values['claude-home'] ?? join(homedir(), '.claude'); + const claudeHomeFlag = values['claude-home']; + const claudeHome = claudeHomeFlag ?? join(homedir(), '.claude'); return { port: Number(values.port), bind, @@ -69,6 +74,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config { dataDir, allowedOrigins: values['allow-origin'] ?? [], printToken: values['print-token'] ?? false, + claudeHome, + claudeHomeFromFlag: claudeHomeFlag !== undefined, claudeProjectsDir: join(claudeHome, 'projects'), claudeSessionsDir: join(claudeHome, 'sessions'), vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost', diff --git a/packages/server/src/core/claude-launcher.ts b/packages/server/src/core/claude-launcher.ts index 7faa82e..807dc71 100644 --- a/packages/server/src/core/claude-launcher.ts +++ b/packages/server/src/core/claude-launcher.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process'; +import { accessSync, constants } from 'node:fs'; export interface SpawnSpec { file: string; @@ -12,22 +13,74 @@ export interface SpawnOptions { resume?: { claudeSessionId: string; fork?: boolean }; /** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir ` répété. */ addDirs?: string[]; + /** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via PATH (`which`). */ + claudeBinPath?: string | null; +} + +/** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */ +export interface ClaudeBinDiagnostic { + /** chemin résolu du binaire, ou null si introuvable. */ + path: string | null; + /** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */ + source: 'configured' | 'path' | null; + /** true si le binaire est présent et exécutable. */ + ok: boolean; } let cachedClaudeBin: string | null = null; -export function resolveClaudeBin(): string { - if (cachedClaudeBin) return cachedClaudeBin; +/** Recherche `claude` dans le PATH (sans throw). null si absent. */ +function findClaudeOnPath(): string | null { try { - cachedClaudeBin = execFileSync('which', ['claude'], { encoding: 'utf8' }).trim(); + return execFileSync('which', ['claude'], { encoding: 'utf8' }).trim() || null; } catch { + return null; + } +} + +function isExecutable(path: string): boolean { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Résout le binaire `claude`. Si `configuredPath` est fourni (réglage UI), il est utilisé tel quel + * — validé exécutable, message clair sinon — et JAMAIS mis en cache (modifiable à chaud). Sinon : + * `which claude`, mis en cache. Un service systemd/launchd démarre avec un PATH minimal sans + * ~/.local/bin → `which claude` y échoue ; d'où le réglage de chemin explicite (et le PATH figé par + * `arboretum install`). + */ +export function resolveClaudeBin(configuredPath?: string | null): string { + if (configuredPath) { + if (!isExecutable(configuredPath)) { + throw new Error(`Configured Claude CLI path is not executable: ${configuredPath}`); + } + return configuredPath; + } + if (cachedClaudeBin) return cachedClaudeBin; + const found = findClaudeOnPath(); + if (!found) { throw new Error( 'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart', ); } + cachedClaudeBin = found; return cachedClaudeBin; } +/** Diagnostic non-throwing pour l'UI (Réglages) : recalculé à chaque appel, jamais caché. */ +export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiagnostic { + if (configuredPath) { + return { path: configuredPath, source: 'configured', ok: isExecutable(configuredPath) }; + } + const found = findClaudeOnPath(); + return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false }; +} + /** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec { const env: NodeJS.ProcessEnv = { @@ -46,5 +99,5 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec { } // Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6). for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir); - return { file: resolveClaudeBin(), args, env }; + return { file: resolveClaudeBin(opts.claudeBinPath), args, env }; } diff --git a/packages/server/src/core/claude-settings.ts b/packages/server/src/core/claude-settings.ts new file mode 100644 index 0000000..2b271ba --- /dev/null +++ b/packages/server/src/core/claude-settings.ts @@ -0,0 +1,63 @@ +// Réglages liés au CLI Claude, persistés dans la table `settings` (clé/valeur), modifiables via +// l'onglet Réglages. Frontière de sécurité identique à scan-settings : clés NON sensibles, écrites +// uniquement via l'allow-list du PATCH /api/v1/settings et les validateurs ci-dessous. +// +// Motivation : un service systemd/launchd démarre avec un PATH minimal qui n'inclut pas ~/.local/bin +// (où vit le binaire `claude`) → `which claude` échoue. Plutôt que de bricoler le PATH du service, +// l'utilisateur peut pointer explicitement le binaire ici (effet : prochaine session, sans redémarrage). +import { accessSync, constants, statSync } from 'node:fs'; +import { getSetting } from '../db/index.js'; +import type { Db } from '../db/index.js'; +import { isSafeAbsolutePath } from './git.js'; + +/** Chemin explicite du binaire `claude` (override de la résolution via PATH). */ +export const CLAUDE_BIN_PATH_KEY = 'claude_bin_path'; +/** Override de la racine ~/.claude (transcripts + registre des sessions). */ +export const CLAUDE_HOME_KEY = 'claude_home'; + +/** Chemin configuré du binaire `claude`, ou null si non défini (⇒ auto-détection via PATH). */ +export function readClaudeBinPath(db: Db): string | null { + const raw = getSetting(db, CLAUDE_BIN_PATH_KEY)?.trim(); + return raw ? raw : null; +} + +/** Racine ~/.claude configurée, ou null si non défini (⇒ défaut/flag). */ +export function readClaudeHome(db: Db): string | null { + const raw = getSetting(db, CLAUDE_HOME_KEY)?.trim(); + return raw ? raw : null; +} + +/** + * Valide un chemin de binaire `claude`. Retourne `''` pour réinitialiser (auto-détection), le chemin + * normalisé s'il est absolu ET pointe vers un fichier exécutable, ou `null` si invalide (⇒ 400). + */ +export function normalizeClaudeBinPath(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const p = raw.trim(); + if (p === '') return ''; + if (!isSafeAbsolutePath(p)) return null; + try { + if (!statSync(p).isFile()) return null; + accessSync(p, constants.X_OK); + } catch { + return null; + } + return p; +} + +/** + * Valide une racine ~/.claude. Retourne `''` pour réinitialiser (défaut), le chemin normalisé s'il + * est absolu ET pointe vers un répertoire existant, ou `null` si invalide (⇒ 400). + */ +export function normalizeClaudeHome(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const p = raw.trim(); + if (p === '') return ''; + if (!isSafeAbsolutePath(p)) return null; + try { + if (!statSync(p).isDirectory()) return null; + } catch { + return null; + } + return p; +} diff --git a/packages/server/src/core/pty-manager.ts b/packages/server/src/core/pty-manager.ts index 203adee..4544ad2 100644 --- a/packages/server/src/core/pty-manager.ts +++ b/packages/server/src/core/pty-manager.ts @@ -7,6 +7,7 @@ import pty from '@homebridge/node-pty-prebuilt-multiarch'; import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared'; import { RingBuffer } from './ring-buffer.js'; import { buildSpawnSpec } from './claude-launcher.js'; +import { readClaudeBinPath } from './claude-settings.js'; import { findByPid } from './session-registry.js'; import { SessionActivityTracker } from './claude-adapter.js'; import type { PushService } from './push-service.js'; @@ -111,8 +112,11 @@ export class PtyManager extends EventEmitter { } // Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1). const command = opts.resume ? 'claude' : (opts.command ?? 'claude'); + // Override de chemin du binaire claude (réglage UI) lu à chaque spawn → effet sans redémarrage. + const claudeBinPath = command === 'claude' ? readClaudeBinPath(this.db) : null; const spec = buildSpawnSpec({ command, + ...(claudeBinPath ? { claudeBinPath } : {}), ...(opts.resume ? { resume: opts.resume } : {}), ...(addedDirs.length ? { addDirs: addedDirs } : {}), }); @@ -403,30 +407,35 @@ export class PtyManager extends EventEmitter { // ---- interne ---- /** - * Push P4-B : notifie sur le FRONT MONTANT vers `waiting` uniquement (le tracker réémet - * souvent le même état), avec un debounce annulable — un `waiting` ultra-bref (Claude répond - * tout seul) ne déclenche pas de notif. Cible tous les abonnements (un seul utilisateur). + * Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention — `waiting` + * (un dialogue bloque) ou `idle` atteint depuis `busy` (Claude a terminé sa réponse, la session + * redevient disponible). Le tracker réémet souvent le même état → on ne réagit qu'au changement. + * Debounce annulable : un état ultra-bref (Claude repart tout seul) ne déclenche pas de notif — + * à l'échéance on revérifie l'état réel. Cible tous les abonnements (un seul utilisateur). */ private maybeNotify(s: ManagedSession, next: SessionActivity | null): void { const prev = s.prevActivity; s.prevActivity = next; if (!this.push) return; - if (next === 'waiting' && prev !== 'waiting') { + const notifiable = + (next === 'waiting' && prev !== 'waiting') || (next === 'idle' && prev === 'busy'); + if (notifiable) { + const target = next; // 'waiting' | 'idle' if (s.notifyTimer) clearTimeout(s.notifyTimer); s.notifyTimer = setTimeout(() => { s.notifyTimer = null; const act = s.tracker?.snapshot(); - if (s.exited || act?.activity !== 'waiting') return; // faux positif : annulé - void this.push?.notify({ - sessionId: s.id, - title: basename(s.cwd) || s.cwd, - body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input', - kind: act.dialog?.kind ?? null, - url: `/sessions/${s.id}`, - }); + if (s.exited || act?.activity !== target) return; // faux positif : l'état a déjà changé + const base = { sessionId: s.id, title: basename(s.cwd) || s.cwd, url: `/sessions/${s.id}` }; + void this.push?.notify( + target === 'idle' + ? { ...base, body: 'available for new instructions', kind: null } + : { ...base, body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input', kind: act.dialog?.kind ?? null }, + ); }, NOTIFY_DEBOUNCE_MS); s.notifyTimer.unref(); - } else if (next !== 'waiting' && s.notifyTimer) { + } else if (s.notifyTimer && next !== 'waiting' && next !== 'idle') { + // On quitte un état notifiable avant l'échéance (ex. Claude repart en busy) → annule. clearTimeout(s.notifyTimer); s.notifyTimer = null; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2f1c6c3..7035d2f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -3,17 +3,33 @@ import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadConfig, type Config } from './config.js'; -import { openDb } from './db/index.js'; +import { openDb, type Db } from './db/index.js'; import { buildApp } from './app.js'; +import { readClaudeHome } from './core/claude-settings.js'; import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js'; const pkg = JSON.parse( readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'), ) as { version: string }; +/** + * Applique l'override `claude_home` (réglage UI) sur la config runtime, sauf si --claude-home a été + * passé explicitement (le flag gagne). Lu une seule fois au démarrage → un changement via l'UI prend + * effet au redémarrage du daemon (comme l'intervalle de scan). Mute `config`. + */ +function applyClaudeHomeOverride(config: Config, db: Db): void { + if (config.claudeHomeFromFlag) return; + const override = readClaudeHome(db); + if (!override) return; + config.claudeHome = override; + config.claudeProjectsDir = join(override, 'projects'); + config.claudeSessionsDir = join(override, 'sessions'); +} + /** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */ export async function runDaemon(config: Config): Promise { const db = openDb(config.dbPath); + applyClaudeHomeOverride(config, db); // override claude_home (réglage UI) avant de câbler les services const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version); const bootstrapToken = auth.ensureBootstrapToken(); diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index d6b75e6..66b42ee 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -7,6 +7,7 @@ import type { Config } from '../config.js'; import { type Db, setSetting } from '../db/index.js'; import type { PushService } from '../core/push-service.js'; import { recordAudit } from '../core/audit-log.js'; +import { diagnoseClaudeBin } from '../core/claude-launcher.js'; import { SCAN_INTERVAL_KEY, SCAN_ROOTS_KEY, @@ -15,6 +16,14 @@ import { readScanIntervalMin, readScanRoots, } from '../core/scan-settings.js'; +import { + CLAUDE_BIN_PATH_KEY, + CLAUDE_HOME_KEY, + normalizeClaudeBinPath, + normalizeClaudeHome, + readClaudeBinPath, + readClaudeHome, +} from '../core/claude-settings.js'; // Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici. export function registerSettingsRoutes( @@ -32,11 +41,16 @@ export function registerSettingsRoutes( dataDir: config.dataDir, vapidPublicKey: push.publicKey() || null, vapidContact: config.vapidContact, + claudeHome: config.claudeHome, + // Diagnostic recalculé à chaque GET : reflète l'état réel (override exécutable ? `which claude` ?). + claudeBin: diagnoseClaudeBin(readClaudeBinPath(db)), }); const snapshot = (): SettingsResponse => ({ settings: { scanRoots: readScanRoots(db), scanIntervalMin: readScanIntervalMin(db), + claudeBinPath: readClaudeBinPath(db), + claudeHome: readClaudeHome(db), }, server: serverInfo(), }); @@ -59,6 +73,20 @@ export function registerSettingsRoutes( } setSetting(db, SCAN_INTERVAL_KEY, String(interval)); } + if ('claudeBinPath' in body) { + const value = normalizeClaudeBinPath(body.claudeBinPath); + if (value === null) { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeBinPath must be an absolute path to an executable file (or "" to reset)' } }); + } + setSetting(db, CLAUDE_BIN_PATH_KEY, value); + } + if ('claudeHome' in body) { + const value = normalizeClaudeHome(body.claudeHome); + if (value === null) { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeHome must be an absolute path to an existing directory (or "" to reset)' } }); + } + setSetting(db, CLAUDE_HOME_KEY, value); + } recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'settings.update', diff --git a/packages/server/test/claude-launcher.test.ts b/packages/server/test/claude-launcher.test.ts index 797aeb5..11a5fc5 100644 --- a/packages/server/test/claude-launcher.test.ts +++ b/packages/server/test/claude-launcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildSpawnSpec } from '../src/core/claude-launcher.js'; +import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core/claude-launcher.js'; // resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH. vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); @@ -27,3 +27,28 @@ describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => { expect(spec.args).toEqual(['--norc']); }); }); + +describe('resolveClaudeBin / diagnoseClaudeBin — override de chemin (réglage UI)', () => { + // process.execPath (node) est un fichier exécutable réel garanti sur la machine de test. + const realBin = process.execPath; + + it('buildSpawnSpec utilise le chemin configuré tel quel quand fourni', () => { + const spec = buildSpawnSpec({ command: 'claude', claudeBinPath: realBin }); + expect(spec.file).toBe(realBin); + }); + + it('resolveClaudeBin retombe sur le PATH (which) sans override', () => { + expect(resolveClaudeBin()).toBe('/usr/bin/claude'); + expect(resolveClaudeBin(null)).toBe('/usr/bin/claude'); + }); + + it('resolveClaudeBin throw si le chemin configuré n’est pas exécutable', () => { + expect(() => resolveClaudeBin('/no/such/claude')).toThrow(/not executable/); + }); + + it('diagnoseClaudeBin : configuré+exécutable → ok ; configuré absent → !ok ; sinon PATH', () => { + expect(diagnoseClaudeBin(realBin)).toEqual({ path: realBin, source: 'configured', ok: true }); + expect(diagnoseClaudeBin('/no/such/claude')).toEqual({ path: '/no/such/claude', source: 'configured', ok: false }); + expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true }); + }); +}); diff --git a/packages/server/test/claude-settings.test.ts b/packages/server/test/claude-settings.test.ts new file mode 100644 index 0000000..4a8deb2 --- /dev/null +++ b/packages/server/test/claude-settings.test.ts @@ -0,0 +1,89 @@ +// Réglages CLI Claude (chemin du binaire + override ~/.claude) : validateurs + lecture/écriture DB. +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { openDb, setSetting, type Db } from '../src/db/index.js'; +import { + CLAUDE_BIN_PATH_KEY, + CLAUDE_HOME_KEY, + normalizeClaudeBinPath, + normalizeClaudeHome, + readClaudeBinPath, + readClaudeHome, +} from '../src/core/claude-settings.js'; + +let dir: string; +let execFile: string; +let plainFile: string; +let subDir: string; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'arboretum-claude-settings-')); + execFile = join(dir, 'claude'); + writeFileSync(execFile, '#!/bin/sh\n'); + chmodSync(execFile, 0o755); // exécutable + plainFile = join(dir, 'notexec'); + writeFileSync(plainFile, 'x'); + chmodSync(plainFile, 0o644); // non exécutable + subDir = join(dir, 'home'); + mkdirSync(subDir); +}); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe('normalizeClaudeBinPath', () => { + it('accepte un chemin absolu vers un fichier exécutable', () => { + expect(normalizeClaudeBinPath(execFile)).toBe(execFile); + }); + it('"" = réinitialisation (auto-détection)', () => { + expect(normalizeClaudeBinPath('')).toBe(''); + expect(normalizeClaudeBinPath(' ')).toBe(''); + }); + it('rejette un fichier non exécutable, un répertoire, un chemin relatif ou avec ".." (null)', () => { + expect(normalizeClaudeBinPath(plainFile)).toBeNull(); + expect(normalizeClaudeBinPath(subDir)).toBeNull(); // répertoire, pas un fichier + expect(normalizeClaudeBinPath('relative/claude')).toBeNull(); + expect(normalizeClaudeBinPath(join(dir, '..', 'x'))).toBeNull(); + expect(normalizeClaudeBinPath(join(dir, 'missing'))).toBeNull(); + expect(normalizeClaudeBinPath(42)).toBeNull(); + }); +}); + +describe('normalizeClaudeHome', () => { + it('accepte un chemin absolu vers un répertoire existant', () => { + expect(normalizeClaudeHome(subDir)).toBe(subDir); + }); + it('"" = réinitialisation (défaut)', () => { + expect(normalizeClaudeHome('')).toBe(''); + }); + it('rejette un fichier, un chemin relatif/inexistant (null)', () => { + expect(normalizeClaudeHome(execFile)).toBeNull(); // fichier, pas un répertoire + expect(normalizeClaudeHome('relative/.claude')).toBeNull(); + expect(normalizeClaudeHome(join(dir, 'missing'))).toBeNull(); + expect(normalizeClaudeHome(null)).toBeNull(); + }); +}); + +describe('readClaudeBinPath / readClaudeHome', () => { + let db: Db; + beforeAll(() => { + db = openDb(join(dir, 'settings.db')); + }); + afterAll(() => db.close()); + + it('null quand non défini', () => { + expect(readClaudeBinPath(db)).toBeNull(); + expect(readClaudeHome(db)).toBeNull(); + }); + it('lit la valeur stockée et traite "" stockée comme null', () => { + setSetting(db, CLAUDE_BIN_PATH_KEY, execFile); + setSetting(db, CLAUDE_HOME_KEY, subDir); + expect(readClaudeBinPath(db)).toBe(execFile); + expect(readClaudeHome(db)).toBe(subDir); + setSetting(db, CLAUDE_BIN_PATH_KEY, ''); // réinitialisé + setSetting(db, CLAUDE_HOME_KEY, ''); + expect(readClaudeBinPath(db)).toBeNull(); + expect(readClaudeHome(db)).toBeNull(); + }); +}); diff --git a/packages/server/test/pty-manager.test.ts b/packages/server/test/pty-manager.test.ts index 08b56ba..4336563 100644 --- a/packages/server/test/pty-manager.test.ts +++ b/packages/server/test/pty-manager.test.ts @@ -414,6 +414,44 @@ describe('PtyManager (pty mocké)', () => { rmSync(sessDir, { recursive: true, force: true }); } }); + + it('une notif sur le front montant busy→idle (Claude a terminé → disponible), après le debounce', async () => { + const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-idle-')); + const notifies: PushPayload[] = []; + const fakePush = { + notify: async (p: PushPayload) => { + notifies.push(p); + }, + } as unknown as PushService; + const m = new PtyManager(db, sessDir, fakePush); + try { + const summary = m.spawn({ cwd, command: 'claude' }); + const p = lastPty(); + const regFile = join(sessDir, `${p.pid}.json`); + const writeReg = (status: string): void => + writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status })); + + // busy d'abord : front montant vers busy, pas de notif + writeReg('busy'); + p.emitData('working…'); + await sleep(260); + expect(notifies).toHaveLength(0); + + // idle : Claude a terminé → front busy→idle → planifie la notif (debounce 1500ms) + writeReg('idle'); + p.emitData('\x1b[2J\x1b[HDone.\r\n'); + await sleep(260); + expect(notifies).toHaveLength(0); // pas encore : debounce en cours + + await sleep(1500); // dépasse le debounce + expect(notifies).toHaveLength(1); + expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: null, url: `/sessions/${summary.id}` }); + expect(notifies[0].body).toMatch(/available/i); + } finally { + m.shutdown(); + rmSync(sessDir, { recursive: true, force: true }); + } + }); }); describe('flow control', () => { diff --git a/packages/server/test/settings-routes.test.ts b/packages/server/test/settings-routes.test.ts index 3383066..a1d37bb 100644 --- a/packages/server/test/settings-routes.test.ts +++ b/packages/server/test/settings-routes.test.ts @@ -48,6 +48,8 @@ beforeAll(() => { dataDir: dir, allowedOrigins: ['https://host.tailnet.ts.net'], printToken: false, + claudeHome: join(dir, 'claude'), + claudeHomeFromFlag: false, claudeProjectsDir: join(dir, 'claude', 'projects'), claudeSessionsDir: join(dir, 'claude', 'sessions'), vapidContact: 'mailto:test@localhost', @@ -77,6 +79,11 @@ describe('GET /api/v1/settings', () => { // défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min expect(body.settings.scanRoots).toEqual([]); expect(body.settings.scanIntervalMin).toBe(5); + // Claude CLI : aucun override par défaut + diagnostic via PATH (execFileSync mocké → /usr/bin/claude). + expect(body.settings.claudeBinPath).toBeNull(); + expect(body.settings.claudeHome).toBeNull(); + expect(body.server.claudeHome).toBe(join(dir, 'claude')); + expect(body.server.claudeBin).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true }); }); it('n’expose AUCUN secret (server_secret, clé privée VAPID)', async () => { @@ -137,3 +144,46 @@ describe('PATCH /api/v1/settings — découverte des dépôts', () => { expect(neg.statusCode).toBe(400); }); }); + +describe('PATCH /api/v1/settings — Claude CLI', () => { + // process.execPath (node) est un fichier exécutable réel garanti sur la machine de test. + const realBin = process.execPath; + + it('enregistre un chemin de binaire exécutable et reflète le diagnostic (source=configured)', async () => { + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } }); + expect(res.statusCode).toBe(200); + const body = res.json() as SettingsResponse; + expect(body.settings.claudeBinPath).toBe(realBin); + expect(body.server.claudeBin).toEqual({ path: realBin, source: 'configured', ok: true }); + }); + + it('réinitialise le chemin avec "" (retour à l’auto-détection via PATH)', async () => { + await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } }); + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: '' } }); + const body = res.json() as SettingsResponse; + expect(body.settings.claudeBinPath).toBeNull(); + expect(body.server.claudeBin.source).toBe('path'); // de nouveau via PATH (mock → /usr/bin/claude) + }); + + it('rejette un chemin non absolu ou non exécutable (400)', async () => { + for (const claudeBinPath of ['relative/claude', '/a/../b', dir /* répertoire, pas un fichier */]) { + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath } }); + expect(res.statusCode).toBe(400); + } + }); + + it('enregistre un override claude_home (répertoire existant) et le réinitialise avec ""', async () => { + const set = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: dir } }); + expect(set.statusCode).toBe(200); + expect((set.json() as SettingsResponse).settings.claudeHome).toBe(dir); + const reset = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: '' } }); + expect((reset.json() as SettingsResponse).settings.claudeHome).toBeNull(); + }); + + it('rejette un claude_home non absolu ou inexistant/fichier (400)', async () => { + for (const claudeHome of ['relative/.claude', join(dir, 'settings.db') /* fichier */, join(dir, 'nope')]) { + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome } }); + expect(res.statusCode).toBe(400); + } + }); +}); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 48ba4a4..a335e96 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -296,6 +296,19 @@ export interface ServerInfo { /** clé publique VAPID (sûre à exposer) ; null si push indisponible. */ vapidPublicKey: string | null; vapidContact: string; + /** racine effective de l'install Claude (~/.claude, --claude-home, ou réglage claude_home). */ + claudeHome: string; + /** diagnostic de résolution du binaire `claude` (présence + exécutabilité). */ + claudeBin: ClaudeBinDiagnostic; +} +/** Diagnostic de résolution du CLI `claude` exposé en lecture dans les Réglages. */ +export interface ClaudeBinDiagnostic { + /** chemin résolu du binaire, ou null si introuvable. */ + path: string | null; + /** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */ + source: 'configured' | 'path' | null; + /** true si le binaire est présent et exécutable. */ + ok: boolean; } export interface SettingsResponse { /** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */ @@ -304,6 +317,10 @@ export interface SettingsResponse { scanRoots: string[]; /** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */ scanIntervalMin: number; + /** chemin explicite du binaire `claude` ; null = auto-détection via PATH. Effet : prochaine session. */ + claudeBinPath: string | null; + /** override de la racine ~/.claude ; null = défaut. Effet : au redémarrage du daemon. */ + claudeHome: string | null; }; server: ServerInfo; } @@ -312,6 +329,10 @@ export interface UpdateSettingsRequest { scanRoots?: string[]; /** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */ scanIntervalMin?: number; + /** chemin absolu du binaire `claude` (fichier exécutable) ; '' pour réinitialiser à l'auto-détection. */ + claudeBinPath?: string; + /** override de la racine ~/.claude (répertoire existant) ; '' pour réinitialiser au défaut. */ + claudeHome?: string; } // ---- Journal d'audit (conformité entreprise) ---- diff --git a/packages/vscode/src/views/tree-items.ts b/packages/vscode/src/views/tree-items.ts index 41d0b22..ab19f6e 100644 --- a/packages/vscode/src/views/tree-items.ts +++ b/packages/vscode/src/views/tree-items.ts @@ -69,7 +69,8 @@ function sessionIcon(s: SessionSummary): vscode.ThemeIcon { function sessionDescription(s: SessionSummary): string { const parts: string[] = []; - if (s.live && s.activity) parts.push(s.activity); + // Affiche 'available' pour idle (cohérent avec le libellé web) ; l'enum reste 'idle' côté protocole. + if (s.live && s.activity) parts.push(s.activity === 'idle' ? 'available' : s.activity); else if (!s.live) parts.push('exited'); if (s.source === 'discovered') parts.push('external'); if (s.groupId) parts.push('group'); diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 3b2d3e0..afaa97b 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -56,7 +56,7 @@ export default { }, registryStatus: { busy: 'busy', - idle: 'idle', + idle: 'available', waiting: 'waiting', }, dialog: { @@ -283,7 +283,7 @@ export default { live: 'Live', waiting: 'Waiting', busy: 'Busy', - idle: 'Idle', + idle: 'Available', exited: 'Exited', resumable: 'Resumable', }, @@ -413,6 +413,20 @@ export default { removeRoot: 'Remove', scanInterval: 'Re-scan interval (minutes)', scanIntervalHint: '0 disables periodic scanning. Changes apply on the next daemon restart.', + // Claude CLI + claudeCli: 'Claude CLI', + claudeCliHint: 'Where Arboretum finds the `claude` binary and its data. Handy when the daemon runs as a service with a minimal PATH that does not include ~/.local/bin.', + claudeBinStatusOk: 'Detected', + claudeBinStatusNotFound: 'Not found — sessions cannot start until this is resolved.', + claudeBinStatusBadPath: 'The configured path is not an executable file.', + claudeBinSourceConfigured: 'configured', + claudeBinSourcePath: 'PATH', + claudeBinPathLabel: 'Binary path override', + claudeBinPathHint: 'Absolute path to the `claude` executable. Leave empty to auto-detect via PATH. Takes effect on the next session.', + claudeBinPathPlaceholder: 'e.g. /home/you/.local/bin/claude', + claudeHomeLabel: 'Claude home directory', + claudeHomeHint: 'Override the ~/.claude location (transcripts & session registry). Applies on the next daemon restart.', + claudeHomePlaceholder: 'e.g. /home/you/.claude', // Serveur (lecture seule) server: 'Server', serverHint: 'Set at startup via CLI flags — changing them requires restarting the daemon.', diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index 3e7b58e..6fc3e7f 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -58,7 +58,7 @@ const fr: typeof en = { }, registryStatus: { busy: 'occupée', - idle: 'inactive', + idle: 'disponible', waiting: 'en attente', }, dialog: { @@ -286,7 +286,7 @@ const fr: typeof en = { live: 'Active', waiting: 'En attente', busy: 'Occupée', - idle: 'Au repos', + idle: 'Disponible', exited: 'Terminée', resumable: 'Reprenable', }, @@ -416,6 +416,20 @@ const fr: typeof en = { removeRoot: 'Retirer', scanInterval: 'Intervalle de re-scan (minutes)', scanIntervalHint: '0 désactive le scan périodique. Les changements prennent effet au prochain redémarrage du daemon.', + // Claude CLI + claudeCli: 'CLI Claude', + claudeCliHint: 'Où Arboretum trouve le binaire `claude` et ses données. Utile quand le daemon tourne en service avec un PATH minimal qui n’inclut pas ~/.local/bin.', + claudeBinStatusOk: 'Détecté', + claudeBinStatusNotFound: 'Introuvable — impossible de lancer une session tant que ce n’est pas résolu.', + claudeBinStatusBadPath: 'Le chemin configuré n’est pas un fichier exécutable.', + claudeBinSourceConfigured: 'configuré', + claudeBinSourcePath: 'PATH', + claudeBinPathLabel: 'Chemin du binaire (override)', + claudeBinPathHint: 'Chemin absolu vers l’exécutable `claude`. Laisser vide pour l’auto-détection via le PATH. Prend effet à la prochaine session.', + claudeBinPathPlaceholder: 'ex. /home/vous/.local/bin/claude', + claudeHomeLabel: 'Répertoire ~/.claude', + claudeHomeHint: 'Surcharge l’emplacement de ~/.claude (transcripts & registre des sessions). Prend effet au redémarrage du daemon.', + claudeHomePlaceholder: 'ex. /home/vous/.claude', // Serveur (lecture seule) server: 'Serveur', serverHint: 'Définis au démarrage via des flags CLI — les modifier nécessite de redémarrer le daemon.', diff --git a/packages/web/src/stores/settings.ts b/packages/web/src/stores/settings.ts index 5654e1b..beee8df 100644 --- a/packages/web/src/stores/settings.ts +++ b/packages/web/src/stores/settings.ts @@ -9,6 +9,8 @@ export const useSettingsStore = defineStore('settings', () => { const server = ref(null); const scanRoots = ref([]); const scanIntervalMin = ref(0); + const claudeBinPath = ref(null); + const claudeHome = ref(null); const loaded = ref(false); const saving = ref(false); @@ -16,6 +18,8 @@ export const useSettingsStore = defineStore('settings', () => { server.value = res.server; scanRoots.value = res.settings.scanRoots; scanIntervalMin.value = res.settings.scanIntervalMin; + claudeBinPath.value = res.settings.claudeBinPath; + claudeHome.value = res.settings.claudeHome; loaded.value = true; } @@ -32,5 +36,5 @@ export const useSettingsStore = defineStore('settings', () => { } } - return { server, scanRoots, scanIntervalMin, loaded, saving, fetch, save }; + return { server, scanRoots, scanIntervalMin, claudeBinPath, claudeHome, loaded, saving, fetch, save }; }); diff --git a/packages/web/src/views/SettingsView.vue b/packages/web/src/views/SettingsView.vue index d093936..6a9fabc 100644 --- a/packages/web/src/views/SettingsView.vue +++ b/packages/web/src/views/SettingsView.vue @@ -137,6 +137,44 @@ + +
+

+ {{ t('settings.claudeCli') }} +

+

{{ t('settings.claudeCliHint') }}

+ + +
+ + + {{ claudeBin.ok ? t('settings.claudeBinStatusOk') : (claudeBin.source === 'configured' ? t('settings.claudeBinStatusBadPath') : t('settings.claudeBinStatusNotFound')) }} + + {{ claudeBin.path }} + + {{ claudeBin.source === 'configured' ? t('settings.claudeBinSourceConfigured') : t('settings.claudeBinSourcePath') }} + +
+ + + + + +
+ + {{ t('settings.save') }} + +
+
+

@@ -202,7 +240,7 @@