Files
nucleic/Sources/nucleic-spike/Spike.swift
T

275 lines
12 KiB
Swift

import Foundation
import NucleicCore
/// M0 spike (PLAN milestone 1): spawn `claude` in stream-json, normalize into
/// `AgentEvent`s, print to console, write the canonical JSONL transcript, and
/// optionally capture the raw native stream side-by-side as a fixture
/// (OBSERVABILITY A.5 — capture format == fixture format).
@main
struct Spike {
struct Options {
var prompt: String?
var cwd = FileManager.default.currentDirectoryPath
var model: String?
var resume: String?
var executable = "claude"
var includePartialMessages = false
var capture = false
var keepStdin = false
var approvalMode: ApprovalMode = .interactive
var allowedTools = ["Read", "Glob", "Grep"]
var outputDirectory: String?
}
enum ApprovalMode: String {
case interactive
case autoAllow = "auto-allow"
case autoDeny = "auto-deny"
}
static func main() async {
let options: Options
do {
options = try parse(Array(CommandLine.arguments.dropFirst()))
} catch let error as SpikeError {
FileHandle.standardError.write(Data((error.message + "\n\n" + usage).utf8))
exit(64)
} catch {
FileHandle.standardError.write(Data(usage.utf8))
exit(64)
}
guard options.prompt != nil || options.resume != nil else {
FileHandle.standardError.write(
Data(("--prompt (or --resume) is required\n\n" + usage).utf8))
exit(64)
}
let sessionID = SessionID.generate()
let outputBase = options.outputDirectory.map { URL(fileURLWithPath: $0) }
?? URL(fileURLWithPath: options.cwd)
.appendingPathComponent(".nucleic-spike/sessions/\(sessionID.rawValue)")
let configuration = ClaudeCodeBackend.Configuration(
executable: options.executable,
includePartialMessages: options.includePartialMessages,
allowedTools: options.allowedTools,
closeStdinAfterPrompt: !options.keepStdin,
captureDirectory: options.capture ? outputBase.appendingPathComponent("capture") : nil)
let backend = ClaudeCodeBackend(configuration: configuration)
let header = SessionHeader(
sessionID: sessionID,
backend: .claudeCode,
worktree: options.cwd,
model: options.model,
createdAt: Date())
let transcriptURL = outputBase.appendingPathComponent("transcript.jsonl")
let writer: TranscriptWriter
do {
writer = try TranscriptWriter(url: transcriptURL, header: header)
} catch {
fail("Cannot create transcript at \(transcriptURL.path): \(error)")
}
print("session \(sessionID.short) cwd \(options.cwd)")
print("transcript \(transcriptURL.path)")
if options.capture {
print("capture \(outputBase.appendingPathComponent("capture").path)")
}
print(String(repeating: "─", count: 72))
let stream: AsyncThrowingStream<AgentEvent, Error>
if let resume = options.resume {
stream = backend.resume(
ResumeSpec(
sessionID: sessionID, backendSessionID: resume, worktree: options.cwd,
prompt: options.prompt.map { AgentInput(text: $0) }))
} else {
stream = backend.start(
RunSpec(
sessionID: sessionID,
worktree: options.cwd,
prompt: AgentInput(text: options.prompt ?? ""),
model: options.model))
}
var normalized: [AgentEvent] = []
var outcome: RunFinished.Outcome = .errored
do {
for try await event in stream {
let canonical = try await writer.append(event)
normalized.append(canonical)
render(canonical)
if case .approvalRequested(let request) = event.kind {
let decision = decide(request, mode: options.approvalMode)
try await backend.respond(to: request.id, decision, by: "spike")
}
if case .runFinished(let finished) = event.kind {
outcome = finished.outcome
}
}
} catch {
fail("stream error: \(error)")
}
try? await writer.sync()
try? await writer.close()
if options.capture {
let normalizedURL = outputBase.appendingPathComponent("capture/normalized.json")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes]
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(normalized) {
try? data.write(to: normalizedURL)
}
}
print(String(repeating: "─", count: 72))
print("outcome: \(outcome.rawValue) events: \(normalized.count)")
exit(outcome == .completed ? 0 : 2)
}
// MARK: - Approval handling
static func decide(_ request: ApprovalRequest, mode: ApprovalMode) -> Decision {
switch mode {
case .autoAllow:
print(" ⚖️ auto-allow: \(request.title)")
return .allow()
case .autoDeny:
print(" ⚖️ auto-deny: \(request.title)")
return .deny(reason: "Denied by spike policy")
case .interactive:
print(" ⚖️ APPROVAL [\(request.risk.rawValue)] \(request.title)")
print(" input: \(request.input.canonicalString().prefix(200))")
while true {
print(" [a]llow / [d]eny / [A]lways this tool / [c]ancel run > ", terminator: "")
switch readLine(strippingNewline: true) {
case "a": return .allow()
case "d": return .deny(reason: "Denied by user")
case "A": return .allowAlways(.toolName)
case "c": return .cancelRun
case nil: return .deny(reason: "No TTY to answer approval")
default: continue
}
}
}
}
// MARK: - Rendering
static func render(_ event: AgentEvent) {
let prefix = String(format: "%4d", event.seq)
switch event.kind {
case .sessionStarted(let started):
print("\(prefix) ▶ session started backend-id=\(started.backendSessionID) model=\(started.model)")
case .userText(let chunk):
print("\(prefix) 🙋 \(chunk.text)")
case .assistantText(let chunk):
print("\(prefix) \(chunk.isPartial ? "┆" : "💬") \(chunk.text)")
case .thinking(let chunk):
print("\(prefix) 🧠 \(chunk.isPartial ? "┆ " : "")\(chunk.text)")
case .toolCallStarted(let call):
print("\(prefix) 🔧 \(call.name) started id=\(call.toolCallID)")
case .toolCallInputDelta:
break // too noisy for the console; lands in the transcript regardless
case .toolCallCompleted(let call):
print("\(prefix) 🔧 \(call.name) \(call.input.canonicalString().prefix(120))")
case .toolResult(let result):
let summary = result.content.canonicalString().prefix(120)
print("\(prefix) \(result.isError ? "❌" : "✅") result \(summary)")
case .fileChange(let change):
print("\(prefix) 📄 \(change.kind.rawValue) \(change.path) (inferred)")
case .approvalRequested(let request):
print("\(prefix) ⏸ approval requested: \(request.title)")
case .approvalResolved(let resolved):
print("\(prefix) ▶ approval resolved by \(resolved.decidedBy)")
case .usage(let usage):
let cost = usage.costUSD.map { String(format: "$%.4f", $0) } ?? "n/a"
print("\(prefix) 📊 in=\(usage.inputTokens ?? 0) cacheRead=\(usage.cachedInputTokens ?? 0) cacheWrite=\(usage.cacheCreationInputTokens ?? 0) out=\(usage.outputTokens ?? 0) cost=\(cost)")
case .rateLimit(let rl):
let resets = rl.resetsAt.map { " resets=\($0)" } ?? ""
print("\(prefix) ⏳ rate-limit \(rl.rateLimitType ?? "?") status=\(rl.status ?? "?")\(resets)")
case .turnCompleted:
print("\(prefix) ── turn completed")
case .runFinished(let finished):
print("\(prefix) ■ run finished: \(finished.outcome.rawValue) (\(finished.durationMs ?? 0)ms)")
case .error(let error):
print("\(prefix) ⚠️ \(error.recoverable ? "recoverable" : "fatal"): \(error.message)")
case .note(let note):
print("\(prefix) 📝 \(note.text)")
case .raw(let raw):
print("\(prefix) ❓ raw[\(event.nativeType ?? "?")] \(raw.native.canonicalString().prefix(120))")
}
}
// MARK: - Arguments
struct SpikeError: Error {
let message: String
}
static func parse(_ arguments: [String]) throws -> Options {
var options = Options()
var iterator = arguments.makeIterator()
func value(for flag: String) throws -> String {
guard let v = iterator.next() else { throw SpikeError(message: "missing value for \(flag)") }
return v
}
while let argument = iterator.next() {
switch argument {
case "--prompt", "-p": options.prompt = try value(for: argument)
case "--cwd", "-C": options.cwd = try value(for: argument)
case "--model", "-m": options.model = try value(for: argument)
case "--resume": options.resume = try value(for: argument)
case "--claude": options.executable = try value(for: argument)
case "--partial": options.includePartialMessages = true
case "--capture": options.capture = true
case "--keep-stdin": options.keepStdin = true
case "--out": options.outputDirectory = try value(for: argument)
case "--approvals":
let raw = try value(for: argument)
guard let mode = ApprovalMode(rawValue: raw) else {
throw SpikeError(message: "unknown approval mode \(raw)")
}
options.approvalMode = mode
case "--allowed-tools":
options.allowedTools = try value(for: argument)
.split(separator: ",").map(String.init)
case "--help", "-h":
print(usage)
exit(0)
default:
throw SpikeError(message: "unknown argument \(argument)")
}
}
return options
}
static let usage = """
nucleic-spike — M0: run one Claude Code session through the normalized event pipeline
USAGE: nucleic-spike --prompt "..." [options]
OPTIONS:
--prompt, -p <text> Initial prompt (required unless --resume)
--cwd, -C <path> Working directory for the agent (default: .)
--model, -m <id> Model override
--resume <session-id> Native resume of a previous backend session
--partial Enable --include-partial-messages (token deltas)
--capture Record native.ndjson + normalized.json fixtures
--approvals <mode> interactive | auto-allow | auto-deny (default: interactive)
--allowed-tools <list> Comma-separated pre-allowed tools (default: Read,Glob,Grep)
--out <dir> Output directory (default: ./.nucleic-spike/sessions/<id>)
--claude <path> Claude executable override (default: claude on PATH)
"""
static func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data((message + "\n").utf8))
exit(2)
}
}