/**
 * claude-cli provider — wraps the locally-installed `claude` binary as an
 * AI SDK v3 LanguageModel. Preserves the Max-subscription zero-cost path
 * by spawning `claude --print` with the user's authenticated session.
 *
 * Session caching: first call uses `--session-id <uuid>`, subsequent calls
 * `--resume <uuid>` — gives the same 98% token savings the Claude Code
 * session mechanism provides.
 *
 * If the user has run `spv skill install --global`, the SpecVerse skill is
 * auto-loaded by Claude Code into every session started by this provider.
 * See detectInstalledSkill() in ../skill-detection.ts.
 */
import { spawn, execSync } from 'child_process';
import { existsSync } from 'fs';
import { randomUUID } from 'crypto';
import { join } from 'path';
/**
 * True when an error message indicates a recoverable session-state failure
 * from claude-cli:
 *   - "Session ID already in use" — externally-managed sessionId collided
 *     with a still-live invocation (concurrent harness runs are the typical
 *     trigger).
 *   - "Session not found" / similar — `--resume` of a non-existent session,
 *     usually when our `initialized` flag is stale (the underlying
 *     claude-cli process exited and forgot the session, but the flag still
 *     says "resume me").
 *
 * Exported for unit testing and so harness code can apply the same predicate
 * if it wants to wrap calls at a higher level.
 */
export function isSessionError(message) {
    return /already in use|session.+not found|no.+session/i.test(message);
}
/**
 * Engines 6.30.5+. Detect responses that look like meta-conversation
 * rather than actual generation: short replies where the model thinks
 * it's between calls in a session (e.g. "Ready for the next microcall.",
 * "Awaiting the next action.", "What's next?"). Surfaces under
 * concurrent dispatch when claude-cli's `--resume` feeds stale
 * conversation state to the model and the model replies to THAT instead
 * of the actual prompt.
 *
 * Heuristic: trimmed response under 250 chars AND no fenced code block
 * AND matches one of the known meta-conversation patterns. Conservative
 * — must hit BOTH the length gate AND the pattern. False positives
 * would only trigger an unnecessary fresh-session retry, not a wrong
 * answer.
 *
 * Exported for testing.
 */
export function isMetaConversationResponse(text) {
    const trimmed = text.trim();
    if (trimmed.length === 0)
        return false; // empty handled separately
    if (trimmed.length > 250)
        return false; // long responses are real attempts
    if (/```/.test(trimmed))
        return false; // any fenced block = real attempt
    // Meta-conversational patterns the model emits when it thinks it's
    // between calls. These are short acknowledgements, not yaml emissions.
    return /\b(ready|awaiting|waiting|standing\s*by|let\s*me\s*know|continue|what'?s\s*next|next\s+(action|microcall|call|task|prompt))\b/i.test(trimmed);
}
/**
 * Find the first working `claude` binary. Tries:
 *   1. Explicit path override if given
 *   2. ~/.claude/local/claude (Claude Code's user install)
 *   3. `claude` on PATH (Homebrew / manual installs)
 * Returns null if none respond to `--version`.
 */
export function detectClaudePath(override) {
    const home = process.env.HOME || '';
    const candidates = override
        ? [override]
        : [join(home, '.claude', 'local', 'claude'), 'claude'];
    for (const candidate of candidates) {
        // For explicit paths, require existence on disk before exec.
        if (candidate !== 'claude' && !existsSync(candidate))
            continue;
        try {
            execSync(`${candidate} --version`, { stdio: 'ignore', timeout: 5000 });
            return candidate;
        }
        catch {
            /* try next */
        }
    }
    return null;
}
/** True if any candidate claude binary responds to --version. */
export function isClaudeCliAvailable(override) {
    return detectClaudePath(override) !== null;
}
/**
 * Extract a flat { system, user } pair from the AI SDK's message array.
 * The claude CLI's --system-prompt / -p flags only accept one string each.
 * For now we concatenate any system messages, and join all user turns with
 * blank lines. (In practice behavior generation is single-turn; this
 * provider isn't targeting multi-turn conversational use.)
 */
function flattenPrompt(messages) {
    const systemChunks = [];
    const userChunks = [];
    for (const msg of messages) {
        if (msg.role === 'system') {
            systemChunks.push(msg.content);
        }
        else if (msg.role === 'user') {
            for (const part of msg.content) {
                if (part.type === 'text')
                    userChunks.push(part.text);
            }
        }
        else if (msg.role === 'assistant') {
            // Prior assistant turns get included into the user prompt as context
            // so the CLI can see them. Unusual for our use case.
            const text = msg.content
                .filter((p) => p.type === 'text')
                .map((p) => p.text)
                .join('');
            if (text)
                userChunks.push(`[Assistant previously said]\n${text}`);
        }
    }
    return {
        system: systemChunks.join('\n\n').trim(),
        user: userChunks.join('\n\n').trim(),
    };
}
/**
 * Factory for a claude-cli-backed LanguageModelV3. Session state is held
 * inside the closure, so each call to claudeCli() gets its own session.
 */
export function claudeCli(options = {}) {
    const claudePath = options.claudePath || detectClaudePath() || 'claude';
    // Behavior gen calls are short; analyse calls can take a couple of minutes
    // on larger codebases. 5 minutes is a sane upper bound for either.
    const timeout = options.timeout ?? 300_000;
    const modelId = options.model ?? 'default';
    // Use externally-managed sessionId when provided so multiple processes
    // can share claude-cli's session state (Phase 5 of TODO #21).
    let sessionId = options.sessionId || randomUUID();
    let initialized = false;
    async function spawnClaude(args, stdinPrompt) {
        return new Promise((resolve, reject) => {
            const proc = spawn(claudePath, args, {
                stdio: ['ignore', 'pipe', 'pipe'],
                env: { ...process.env },
            });
            let out = '';
            let err = '';
            const timer = setTimeout(() => {
                proc.kill();
                reject(new Error(`claude-cli timed out after ${timeout}ms`));
            }, timeout);
            proc.stdout?.on('data', (d) => {
                out += d.toString();
            });
            proc.stderr?.on('data', (d) => {
                err += d.toString();
            });
            proc.on('close', (code) => {
                clearTimeout(timer);
                if (code !== 0) {
                    reject(new Error(`claude-cli exited ${code}: ${err || out}`));
                    return;
                }
                resolve(out);
            });
            proc.on('error', (e) => {
                clearTimeout(timer);
                reject(e);
            });
        });
    }
    const model = {
        specificationVersion: 'v3',
        provider: 'claude-cli',
        modelId,
        supportedUrls: {},
        async doGenerate(opts) {
            const { system, user } = flattenPrompt(opts.prompt);
            // Build args for either a fresh session or a resume of an existing one.
            //
            // `--allowedTools=Read` lets the LLM read skill reference files
            // (e.g. ~/.claude/skills/realize-emit/reference/constraints.md)
            // without prompting for permission. Required for skill-loaded
            // prompts; harmless when no skill is installed (no Read calls
            // are made). The `=` syntax avoids claude-cli's positional-arg
            // parsing confusion that consumes the prompt as the tool value.
            const buildArgs = (sid, asResume) => {
                const a = ['--print', '--allowedTools=Read'];
                if (asResume) {
                    a.push('--resume', sid);
                }
                else {
                    a.push('--session-id', sid);
                    if (system)
                        a.push('--system-prompt', system);
                }
                if (modelId !== 'default')
                    a.push('--model', modelId);
                a.push('-p', user);
                return a;
            };
            // claude-cli rejects two related session-failure modes that we'd rather
            // recover from than propagate as opaque errors. See `isSessionError`
            // below for the patterns we recognise. Either way we recover by minting
            // a fresh UUID and treating the next call as a first call. We retry
            // ONCE; subsequent failures propagate.
            //
            // Engines 6.30.4+: also retry once when stdout is empty after exit-0
            // (no thrown error). This pattern surfaces under cap=5 concurrent
            // dispatch — when a slot finishes a long-running call (60-80s) and
            // the next call dispatches against the closure's session state, the
            // shared `sessionId` / `initialized` may be stale, and claude-cli
            // returns empty stdout instead of erroring. Fresh-session retry
            // recovers cleanly. See project_analyse-capacity-drains.md memory
            // entry on the concurrency-fragmentation finding (2026-05-06).
            let out;
            try {
                out = await spawnClaude(buildArgs(sessionId, initialized), user);
                if (out.trim().length === 0 || isMetaConversationResponse(out)) {
                    // Session-stale signal: either empty stdout OR a short meta-
                    // conversational reply (e.g. "Ready for the next microcall.")
                    // — claude-cli's `--resume` fed the model stale conversation
                    // state and the model replied to that instead of the actual
                    // prompt. Fresh session + retry recovers (engines 6.30.4 for
                    // empty case; 6.30.5 extended to meta-conversation case).
                    sessionId = randomUUID();
                    initialized = false;
                    out = await spawnClaude(buildArgs(sessionId, false), user);
                }
            }
            catch (e) {
                if (!isSessionError(e?.message ?? ''))
                    throw e;
                sessionId = randomUUID();
                initialized = false;
                out = await spawnClaude(buildArgs(sessionId, false), user);
            }
            initialized = true;
            return {
                content: [{ type: 'text', text: out.trim() }],
                finishReason: { unified: 'stop', raw: undefined },
                usage: {
                    // The CLI doesn't expose token counts in --print mode.
                    inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
                    outputTokens: { total: undefined, text: undefined, reasoning: undefined },
                },
                warnings: [],
            };
        },
        async doStream(opts) {
            // engines 6.66.0+: real streaming via claude-cli's stream-json mode.
            // Spawns with --output-format stream-json --verbose --include-partial-messages;
            // parses each line as JSON and extracts text deltas in real time;
            // emits text-delta stream parts as they arrive (no full-buffer).
            // Powers app-demo's AI pane real-time UX (refactor 2026-05-16).
            //
            // Trade-off vs doGenerate: NO empty/meta-conversation retry logic.
            // Session-stale recovery only fires in doGenerate (where post-spawn
            // detection is possible). For streaming consumers, an empty first
            // attempt surfaces as zero deltas; consumer can decide what to do.
            // Acceptable for app-demo (single-user, sequential — concurrency
            // fragmentation that motivated the retry isn't a concern).
            const { system, user } = flattenPrompt(opts.prompt);
            // Build args — same shape as doGenerate, plus stream-json flags
            const buildStreamArgs = (sid, asResume) => {
                const a = [
                    '--print',
                    '--output-format', 'stream-json',
                    '--verbose',
                    '--include-partial-messages',
                    '--allowedTools=Read',
                ];
                if (asResume) {
                    a.push('--resume', sid);
                }
                else {
                    a.push('--session-id', sid);
                    if (system)
                        a.push('--system-prompt', system);
                }
                if (modelId !== 'default')
                    a.push('--model', modelId);
                a.push('-p', user);
                return a;
            };
            const args = buildStreamArgs(sessionId, initialized);
            // Mark as initialized so any subsequent doStream / doGenerate call
            // resumes the session (matches doGenerate's contract).
            initialized = true;
            // Build the ReadableStream that the AI SDK consumes. We spawn the
            // child + push parts into the stream as data arrives.
            const stream = new ReadableStream({
                start(controller) {
                    let textStarted = false;
                    let lineBuffer = '';
                    let watchdog = null;
                    const proc = spawn(claudePath, args, {
                        stdio: ['ignore', 'pipe', 'pipe'],
                        env: { ...process.env },
                    });
                    // Honor caller's abort signal — kill the child + close stream.
                    const onAbort = () => {
                        if (!proc.killed)
                            proc.kill('SIGTERM');
                        controller.error(new Error('claude-cli aborted'));
                    };
                    if (opts.abortSignal) {
                        if (opts.abortSignal.aborted) {
                            onAbort();
                            return;
                        }
                        opts.abortSignal.addEventListener('abort', onAbort, { once: true });
                    }
                    watchdog = setTimeout(() => {
                        if (!proc.killed)
                            proc.kill('SIGTERM');
                        controller.error(new Error(`claude-cli streaming timed out after ${timeout}ms`));
                    }, timeout);
                    const emitTextDelta = (delta) => {
                        if (!delta)
                            return;
                        if (!textStarted) {
                            controller.enqueue({ type: 'text-start', id: 'single' });
                            textStarted = true;
                        }
                        controller.enqueue({ type: 'text-delta', id: 'single', delta });
                    };
                    proc.stdout?.on('data', (d) => {
                        lineBuffer += d.toString();
                        const lines = lineBuffer.split('\n');
                        lineBuffer = lines.pop() || '';
                        for (const line of lines) {
                            if (!line.trim())
                                continue;
                            const delta = extractTextFromStreamJsonLine(line);
                            if (delta)
                                emitTextDelta(delta);
                        }
                    });
                    let stderrBuf = '';
                    proc.stderr?.on('data', (d) => {
                        stderrBuf += d.toString();
                    });
                    proc.on('close', (code) => {
                        if (watchdog)
                            clearTimeout(watchdog);
                        // Flush the residual line buffer.
                        if (lineBuffer.trim()) {
                            const delta = extractTextFromStreamJsonLine(lineBuffer);
                            if (delta)
                                emitTextDelta(delta);
                        }
                        if (code !== 0) {
                            controller.error(new Error(`claude-cli exited ${code}: ${stderrBuf || '(no stderr)'}`));
                            return;
                        }
                        if (textStarted) {
                            controller.enqueue({ type: 'text-end', id: 'single' });
                        }
                        controller.enqueue({
                            type: 'finish',
                            finishReason: { unified: 'stop', raw: undefined },
                            usage: {
                                // claude-cli stream-json doesn't expose usage today
                                inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
                                outputTokens: { total: undefined, text: undefined, reasoning: undefined },
                            },
                        });
                        controller.close();
                    });
                    proc.on('error', (e) => {
                        if (watchdog)
                            clearTimeout(watchdog);
                        controller.error(e);
                    });
                },
            });
            return { stream };
        },
    };
    return model;
}
/**
 * Parse a single stream-json line from claude-cli's --output-format
 * stream-json mode. Returns the extracted text delta or null when the
 * line doesn't contain user-visible text (system messages, init blocks,
 * malformed JSON, etc.).
 *
 * Handles the three message shapes claude-cli emits:
 *   - `assistant` messages with content blocks (full content snapshot)
 *   - `content_block_delta` (token-level deltas — primary streaming path)
 *   - `stream_event` wrapper with delta.type=text_delta
 *
 * Exported for unit testing.
 */
export function extractTextFromStreamJsonLine(line) {
    let msg;
    try {
        msg = JSON.parse(line);
    }
    catch {
        return null;
    }
    // Token-level deltas — primary streaming source.
    if (msg.type === 'content_block_delta' && msg.delta?.text) {
        return msg.delta.text;
    }
    if (msg.type === 'stream_event' && msg.event?.delta?.type === 'text_delta') {
        return msg.event.delta.text;
    }
    // Full content snapshot (claude-cli sometimes emits a complete assistant
    // message rather than incremental deltas — typically for short responses).
    // Avoid emitting both deltas AND snapshots for the same content; for now
    // we take snapshots only when no content_block_delta came through. The
    // accumulator in doGenerate normalizes either way; for doStream we accept
    // some over-emission in the snapshot edge case rather than complex
    // de-duplication. Real-world: claude-cli streams via deltas, snapshots
    // are rare.
    if (msg.type === 'assistant' && msg.message?.content) {
        const texts = msg.message.content
            .filter((b) => b.type === 'text' && b.text)
            .map((b) => b.text);
        return texts.length > 0 ? texts.join('') : null;
    }
    return null;
}
//# sourceMappingURL=claude-cli.js.map