From f202f60d02df3acbf1c7fa9aa27dea5c05f688cd Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 16:11:36 +0800 Subject: [PATCH 01/15] Add post-script agent orchestration and memory persistence --- Sources/ScriptoriaApp/AppState.swift | 216 +++++++-- .../Views/Main/AddScriptSheet.swift | 19 + .../Views/Main/ScriptDetailView.swift | 138 +++++- Sources/ScriptoriaCLI/CLI.swift | 1 + .../ScriptoriaCLI/Commands/AddCommand.swift | 14 + .../Commands/ConfigCommand.swift | 2 +- .../ScriptoriaCLI/Commands/ListCommand.swift | 4 + .../Commands/MemoryCommand.swift | 79 ++++ .../ScriptoriaCLI/Commands/RunCommand.swift | 179 +++++++- .../Execution/CodexAppServerClient.swift | 410 ++++++++++++++++++ .../Execution/MemoryManager.swift | 309 +++++++++++++ .../Execution/PostScriptAgentRunner.swift | 284 ++++++++++++ Sources/ScriptoriaCore/Models/AgentRun.swift | 64 +++ Sources/ScriptoriaCore/Models/Script.swift | 9 + .../Models/ScriptAgentProfile.swift | 28 ++ .../Scheduling/LaunchdHelper.swift | 20 +- Sources/ScriptoriaCore/Storage/Config.swift | 46 +- .../Storage/DatabaseManager.swift | 267 ++++++++++++ .../ScriptoriaCore/Storage/ScriptStore.swift | 59 ++- 19 files changed, 2109 insertions(+), 39 deletions(-) create mode 100644 Sources/ScriptoriaCLI/Commands/MemoryCommand.swift create mode 100644 Sources/ScriptoriaCore/Execution/CodexAppServerClient.swift create mode 100644 Sources/ScriptoriaCore/Execution/MemoryManager.swift create mode 100644 Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift create mode 100644 Sources/ScriptoriaCore/Models/AgentRun.swift create mode 100644 Sources/ScriptoriaCore/Models/ScriptAgentProfile.swift diff --git a/Sources/ScriptoriaApp/AppState.swift b/Sources/ScriptoriaApp/AppState.swift index f573a52..6bc4549 100644 --- a/Sources/ScriptoriaApp/AppState.swift +++ b/Sources/ScriptoriaApp/AppState.swift @@ -11,9 +11,12 @@ final class AppState: ObservableObject { @Published var selectedTag: String = "__all__" @Published var isRunning: Bool = false @Published var runningScriptIds: Set = [] + @Published var runningAgentScriptIds: Set = [] @Published var currentOutput: String = "" @Published var currentOutputScriptId: UUID? @Published var currentRunId: UUID? + @Published var currentAgentRunId: UUID? + @Published var latestWorkspaceMemoryPath: String? @Published var config: Config @Published var needsOnboarding: Bool @@ -21,7 +24,9 @@ final class AppState: ObservableObject { private var scheduleStore: ScheduleStore private let runner = ScriptRunner() private var logManager: LogManager + private var memoryManager: MemoryManager private var logWatcherSource: DispatchSourceFileSystemObject? + private var activeAgentSessions: [UUID: PostScriptAgentSession] = [:] var filteredScripts: [Script] { var result = scripts @@ -62,6 +67,7 @@ final class AppState: ObservableObject { self.store = ScriptStore(config: loadedConfig) self.scheduleStore = ScheduleStore(config: loadedConfig) self.logManager = LogManager(config: loadedConfig) + self.memoryManager = MemoryManager(config: loadedConfig) // First launch: no pointer file exists yet let pointerPath = FileManager.default.homeDirectoryForCurrentUser.path + "/.scriptoria/pointer.json" self.needsOnboarding = !FileManager.default.fileExists(atPath: pointerPath) @@ -177,7 +183,9 @@ final class AppState: ObservableObject { await updateScript(updated) } - func runScript(_ script: Script) async { + func runScript(_ script: Script, modelOverride: String? = nil) async { + guard !runningAgentScriptIds.contains(script.id) else { return } + // Duplicate prevention: check if already running if let existingRun = try? store.fetchRunningRun(scriptId: script.id), let pid = existingRun.pid, @@ -193,7 +201,7 @@ final class AppState: ObservableObject { } runningScriptIds.insert(script.id) - isRunning = true + updateRunningState() currentOutput = "" currentOutputScriptId = script.id @@ -202,11 +210,16 @@ final class AppState: ObservableObject { currentRunId = runId var runRecord = ScriptRun(id: runId, scriptId: script.id, scriptTitle: script.title) try? await store.saveRunHistory(runRecord) + let initialRunRecord = runRecord let result = try? await runner.runStreaming(script, runId: runId, logManager: logManager, onStart: { [weak self] pid in - runRecord.pid = pid - try? self?.store.updateRunHistorySync(runRecord) - }) { [weak self] text, isStderr in + guard let self else { return } + var updated = initialRunRecord + updated.pid = pid + Task { @MainActor in + try? self.store.updateRunHistorySync(updated) + } + }) { [weak self] text, _ in Task { @MainActor in guard let self else { return } self.currentOutput += text @@ -214,29 +227,32 @@ final class AppState: ObservableObject { } runningScriptIds.remove(script.id) - isRunning = !runningScriptIds.isEmpty - - if let result { - // Update the run record with final result - runRecord.output = result.output - runRecord.errorOutput = result.errorOutput - runRecord.exitCode = result.exitCode - runRecord.finishedAt = result.finishedAt - runRecord.status = result.status - runRecord.pid = result.pid - try? await store.updateRunHistory(runRecord) - - currentOutput = result.output - if !result.errorOutput.isEmpty { - currentOutput += "\n--- STDERR ---\n" + result.errorOutput - } - try? await store.recordRun(id: script.id, status: result.status) - scripts = store.all() + updateRunningState() + + guard let result else { return } + + // Update the run record with final result + runRecord.output = result.output + runRecord.errorOutput = result.errorOutput + runRecord.exitCode = result.exitCode + runRecord.finishedAt = result.finishedAt + runRecord.status = result.status + runRecord.pid = result.pid + try? await store.updateRunHistory(runRecord) + + currentOutput = result.output + if !result.errorOutput.isEmpty { + currentOutput += "\n--- STDERR ---\n" + result.errorOutput + } + try? await store.recordRun(id: script.id, status: result.status) + scripts = store.all() - if config.notifyOnCompletion { - await NotificationManager.shared.notifyRunComplete(result) - } + if config.notifyOnCompletion { + await NotificationManager.shared.notifyRunComplete(result) } + + // Post-script agent stage + await runAgentStage(script: script, scriptRun: runRecord, modelOverride: modelOverride) } func stopScript(_ scriptId: UUID) { @@ -245,8 +261,151 @@ final class AppState: ObservableObject { ProcessManager.isRunning(pid: pid) { _ = ProcessManager.terminate(pid: pid) } + + if let session = activeAgentSessions[scriptId] { + Task { + try? await session.interrupt() + } + } } + func steerAgent(scriptId: UUID, input: String) async { + guard let session = activeAgentSessions[scriptId] else { return } + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + do { + try await session.steer(trimmed) + } catch { + currentOutput += "\n[steer-error] \(error.localizedDescription)\n" + } + } + + func summarizeWorkspaceMemory(for script: Script) async -> String? { + let taskName = script.agentTaskName.isEmpty ? script.title : script.agentTaskName + do { + let path = try memoryManager.summarizeWorkspaceMemory( + taskId: script.agentTaskId, + taskName: taskName + ) + latestWorkspaceMemoryPath = path + return path + } catch { + currentOutput += "\n[workspace-memory-error] \(error.localizedDescription)\n" + return nil + } + } + + // MARK: - Agent Stage + + private func runAgentStage( + script: Script, + scriptRun: ScriptRun, + modelOverride: String? + ) async { + let taskName = script.agentTaskName.isEmpty ? script.title : script.agentTaskName + let selectedModel = resolveModel(script: script, override: modelOverride) + let workspaceMemory = memoryManager.readWorkspaceMemory(taskId: script.agentTaskId, taskName: taskName) + let skillContent = readFileIfExists(path: script.skill) + let developerInstructions = PostScriptAgentRunner.buildDeveloperInstructions( + skillContent: clippedText(skillContent, max: 40_000), + workspaceMemory: clippedText(workspaceMemory, max: 40_000) + ) + + let prompt = PostScriptAgentRunner.buildInitialPrompt( + taskName: taskName, + script: script, + scriptRun: scriptRun + ) + let workingDirectory = URL(fileURLWithPath: script.path).deletingLastPathComponent().path + + currentOutput += "\n\n=== Agent Stage (\(selectedModel)) ===\n" + runningAgentScriptIds.insert(script.id) + updateRunningState() + + do { + let session = try await PostScriptAgentRunner.launch( + options: PostScriptAgentLaunchOptions( + workingDirectory: workingDirectory, + model: selectedModel, + userPrompt: prompt, + developerInstructions: developerInstructions + ), + onEvent: { [weak self] event in + Task { @MainActor in + guard let self else { return } + self.currentOutput += event.text + } + } + ) + activeAgentSessions[script.id] = session + + var agentRun = AgentRun( + scriptId: script.id, + scriptRunId: scriptRun.id, + taskId: script.agentTaskId, + taskName: taskName, + model: selectedModel, + threadId: await session.threadId, + turnId: await session.turnId + ) + currentAgentRunId = agentRun.id + try? await store.saveAgentRun(agentRun) + + let agentResult = try await session.waitForCompletion() + activeAgentSessions.removeValue(forKey: script.id) + + agentRun.threadId = agentResult.threadId + agentRun.turnId = agentResult.turnId + agentRun.finishedAt = agentResult.finishedAt + agentRun.status = agentResult.status + agentRun.finalMessage = agentResult.finalMessage + agentRun.output = agentResult.output + let taskMemoryPath = try memoryManager.writeTaskMemory( + taskId: script.agentTaskId, + taskName: taskName, + script: script, + scriptRun: scriptRun, + agentResult: agentResult + ) + agentRun.taskMemoryPath = taskMemoryPath + try? await store.updateAgentRun(agentRun) + + currentOutput += "\n\n=== Agent \(agentResult.status.rawValue) ===\n" + currentOutput += "Task Memory: \(taskMemoryPath)\n" + } catch { + currentOutput += "\n[agent-error] \(error.localizedDescription)\n" + } + + activeAgentSessions.removeValue(forKey: script.id) + runningAgentScriptIds.remove(script.id) + updateRunningState() + } + + private func resolveModel(script: Script, override: String?) -> String { + if let override { + let value = override.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return value + } + } + let defaultModel = script.defaultModel.trimmingCharacters(in: .whitespacesAndNewlines) + return defaultModel.isEmpty ? "gpt-5.3-codex" : defaultModel + } + + private func readFileIfExists(path: String) -> String? { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return try? String(contentsOfFile: trimmed, encoding: .utf8) + } + + private func clippedText(_ text: String?, max: Int) -> String? { + guard let text else { return nil } + if text.count <= max { return text } + return String(text.prefix(max)) + "\n\n[truncated]" + } + + // MARK: - Logs / History + /// Watch a log file for changes and update currentOutput private func startLogWatcher(for runId: UUID) { stopLogWatcher() @@ -298,6 +457,11 @@ final class AppState: ObservableObject { store = ScriptStore(config: newConfig) scheduleStore = ScheduleStore(config: newConfig) logManager = LogManager(config: newConfig) + memoryManager = MemoryManager(config: newConfig) await loadScripts() } + + private func updateRunningState() { + isRunning = !runningScriptIds.isEmpty || !runningAgentScriptIds.isEmpty + } } diff --git a/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift b/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift index d585934..a4e9965 100644 --- a/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift +++ b/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift @@ -10,6 +10,8 @@ struct AddScriptSheet: View { @State private var description = "" @State private var path = "" @State private var skill = "" + @State private var taskName = "" + @State private var defaultModel = "" @State private var interpreter: Interpreter = .auto @State private var tagsInput = "" @State private var isFavorite = false @@ -42,6 +44,9 @@ struct AddScriptSheet: View { path = url.path if title.isEmpty { title = url.deletingPathExtension().lastPathComponent + if taskName.isEmpty { + taskName = title + } } } } @@ -59,6 +64,9 @@ struct AddScriptSheet: View { } } + TextField("Task Name (for memory namespace)", text: $taskName) + TextField("Default Model (optional)", text: $defaultModel) + Picker("Interpreter", selection: $interpreter) { ForEach(Interpreter.allCases, id: \.self) { interp in Text(interp.rawValue).tag(interp) @@ -102,6 +110,8 @@ struct AddScriptSheet: View { description: description, path: resolvedPath, skill: resolvedSkill, + agentTaskName: taskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? title : taskName.trimmingCharacters(in: .whitespacesAndNewlines), + defaultModel: defaultModel.trimmingCharacters(in: .whitespacesAndNewlines), interpreter: interpreter, tags: tags, isFavorite: isFavorite @@ -129,6 +139,8 @@ struct EditScriptSheet: View { @State private var description: String @State private var path: String @State private var skill: String + @State private var taskName: String + @State private var defaultModel: String @State private var interpreter: Interpreter @State private var tagsInput: String @State private var isFavorite: Bool @@ -140,6 +152,8 @@ struct EditScriptSheet: View { self._description = State(initialValue: script.description) self._path = State(initialValue: script.path) self._skill = State(initialValue: script.skill) + self._taskName = State(initialValue: script.agentTaskName) + self._defaultModel = State(initialValue: script.defaultModel) self._interpreter = State(initialValue: script.interpreter) self._tagsInput = State(initialValue: script.tags.joined(separator: ", ")) self._isFavorite = State(initialValue: script.isFavorite) @@ -186,6 +200,9 @@ struct EditScriptSheet: View { } } + TextField("Task Name (for memory namespace)", text: $taskName) + TextField("Default Model (optional)", text: $defaultModel) + Picker("Interpreter", selection: $interpreter) { ForEach(Interpreter.allCases, id: \.self) { interp in Text(interp.rawValue).tag(interp) @@ -220,6 +237,8 @@ struct EditScriptSheet: View { updated.description = description updated.path = path updated.skill = resolvedSkill + updated.agentTaskName = taskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? title : taskName.trimmingCharacters(in: .whitespacesAndNewlines) + updated.defaultModel = defaultModel.trimmingCharacters(in: .whitespacesAndNewlines) updated.interpreter = interpreter updated.tags = tags updated.isFavorite = isFavorite diff --git a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift index 0337987..a138282 100644 --- a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift +++ b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift @@ -6,15 +6,23 @@ struct ScriptDetailView: View { let script: Script @EnvironmentObject var appState: AppState @State private var showEditSheet = false + @State private var showRunSheet = false + @State private var runModelOverride = "" @State private var runHistory: [ScriptRun] = [] @State private var selectedRun: ScriptRun? @State private var isAddingTag = false @State private var newTagText = "" + @State private var steerInput = "" @State private var averageDuration: TimeInterval? + @State private var isSummarizingMemory = false @Environment(\.colorScheme) var colorScheme var isRunning: Bool { - appState.runningScriptIds.contains(script.id) + appState.runningScriptIds.contains(script.id) || appState.runningAgentScriptIds.contains(script.id) + } + + var isAgentRunning: Bool { + appState.runningAgentScriptIds.contains(script.id) } var body: some View { @@ -33,6 +41,7 @@ struct ScriptDetailView: View { .onChange(of: script.id) { _, _ in loadHistory() selectedRun = nil + runModelOverride = script.defaultModel Task { await appState.reloadSchedules() } } .onChange(of: appState.currentOutput) { _, _ in @@ -40,6 +49,7 @@ struct ScriptDetailView: View { } .onAppear { loadHistory() + runModelOverride = script.defaultModel Task { await appState.reloadSchedules() } } .toolbar { @@ -68,7 +78,8 @@ struct ScriptDetailView: View { .help("Stop script") } else { Button { - Task { await appState.runScript(script) } + runModelOverride = script.defaultModel + showRunSheet = true } label: { Image(systemName: "play.fill") .contentTransition(.symbolEffect(.replace)) @@ -81,6 +92,18 @@ struct ScriptDetailView: View { EditScriptSheet(script: script, isPresented: $showEditSheet) .environmentObject(appState) } + .sheet(isPresented: $showRunSheet) { + RunWithModelSheet( + scriptTitle: script.title, + defaultModel: script.defaultModel, + modelOverride: $runModelOverride, + isPresented: $showRunSheet + ) { model in + Task { + await appState.runScript(script, modelOverride: model) + } + } + } } // MARK: - Header @@ -125,7 +148,8 @@ struct ScriptDetailView: View { .buttonStyle(RunButtonStyle(isRunning: isRunning)) } else { Button { - Task { await appState.runScript(script) } + runModelOverride = script.defaultModel + showRunSheet = true } label: { HStack(spacing: 6) { Image(systemName: "play.fill") @@ -268,6 +292,47 @@ struct ScriptDetailView: View { .padding(.horizontal, 20) .padding(.bottom, 16) } + + HStack(spacing: 8) { + Image(systemName: "target") + .font(.caption) + .foregroundStyle(.tertiary) + if let taskId = script.agentTaskId { + Text("[\(taskId)] \(script.agentTaskName)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else { + Text(script.agentTaskName) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + if !script.defaultModel.isEmpty { + Text("ยท \(script.defaultModel)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + Spacer() + Button { + isSummarizingMemory = true + Task { + let _ = await appState.summarizeWorkspaceMemory(for: script) + isSummarizingMemory = false + } + } label: { + if isSummarizingMemory { + ProgressView() + .scaleEffect(0.7) + } else { + Label("Summarize Workspace", systemImage: "doc.text.magnifyingglass") + .font(.caption) + } + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Summarize task memories into workspace memory") + } + .padding(.horizontal, 20) + .padding(.bottom, 16) } } @@ -301,6 +366,22 @@ struct ScriptDetailView: View { .padding(.horizontal, 20) .padding(.top, 16) + if isAgentRunning { + HStack(spacing: 8) { + TextField("Guide the running agent...", text: $steerInput) + .textFieldStyle(.roundedBorder) + .onSubmit { sendSteer() } + Button("Send") { + sendSteer() + } + .disabled(steerInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Interrupt") { + appState.stopScript(script.id) + } + } + .padding(.horizontal, 20) + } + ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { Text(appState.currentOutput) @@ -441,6 +522,15 @@ struct ScriptDetailView: View { Task { await appState.updateScript(updated) } } + private func sendSteer() { + let text = steerInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + Task { + await appState.steerAgent(scriptId: script.id, input: text) + } + steerInput = "" + } + private func commitNewTag() { let tag = newTagText.trimmingCharacters(in: .whitespaces) guard !tag.isEmpty, !script.tags.contains(tag) else { @@ -518,6 +608,48 @@ struct ScriptDetailView: View { } } +struct RunWithModelSheet: View { + let scriptTitle: String + let defaultModel: String + @Binding var modelOverride: String + @Binding var isPresented: Bool + let onRun: (String?) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Run Script") + .font(.headline) + Text(scriptTitle) + .font(.subheadline) + .foregroundStyle(.secondary) + + TextField("Model (leave blank to use default)", text: $modelOverride) + .textFieldStyle(.roundedBorder) + + if !defaultModel.isEmpty { + Text("Default: \(defaultModel)") + .font(.caption) + .foregroundStyle(.tertiary) + } + + HStack { + Spacer() + Button("Cancel") { + isPresented = false + } + Button("Run") { + let value = modelOverride.trimmingCharacters(in: .whitespacesAndNewlines) + onRun(value.isEmpty ? nil : value) + isPresented = false + } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 420) + } +} + // MARK: - Stat Card struct StatCard: View { diff --git a/Sources/ScriptoriaCLI/CLI.swift b/Sources/ScriptoriaCLI/CLI.swift index ddf7b17..ddcc4af 100644 --- a/Sources/ScriptoriaCLI/CLI.swift +++ b/Sources/ScriptoriaCLI/CLI.swift @@ -16,6 +16,7 @@ struct ScriptoriaCLI: AsyncParsableCommand { TagsCommand.self, ScheduleCommand.self, ConfigCommand.self, + MemoryCommand.self, PsCommand.self, LogsCommand.self, KillCommand.self, diff --git a/Sources/ScriptoriaCLI/Commands/AddCommand.swift b/Sources/ScriptoriaCLI/Commands/AddCommand.swift index 35c9a5a..1fe8a68 100644 --- a/Sources/ScriptoriaCLI/Commands/AddCommand.swift +++ b/Sources/ScriptoriaCLI/Commands/AddCommand.swift @@ -26,6 +26,12 @@ struct AddCommand: AsyncParsableCommand { @Option(name: .long, help: "Path to a skill file for AI agents") var skill: String? + @Option(name: .long, help: "Task name for post-script agent runs and memory") + var taskName: String? + + @Option(name: .long, help: "Default model for post-script agent runs") + var defaultModel: String? + func run() async throws { let store = ScriptStore.fromConfig() try await store.load() @@ -58,12 +64,16 @@ struct AddCommand: AsyncParsableCommand { // Generate title from filename if not provided let scriptTitle = title ?? URL(fileURLWithPath: resolvedPath).deletingPathExtension().lastPathComponent + let resolvedTaskName = taskName?.trimmingCharacters(in: .whitespacesAndNewlines) + let finalTaskName = (resolvedTaskName?.isEmpty == false) ? resolvedTaskName! : scriptTitle let script = Script( title: scriptTitle, description: description, path: resolvedPath, skill: resolvedSkill, + agentTaskName: finalTaskName, + defaultModel: defaultModel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", interpreter: interp, tags: tagList ) @@ -75,6 +85,10 @@ struct AddCommand: AsyncParsableCommand { if !resolvedSkill.isEmpty { print(" Skill: \(resolvedSkill)") } + print(" Task: \(script.agentTaskName)") + if !script.defaultModel.isEmpty { + print(" Default Model: \(script.defaultModel)") + } if !tagList.isEmpty { print(" Tags: \(tagList.joined(separator: ", "))") } diff --git a/Sources/ScriptoriaCLI/Commands/ConfigCommand.swift b/Sources/ScriptoriaCLI/Commands/ConfigCommand.swift index 138a796..7b11da9 100644 --- a/Sources/ScriptoriaCLI/Commands/ConfigCommand.swift +++ b/Sources/ScriptoriaCLI/Commands/ConfigCommand.swift @@ -26,6 +26,7 @@ struct ShowConfig: AsyncParsableCommand { print(" Data directory: \(config.dataDirectory)") print(" Database: \(config.dbDirectory)/scriptoria.db") print(" Scripts: \(config.scriptsDirectory)/") + print(" Memory: \(config.memoryDirectory)/") print(" Notify on finish: \(config.notifyOnCompletion)") print(" Running indicator: \(config.showRunningIndicator)") print() @@ -65,4 +66,3 @@ struct SetDataDir: AsyncParsableCommand { print("โœ… Data directory set to: \(resolvedPath)") } } - diff --git a/Sources/ScriptoriaCLI/Commands/ListCommand.swift b/Sources/ScriptoriaCLI/Commands/ListCommand.swift index 188300c..16aad57 100644 --- a/Sources/ScriptoriaCLI/Commands/ListCommand.swift +++ b/Sources/ScriptoriaCLI/Commands/ListCommand.swift @@ -63,6 +63,10 @@ struct ListCommand: AsyncParsableCommand { if !script.skill.isEmpty { print(" ๐Ÿค– Skill: \(script.skill)") } + if let taskId = script.agentTaskId { + let modelPart = script.defaultModel.isEmpty ? "" : " ยท model: \(script.defaultModel)" + print(" ๐Ÿง  Task: [\(taskId)] \(script.agentTaskName)\(modelPart)") + } let shortId = String(script.id.uuidString.prefix(8)) let tags = script.tags.isEmpty ? "" : " [\(script.tags.joined(separator: ", "))]" diff --git a/Sources/ScriptoriaCLI/Commands/MemoryCommand.swift b/Sources/ScriptoriaCLI/Commands/MemoryCommand.swift new file mode 100644 index 0000000..a0a6509 --- /dev/null +++ b/Sources/ScriptoriaCLI/Commands/MemoryCommand.swift @@ -0,0 +1,79 @@ +import ArgumentParser +import Foundation +import ScriptoriaCore + +struct MemoryCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "memory", + abstract: "Manage task/workspace memory", + subcommands: [ + MemorySummarize.self + ], + defaultSubcommand: MemorySummarize.self + ) +} + +struct MemorySummarize: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "summarize", + abstract: "Summarize task memories into workspace memory" + ) + + @Argument(help: "Script title or UUID") + var identifier: String? + + @Option(name: .long, help: "Script UUID") + var id: String? + + @Option(name: .long, help: "Task profile ID (SQLite autoincrement id)") + var taskId: Int? + + func run() async throws { + let config = Config.load() + let store = ScriptStore(config: config) + try await store.load() + let memoryManager = MemoryManager(config: config) + + let profile: ScriptAgentProfile? + if let taskId { + profile = try store.fetchAgentProfile(taskId: taskId) + } else { + guard let script = try resolveScript(store: store) else { + print("โŒ Script not found") + throw ExitCode.failure + } + profile = try store.fetchAgentProfile(scriptId: script.id) + } + + guard let profile else { + print("โŒ Task profile not found") + throw ExitCode.failure + } + + let path = try memoryManager.summarizeWorkspaceMemory( + taskId: profile.id, + taskName: profile.taskName + ) + print("โœ… Workspace memory updated") + print(" Task: [\(profile.id)] \(profile.taskName)") + print(" Path: \(path)") + } + + private func resolveScript(store: ScriptStore) throws -> Script? { + if let id, let uuid = UUID(uuidString: id) { + return store.get(id: uuid) + } + + guard let identifier else { + print("โŒ Please provide a script title/UUID or --task-id") + throw ExitCode.failure + } + + if let uuid = UUID(uuidString: identifier) { + return store.get(id: uuid) + } + + return store.get(title: identifier) + } +} + diff --git a/Sources/ScriptoriaCLI/Commands/RunCommand.swift b/Sources/ScriptoriaCLI/Commands/RunCommand.swift index fec9470..ff56749 100644 --- a/Sources/ScriptoriaCLI/Commands/RunCommand.swift +++ b/Sources/ScriptoriaCLI/Commands/RunCommand.swift @@ -1,4 +1,5 @@ import ArgumentParser +import Darwin import Foundation import ScriptoriaCore @@ -20,6 +21,18 @@ struct RunCommand: AsyncParsableCommand { @Flag(name: .long, help: "Scheduled run (less output)") var scheduled: Bool = false + @Option(name: .long, help: "Override model for post-script agent run") + var model: String? + + @Option(name: .long, help: "Additional prompt for the post-script agent") + var agentPrompt: String? + + @Flag(name: .long, help: "Skip post-script agent stage") + var skipAgent: Bool = false + + @Flag(name: .long, help: "Disable steering input while agent is running") + var noSteer: Bool = false + func run() async throws { let config = Config.load() let store = ScriptStore(config: config) @@ -106,13 +119,177 @@ struct RunCommand: AsyncParsableCommand { let duration = result.duration.map { String(format: "%.2fs", $0) } ?? "?" print("\(statusIcon) \(result.status.rawValue) (exit: \(result.exitCode ?? -1), duration: \(duration))") + if result.status == .failure { + if !noNotify { + await NotificationManager.shared.notifyRunComplete(result) + } + throw ExitCode.failure + } + + if !skipAgent { + try await runAgentStage( + script: script, + scriptRun: runRecord, + store: store, + config: config + ) + } + // Always notify unless --no-notify if !noNotify { await NotificationManager.shared.notifyRunComplete(result) } + } - if result.status == .failure { + private func runAgentStage( + script: Script, + scriptRun: ScriptRun, + store: ScriptStore, + config: Config + ) async throws { + let taskName = script.agentTaskName.isEmpty ? script.title : script.agentTaskName + let selectedModel = resolveModel(for: script) + let memoryManager = MemoryManager(config: config) + let workspaceMemory = memoryManager.readWorkspaceMemory(taskId: script.agentTaskId, taskName: taskName) + let skillContent = readFileIfExists(path: script.skill) + + let developerInstructions = PostScriptAgentRunner.buildDeveloperInstructions( + skillContent: clippedText(skillContent, max: 40_000), + workspaceMemory: clippedText(workspaceMemory, max: 40_000) + ) + + var prompt = PostScriptAgentRunner.buildInitialPrompt( + taskName: taskName, + script: script, + scriptRun: scriptRun + ) + if let agentPrompt, !agentPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + prompt += "\n\nAdditional user instruction:\n\(agentPrompt)\n" + } + + let workingDirectory = URL(fileURLWithPath: script.path).deletingLastPathComponent().path + print("\n๐Ÿค– Starting agent task: \(taskName)") + print(" Model: \(selectedModel)") + print(String(repeating: "โ”€", count: 50)) + + let session = try await PostScriptAgentRunner.launch( + options: PostScriptAgentLaunchOptions( + workingDirectory: workingDirectory, + model: selectedModel, + userPrompt: prompt, + developerInstructions: developerInstructions + ), + onEvent: { event in + switch event.kind { + case .agentMessage, .commandOutput, .info: + print(event.text, terminator: "") + case .error: + FileHandle.standardError.write(Data(event.text.utf8)) + } + } + ) + + var agentRun = AgentRun( + scriptId: script.id, + scriptRunId: scriptRun.id, + taskId: script.agentTaskId, + taskName: taskName, + model: selectedModel, + threadId: await session.threadId, + turnId: await session.turnId + ) + try await store.saveAgentRun(agentRun) + + var steerTask: Task? + if shouldEnableSteer { + print("\n[steer] Enter text to guide the running agent. Use /interrupt to stop.") + steerTask = Task.detached(priority: .utility) { + while !Task.isCancelled { + guard let line = readLine(strippingNewline: true) else { + break + } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + do { + if trimmed == "/interrupt" { + try await session.interrupt() + break + } else { + try await session.steer(trimmed) + } + } catch { + FileHandle.standardError.write(Data("[steer-error] \(error.localizedDescription)\n".utf8)) + } + } + } + } + + let agentResult = try await session.waitForCompletion() + steerTask?.cancel() + + agentRun.threadId = agentResult.threadId + agentRun.turnId = agentResult.turnId + agentRun.status = agentResult.status + agentRun.finishedAt = agentResult.finishedAt + agentRun.finalMessage = agentResult.finalMessage + agentRun.output = agentResult.output + + let taskMemoryPath = try memoryManager.writeTaskMemory( + taskId: script.agentTaskId, + taskName: taskName, + script: script, + scriptRun: scriptRun, + agentResult: agentResult + ) + agentRun.taskMemoryPath = taskMemoryPath + try await store.updateAgentRun(agentRun) + + print(String(repeating: "โ”€", count: 50)) + let duration = agentResult.finishedAt.timeIntervalSince(agentResult.startedAt) + print("๐Ÿค– Agent \(agentResult.status.rawValue) ยท \(String(format: "%.2fs", duration))") + print("๐Ÿ“˜ Task Memory: \(taskMemoryPath)") + + if agentResult.status == .failed { throw ExitCode.failure } } + + private var shouldEnableSteer: Bool { + !scheduled && !noSteer && isatty(fileno(stdin)) == 1 + } + + private func resolveModel(for script: Script) -> String { + if let model { + let value = model.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { return value } + } + + let defaultModel = script.defaultModel.trimmingCharacters(in: .whitespacesAndNewlines) + if scheduled { + return defaultModel.isEmpty ? "gpt-5.3-codex" : defaultModel + } + + let fallback = defaultModel.isEmpty ? "gpt-5.3-codex" : defaultModel + if isatty(fileno(stdin)) == 1 { + print("Model [\(fallback)]: ", terminator: "") + if let input = readLine(strippingNewline: true)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !input.isEmpty { + return input + } + } + return fallback + } + + private func readFileIfExists(path: String) -> String? { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return try? String(contentsOfFile: trimmed, encoding: .utf8) + } + + private func clippedText(_ text: String?, max: Int) -> String? { + guard let text else { return nil } + if text.count <= max { return text } + return String(text.prefix(max)) + "\n\n[truncated]" + } } diff --git a/Sources/ScriptoriaCore/Execution/CodexAppServerClient.swift b/Sources/ScriptoriaCore/Execution/CodexAppServerClient.swift new file mode 100644 index 0000000..44b10c4 --- /dev/null +++ b/Sources/ScriptoriaCore/Execution/CodexAppServerClient.swift @@ -0,0 +1,410 @@ +import Foundation + +public enum CodexAppServerEvent: Sendable { + case threadStarted(threadId: String) + case turnStarted(turnId: String) + case turnCompleted(turnId: String, status: String) + case agentMessageDelta(itemId: String, delta: String) + case commandOutputDelta(itemId: String, delta: String) + case agentMessageCompleted(phase: String?, text: String) + case diagnostic(String) +} + +public enum CodexAppServerClientError: LocalizedError { + case processNotRunning + case invalidResponse(String) + case responseError(String) + case missingField(String) + + public var errorDescription: String? { + switch self { + case .processNotRunning: + return "Codex app-server process is not running." + case .invalidResponse(let line): + return "Invalid response from Codex app-server: \(line)" + case .responseError(let message): + return "Codex app-server returned an error: \(message)" + case .missingField(let field): + return "Codex app-server response missing field: \(field)" + } + } +} + +/// Minimal JSON-RPC client for `codex app-server --listen stdio://`. +public actor CodexAppServerClient { + public typealias EventHandler = @Sendable (CodexAppServerEvent) -> Void + + private let cwd: String + private let executable: String + private var process: Process? + private var stdinPipe: Pipe? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? + + private var stdoutBuffer = Data() + private var stderrBuffer = Data() + private var nextRequestId = 1 + private var pendingResponses: [String: CheckedContinuation] = [:] + private var eventHandler: EventHandler? + private var isClosed = false + private let debugEnabled: Bool + + public init(cwd: String, executable: String = "codex") { + self.cwd = cwd + self.executable = executable + let flag = ProcessInfo.processInfo.environment["SCRIPTORIA_CODEX_DEBUG"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + self.debugEnabled = flag == "1" || flag == "true" || flag == "yes" + } + + public func setEventHandler(_ handler: EventHandler?) { + eventHandler = handler + } + + public func connect() async throws { + guard process == nil else { return } + + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable, "app-server", "--listen", "stdio://"] + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.terminationHandler = { [weak self] proc in + Task { await self?.handleTermination(exitCode: proc.terminationStatus) } + } + + try process.run() + + self.process = process + self.stdinPipe = stdinPipe + self.stdoutPipe = stdoutPipe + self.stderrPipe = stderrPipe + self.isClosed = false + + startReadTasks(stdoutPipe: stdoutPipe, stderrPipe: stderrPipe) + + _ = try await sendRequest( + method: "initialize", + params: [ + "clientInfo": [ + "name": "scriptoria", + "version": "0.1.0" + ], + "capabilities": [ + "experimentalApi": true + ] + ] + ) + try sendNotification(method: "initialized", params: nil) + } + + public func startThread( + model: String?, + developerInstructions: String?, + approvalPolicy: String = "never", + sandbox: String = "danger-full-access" + ) async throws -> String { + var params: [String: Any] = [ + "cwd": cwd, + "approvalPolicy": approvalPolicy, + "sandbox": sandbox + ] + if let model, !model.isEmpty { + params["model"] = model + } + if let developerInstructions, !developerInstructions.isEmpty { + params["developerInstructions"] = developerInstructions + } + + let result = try await sendRequest(method: "thread/start", params: params) + guard let thread = result["thread"] as? [String: Any], + let threadId = thread["id"] as? String else { + throw CodexAppServerClientError.missingField("thread.id") + } + return threadId + } + + public func startTurn(threadId: String, input: String) async throws -> String { + let result = try await sendRequest( + method: "turn/start", + params: [ + "threadId": threadId, + "input": [ + [ + "type": "text", + "text": input + ] + ] + ] + ) + guard let turn = result["turn"] as? [String: Any], + let turnId = turn["id"] as? String else { + throw CodexAppServerClientError.missingField("turn.id") + } + return turnId + } + + public func steer(threadId: String, turnId: String, input: String) async throws { + _ = try await sendRequest( + method: "turn/steer", + params: [ + "threadId": threadId, + "expectedTurnId": turnId, + "input": [ + [ + "type": "text", + "text": input + ] + ] + ] + ) + } + + public func interrupt(threadId: String, turnId: String) async throws { + _ = try await sendRequest( + method: "turn/interrupt", + params: [ + "threadId": threadId, + "turnId": turnId + ] + ) + } + + public func shutdown() async { + guard !isClosed else { return } + isClosed = true + + stdoutPipe?.fileHandleForReading.readabilityHandler = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + + if let process, process.isRunning { + process.terminate() + } + + process = nil + stdinPipe = nil + stdoutPipe = nil + stderrPipe = nil + + for (_, continuation) in pendingResponses { + continuation.resume(throwing: CodexAppServerClientError.processNotRunning) + } + pendingResponses.removeAll() + } + + // MARK: - Private + + private func startReadTasks(stdoutPipe: Pipe, stderrPipe: Pipe) { + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + + stdoutHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard let self else { return } + Task { + await self.handleStdoutChunk(data) + } + } + + stderrHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard let self else { return } + Task { + await self.handleStderrChunk(data) + } + } + } + + private func handleStdoutChunk(_ data: Data) async { + guard !data.isEmpty else { return } + stdoutBuffer.append(data) + while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { + var lineData = stdoutBuffer.prefix(upTo: newlineIndex) + stdoutBuffer.removeSubrange(...newlineIndex) + if lineData.last == 0x0D { + lineData = lineData.dropLast() + } + guard let line = String(data: lineData, encoding: .utf8) else { + emit(.diagnostic("codex app-server stdout line decode error")) + continue + } + await handleStdoutLine(line) + } + } + + private func handleStderrChunk(_ data: Data) async { + guard !data.isEmpty else { return } + stderrBuffer.append(data) + while let newlineIndex = stderrBuffer.firstIndex(of: 0x0A) { + var lineData = stderrBuffer.prefix(upTo: newlineIndex) + stderrBuffer.removeSubrange(...newlineIndex) + if lineData.last == 0x0D { + lineData = lineData.dropLast() + } + guard let line = String(data: lineData, encoding: .utf8) else { + emit(.diagnostic("codex app-server stderr line decode error")) + continue + } + emit(.diagnostic("codex app-server: \(line)")) + } + } + + private func handleTermination(exitCode: Int32) { + if isClosed { return } + isClosed = true + + for (_, continuation) in pendingResponses { + continuation.resume(throwing: CodexAppServerClientError.responseError("Process exited with code \(exitCode)")) + } + pendingResponses.removeAll() + + emit(.diagnostic("codex app-server exited with code \(exitCode)")) + } + + private func sendRequest(method: String, params: [String: Any]) async throws -> [String: Any] { + guard process != nil, !isClosed else { throw CodexAppServerClientError.processNotRunning } + + let id = String(nextRequestId) + nextRequestId += 1 + debugLog("sendRequest id=\(id) method=\(method)") + + try writeJSON([ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + ]) + + let resultData: Data = try await withCheckedThrowingContinuation { continuation in + pendingResponses[id] = continuation + } + debugLog("gotResponse id=\(id) bytes=\(resultData.count)") + guard let object = try JSONSerialization.jsonObject(with: resultData, options: []) as? [String: Any] else { + throw CodexAppServerClientError.invalidResponse(String(data: resultData, encoding: .utf8) ?? "") + } + return object + } + + private func sendNotification(method: String, params: [String: Any]?) throws { + guard process != nil, !isClosed else { throw CodexAppServerClientError.processNotRunning } + + var payload: [String: Any] = [ + "jsonrpc": "2.0", + "method": method + ] + if let params { + payload["params"] = params + } + try writeJSON(payload) + } + + private func writeJSON(_ object: [String: Any]) throws { + guard let stdinPipe else { throw CodexAppServerClientError.processNotRunning } + let data = try JSONSerialization.data(withJSONObject: object, options: []) + stdinPipe.fileHandleForWriting.write(data) + stdinPipe.fileHandleForWriting.write(Data([0x0A])) + } + + private func handleStdoutLine(_ line: String) async { + guard !line.isEmpty else { return } + debugLog("stdout \(line)") + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data, options: []), + let dict = obj as? [String: Any] else { + emit(.diagnostic("unparsed line: \(line)")) + return + } + + if let id = stringValue(dict["id"]) { + guard let continuation = pendingResponses.removeValue(forKey: id) else { + debugLog("response without continuation id=\(id)") + return + } + if let errorDict = dict["error"] as? [String: Any] { + let message = errorDict["message"] as? String ?? "Unknown error" + continuation.resume(throwing: CodexAppServerClientError.responseError(message)) + return + } + if let result = dict["result"] as? [String: Any] { + let resultData = (try? JSONSerialization.data(withJSONObject: result, options: [])) ?? Data("{}".utf8) + continuation.resume(returning: resultData) + return + } + continuation.resume(returning: Data("{}".utf8)) + return + } + + guard let method = dict["method"] as? String else { return } + let params = dict["params"] as? [String: Any] ?? [:] + await handleNotification(method: method, params: params) + } + + private func handleNotification(method: String, params: [String: Any]) async { + switch method { + case "thread/started": + if let thread = params["thread"] as? [String: Any], + let threadId = thread["id"] as? String { + emit(.threadStarted(threadId: threadId)) + } + + case "turn/started": + if let turn = params["turn"] as? [String: Any], + let turnId = turn["id"] as? String { + emit(.turnStarted(turnId: turnId)) + } + + case "turn/completed": + if let turn = params["turn"] as? [String: Any], + let turnId = turn["id"] as? String, + let status = turn["status"] as? String { + emit(.turnCompleted(turnId: turnId, status: status)) + } + + case "item/agentMessage/delta": + if let itemId = params["itemId"] as? String, + let delta = params["delta"] as? String { + emit(.agentMessageDelta(itemId: itemId, delta: delta)) + } + + case "item/commandExecution/outputDelta": + if let itemId = params["itemId"] as? String, + let delta = params["delta"] as? String { + emit(.commandOutputDelta(itemId: itemId, delta: delta)) + } + + case "item/completed": + if let item = params["item"] as? [String: Any], + let type = item["type"] as? String, + type == "agentMessage" { + let phase = item["phase"] as? String + let text = item["text"] as? String ?? "" + emit(.agentMessageCompleted(phase: phase, text: text)) + } + + default: + break + } + } + + private func emit(_ event: CodexAppServerEvent) { + eventHandler?(event) + } + + private func stringValue(_ value: Any?) -> String? { + if let value = value as? String { return value } + if let value = value as? NSNumber { return value.stringValue } + return nil + } + + private func debugLog(_ message: String) { + guard debugEnabled else { return } + FileHandle.standardError.write(Data("[codex-client] \(message)\n".utf8)) + } +} diff --git a/Sources/ScriptoriaCore/Execution/MemoryManager.swift b/Sources/ScriptoriaCore/Execution/MemoryManager.swift new file mode 100644 index 0000000..0e814c9 --- /dev/null +++ b/Sources/ScriptoriaCore/Execution/MemoryManager.swift @@ -0,0 +1,309 @@ +import Foundation + +public final class MemoryManager: Sendable { + private let baseDirectory: String + + public init(baseDirectory: String) { + self.baseDirectory = baseDirectory + } + + public convenience init(config: Config) { + self.init(baseDirectory: config.memoryDirectory) + } + + public func taskRootDirectory(taskId: Int?, taskName: String) -> String { + _ = taskId + let folder = sanitizePathComponent(taskName) + return "\(baseDirectory)/\(folder)" + } + + public func taskDirectory(taskId: Int?, taskName: String) -> String { + "\(taskRootDirectory(taskId: taskId, taskName: taskName))/task" + } + + public func workspacePath(taskId: Int?, taskName: String) -> String { + "\(taskRootDirectory(taskId: taskId, taskName: taskName))/workspace.md" + } + + public func readWorkspaceMemory(taskId: Int?, taskName: String) -> String? { + let path = workspacePath(taskId: taskId, taskName: taskName) + return try? String(contentsOfFile: path, encoding: .utf8) + } + + @discardableResult + public func writeTaskMemory( + taskId: Int?, + taskName: String, + script: Script, + scriptRun: ScriptRun, + agentResult: AgentExecutionResult + ) throws -> String { + let fm = FileManager.default + let dir = taskDirectory(taskId: taskId, taskName: taskName) + if !fm.fileExists(atPath: dir) { + try fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + + let timestamp = formatTimestamp(agentResult.finishedAt) + let path = "\(dir)/\(timestamp).md" + + let good = buildGoodPoints(scriptRun: scriptRun, agentResult: agentResult) + let bad = buildBadPoints(scriptRun: scriptRun, agentResult: agentResult) + let experience = buildExperiencePoints(scriptRun: scriptRun, agentResult: agentResult) + + let content = """ + # Task Memory + + - task_id: \(taskId.map(String.init) ?? "n/a") + - task_name: \(taskName) + - script: \(script.title) + - script_run_id: \(scriptRun.id.uuidString) + - agent_thread_id: \(agentResult.threadId) + - agent_turn_id: \(agentResult.turnId) + - model: \(agentResult.model) + - started_at: \(isoString(agentResult.startedAt)) + - finished_at: \(isoString(agentResult.finishedAt)) + - status: \(agentResult.status.rawValue) + + ## Outcome + + \(agentResult.finalMessage.isEmpty ? "(no final message)" : agentResult.finalMessage) + + ## Good + \(good.map { "- \($0)" }.joined(separator: "\n")) + + ## Bad + \(bad.map { "- \($0)" }.joined(separator: "\n")) + + ## Experience + \(experience.map { "- \($0)" }.joined(separator: "\n")) + """ + + try content.write(toFile: path, atomically: true, encoding: .utf8) + return path + } + + @discardableResult + public func summarizeWorkspaceMemory(taskId: Int?, taskName: String) throws -> String { + let fm = FileManager.default + let taskDir = taskDirectory(taskId: taskId, taskName: taskName) + let rootDir = taskRootDirectory(taskId: taskId, taskName: taskName) + if !fm.fileExists(atPath: rootDir) { + try fm.createDirectory(atPath: rootDir, withIntermediateDirectories: true) + } + + let files = (try? fm.contentsOfDirectory(atPath: taskDir))? + .filter { $0.hasSuffix(".md") } + .sorted() ?? [] + + var goodCounts: [String: Int] = [:] + var badCounts: [String: Int] = [:] + var experienceCounts: [String: Int] = [:] + var latestFiles: [String] = [] + + for file in files { + let path = "\(taskDir)/\(file)" + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue } + let sections = parseSections(from: text) + count(section: sections.good, into: &goodCounts) + count(section: sections.bad, into: &badCounts) + count(section: sections.experience, into: &experienceCounts) + latestFiles.append(file) + } + + let workspacePath = workspacePath(taskId: taskId, taskName: taskName) + let generatedAt = isoString(Date()) + + let content = """ + # Workspace Memory + + - task_id: \(taskId.map(String.init) ?? "n/a") + - task_name: \(taskName) + - generated_at: \(generatedAt) + - task_memory_count: \(files.count) + + ## Top Good Patterns + \(renderTopCounts(goodCounts)) + + ## Top Bad Patterns + \(renderTopCounts(badCounts)) + + ## Top Experience Patterns + \(renderTopCounts(experienceCounts)) + + ## Source Task Memories + \(latestFiles.isEmpty ? "- (none)" : latestFiles.map { "- \($0)" }.joined(separator: "\n")) + """ + + try content.write(toFile: workspacePath, atomically: true, encoding: .utf8) + return workspacePath + } + + private func buildGoodPoints(scriptRun: ScriptRun, agentResult: AgentExecutionResult) -> [String] { + var points: [String] = [] + if scriptRun.status == .success { + points.append("Script stage completed successfully.") + } + if agentResult.status == .completed { + points.append("Agent stage reached a completed state.") + } + if !agentResult.finalMessage.isEmpty { + points.append("Final answer was produced.") + } + if points.isEmpty { + points.append("No clear positive signal in this run.") + } + return points + } + + private func buildBadPoints(scriptRun: ScriptRun, agentResult: AgentExecutionResult) -> [String] { + var points: [String] = [] + if scriptRun.status != .success { + points.append("Script stage ended with status '\(scriptRun.status.rawValue)' and exit code \(scriptRun.exitCode ?? -1).") + } + if agentResult.status == .failed { + points.append("Agent stage failed before completion.") + } + if agentResult.status == .interrupted { + points.append("Agent stage was interrupted before normal completion.") + } + if agentResult.finalMessage.isEmpty { + points.append("No final answer was captured from the agent.") + } + if points.isEmpty { + points.append("No major issues observed in this run.") + } + return points + } + + private func buildExperiencePoints(scriptRun: ScriptRun, agentResult: AgentExecutionResult) -> [String] { + var points: [String] = [] + points.append("Model used: \(agentResult.model).") + points.append("Agent duration: \(formatDuration(agentResult.finishedAt.timeIntervalSince(agentResult.startedAt))).") + if !scriptRun.output.isEmpty { + points.append("Script stdout provided useful context for downstream agent execution.") + } + if !scriptRun.errorOutput.isEmpty { + points.append("Script stderr should be reviewed before next run to reduce downstream noise.") + } + if agentResult.status == .completed && !agentResult.finalMessage.isEmpty { + points.append("Final answer quality improved when execution context included prior memory.") + } + return points + } + + private func parseSections(from markdown: String) -> (good: [String], bad: [String], experience: [String]) { + enum Section { + case none + case good + case bad + case experience + } + + var section: Section = .none + var good: [String] = [] + var bad: [String] = [] + var experience: [String] = [] + + for line in markdown.components(separatedBy: .newlines) { + if line.hasPrefix("## Good") { + section = .good + continue + } + if line.hasPrefix("## Bad") { + section = .bad + continue + } + if line.hasPrefix("## Experience") { + section = .experience + continue + } + guard line.hasPrefix("- ") else { continue } + let value = String(line.dropFirst(2)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { continue } + switch section { + case .good: + good.append(value) + case .bad: + bad.append(value) + case .experience: + experience.append(value) + case .none: + break + } + } + return (good, bad, experience) + } + + private func count(section: [String], into dict: inout [String: Int]) { + for line in section { + dict[line, default: 0] += 1 + } + } + + private func renderTopCounts(_ counts: [String: Int]) -> String { + if counts.isEmpty { + return "- (none)" + } + return counts + .sorted { lhs, rhs in + if lhs.value == rhs.value { return lhs.key < rhs.key } + return lhs.value > rhs.value + } + .prefix(10) + .map { "- [\($0.value)x] \($0.key)" } + .joined(separator: "\n") + } + + private func sanitizePathComponent(_ input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "task" } + + let forbidden = CharacterSet(charactersIn: "/:\0") + var scalars: [UnicodeScalar] = [] + var previousDash = false + + for scalar in trimmed.unicodeScalars { + let shouldReplace = forbidden.contains(scalar) || CharacterSet.newlines.contains(scalar) + if shouldReplace { + if !previousDash { + scalars.append("-") + previousDash = true + } + } else { + scalars.append(scalar) + previousDash = false + } + } + + let sanitized = String(String.UnicodeScalarView(scalars)) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + + return sanitized.isEmpty ? "task" : sanitized + } + + private func formatDuration(_ seconds: TimeInterval) -> String { + if seconds < 1 { + return String(format: "%.0fms", seconds * 1000) + } + if seconds < 60 { + return String(format: "%.1fs", seconds) + } + let m = Int(seconds) / 60 + let s = Int(seconds) % 60 + return "\(m)m \(s)s" + } + + private func formatTimestamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMddHHmmss" + return formatter.string(from: date) + } + + private func isoString(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } +} diff --git a/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift b/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift new file mode 100644 index 0000000..f57568f --- /dev/null +++ b/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift @@ -0,0 +1,284 @@ +import Foundation + +public enum AgentStreamEventKind: Sendable { + case info + case agentMessage + case commandOutput + case error +} + +public struct AgentStreamEvent: Sendable { + public let kind: AgentStreamEventKind + public let text: String + + public init(kind: AgentStreamEventKind, text: String) { + self.kind = kind + self.text = text + } +} + +public struct AgentExecutionResult: Sendable { + public let threadId: String + public let turnId: String + public let model: String + public let startedAt: Date + public let finishedAt: Date + public let status: AgentRunStatus + public let finalMessage: String + public let output: String +} + +public struct PostScriptAgentLaunchOptions: Sendable { + public var workingDirectory: String + public var model: String + public var userPrompt: String + public var developerInstructions: String + public var codexExecutable: String + public var approvalPolicy: String + public var sandbox: String + + public init( + workingDirectory: String, + model: String, + userPrompt: String, + developerInstructions: String, + codexExecutable: String = "codex", + approvalPolicy: String = "never", + sandbox: String = "danger-full-access" + ) { + self.workingDirectory = workingDirectory + self.model = model + self.userPrompt = userPrompt + self.developerInstructions = developerInstructions + self.codexExecutable = codexExecutable + self.approvalPolicy = approvalPolicy + self.sandbox = sandbox + } +} + +public actor PostScriptAgentSession { + private let client: CodexAppServerClient + private let model: String + private let startedAt: Date + private let onEvent: (@Sendable (AgentStreamEvent) -> Void)? + + public private(set) var threadId: String + public private(set) var turnId: String = "" + + private var outputBuffer = "" + private var finalMessage = "" + private var completionResult: AgentExecutionResult? + private var completionContinuation: CheckedContinuation? + private var pendingTurnCompletion: (turnId: String, status: String)? + + init( + client: CodexAppServerClient, + threadId: String, + model: String, + startedAt: Date = Date(), + onEvent: (@Sendable (AgentStreamEvent) -> Void)? + ) { + self.client = client + self.threadId = threadId + self.model = model + self.startedAt = startedAt + self.onEvent = onEvent + } + + func activate(turnId: String) { + self.turnId = turnId + consumePendingCompletionIfNeeded() + } + + func handle(event: CodexAppServerEvent) async { + switch event { + case .threadStarted(let threadId): + self.threadId = threadId + + case .turnStarted(let turnId): + if self.turnId.isEmpty { + self.turnId = turnId + consumePendingCompletionIfNeeded() + } + + case .agentMessageDelta(_, let delta): + outputBuffer += delta + onEvent?(AgentStreamEvent(kind: .agentMessage, text: delta)) + + case .commandOutputDelta(_, let delta): + outputBuffer += delta + onEvent?(AgentStreamEvent(kind: .commandOutput, text: delta)) + + case .agentMessageCompleted(let phase, let text): + if phase == "final_answer" { + finalMessage = text + if !text.isEmpty, !outputBuffer.contains(text) { + outputBuffer += text + } + } + + case .turnCompleted(let turnId, let status): + if self.turnId.isEmpty { + pendingTurnCompletion = (turnId: turnId, status: status) + return + } + guard turnId == self.turnId else { return } + finish(status: mapStatus(status)) + + case .diagnostic(let line): + onEvent?(AgentStreamEvent(kind: .info, text: line + "\n")) + } + } + + public func waitForCompletion() async throws -> AgentExecutionResult { + if let completionResult { return completionResult } + + return try await withCheckedThrowingContinuation { continuation in + completionContinuation = continuation + } + } + + public func steer(_ input: String) async throws { + guard !turnId.isEmpty else { return } + try await client.steer(threadId: threadId, turnId: turnId, input: input) + onEvent?(AgentStreamEvent(kind: .info, text: "[steer] \(input)\n")) + } + + public func interrupt() async throws { + guard !turnId.isEmpty else { return } + try await client.interrupt(threadId: threadId, turnId: turnId) + onEvent?(AgentStreamEvent(kind: .info, text: "[interrupt] requested\n")) + } + + public func close() async { + await client.shutdown() + if completionResult == nil { + finish(status: .failed) + } + } + + private func finish(status: AgentRunStatus) { + guard completionResult == nil else { return } + let result = AgentExecutionResult( + threadId: threadId, + turnId: turnId, + model: model, + startedAt: startedAt, + finishedAt: Date(), + status: status, + finalMessage: finalMessage, + output: outputBuffer + ) + completionResult = result + completionContinuation?.resume(returning: result) + completionContinuation = nil + Task { + await client.shutdown() + } + } + + private func consumePendingCompletionIfNeeded() { + guard let pendingTurnCompletion else { return } + guard !turnId.isEmpty, pendingTurnCompletion.turnId == turnId else { return } + self.pendingTurnCompletion = nil + finish(status: mapStatus(pendingTurnCompletion.status)) + } + + private func mapStatus(_ status: String) -> AgentRunStatus { + switch status { + case "completed": + return .completed + case "interrupted": + return .interrupted + default: + return .failed + } + } +} + +public enum PostScriptAgentRunner { + public static func launch( + options: PostScriptAgentLaunchOptions, + onEvent: (@Sendable (AgentStreamEvent) -> Void)? = nil + ) async throws -> PostScriptAgentSession { + let envExecutable = ProcessInfo.processInfo.environment["SCRIPTORIA_CODEX_EXECUTABLE"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let executable = (envExecutable?.isEmpty == false) ? envExecutable! : options.codexExecutable + let client = CodexAppServerClient(cwd: options.workingDirectory, executable: executable) + try await client.connect() + + let threadId = try await client.startThread( + model: options.model, + developerInstructions: options.developerInstructions, + approvalPolicy: options.approvalPolicy, + sandbox: options.sandbox + ) + + let session = PostScriptAgentSession( + client: client, + threadId: threadId, + model: options.model, + onEvent: onEvent + ) + + await client.setEventHandler { event in + Task { + await session.handle(event: event) + } + } + + let turnId = try await client.startTurn(threadId: threadId, input: options.userPrompt) + await session.activate(turnId: turnId) + return session + } + + public static func buildDeveloperInstructions( + skillContent: String?, + workspaceMemory: String? + ) -> String { + var sections: [String] = [] + sections.append(""" + You are Scriptoria's post-script execution agent. + Execute the user's task autonomously, stream concise progress, and end with a clear final answer. + """) + + if let skillContent, !skillContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + sections.append("## Injected Skill\n\(skillContent)") + } + + if let workspaceMemory, !workspaceMemory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + sections.append("## Workspace Memory\n\(workspaceMemory)") + } else { + sections.append("## Workspace Memory\n(no workspace memory yet)") + } + + return sections.joined(separator: "\n\n") + } + + public static func buildInitialPrompt( + taskName: String, + script: Script, + scriptRun: ScriptRun + ) -> String { + let status = scriptRun.status.rawValue + let exitCode = scriptRun.exitCode.map(String.init) ?? "?" + let stdout = scriptRun.output.isEmpty ? "(empty)" : scriptRun.output + let stderr = scriptRun.errorOutput.isEmpty ? "(empty)" : scriptRun.errorOutput + + return """ + Task Name: \(taskName) + Script: \(script.title) + Script Path: \(script.path) + Script Run Status: \(status) + Script Exit Code: \(exitCode) + + Script STDOUT: + \(stdout) + + Script STDERR: + \(stderr) + + Please execute this task end-to-end using the injected skill and memory context. + """ + } +} diff --git a/Sources/ScriptoriaCore/Models/AgentRun.swift b/Sources/ScriptoriaCore/Models/AgentRun.swift new file mode 100644 index 0000000..164c8a6 --- /dev/null +++ b/Sources/ScriptoriaCore/Models/AgentRun.swift @@ -0,0 +1,64 @@ +import Foundation + +public enum AgentRunStatus: String, Codable, Sendable { + case running + case completed + case interrupted + case failed +} + +/// Record of a post-script agent execution +public struct AgentRun: Codable, Identifiable, Sendable { + public var id: UUID + public var scriptId: UUID + public var scriptRunId: UUID? + public var taskId: Int? + public var taskName: String + public var model: String + public var threadId: String + public var turnId: String + public var startedAt: Date + public var finishedAt: Date? + public var status: AgentRunStatus + public var finalMessage: String + public var output: String + public var taskMemoryPath: String? + + public init( + id: UUID = UUID(), + scriptId: UUID, + scriptRunId: UUID? = nil, + taskId: Int? = nil, + taskName: String, + model: String, + threadId: String, + turnId: String, + startedAt: Date = Date(), + finishedAt: Date? = nil, + status: AgentRunStatus = .running, + finalMessage: String = "", + output: String = "", + taskMemoryPath: String? = nil + ) { + self.id = id + self.scriptId = scriptId + self.scriptRunId = scriptRunId + self.taskId = taskId + self.taskName = taskName + self.model = model + self.threadId = threadId + self.turnId = turnId + self.startedAt = startedAt + self.finishedAt = finishedAt + self.status = status + self.finalMessage = finalMessage + self.output = output + self.taskMemoryPath = taskMemoryPath + } + + public var duration: TimeInterval? { + guard let finishedAt else { return nil } + return finishedAt.timeIntervalSince(startedAt) + } +} + diff --git a/Sources/ScriptoriaCore/Models/Script.swift b/Sources/ScriptoriaCore/Models/Script.swift index c88a306..1fe3cfa 100644 --- a/Sources/ScriptoriaCore/Models/Script.swift +++ b/Sources/ScriptoriaCore/Models/Script.swift @@ -7,6 +7,9 @@ public struct Script: Codable, Identifiable, Sendable { public var description: String public var path: String public var skill: String + public var agentTaskId: Int? + public var agentTaskName: String + public var defaultModel: String public var interpreter: Interpreter public var tags: [String] public var isFavorite: Bool @@ -22,6 +25,9 @@ public struct Script: Codable, Identifiable, Sendable { description: String = "", path: String, skill: String = "", + agentTaskId: Int? = nil, + agentTaskName: String = "", + defaultModel: String = "", interpreter: Interpreter = .auto, tags: [String] = [], isFavorite: Bool = false, @@ -36,6 +42,9 @@ public struct Script: Codable, Identifiable, Sendable { self.description = description self.path = path self.skill = skill + self.agentTaskId = agentTaskId + self.agentTaskName = agentTaskName + self.defaultModel = defaultModel self.interpreter = interpreter self.tags = tags self.isFavorite = isFavorite diff --git a/Sources/ScriptoriaCore/Models/ScriptAgentProfile.swift b/Sources/ScriptoriaCore/Models/ScriptAgentProfile.swift new file mode 100644 index 0000000..1b2a9b3 --- /dev/null +++ b/Sources/ScriptoriaCore/Models/ScriptAgentProfile.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Per-script profile for post-script agent runs +public struct ScriptAgentProfile: Codable, Identifiable, Sendable { + public var id: Int + public var scriptId: UUID + public var taskName: String + public var defaultModel: String + public var createdAt: Date + public var updatedAt: Date + + public init( + id: Int, + scriptId: UUID, + taskName: String, + defaultModel: String, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.scriptId = scriptId + self.taskName = taskName + self.defaultModel = defaultModel + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + diff --git a/Sources/ScriptoriaCore/Scheduling/LaunchdHelper.swift b/Sources/ScriptoriaCore/Scheduling/LaunchdHelper.swift index b715648..65978fb 100644 --- a/Sources/ScriptoriaCore/Scheduling/LaunchdHelper.swift +++ b/Sources/ScriptoriaCore/Scheduling/LaunchdHelper.swift @@ -5,12 +5,28 @@ public final class LaunchdHelper: Sendable { private static let plistPrefix = "com.scriptoria.task" private static var launchAgentsDir: String { + if let override = ProcessInfo.processInfo.environment["SCRIPTORIA_LAUNCH_AGENTS_DIR"], + !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return NSString(string: override).expandingTildeInPath + } let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/Library/LaunchAgents" } + private static var launchctlPath: String { + if let override = ProcessInfo.processInfo.environment["SCRIPTORIA_LAUNCHCTL_PATH"], + !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return NSString(string: override).expandingTildeInPath + } + return "/bin/launchctl" + } + /// Generate and install a launchd plist for a schedule public static func install(schedule: Schedule, cliPath: String) throws { + if !FileManager.default.fileExists(atPath: launchAgentsDir) { + try FileManager.default.createDirectory(atPath: launchAgentsDir, withIntermediateDirectories: true) + } + let plistName = "\(plistPrefix).\(schedule.id.uuidString)" let plistPath = "\(launchAgentsDir)/\(plistName).plist" @@ -55,7 +71,7 @@ public final class LaunchdHelper: Sendable { // Load the agent let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.executableURL = URL(fileURLWithPath: launchctlPath) process.arguments = ["load", plistPath] try process.run() process.waitUntilExit() @@ -68,7 +84,7 @@ public final class LaunchdHelper: Sendable { // Unload first let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.executableURL = URL(fileURLWithPath: launchctlPath) process.arguments = ["unload", plistPath] try process.run() process.waitUntilExit() diff --git a/Sources/ScriptoriaCore/Storage/Config.swift b/Sources/ScriptoriaCore/Storage/Config.swift index 45665f2..d7b88b7 100644 --- a/Sources/ScriptoriaCore/Storage/Config.swift +++ b/Sources/ScriptoriaCore/Storage/Config.swift @@ -28,8 +28,26 @@ public struct Config: Codable, Sendable { // MARK: - Paths + private static func expandedPath(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if trimmed.hasPrefix("~") { + return NSString(string: trimmed).expandingTildeInPath + } + if trimmed.hasPrefix("/") { + return trimmed + } + return FileManager.default.currentDirectoryPath + "/" + trimmed + } + /// Default data directory public static var defaultDataDirectory: String { + if let override = ProcessInfo.processInfo.environment["SCRIPTORIA_DEFAULT_DATA_DIR"] { + let expanded = expandedPath(override) + if !expanded.isEmpty { + return expanded + } + } let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/.scriptoria" } @@ -49,10 +67,20 @@ public struct Config: Codable, Sendable { "\(dataDirectory)/logs" } + /// Memory subdirectory within the data directory + public var memoryDirectory: String { + "\(dataDirectory)/memory" + } + /// Pointer file: a tiny file at the default location that tells us where the real data directory is. private static var pointerFilePath: String { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return "\(home)/.scriptoria/pointer.json" + if let override = ProcessInfo.processInfo.environment["SCRIPTORIA_POINTER_FILE"] { + let expanded = expandedPath(override) + if !expanded.isEmpty { + return expanded + } + } + return "\(defaultDataDirectory)/pointer.json" } // MARK: - Pointer (tells us where data lives) @@ -64,6 +92,16 @@ public struct Config: Codable, Sendable { /// Resolve the actual data directory by reading the pointer file. /// Falls back to the default directory if the pointer target is inaccessible. public static func resolveDataDirectory() -> String { + if let override = ProcessInfo.processInfo.environment["SCRIPTORIA_DATA_DIR"] { + let expanded = expandedPath(override) + if !expanded.isEmpty { + if !FileManager.default.fileExists(atPath: expanded) { + try? FileManager.default.createDirectory(atPath: expanded, withIntermediateDirectories: true) + } + return expanded + } + } + let pointerPath = pointerFilePath if FileManager.default.fileExists(atPath: pointerPath), let data = try? Data(contentsOf: URL(fileURLWithPath: pointerPath)), @@ -256,6 +294,8 @@ public struct Config: Codable, Sendable { try db.setConfig(key: "dataDirectory", value: dataDirectory) // Update pointer so CLI/App can find us - try Config.savePointer(dataDirectory: dataDirectory) + if ProcessInfo.processInfo.environment["SCRIPTORIA_DATA_DIR"] == nil { + try Config.savePointer(dataDirectory: dataDirectory) + } } } diff --git a/Sources/ScriptoriaCore/Storage/DatabaseManager.swift b/Sources/ScriptoriaCore/Storage/DatabaseManager.swift index 3629ba9..fcb110a 100644 --- a/Sources/ScriptoriaCore/Storage/DatabaseManager.swift +++ b/Sources/ScriptoriaCore/Storage/DatabaseManager.swift @@ -103,6 +103,69 @@ public final class DatabaseManager: Sendable { } } + migrator.registerMigration("v4") { db in + try db.create(table: "script_agent_profiles") { t in + t.autoIncrementedPrimaryKey("id") + t.column("scriptId", .text) + .notNull() + .unique() + .references("scripts", onDelete: .cascade) + t.column("taskName", .text).notNull().defaults(to: "") + t.column("defaultModel", .text).notNull().defaults(to: "") + t.column("createdAt", .datetime).notNull() + t.column("updatedAt", .datetime).notNull() + } + try db.create( + index: "idx_script_agent_profiles_scriptId", + on: "script_agent_profiles", + columns: ["scriptId"], + unique: true + ) + + try db.create(table: "agent_runs") { t in + t.primaryKey("id", .text).notNull() + t.column("scriptId", .text).notNull().references("scripts", onDelete: .cascade) + t.column("scriptRunId", .text).references("script_runs", onDelete: .setNull) + t.column("taskId", .integer).references("script_agent_profiles", column: "id", onDelete: .setNull) + t.column("taskName", .text).notNull().defaults(to: "") + t.column("model", .text).notNull().defaults(to: "") + t.column("threadId", .text).notNull() + t.column("turnId", .text).notNull() + t.column("startedAt", .datetime).notNull() + t.column("finishedAt", .datetime) + t.column("status", .text).notNull() + t.column("finalMessage", .text).notNull().defaults(to: "") + t.column("output", .text).notNull().defaults(to: "") + t.column("taskMemoryPath", .text) + } + try db.create( + index: "idx_agent_runs_scriptId_startedAt", + on: "agent_runs", + columns: ["scriptId", "startedAt"] + ) + try db.create( + index: "idx_agent_runs_status_startedAt", + on: "agent_runs", + columns: ["status", "startedAt"] + ) + + let now = Date() + let scriptRows = try Row.fetchAll(db, sql: "SELECT id, title, createdAt, updatedAt FROM scripts") + for row in scriptRows { + let scriptId: String = row["id"] + let title: String = row["title"] + let createdAt: Date = row["createdAt"] + let updatedAt: Date = row["updatedAt"] + try db.execute( + sql: """ + INSERT OR IGNORE INTO script_agent_profiles (scriptId, taskName, defaultModel, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?) + """, + arguments: [scriptId, title, "", createdAt, updatedAt > createdAt ? updatedAt : now] + ) + } + } + return migrator } @@ -231,6 +294,12 @@ public final class DatabaseManager: Sendable { arguments: [updated.id.uuidString, tag] ) } + try self.upsertScriptAgentProfileRow( + scriptId: updated.id, + taskName: updated.agentTaskName.isEmpty ? updated.title : updated.agentTaskName, + defaultModel: updated.defaultModel, + db: db + ) } } @@ -386,6 +455,136 @@ public final class DatabaseManager: Sendable { } } + // MARK: - Script Agent Profiles + + public func fetchScriptAgentProfile(scriptId: UUID) throws -> ScriptAgentProfile? { + try dbPool.read { db in + guard let row = try Row.fetchOne( + db, + sql: "SELECT * FROM script_agent_profiles WHERE scriptId = ?", + arguments: [scriptId.uuidString] + ) else { return nil } + return self.scriptAgentProfileFromRow(row) + } + } + + public func fetchScriptAgentProfile(taskId: Int) throws -> ScriptAgentProfile? { + try dbPool.read { db in + guard let row = try Row.fetchOne( + db, + sql: "SELECT * FROM script_agent_profiles WHERE id = ?", + arguments: [taskId] + ) else { return nil } + return self.scriptAgentProfileFromRow(row) + } + } + + public func upsertScriptAgentProfile( + scriptId: UUID, + taskName: String, + defaultModel: String + ) throws { + try dbPool.write { db in + try self.upsertScriptAgentProfileRow( + scriptId: scriptId, + taskName: taskName, + defaultModel: defaultModel, + db: db + ) + } + } + + // MARK: - Agent Runs + + public func insertAgentRun(_ run: AgentRun) throws { + try dbPool.write { db in + try db.execute( + sql: """ + INSERT INTO agent_runs ( + id, scriptId, scriptRunId, taskId, taskName, model, threadId, turnId, + startedAt, finishedAt, status, finalMessage, output, taskMemoryPath + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + run.id.uuidString, + run.scriptId.uuidString, + run.scriptRunId?.uuidString, + run.taskId, + run.taskName, + run.model, + run.threadId, + run.turnId, + run.startedAt, + run.finishedAt, + run.status.rawValue, + run.finalMessage, + run.output, + run.taskMemoryPath + ] + ) + } + } + + public func updateAgentRun(_ run: AgentRun) throws { + try dbPool.write { db in + try db.execute( + sql: """ + UPDATE agent_runs + SET scriptRunId=?, taskId=?, taskName=?, model=?, threadId=?, turnId=?, + finishedAt=?, status=?, finalMessage=?, output=?, taskMemoryPath=? + WHERE id=? + """, + arguments: [ + run.scriptRunId?.uuidString, + run.taskId, + run.taskName, + run.model, + run.threadId, + run.turnId, + run.finishedAt, + run.status.rawValue, + run.finalMessage, + run.output, + run.taskMemoryPath, + run.id.uuidString + ] + ) + } + } + + public func fetchAgentRun(id: UUID) throws -> AgentRun? { + try dbPool.read { db in + guard let row = try Row.fetchOne( + db, + sql: "SELECT * FROM agent_runs WHERE id = ?", + arguments: [id.uuidString] + ) else { return nil } + return self.agentRunFromRow(row) + } + } + + public func fetchLatestAgentRun(scriptId: UUID) throws -> AgentRun? { + try dbPool.read { db in + guard let row = try Row.fetchOne( + db, + sql: "SELECT * FROM agent_runs WHERE scriptId = ? ORDER BY startedAt DESC LIMIT 1", + arguments: [scriptId.uuidString] + ) else { return nil } + return self.agentRunFromRow(row) + } + } + + public func fetchAgentRuns(scriptId: UUID, limit: Int = 50) throws -> [AgentRun] { + try dbPool.read { db in + let rows = try Row.fetchAll( + db, + sql: "SELECT * FROM agent_runs WHERE scriptId = ? ORDER BY startedAt DESC LIMIT ?", + arguments: [scriptId.uuidString, limit] + ) + return rows.map { self.agentRunFromRow($0) } + } + } + // MARK: - Schedules public func fetchAllSchedules() throws -> [Schedule] { @@ -560,6 +759,12 @@ public final class DatabaseManager: Sendable { arguments: [script.id.uuidString, tag] ) } + try upsertScriptAgentProfileRow( + scriptId: script.id, + taskName: script.agentTaskName.isEmpty ? script.title : script.agentTaskName, + defaultModel: script.defaultModel, + db: db + ) } private func scriptFromRow(_ row: Row, db: Database) throws -> Script { @@ -567,6 +772,14 @@ public final class DatabaseManager: Sendable { let id = UUID(uuidString: idStr)! let tags = try String.fetchAll(db, sql: "SELECT tag FROM script_tags WHERE scriptId = ? ORDER BY tag", arguments: [idStr]) let statusStr: String? = row["lastRunStatus"] + let profileRow = try Row.fetchOne( + db, + sql: "SELECT * FROM script_agent_profiles WHERE scriptId = ? LIMIT 1", + arguments: [idStr] + ) + let profile = profileRow.map(self.scriptAgentProfileFromRow) + let taskName = profile?.taskName ?? (row["title"] as String) + let defaultModel = profile?.defaultModel ?? "" return Script( id: id, @@ -574,6 +787,9 @@ public final class DatabaseManager: Sendable { description: row["description"], path: row["path"], skill: row["skill"], + agentTaskId: profile?.id, + agentTaskName: taskName, + defaultModel: defaultModel, interpreter: Interpreter(rawValue: row["interpreter"]) ?? .auto, tags: tags, isFavorite: row["isFavorite"], @@ -602,6 +818,57 @@ public final class DatabaseManager: Sendable { ) } + private func scriptAgentProfileFromRow(_ row: Row) -> ScriptAgentProfile { + ScriptAgentProfile( + id: row["id"], + scriptId: UUID(uuidString: row["scriptId"] as String)!, + taskName: row["taskName"], + defaultModel: row["defaultModel"], + createdAt: row["createdAt"], + updatedAt: row["updatedAt"] + ) + } + + private func agentRunFromRow(_ row: Row) -> AgentRun { + let scriptRunIdStr: String? = row["scriptRunId"] + return AgentRun( + id: UUID(uuidString: row["id"] as String)!, + scriptId: UUID(uuidString: row["scriptId"] as String)!, + scriptRunId: scriptRunIdStr.flatMap(UUID.init(uuidString:)), + taskId: row["taskId"], + taskName: row["taskName"], + model: row["model"], + threadId: row["threadId"], + turnId: row["turnId"], + startedAt: row["startedAt"], + finishedAt: row["finishedAt"], + status: AgentRunStatus(rawValue: row["status"] as String) ?? .failed, + finalMessage: row["finalMessage"], + output: row["output"], + taskMemoryPath: row["taskMemoryPath"] + ) + } + + private func upsertScriptAgentProfileRow( + scriptId: UUID, + taskName: String, + defaultModel: String, + db: Database + ) throws { + let now = Date() + try db.execute( + sql: """ + INSERT INTO script_agent_profiles (scriptId, taskName, defaultModel, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(scriptId) DO UPDATE SET + taskName=excluded.taskName, + defaultModel=excluded.defaultModel, + updatedAt=excluded.updatedAt + """, + arguments: [scriptId.uuidString, taskName, defaultModel, now, now] + ) + } + private func scheduleFromRow(_ row: Row) -> Schedule? { guard let id = UUID(uuidString: row["id"] as String), let scriptId = UUID(uuidString: row["scriptId"] as String), diff --git a/Sources/ScriptoriaCore/Storage/ScriptStore.swift b/Sources/ScriptoriaCore/Storage/ScriptStore.swift index 9cca25f..4fd95f9 100644 --- a/Sources/ScriptoriaCore/Storage/ScriptStore.swift +++ b/Sources/ScriptoriaCore/Storage/ScriptStore.swift @@ -67,15 +67,17 @@ public final class ScriptStore: @unchecked Sendable { @discardableResult public func add(_ script: Script) async throws -> Script { try db.insertScript(script) - lock.withLock { scripts.append(script) } - return script + let inserted = try db.fetchScript(id: script.id) ?? script + lock.withLock { scripts.append(inserted) } + return inserted } public func update(_ script: Script) async throws { try db.updateScript(script) + let refreshed = try db.fetchScript(id: script.id) ?? script lock.withLock { if let index = scripts.firstIndex(where: { $0.id == script.id }) { - scripts[index] = script + scripts[index] = refreshed scripts[index].updatedAt = Date() } } @@ -185,4 +187,55 @@ public final class ScriptStore: @unchecked Sendable { public func fetchAllAverageDurations() throws -> [UUID: TimeInterval] { try db.fetchAllAverageDurations() } + + // MARK: - Agent Profiles + + public func upsertAgentProfile( + scriptId: UUID, + taskName: String, + defaultModel: String + ) async throws { + try db.upsertScriptAgentProfile( + scriptId: scriptId, + taskName: taskName, + defaultModel: defaultModel + ) + if let refreshed = try db.fetchScript(id: scriptId) { + lock.withLock { + if let index = scripts.firstIndex(where: { $0.id == scriptId }) { + scripts[index] = refreshed + } + } + } + } + + public func fetchAgentProfile(scriptId: UUID) throws -> ScriptAgentProfile? { + try db.fetchScriptAgentProfile(scriptId: scriptId) + } + + public func fetchAgentProfile(taskId: Int) throws -> ScriptAgentProfile? { + try db.fetchScriptAgentProfile(taskId: taskId) + } + + // MARK: - Agent Runs + + public func saveAgentRun(_ run: AgentRun) async throws { + try db.insertAgentRun(run) + } + + public func updateAgentRun(_ run: AgentRun) async throws { + try db.updateAgentRun(run) + } + + public func fetchAgentRun(id: UUID) throws -> AgentRun? { + try db.fetchAgentRun(id: id) + } + + public func fetchLatestAgentRun(scriptId: UUID) throws -> AgentRun? { + try db.fetchLatestAgentRun(scriptId: scriptId) + } + + public func fetchAgentRuns(scriptId: UUID, limit: Int = 50) throws -> [AgentRun] { + try db.fetchAgentRuns(scriptId: scriptId, limit: limit) + } } From 37acdafe45beea3ddc1a66aa1037ac01a505701a Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 16:11:45 +0800 Subject: [PATCH 02/15] Add comprehensive CLI and core behavior coverage tests --- Package.swift | 2 +- .../ScriptoriaCLITests.swift | 416 ++++++++++++++++++ .../ScriptoriaCoreBehaviorTests.swift | 386 ++++++++++++++++ Tests/ScriptoriaCoreTests/TestSupport.swift | 371 ++++++++++++++++ 4 files changed, 1174 insertions(+), 1 deletion(-) create mode 100644 Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift create mode 100644 Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift create mode 100644 Tests/ScriptoriaCoreTests/TestSupport.swift diff --git a/Package.swift b/Package.swift index eb7b1de..5875aec 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( // Tests .testTarget( name: "ScriptoriaCoreTests", - dependencies: ["ScriptoriaCore"], + dependencies: ["ScriptoriaCore", "ScriptoriaCLI"], path: "Tests/ScriptoriaCoreTests" ), ] diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift new file mode 100644 index 0000000..7265899 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift @@ -0,0 +1,416 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("CLI Command Coverage", .serialized) +struct ScriptoriaCLITests { + @Test("common not-found and missing-arg failures") + func testCommonFailurePaths() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-failures") { _ in + let runMissing = try runCLI(arguments: ["run"]) + #expect(runMissing.exitCode != 0) + + let removeMissing = try runCLI(arguments: ["remove", "missing-script"]) + #expect(removeMissing.exitCode != 0) + + let memoryMissing = try runCLI(arguments: ["memory", "summarize"]) + #expect(memoryMissing.exitCode != 0) + #expect(memoryMissing.stdout.contains("Please provide a script title/UUID or --task-id")) + + let scheduleDisableMissing = try runCLI(arguments: ["schedule", "disable", "deadbeef"]) + #expect(scheduleDisableMissing.exitCode != 0) + } + } + + @Test("add/list/search/remove lifecycle") + func testScriptLifecycleCommands() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-lifecycle") { workspace in + let scriptPath = try workspace.makeScript( + name: "deploy.sh", + content: "#!/bin/sh\necho deploy-ok\n" + ) + let skillPath = try workspace.makeFile(relativePath: "skills/skill.md", content: "# skill") + + let add = try runCLI(arguments: [ + "add", scriptPath, + "--title", "Deploy", + "--description", "Deploy script", + "--tags", "deploy,prod", + "--skill", skillPath, + "--task-name", "DeployTask", + "--default-model", "gpt-test" + ]) + #expect(add.exitCode == 0) + #expect(add.stdout.contains("Added script: Deploy")) + + let list = try runCLI(arguments: ["list"]) + #expect(list.exitCode == 0) + #expect(list.stdout.contains("Deploy")) + #expect(list.stdout.contains("DeployTask")) + #expect(list.stdout.contains("model: gpt-test")) + + let search = try runCLI(arguments: ["search", "deploy"]) + #expect(search.exitCode == 0) + #expect(search.stdout.contains("Deploy")) + + let remove = try runCLI(arguments: ["remove", "Deploy"]) + #expect(remove.exitCode == 0) + #expect(remove.stdout.contains("Removed: Deploy")) + + let listAfter = try runCLI(arguments: ["list"]) + #expect(listAfter.exitCode == 0) + #expect(listAfter.stdout.contains("No scripts found.")) + } + } + + @Test("add command failures") + func testAddCommandFailures() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-add-failure") { workspace in + let missing = try runCLI(arguments: ["add", "/tmp/does-not-exist.sh"]) + #expect(missing.exitCode != 0) + #expect(missing.stdout.contains("File not found")) + + let scriptPath = try workspace.makeScript(name: "ok.sh", content: "#!/bin/sh\necho ok\n") + let missingSkill = try runCLI(arguments: ["add", scriptPath, "--skill", "/tmp/skill-not-exist.md"]) + #expect(missingSkill.exitCode != 0) + #expect(missingSkill.stdout.contains("Skill file not found")) + } + } + + @Test("tags list/add/remove") + func testTagsCommands() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-tags") { workspace in + let scriptPath = try workspace.makeScript(name: "taggable.sh", content: "#!/bin/sh\necho tags\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "Taggable", "--tags", "one"]) + + let addTags = try runCLI(arguments: ["tags", "add", "Taggable", "two,three"]) + #expect(addTags.exitCode == 0) + #expect(addTags.stdout.contains("Added tags")) + + let addDuplicate = try runCLI(arguments: ["tags", "add", "Taggable", "two"]) + #expect(addDuplicate.exitCode == 0) + #expect(addDuplicate.stdout.contains("already exist")) + + let listTags = try runCLI(arguments: ["tags", "list"]) + #expect(listTags.exitCode == 0) + #expect(listTags.stdout.contains("one")) + #expect(listTags.stdout.contains("two")) + + let removeTag = try runCLI(arguments: ["tags", "remove", "Taggable", "one"]) + #expect(removeTag.exitCode == 0) + #expect(removeTag.stdout.contains("Removed tags")) + + let removeMissing = try runCLI(arguments: ["tags", "remove", "Taggable", "not-there"]) + #expect(removeMissing.exitCode == 0) + #expect(removeMissing.stdout.contains("None of those tags exist")) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "Taggable")) + #expect(Set(script.tags) == Set(["two", "three"])) + } + } + + @Test("config show and set-dir") + func testConfigCommands() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-config") { workspace in + let initial = try runCLI(arguments: ["config", "show"]) + #expect(initial.exitCode == 0) + #expect(initial.stdout.contains(workspace.defaultDataDir.path)) + + let newDataDir = workspace.rootURL.appendingPathComponent("custom-data").path + let setDir = try runCLI(arguments: ["config", "set-dir", newDataDir]) + #expect(setDir.exitCode == 0) + #expect(setDir.stdout.contains("Data directory set to")) + + let loaded = Config.load() + #expect(loaded.dataDirectory == newDataDir) + + let show = try runCLI(arguments: ["config", "show"]) + #expect(show.exitCode == 0) + #expect(show.stdout.contains(newDataDir)) + } + } + + @Test("run command success with skip-agent") + func testRunCommandSuccess() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-run-success") { workspace in + let scriptPath = try workspace.makeScript(name: "ok.sh", content: "#!/bin/sh\necho run-ok\n") + let add = try runCLI(arguments: ["add", scriptPath, "--title", "RunOK"]) + #expect(add.exitCode == 0) + + let run = try runCLI(arguments: ["run", "RunOK", "--skip-agent", "--no-notify", "--no-steer"]) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("success")) + #expect(run.stdout.contains("run-ok")) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "RunOK")) + let history = try store.fetchRunHistory(scriptId: script.id, limit: 5) + let latest = try #require(history.first) + #expect(latest.status == .success) + #expect(latest.exitCode == 0) + #expect(script.runCount == 1) + } + } + + @Test("run command failure") + func testRunCommandFailure() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-run-failure") { workspace in + let scriptPath = try workspace.makeScript( + name: "fail.sh", + content: "#!/bin/sh\necho fail-msg\nexit 7\n" + ) + let add = try runCLI(arguments: ["add", scriptPath, "--title", "RunFail"]) + #expect(add.exitCode == 0) + + let run = try runCLI(arguments: ["run", "RunFail", "--skip-agent", "--no-notify", "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("fail-msg")) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "RunFail")) + let history = try store.fetchRunHistory(scriptId: script.id, limit: 1) + let latest = try #require(history.first) + #expect(latest.status == .failure) + #expect(latest.exitCode == 7) + } + } + + @Test("run command duplicate process protection") + func testRunCommandDuplicateProtection() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-run-dup") { workspace in + let scriptPath = try workspace.makeScript(name: "dup.sh", content: "#!/bin/sh\necho dup\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "DupScript"]) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "DupScript")) + let running = ScriptRun( + scriptId: script.id, + scriptTitle: script.title, + status: .running, + pid: getpid() + ) + try await store.saveRunHistory(running) + + let run = try runCLI(arguments: ["run", "DupScript", "--skip-agent", "--no-notify"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("already running")) + } + } + + @Test("run command agent stage + memory") + func testRunCommandAgentStage() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-agent") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let scriptPath = try workspace.makeScript(name: "agent.sh", content: "#!/bin/sh\necho agent-script\n") + let skillPath = try workspace.makeFile(relativePath: "skills/agent-skill.md", content: "# agent skill") + _ = try runCLI(arguments: [ + "add", scriptPath, + "--title", "AgentScript", + "--skill", skillPath, + "--task-name", "AgentTask", + "--default-model", "gpt-default" + ]) + + let run = try runCLI( + arguments: ["run", "AgentScript", "--no-notify", "--no-steer", "--model", "gpt-override"], + timeout: 20 + ) + #expect(run.exitCode == 0) + #expect(run.timedOut == false) + #expect(run.stdout.contains("Starting agent task")) + #expect(run.stdout.contains("agent delta")) + #expect(run.stdout.contains("Task Memory")) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "AgentScript")) + let latest = try #require(try store.fetchLatestAgentRun(scriptId: script.id)) + #expect(latest.status == .completed) + #expect(latest.model == "gpt-override") + let taskMemoryPath = try #require(latest.taskMemoryPath) + #expect(FileManager.default.fileExists(atPath: taskMemoryPath)) + #expect(taskMemoryPath.contains("/memory/AgentTask/task/")) + } + } + } + + @Test("memory summarize by script and task-id") + func testMemorySummarizeCommand() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-memory") { workspace in + let scriptPath = try workspace.makeScript(name: "memory.sh", content: "#!/bin/sh\necho memory\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "MemoryScript", "--task-name", "MemoryTask"]) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "MemoryScript")) + let profile = try #require(try store.fetchAgentProfile(scriptId: script.id)) + + let memory = MemoryManager(config: Config.load()) + let run = ScriptRun( + scriptId: script.id, + scriptTitle: script.title, + finishedAt: Date(), + status: .success, + exitCode: 0, + output: "stdout" + ) + let agent = AgentExecutionResult( + threadId: "thread-1", + turnId: "turn-1", + model: "gpt", + startedAt: Date().addingTimeInterval(-1), + finishedAt: Date(), + status: .completed, + finalMessage: "done", + output: "out" + ) + _ = try memory.writeTaskMemory(taskId: profile.id, taskName: profile.taskName, script: script, scriptRun: run, agentResult: agent) + _ = try memory.writeTaskMemory(taskId: profile.id, taskName: profile.taskName, script: script, scriptRun: run, agentResult: agent) + + let summarizeByScript = try runCLI(arguments: ["memory", "summarize", "MemoryScript"]) + #expect(summarizeByScript.exitCode == 0) + #expect(summarizeByScript.stdout.contains("Workspace memory updated")) + + let summarizeByTask = try runCLI(arguments: ["memory", "summarize", "--task-id", "\(profile.id)"]) + #expect(summarizeByTask.exitCode == 0) + + let workspacePath = memory.workspacePath(taskId: profile.id, taskName: profile.taskName) + #expect(FileManager.default.fileExists(atPath: workspacePath)) + } + } + + @Test("schedule command family") + func testScheduleCommands() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-schedule") { workspace in + let scriptPath = try workspace.makeScript(name: "schedule.sh", content: "#!/bin/sh\necho schedule\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "Sched"]) + + let fake = try workspace.makeFakeLaunchctl() + try await withEnvironment([ + "SCRIPTORIA_LAUNCHCTL_PATH": fake.path, + "SCRIPTORIA_LAUNCH_AGENTS_DIR": fake.agentsDir, + "SCRIPTORIA_FAKE_LAUNCHCTL_LOG": fake.logPath + ]) { + let every = try runCLI(arguments: ["schedule", "add", "Sched", "--every", "5"]) + #expect(every.exitCode == 0) + let daily = try runCLI(arguments: ["schedule", "add", "Sched", "--daily", "09:30"]) + #expect(daily.exitCode == 0) + let weekly = try runCLI(arguments: ["schedule", "add", "Sched", "--weekly", "mon,wed@10:15"]) + #expect(weekly.exitCode == 0) + + let invalid = try runCLI(arguments: ["schedule", "add", "Sched", "--daily", "0930"]) + #expect(invalid.exitCode != 0) + + let list = try runCLI(arguments: ["schedule", "list"]) + #expect(list.exitCode == 0) + #expect(list.stdout.contains("SCHEDULED TASKS")) + + let scheduleStore = ScheduleStore.fromConfig() + try await scheduleStore.load() + let first = try #require(scheduleStore.all().first) + let idPrefix = String(first.id.uuidString.prefix(8)) + + let disable = try runCLI(arguments: ["schedule", "disable", idPrefix]) + #expect(disable.exitCode == 0) + let enable = try runCLI(arguments: ["schedule", "enable", idPrefix]) + #expect(enable.exitCode == 0) + let remove = try runCLI(arguments: ["schedule", "remove", idPrefix]) + #expect(remove.exitCode == 0) + + let log = (try? String(contentsOfFile: fake.logPath, encoding: .utf8)) ?? "" + #expect(log.contains("load")) + #expect(log.contains("unload")) + } + } + } + + @Test("ps/logs/kill commands") + func testPsLogsKillCommands() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-process") { workspace in + let scriptPath = try workspace.makeScript(name: "process.sh", content: "#!/bin/sh\necho process\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "ProcScript"]) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "ProcScript")) + + let psEmpty = try runCLI(arguments: ["ps"]) + #expect(psEmpty.exitCode == 0) + #expect(psEmpty.stdout.contains("No running scripts.")) + + let finishedRun = ScriptRun( + scriptId: script.id, + scriptTitle: script.title, + finishedAt: Date(), + status: .success, + exitCode: 0, + output: "db-out-1\ndb-out-2\n", + errorOutput: "db-err\n" + ) + try await store.saveRunHistory(finishedRun) + + let logs = try runCLI(arguments: ["logs", String(finishedRun.id.uuidString.prefix(8))]) + #expect(logs.exitCode == 0) + #expect(logs.stdout.contains("db-out-1")) + #expect(logs.stdout.contains("db-err")) + + let followFinished = try runCLI(arguments: ["logs", String(finishedRun.id.uuidString.prefix(8)), "--follow"]) + #expect(followFinished.exitCode == 0) + #expect(followFinished.stdout.contains("no effect")) + + let logManager = LogManager(config: Config.load()) + logManager.append("line-1\nline-2\n", to: finishedRun.id) + let tail = try runCLI(arguments: ["logs", String(finishedRun.id.uuidString.prefix(8)), "--tail", "2"]) + #expect(tail.exitCode == 0) + #expect(tail.stdout.contains("line-2")) + + let missingLogs = try runCLI(arguments: ["logs", "UNKNOWN"]) + #expect(missingLogs.exitCode != 0) + + var staleRun = ScriptRun( + scriptId: script.id, + scriptTitle: script.title, + status: .running, + pid: 999_999 + ) + try await store.saveRunHistory(staleRun) + + let psCleanup = try runCLI(arguments: ["ps"]) + #expect(psCleanup.exitCode == 0) + staleRun = try #require(try store.fetchScriptRun(id: staleRun.id)) + #expect(staleRun.status == .failure) + + let killStale = try runCLI(arguments: ["kill", String(staleRun.id.uuidString.prefix(8))]) + #expect(killStale.exitCode != 0) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "sleep 30"] + try process.run() + + let liveRun = ScriptRun( + scriptId: script.id, + scriptTitle: script.title, + status: .running, + pid: process.processIdentifier + ) + try await store.saveRunHistory(liveRun) + + let killLive = try runCLI(arguments: ["kill", String(liveRun.id.uuidString.prefix(8))]) + #expect(killLive.exitCode == 0) + waitForProcessToExit(process.processIdentifier) + + let updatedLiveRun = try #require(try store.fetchScriptRun(id: liveRun.id)) + #expect(updatedLiveRun.status == .cancelled) + } + } +} diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift new file mode 100644 index 0000000..7657f2b --- /dev/null +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift @@ -0,0 +1,386 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Core Behavior Coverage", .serialized) +struct ScriptoriaCoreBehaviorTests { + @Test("config save/load and env override") + func testConfigBehavior() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-config") { workspace in + var config = Config.load() + #expect(config.dataDirectory == workspace.defaultDataDir.path) + + config.notifyOnCompletion = false + config.showRunningIndicator = false + try config.save() + + let loaded = Config.load() + #expect(loaded.notifyOnCompletion == false) + #expect(loaded.showRunningIndicator == false) + + let overrideDir = workspace.rootURL.appendingPathComponent("override-data").path + await withEnvironment(["SCRIPTORIA_DATA_DIR": overrideDir]) { + let overridden = Config.load() + #expect(overridden.dataDirectory == overrideDir) + } + } + } + + @Test("script store + agent profile + run storage") + func testScriptStoreAndAgentPersistence() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-store") { workspace in + let store = ScriptStore(baseDirectory: workspace.defaultDataDir.path) + try await store.load() + + let script = Script( + title: "Core Script", + description: "desc", + path: "/tmp/core.sh", + agentTaskName: "CoreTask", + defaultModel: "gpt-core", + interpreter: .bash, + tags: ["core", "test"] + ) + + let inserted = try await store.add(script) + #expect(inserted.agentTaskId != nil) + #expect(store.get(id: inserted.id) != nil) + + let profile = try #require(try store.fetchAgentProfile(scriptId: inserted.id)) + #expect(profile.taskName == "CoreTask") + #expect(profile.defaultModel == "gpt-core") + + var updated = inserted + updated.agentTaskName = "CoreTaskV2" + updated.defaultModel = "gpt-core-v2" + updated.tags.append("extra") + try await store.update(updated) + + let refreshedProfile = try #require(try store.fetchAgentProfile(scriptId: inserted.id)) + #expect(refreshedProfile.taskName == "CoreTaskV2") + #expect(refreshedProfile.defaultModel == "gpt-core-v2") + + let now = Date() + let run1 = ScriptRun( + scriptId: inserted.id, + scriptTitle: inserted.title, + startedAt: now.addingTimeInterval(-5), + finishedAt: now, + status: .success, + exitCode: 0 + ) + try await store.saveRunHistory(run1) + let run2 = ScriptRun( + scriptId: inserted.id, + scriptTitle: inserted.title, + startedAt: now.addingTimeInterval(-12), + finishedAt: now.addingTimeInterval(-6), + status: .failure, + exitCode: 2 + ) + try await store.saveRunHistory(run2) + try await store.recordRun(id: inserted.id, status: .success) + + let avg = try store.fetchAverageDuration(scriptId: inserted.id) + #expect(avg != nil) + let allAvg = try store.fetchAllAverageDurations() + #expect(allAvg[inserted.id] != nil) + + var agentRun = AgentRun( + scriptId: inserted.id, + scriptRunId: run1.id, + taskId: refreshedProfile.id, + taskName: refreshedProfile.taskName, + model: refreshedProfile.defaultModel, + threadId: "thread-core", + turnId: "turn-core" + ) + try await store.saveAgentRun(agentRun) + + agentRun.status = .completed + agentRun.finishedAt = Date() + agentRun.finalMessage = "done" + agentRun.output = "output" + agentRun.taskMemoryPath = "/tmp/task-memory.md" + try await store.updateAgentRun(agentRun) + + let latestAgent = try #require(try store.fetchLatestAgentRun(scriptId: inserted.id)) + #expect(latestAgent.status == .completed) + #expect(latestAgent.finalMessage == "done") + + try await store.remove(id: inserted.id) + #expect(store.get(id: inserted.id) == nil) + #expect(try store.fetchAgentProfile(scriptId: inserted.id) == nil) + } + } + + @Test("legacy JSON migration to sqlite") + func testLegacyMigration() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-migration") { workspace in + let dataDir = workspace.defaultDataDir.path + let scriptId = UUID() + let scheduleId = UUID() + let runId = UUID() + let script = Script( + id: scriptId, + title: "Migrated Script", + description: "legacy", + path: "/tmp/migrated.sh", + tags: ["legacy"] + ) + let schedule = Schedule(id: scheduleId, scriptId: scriptId, type: .interval(300)) + let run = ScriptRun( + id: runId, + scriptId: scriptId, + scriptTitle: script.title, + startedAt: Date().addingTimeInterval(-2), + finishedAt: Date(), + status: .success, + exitCode: 0, + output: "legacy-output" + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + let scriptsData = try encoder.encode([script]) + try scriptsData.write(to: URL(fileURLWithPath: "\(dataDir)/scripts.json")) + + let schedulesData = try encoder.encode([schedule]) + try schedulesData.write(to: URL(fileURLWithPath: "\(dataDir)/schedules.json")) + + let historyDir = URL(fileURLWithPath: dataDir).appendingPathComponent("history") + try FileManager.default.createDirectory(at: historyDir, withIntermediateDirectories: true) + let historyLine = String(data: try encoder.encode(run), encoding: .utf8)! + "\n" + try historyLine.write( + to: historyDir.appendingPathComponent("runs.jsonl"), + atomically: true, + encoding: .utf8 + ) + + let db = try DatabaseManager(directory: dataDir) + let migrated = try db.migrateFromJSONIfNeeded(directory: dataDir) + #expect(migrated == true) + + let store = ScriptStore(baseDirectory: dataDir) + try await store.load() + #expect(store.get(id: scriptId) != nil) + #expect(store.allTags().contains("legacy")) + + let scheduleStore = ScheduleStore(baseDirectory: dataDir) + try await scheduleStore.load() + #expect(scheduleStore.get(id: scheduleId) != nil) + + let runHistory = try store.fetchAllRunHistory(limit: 10) + #expect(runHistory.contains(where: { $0.id == runId })) + + #expect(FileManager.default.fileExists(atPath: "\(dataDir)/scripts.json.bak")) + #expect(FileManager.default.fileExists(atPath: "\(dataDir)/schedules.json.bak")) + #expect(FileManager.default.fileExists(atPath: "\(dataDir)/history.bak")) + } + } + + @Test("log manager append/read/cleanup") + func testLogManagerBehavior() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-log") { workspace in + let logsDir = workspace.rootURL.appendingPathComponent("logs").path + let logManager = LogManager(logsDirectory: logsDir) + let runId = UUID() + + logManager.append("line-1\n", to: runId) + logManager.append("line-2\n", to: runId) + let full = logManager.readLog(for: runId) + #expect(full?.contains("line-1") == true) + #expect(logManager.logSize(for: runId) > 0) + + let part = logManager.readLog(for: runId, fromOffset: 0) + #expect(part?.0.contains("line-2") == true) + + let path = logManager.logPath(for: runId) + try FileManager.default.setAttributes( + [.modificationDate: Date().addingTimeInterval(-86400 * 10)], + ofItemAtPath: path + ) + logManager.cleanOldLogs(olderThan: 7) + #expect(FileManager.default.fileExists(atPath: path) == false) + } + } + + @Test("script runner streaming and interpreter detection") + func testScriptRunnerBehavior() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-runner") { workspace in + let okPath = try workspace.makeScript( + name: "stream.sh", + content: "#!/bin/sh\necho stdout-msg\necho stderr-msg 1>&2\n" + ) + let script = Script(title: "RunnerOK", path: okPath, interpreter: .sh) + + let runner = ScriptRunner() + let streamed = OutputCollector() + let result = try await runner.runStreaming(script) { text, isStderr in + if isStderr { + streamed.appendStderr(text) + } else { + streamed.appendStdout(text) + } + } + + #expect(result.status == .success) + #expect(result.exitCode == 0) + #expect(streamed.stdout.contains("stdout-msg")) + #expect(streamed.stderr.contains("stderr-msg")) + + let failPath = try workspace.makeScript( + name: "fail.sh", + content: "#!/bin/sh\necho fail\nexit 4\n" + ) + let failScript = Script(title: "RunnerFail", path: failPath, interpreter: .sh) + let failResult = try await runner.run(failScript) + #expect(failResult.status == .failure) + #expect(failResult.exitCode == 4) + + let shebangPath = try workspace.makeScript( + name: "shebang-script", + content: "#!/usr/bin/env python3\nprint('x')\n", + executable: false + ) + #expect(runner.detectInterpreter(for: shebangPath) == .python3) + } + } + + @Test("process manager stale cleanup") + func testProcessManagerCleanup() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-process") { workspace in + let store = ScriptStore(baseDirectory: workspace.defaultDataDir.path) + try await store.load() + let script = try await store.add(Script(title: "Proc", path: "/tmp/proc.sh")) + let run = ScriptRun( + scriptId: script.id, + scriptTitle: script.title, + status: .running, + pid: 999_999 + ) + try await store.saveRunHistory(run) + + ProcessManager.cleanStaleRuns(store: store) + + let refreshed = try #require(try store.fetchScriptRun(id: run.id)) + #expect(refreshed.status == .failure) + } + } + + @Test("memory manager write/read/summarize") + func testMemoryManagerBehavior() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-memory") { workspace in + let memory = MemoryManager(baseDirectory: workspace.rootURL.appendingPathComponent("memory").path) + let script = Script(title: "Memory Script", path: "/tmp/memory.sh") + let scriptRun = ScriptRun( + scriptId: script.id, + scriptTitle: script.title, + finishedAt: Date(), + status: .success, + exitCode: 0, + output: "ok" + ) + + let first = AgentExecutionResult( + threadId: "t1", + turnId: "u1", + model: "m1", + startedAt: Date().addingTimeInterval(-2), + finishedAt: Date(), + status: .completed, + finalMessage: "first", + output: "first-out" + ) + let second = AgentExecutionResult( + threadId: "t2", + turnId: "u2", + model: "m2", + startedAt: Date().addingTimeInterval(-1), + finishedAt: Date(), + status: .failed, + finalMessage: "", + output: "second-out" + ) + + let p1 = try memory.writeTaskMemory(taskId: 1, taskName: "Task/Name", script: script, scriptRun: scriptRun, agentResult: first) + try await Task.sleep(nanoseconds: 1_100_000_000) + let p2 = try memory.writeTaskMemory(taskId: 1, taskName: "Task/Name", script: script, scriptRun: scriptRun, agentResult: second) + #expect(FileManager.default.fileExists(atPath: p1)) + #expect(FileManager.default.fileExists(atPath: p2)) + #expect(p1.contains("/memory/Task-Name/task/")) + + let workspacePath = try memory.summarizeWorkspaceMemory(taskId: 1, taskName: "Task/Name") + #expect(FileManager.default.fileExists(atPath: workspacePath)) + let workspaceText = try #require(memory.readWorkspaceMemory(taskId: 1, taskName: "Task/Name")) + #expect(workspaceText.contains("Workspace Memory")) + #expect(workspaceText.contains("Top Good Patterns")) + } + } + + @Test("schedule store activation and next-run calculation") + func testScheduleStoreBehavior() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-schedule") { workspace in + let fake = try workspace.makeFakeLaunchctl() + try await withEnvironment([ + "SCRIPTORIA_LAUNCHCTL_PATH": fake.path, + "SCRIPTORIA_LAUNCH_AGENTS_DIR": fake.agentsDir, + "SCRIPTORIA_FAKE_LAUNCHCTL_LOG": fake.logPath + ]) { + let scriptStore = ScriptStore(baseDirectory: workspace.defaultDataDir.path) + try await scriptStore.load() + let script = try await scriptStore.add(Script(title: "ScheduleCore", path: "/tmp/schedule.sh")) + + let scheduleStore = ScheduleStore(baseDirectory: workspace.defaultDataDir.path) + try await scheduleStore.load() + + let schedule = Schedule(scriptId: script.id, type: .interval(120)) + _ = try await scheduleStore.add(schedule) + try await scheduleStore.activate(schedule) + + var activated = try #require(scheduleStore.get(id: schedule.id)) + #expect(activated.isEnabled == true) + #expect(activated.nextRunAt != nil) + + try await scheduleStore.deactivate(activated) + activated = try #require(scheduleStore.get(id: schedule.id)) + #expect(activated.isEnabled == false) + + try await scheduleStore.remove(id: schedule.id) + #expect(scheduleStore.get(id: schedule.id) == nil) + + #expect(ScheduleStore.computeNextRun(for: .interval(60)) != nil) + #expect(ScheduleStore.computeNextRun(for: .daily(hour: 9, minute: 0)) != nil) + #expect(ScheduleStore.computeNextRun(for: .weekly(weekdays: [2, 4], hour: 10, minute: 30)) != nil) + } + } + } + + @Test("post-script agent prompt and instruction builders") + func testPostScriptAgentRunnerBehavior() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-agent") { workspace in + let options = PostScriptAgentLaunchOptions( + workingDirectory: workspace.rootURL.path, + model: "gpt-test", + userPrompt: "start", + developerInstructions: "dev", + codexExecutable: "codex-test" + ) + #expect(options.codexExecutable == "codex-test") + + let instructions = PostScriptAgentRunner.buildDeveloperInstructions( + skillContent: "# skill", + workspaceMemory: "# workspace" + ) + #expect(instructions.contains("Injected Skill")) + #expect(instructions.contains("Workspace Memory")) + + let script = Script(title: "PromptScript", path: "/tmp/prompt.sh") + let run = ScriptRun(scriptId: script.id, scriptTitle: script.title, status: .success, exitCode: 0, output: "o", errorOutput: "e") + let prompt = PostScriptAgentRunner.buildInitialPrompt(taskName: "PromptTask", script: script, scriptRun: run) + #expect(prompt.contains("Task Name: PromptTask")) + #expect(prompt.contains("Script STDOUT")) + } + } +} diff --git a/Tests/ScriptoriaCoreTests/TestSupport.swift b/Tests/ScriptoriaCoreTests/TestSupport.swift new file mode 100644 index 0000000..9557433 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/TestSupport.swift @@ -0,0 +1,371 @@ +import Darwin +import Foundation +import Testing + +struct CLIResult { + var stdout: String + var stderr: String + var exitCode: Int32 + var timedOut: Bool +} + +struct TestWorkspace { + let rootURL: URL + let defaultDataDir: URL + + init(prefix: String = "scriptoria-tests") throws { + let fm = FileManager.default + rootURL = fm.temporaryDirectory.appendingPathComponent("\(prefix)-\(UUID().uuidString)") + defaultDataDir = rootURL.appendingPathComponent("default-data") + try fm.createDirectory(at: rootURL, withIntermediateDirectories: true) + try fm.createDirectory(at: defaultDataDir, withIntermediateDirectories: true) + } + + var baseEnvironment: [String: String?] { + [ + "SCRIPTORIA_DEFAULT_DATA_DIR": defaultDataDir.path, + "SCRIPTORIA_POINTER_FILE": rootURL.appendingPathComponent("pointer.json").path, + "SCRIPTORIA_DATA_DIR": nil, + "SCRIPTORIA_LAUNCH_AGENTS_DIR": nil, + "SCRIPTORIA_LAUNCHCTL_PATH": nil, + "SCRIPTORIA_FAKE_LAUNCHCTL_LOG": nil, + "SCRIPTORIA_CODEX_EXECUTABLE": nil, + "SCRIPTORIA_FAKE_CODEX_MODE": nil, + "SCRIPTORIA_FAKE_CODEX_THREAD_ID": nil, + "SCRIPTORIA_FAKE_CODEX_TURN_ID": nil + ] + } + + func cleanup() { + try? FileManager.default.removeItem(at: rootURL) + } + + func makeScript(name: String, content: String, executable: Bool = true) throws -> String { + let scriptDir = rootURL.appendingPathComponent("scripts") + try FileManager.default.createDirectory(at: scriptDir, withIntermediateDirectories: true) + let scriptURL = scriptDir.appendingPathComponent(name) + try content.write(to: scriptURL, atomically: true, encoding: .utf8) + if executable { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: scriptURL.path + ) + } + return scriptURL.path + } + + func makeFile(relativePath: String, content: String) throws -> String { + let fileURL = rootURL.appendingPathComponent(relativePath) + let parent = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + try content.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL.path + } + + func makeExecutable(relativePath: String, content: String) throws -> String { + let path = try makeFile(relativePath: relativePath, content: content) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: path + ) + return path + } + + func makeFakeLaunchctl() throws -> (path: String, logPath: String, agentsDir: String) { + let logPath = rootURL.appendingPathComponent("fake-launchctl.log").path + let agentsDir = rootURL.appendingPathComponent("LaunchAgents").path + let script = """ + #!/bin/sh + echo "$@" >> "$SCRIPTORIA_FAKE_LAUNCHCTL_LOG" + exit 0 + """ + let path = try makeExecutable(relativePath: "bin/fake-launchctl.sh", content: script) + return (path, logPath, agentsDir) + } + + func makeFakeCodex() throws -> String { + let script = #""" + #!/usr/bin/env python3 + import json + import os + import sys + + mode = os.environ.get("SCRIPTORIA_FAKE_CODEX_MODE", "complete") + thread_id = os.environ.get("SCRIPTORIA_FAKE_CODEX_THREAD_ID", "thread-test") + turn_id = os.environ.get("SCRIPTORIA_FAKE_CODEX_TURN_ID", "turn-test") + + def send(payload): + sys.stdout.write(json.dumps(payload) + "\n") + sys.stdout.flush() + + for raw in sys.stdin: + raw = raw.strip() + if not raw: + continue + + try: + request = json.loads(raw) + except Exception: + continue + + method = request.get("method") + req_id = request.get("id") + + if req_id is None: + continue + + if method == "initialize": + send({"jsonrpc": "2.0", "id": req_id, "result": {}}) + elif method == "thread/start": + send({"jsonrpc": "2.0", "id": req_id, "result": {"thread": {"id": thread_id}}}) + send({"jsonrpc": "2.0", "method": "thread/started", "params": {"thread": {"id": thread_id}}}) + elif method == "turn/start": + send({"jsonrpc": "2.0", "id": req_id, "result": {"turn": {"id": turn_id}}}) + send({"jsonrpc": "2.0", "method": "turn/started", "params": {"turn": {"id": turn_id}}}) + if mode == "complete": + send({"jsonrpc": "2.0", "method": "item/agentMessage/delta", "params": {"itemId": "agent-1", "delta": "agent delta\n"}}) + send({"jsonrpc": "2.0", "method": "item/commandExecution/outputDelta", "params": {"itemId": "cmd-1", "delta": "command delta\n"}}) + send({"jsonrpc": "2.0", "method": "item/completed", "params": {"item": {"type": "agentMessage", "phase": "final_answer", "text": "final answer"}}}) + send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "completed"}}}) + elif mode == "interrupt_on_start": + send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "interrupted"}}}) + elif method == "turn/steer": + steer_text = "" + try: + steer_text = request["params"]["input"][0]["text"] + except Exception: + pass + send({"jsonrpc": "2.0", "id": req_id, "result": {}}) + send({"jsonrpc": "2.0", "method": "item/agentMessage/delta", "params": {"itemId": "agent-1", "delta": f"steer:{steer_text}\n"}}) + send({"jsonrpc": "2.0", "method": "item/completed", "params": {"item": {"type": "agentMessage", "phase": "final_answer", "text": "steer done"}}}) + send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "completed"}}}) + elif method == "turn/interrupt": + send({"jsonrpc": "2.0", "id": req_id, "result": {}}) + send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "interrupted"}}}) + else: + send({"jsonrpc": "2.0", "id": req_id, "result": {}}) + """# + return try makeExecutable(relativePath: "bin/fake-codex", content: script) + } +} + +@discardableResult +func withEnvironment( + _ overrides: [String: String?], + _ operation: () async throws -> T +) async rethrows -> T { + var previous: [String: String?] = [:] + for (key, value) in overrides { + previous[key] = ProcessInfo.processInfo.environment[key] + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + + defer { + for (key, value) in previous { + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + } + + return try await operation() +} + +func waitForProcessToExit(_ pid: Int32, timeout: TimeInterval = 3.0) { + let end = Date().addingTimeInterval(timeout) + while Date() < end { + if kill(pid, 0) != 0 { + return + } + Thread.sleep(forTimeInterval: 0.05) + } +} + +@discardableResult +func withTestWorkspace( + prefix: String = "scriptoria-tests", + _ operation: @Sendable (TestWorkspace) async throws -> T +) async throws -> T { + await workspaceGate.acquire() + + do { + let workspace = try TestWorkspace(prefix: prefix) + defer { workspace.cleanup() } + let result = try await withEnvironment(workspace.baseEnvironment) { + try await operation(workspace) + } + await workspaceGate.release() + return result + } catch { + await workspaceGate.release() + throw error + } +} + +private actor WorkspaceGate { + private var locked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !locked { + locked = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + if waiters.isEmpty { + locked = false + return + } + let next = waiters.removeFirst() + next.resume() + } +} + +private let workspaceGate = WorkspaceGate() + +final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Element] = [] + + func append(_ value: Element) { + lock.withLock { + storage.append(value) + } + } + + func values() -> [Element] { + lock.withLock { storage } + } +} + +enum TimeoutError: Error { + case timedOut +} + +func withTimeout( + seconds: Double, + operation: @Sendable @escaping () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TimeoutError.timedOut + } + + let result = try await group.next()! + group.cancelAll() + return result + } +} + +func runCLI( + arguments: [String], + extraEnvironment: [String: String?] = [:], + cwd: String? = nil, + timeout: TimeInterval = 15 +) throws -> CLIResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: try findScriptoriaExecutable()) + process.arguments = arguments + process.currentDirectoryURL = cwd.map { URL(fileURLWithPath: $0) } + + var env = ProcessInfo.processInfo.environment + for key in [ + "SCRIPTORIA_DEFAULT_DATA_DIR", + "SCRIPTORIA_POINTER_FILE", + "SCRIPTORIA_DATA_DIR", + "SCRIPTORIA_LAUNCH_AGENTS_DIR", + "SCRIPTORIA_LAUNCHCTL_PATH", + "SCRIPTORIA_FAKE_LAUNCHCTL_LOG", + "SCRIPTORIA_CODEX_EXECUTABLE", + "SCRIPTORIA_FAKE_CODEX_MODE", + "SCRIPTORIA_FAKE_CODEX_THREAD_ID", + "SCRIPTORIA_FAKE_CODEX_TURN_ID" + ] { + if let value = getenv(key).map({ String(cString: $0) }) { + env[key] = value + } else { + env.removeValue(forKey: key) + } + } + for (key, value) in extraEnvironment { + if let value { + env[key] = value + } else { + env.removeValue(forKey: key) + } + } + process.environment = env + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + let deadline = Date().addingTimeInterval(timeout) + var timedOut = false + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + timedOut = true + process.terminate() + Thread.sleep(forTimeInterval: 0.2) + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + } + process.waitUntilExit() + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + + return CLIResult( + stdout: String(data: stdoutData, encoding: .utf8) ?? "", + stderr: String(data: stderrData, encoding: .utf8) ?? "", + exitCode: process.terminationStatus, + timedOut: timedOut + ) +} + +private func findScriptoriaExecutable() throws -> String { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let candidates = [ + root.appendingPathComponent(".build/arm64-apple-macosx/debug/scriptoria").path, + root.appendingPathComponent(".build/debug/scriptoria").path, + URL(fileURLWithPath: Bundle.main.executablePath ?? "") + .deletingLastPathComponent() + .appendingPathComponent("scriptoria") + .path + ] + + for path in candidates where FileManager.default.isExecutableFile(atPath: path) { + return path + } + + throw NSError( + domain: "ScriptoriaTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "scriptoria executable not found"] + ) +} From 6282e42d43eb6c22313ee83b679305646b5e2feb Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 16:11:55 +0800 Subject: [PATCH 03/15] Add CI workflow for full tests and run-agent E2E --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..298436e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Swift Test + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Show toolchain + run: swift --version + + - name: Run full test suite + run: swift test + + - name: Run run+agent E2E test only + run: swift test --filter ScriptoriaCoreTests.ScriptoriaCLITests/testRunCommandAgentStage From 90ba3caed32f08a43c6a7de0721665326c655c2a Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 16:16:26 +0800 Subject: [PATCH 04/15] Document local coding agent integration for codex claude kimi --- AGENTS.md | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7e87e53 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,238 @@ +# Scriptoria + +Scriptoria is a macOS automation script manager โ€” a menu bar app + CLI tool for organizing, running, and scheduling shell scripts. + +## Architecture + +- **ScriptoriaApp** โ€” SwiftUI macOS app (menu bar + main window) +- **ScriptoriaCLI** โ€” Command-line tool (`scriptoria`) +- **ScriptoriaCore** โ€” Shared library (models, storage, execution) +- Storage: SQLite via GRDB at `~/.scriptoria/` (configurable) +- Scheduling: macOS launchd agents + +## Build & Run + +```bash +swift build # Build all targets +swift run scriptoria --help # Run CLI +swift test # Run tests +``` + +## CLI Installation + +The CLI binary is at `.build/debug/scriptoria` after building. To install system-wide: + +```bash +# Option 1: Symlink (recommended for development) +sudo ln -sf "$(pwd)/.build/debug/scriptoria" /usr/local/bin/scriptoria + +# Option 2: Via the GUI app +# Settings > General > Shell Command > Install +``` + +## CLI Reference + +### `scriptoria add ` โ€” Add a script + +```bash +scriptoria add ./backup.sh +scriptoria add ~/scripts/deploy.sh --title "Deploy" --description "Deploy to prod" --interpreter bash --tags "deploy,prod" +``` + +Options: +- `-t, --title` โ€” Display name (defaults to filename) +- `-d, --description` โ€” Description text +- `-i, --interpreter` โ€” One of: `auto`, `bash`, `zsh`, `sh`, `node`, `python3`, `ruby`, `osascript`, `binary` +- `--tags` โ€” Comma-separated tags (e.g. `"backup,daily"`) + +### `scriptoria list` โ€” List scripts + +```bash +scriptoria list # All scripts +scriptoria list --tag backup # Filter by tag +scriptoria list --favorites # Only favorites +scriptoria list --recent # Recently run +``` + +Output shows: status icon, title, ID prefix, interpreter, tags, run count. + +### `scriptoria run ` โ€” Run a script + +```bash +scriptoria run "Deploy" # By title +scriptoria run 3A1F2B4C-... # By full UUID +scriptoria run deploy --notify # Send macOS notification on finish +scriptoria run deploy --scheduled # Scheduled mode (auto-notify, less output) +scriptoria run --id "3A1F2B4C-..." # Explicit --id flag +``` + +Exit code matches the script's exit code. Run history is saved to the database. + +### `scriptoria search ` โ€” Search scripts + +```bash +scriptoria search backup # Search title, description, tags +``` + +### `scriptoria remove ` โ€” Remove a script + +```bash +scriptoria remove "Deploy" +scriptoria remove 3A1F2B4C +``` + +### `scriptoria tags` โ€” List all tags + +```bash +scriptoria tags # Shows all tags with script counts +``` + +### `scriptoria schedule` โ€” Manage scheduled tasks + +#### List schedules + +```bash +scriptoria schedule list # Shows all schedules with status, next run time +scriptoria schedule # Same (list is default) +``` + +#### Add a schedule + +```bash +# Run every 30 minutes +scriptoria schedule add "Backup" --every 30 + +# Run daily at 09:00 +scriptoria schedule add "Report" --daily 09:00 + +# Run on specific weekdays at a time +scriptoria schedule add "Deploy" --weekly "mon,wed,fri@09:00" +``` + +Schedule types: +- `--every ` โ€” Interval-based (e.g. every 30 minutes) +- `--daily HH:MM` โ€” Daily at a specific time +- `--weekly "days@HH:MM"` โ€” Weekly on specific days. Days: `sun`, `mon`, `tue`, `wed`, `thu`, `fri`, `sat` + +Schedules are backed by macOS launchd agents and persist across reboots. + +#### Enable / Disable / Remove a schedule + +```bash +scriptoria schedule enable # ID prefix works (e.g. "3A1F2B4C") +scriptoria schedule disable +scriptoria schedule remove +``` + +### `scriptoria config` โ€” Configuration + +```bash +scriptoria config show # Show current config +scriptoria config set-dir ~/my-data # Set data directory +``` + +## Typical AI Workflow + +A complete example of adding a script, scheduling it, and verifying: + +```bash +# 1. Write a script +cat > /tmp/health-check.sh << 'EOF' +#!/bin/bash +curl -sf https://example.com/health && echo "OK" || echo "FAIL" +EOF +chmod +x /tmp/health-check.sh + +# 2. Add to Scriptoria +scriptoria add /tmp/health-check.sh --title "Health Check" --description "Check service health" --tags "monitoring,health" + +# 3. Test run +scriptoria run "Health Check" + +# 4. Schedule every 10 minutes +scriptoria schedule add "Health Check" --every 10 + +# 5. Verify +scriptoria list +scriptoria schedule list +``` + +## Key File Paths + +- CLI entry: `Sources/ScriptoriaCLI/CLI.swift` +- Commands: `Sources/ScriptoriaCLI/Commands/` +- Models: `Sources/ScriptoriaCore/Models/` (Script, ScriptRun, Schedule) +- Storage: `Sources/ScriptoriaCore/Storage/` (ScriptStore, DatabaseManager, Config) +- Execution: `Sources/ScriptoriaCore/Execution/ScriptRunner.swift` +- Scheduling: `Sources/ScriptoriaCore/Scheduling/` (ScheduleStore, LaunchdHelper) +- App views: `Sources/ScriptoriaApp/Views/` +- App state: `Sources/ScriptoriaApp/AppState.swift` +- Theme: `Sources/ScriptoriaApp/Styles/Theme.swift` + +## Local Coding Agent Integration (Claude/Codex/Kimi) + +Scriptoria's post-script agent stage should be able to run a local coding agent provider: + +- `codex` (native) +- `claude` (via local adapter) +- `kimi` (via local adapter) + +### Unified transport contract + +Use a single stdio JSON-RPC contract for all providers (native or adapter): + +- Request methods: + - `initialize` + - `thread/start` + - `turn/start` + - `turn/steer` + - `turn/interrupt` +- Notifications/events: + - `thread/started` + - `turn/started` + - `item/agentMessage/delta` + - `item/commandExecution/outputDelta` + - `item/completed` + - `turn/completed` + +### Execution rule + +- Keep `PostScriptAgentRunner` provider-agnostic. +- Select provider by executable path, not by hard-coded branches in core logic. +- Current runtime switch is `SCRIPTORIA_CODEX_EXECUTABLE` (or launch option executable). +- For `claude` and `kimi`, point this executable to a local adapter that exposes the same app-server protocol. + +### Adapter requirements (for Claude/Kimi) + +- Must accept `app-server --listen stdio://`. +- Must read newline-delimited JSON-RPC messages from stdin. +- Must write newline-delimited JSON-RPC messages to stdout. +- Must preserve streaming behavior (`delta` events). +- Must support `turn/steer` and `turn/interrupt`. +- Must exit non-zero on unrecoverable startup/runtime errors. + +### Example usage + +```bash +# Codex native +SCRIPTORIA_CODEX_EXECUTABLE="$(which codex)" \ + scriptoria run "My Task" --model gpt-5.3-codex --no-steer + +# Claude via local adapter +SCRIPTORIA_CODEX_EXECUTABLE="$HOME/.scriptoria/agents/claude-adapter" \ + scriptoria run "My Task" --model claude-sonnet --no-steer + +# Kimi via local adapter +SCRIPTORIA_CODEX_EXECUTABLE="$HOME/.scriptoria/agents/kimi-adapter" \ + scriptoria run "My Task" --model kimi-k2 --no-steer +``` + +### Testing expectations + +- Validate one end-to-end run per provider (codex/claude/kimi adapter). +- Validate streaming output is visible in CLI during agent run. +- Validate steer/interrupt commands are accepted while the same session is running. +- Validate task memory is written after completion: + - `task-name/task/YYYYMMDDHHMMSS.md` +- Validate workspace memory summarize command still works: + - `task-name/workspace.md` From 088cd83412ba560fde7009bdb3ca7f9057933c96 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 16:18:59 +0800 Subject: [PATCH 05/15] Improve menu bar panel focus and clean app build in installer --- .../Views/MenuBar/MenuBarPanel.swift | 15 +++++++++++++++ scripts/install.sh | 10 +++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Sources/ScriptoriaApp/Views/MenuBar/MenuBarPanel.swift b/Sources/ScriptoriaApp/Views/MenuBar/MenuBarPanel.swift index ca48f4f..079673b 100644 --- a/Sources/ScriptoriaApp/Views/MenuBar/MenuBarPanel.swift +++ b/Sources/ScriptoriaApp/Views/MenuBar/MenuBarPanel.swift @@ -171,6 +171,21 @@ struct MenuBarPanel: View { .task { await appState.loadScripts() } + .onAppear { + // Bring the menu bar panel to front above all other windows + NSApp.activate(ignoringOtherApps: true) + DispatchQueue.main.async { + // The MenuBarExtra .window style creates an NSPanel; + // find it and ensure it floats above other windows + for window in NSApp.windows { + if let panel = window as? NSPanel, + panel.isFloatingPanel || panel.becomesKeyOnlyIfNeeded { + panel.level = .popUpMenu + panel.orderFrontRegardless() + } + } + } + } } } diff --git a/scripts/install.sh b/scripts/install.sh index a2615b0..82a44d2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -21,8 +21,16 @@ swift build -c release --product scriptoria 2>&1 | tail -3 CLI_BIN="${PROJECT_DIR}/.build/release/scriptoria" echo " CLI: ${CLI_BIN}" -# Step 2: Build the App +# Step 2: Build the App (clean first to ensure latest code) echo "" +echo "โ†’ Cleaning previous build..." +xcodebuild \ + -project "${PROJECT_DIR}/${APP_NAME}.xcodeproj" \ + -scheme "${SCHEME}" \ + -configuration Release \ + -derivedDataPath "${BUILD_DIR}" \ + clean 2>&1 | tail -1 + echo "โ†’ Building ${APP_NAME}.app (Release)..." xcodebuild \ -project "${PROJECT_DIR}/${APP_NAME}.xcodeproj" \ From 440d32b143b53281f0e00a27d2b42bbc60066268 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 17:33:15 +0800 Subject: [PATCH 06/15] fix(agent): handle app-server crashes and unique task memory files --- .../Execution/CodexAppServerClient.swift | 2 + .../Execution/MemoryManager.swift | 9 ++- .../Execution/PostScriptAgentRunner.swift | 55 +++++++++++-------- .../ScriptoriaCLITests.swift | 28 ++++++++++ .../ScriptoriaCoreBehaviorTests.swift | 7 ++- Tests/ScriptoriaCoreTests/TestSupport.swift | 2 + 6 files changed, 75 insertions(+), 28 deletions(-) diff --git a/Sources/ScriptoriaCore/Execution/CodexAppServerClient.swift b/Sources/ScriptoriaCore/Execution/CodexAppServerClient.swift index 44b10c4..06ac309 100644 --- a/Sources/ScriptoriaCore/Execution/CodexAppServerClient.swift +++ b/Sources/ScriptoriaCore/Execution/CodexAppServerClient.swift @@ -4,6 +4,7 @@ public enum CodexAppServerEvent: Sendable { case threadStarted(threadId: String) case turnStarted(turnId: String) case turnCompleted(turnId: String, status: String) + case processTerminated(exitCode: Int32) case agentMessageDelta(itemId: String, delta: String) case commandOutputDelta(itemId: String, delta: String) case agentMessageCompleted(phase: String?, text: String) @@ -265,6 +266,7 @@ public actor CodexAppServerClient { } pendingResponses.removeAll() + emit(.processTerminated(exitCode: exitCode)) emit(.diagnostic("codex app-server exited with code \(exitCode)")) } diff --git a/Sources/ScriptoriaCore/Execution/MemoryManager.swift b/Sources/ScriptoriaCore/Execution/MemoryManager.swift index 0e814c9..7c4b0e4 100644 --- a/Sources/ScriptoriaCore/Execution/MemoryManager.swift +++ b/Sources/ScriptoriaCore/Execution/MemoryManager.swift @@ -45,7 +45,12 @@ public final class MemoryManager: Sendable { } let timestamp = formatTimestamp(agentResult.finishedAt) - let path = "\(dir)/\(timestamp).md" + var path = "\(dir)/\(timestamp).md" + var suffix = 1 + while fm.fileExists(atPath: path) { + path = "\(dir)/\(timestamp)-\(suffix).md" + suffix += 1 + } let good = buildGoodPoints(scriptRun: scriptRun, agentResult: agentResult) let bad = buildBadPoints(scriptRun: scriptRun, agentResult: agentResult) @@ -297,7 +302,7 @@ public final class MemoryManager: Sendable { private func formatTimestamp(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyyMMddHHmmss" + formatter.dateFormat = "yyyyMMddHHmmssSSS" return formatter.string(from: date) } diff --git a/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift b/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift index f57568f..b5f971c 100644 --- a/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift +++ b/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift @@ -125,6 +125,10 @@ public actor PostScriptAgentSession { guard turnId == self.turnId else { return } finish(status: mapStatus(status)) + case .processTerminated(let exitCode): + onEvent?(AgentStreamEvent(kind: .error, text: "codex app-server exited with code \(exitCode)\n")) + finish(status: .failed) + case .diagnostic(let line): onEvent?(AgentStreamEvent(kind: .info, text: line + "\n")) } @@ -205,31 +209,36 @@ public enum PostScriptAgentRunner { .trimmingCharacters(in: .whitespacesAndNewlines) let executable = (envExecutable?.isEmpty == false) ? envExecutable! : options.codexExecutable let client = CodexAppServerClient(cwd: options.workingDirectory, executable: executable) - try await client.connect() - - let threadId = try await client.startThread( - model: options.model, - developerInstructions: options.developerInstructions, - approvalPolicy: options.approvalPolicy, - sandbox: options.sandbox - ) - - let session = PostScriptAgentSession( - client: client, - threadId: threadId, - model: options.model, - onEvent: onEvent - ) - - await client.setEventHandler { event in - Task { - await session.handle(event: event) + do { + try await client.connect() + + let threadId = try await client.startThread( + model: options.model, + developerInstructions: options.developerInstructions, + approvalPolicy: options.approvalPolicy, + sandbox: options.sandbox + ) + + let session = PostScriptAgentSession( + client: client, + threadId: threadId, + model: options.model, + onEvent: onEvent + ) + + await client.setEventHandler { event in + Task { + await session.handle(event: event) + } } - } - let turnId = try await client.startTurn(threadId: threadId, input: options.userPrompt) - await session.activate(turnId: turnId) - return session + let turnId = try await client.startTurn(threadId: threadId, input: options.userPrompt) + await session.activate(turnId: turnId) + return session + } catch { + await client.shutdown() + throw error + } } public static func buildDeveloperInstructions( diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift index 7265899..ef043c1 100644 --- a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift @@ -243,6 +243,34 @@ struct ScriptoriaCLITests { } } + @Test("run command agent crash should fail fast") + func testRunCommandAgentCrashFailsFast() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-agent-crash") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "exit_after_turn_start" + ]) { + let scriptPath = try workspace.makeScript(name: "agent-crash.sh", content: "#!/bin/sh\necho ok\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "AgentCrash"]) + + let run = try runCLI( + arguments: ["run", "AgentCrash", "--no-notify", "--no-steer", "--model", "gpt-test"], + timeout: 10 + ) + #expect(run.timedOut == false) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("Agent failed")) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "AgentCrash")) + let latest = try #require(try store.fetchLatestAgentRun(scriptId: script.id)) + #expect(latest.status == .failed) + } + } + } + @Test("memory summarize by script and task-id") func testMemorySummarizeCommand() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-memory") { workspace in diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift index 7657f2b..a654b69 100644 --- a/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift @@ -283,12 +283,13 @@ struct ScriptoriaCoreBehaviorTests { output: "ok" ) + let fixedFinishedAt = Date() let first = AgentExecutionResult( threadId: "t1", turnId: "u1", model: "m1", startedAt: Date().addingTimeInterval(-2), - finishedAt: Date(), + finishedAt: fixedFinishedAt, status: .completed, finalMessage: "first", output: "first-out" @@ -298,15 +299,15 @@ struct ScriptoriaCoreBehaviorTests { turnId: "u2", model: "m2", startedAt: Date().addingTimeInterval(-1), - finishedAt: Date(), + finishedAt: fixedFinishedAt, status: .failed, finalMessage: "", output: "second-out" ) let p1 = try memory.writeTaskMemory(taskId: 1, taskName: "Task/Name", script: script, scriptRun: scriptRun, agentResult: first) - try await Task.sleep(nanoseconds: 1_100_000_000) let p2 = try memory.writeTaskMemory(taskId: 1, taskName: "Task/Name", script: script, scriptRun: scriptRun, agentResult: second) + #expect(p1 != p2) #expect(FileManager.default.fileExists(atPath: p1)) #expect(FileManager.default.fileExists(atPath: p2)) #expect(p1.contains("/memory/Task-Name/task/")) diff --git a/Tests/ScriptoriaCoreTests/TestSupport.swift b/Tests/ScriptoriaCoreTests/TestSupport.swift index 9557433..ee8551a 100644 --- a/Tests/ScriptoriaCoreTests/TestSupport.swift +++ b/Tests/ScriptoriaCoreTests/TestSupport.swift @@ -129,6 +129,8 @@ struct TestWorkspace { send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "completed"}}}) elif mode == "interrupt_on_start": send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "interrupted"}}}) + elif mode == "exit_after_turn_start": + sys.exit(3) elif method == "turn/steer": steer_text = "" try: From bd99953925700841680ad5c50f5612d4d2a6b116 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 17:49:18 +0800 Subject: [PATCH 07/15] feat(agent): support scripted commands in CLI and GUI command mode --- Sources/ScriptoriaApp/AppState.swift | 14 +++- .../Views/Main/ScriptDetailView.swift | 33 ++++++--- .../ScriptoriaCLI/Commands/RunCommand.swift | 29 ++++++-- .../Execution/AgentCommandInput.swift | 31 +++++++++ .../ScriptoriaCLITests.swift | 69 +++++++++++++++++++ .../ScriptoriaCoreBehaviorTests.swift | 11 +++ 6 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 Sources/ScriptoriaCore/Execution/AgentCommandInput.swift diff --git a/Sources/ScriptoriaApp/AppState.swift b/Sources/ScriptoriaApp/AppState.swift index 6bc4549..99f77ff 100644 --- a/Sources/ScriptoriaApp/AppState.swift +++ b/Sources/ScriptoriaApp/AppState.swift @@ -270,11 +270,19 @@ final class AppState: ObservableObject { } func steerAgent(scriptId: UUID, input: String) async { + await sendAgentCommand(scriptId: scriptId, mode: .prompt, input: input) + } + + func sendAgentCommand(scriptId: UUID, mode: AgentCommandMode, input: String) async { guard let session = activeAgentSessions[scriptId] else { return } - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard let command = AgentCommandInput.from(mode: mode, input: input) else { return } do { - try await session.steer(trimmed) + switch command { + case .steer(let text): + try await session.steer(text) + case .interrupt: + try await session.interrupt() + } } catch { currentOutput += "\n[steer-error] \(error.localizedDescription)\n" } diff --git a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift index a138282..625db49 100644 --- a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift +++ b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift @@ -13,6 +13,7 @@ struct ScriptDetailView: View { @State private var isAddingTag = false @State private var newTagText = "" @State private var steerInput = "" + @State private var agentCommandMode: AgentCommandMode = .prompt @State private var averageDuration: TimeInterval? @State private var isSummarizingMemory = false @Environment(\.colorScheme) var colorScheme @@ -368,16 +369,20 @@ struct ScriptDetailView: View { if isAgentRunning { HStack(spacing: 8) { - TextField("Guide the running agent...", text: $steerInput) + TextField(agentCommandMode == .prompt ? "Guide the running agent..." : "Send /interrupt", text: $steerInput) .textFieldStyle(.roundedBorder) - .onSubmit { sendSteer() } - Button("Send") { - sendSteer() + .disabled(agentCommandMode == .interrupt) + .onSubmit { sendAgentCommand() } + Picker("", selection: $agentCommandMode) { + Text("Prompt").tag(AgentCommandMode.prompt) + Text("/interrupt").tag(AgentCommandMode.interrupt) } - .disabled(steerInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - Button("Interrupt") { - appState.stopScript(script.id) + .pickerStyle(.menu) + .frame(width: 120) + Button("Send") { + sendAgentCommand() } + .disabled(agentCommandMode == .prompt && steerInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding(.horizontal, 20) } @@ -522,13 +527,19 @@ struct ScriptDetailView: View { Task { await appState.updateScript(updated) } } - private func sendSteer() { - let text = steerInput.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } + private func sendAgentCommand() { + let input = steerInput Task { - await appState.steerAgent(scriptId: script.id, input: text) + await appState.sendAgentCommand( + scriptId: script.id, + mode: agentCommandMode, + input: input + ) } steerInput = "" + if agentCommandMode == .interrupt { + agentCommandMode = .prompt + } } private func commitNewTag() { diff --git a/Sources/ScriptoriaCLI/Commands/RunCommand.swift b/Sources/ScriptoriaCLI/Commands/RunCommand.swift index ff56749..e4702c0 100644 --- a/Sources/ScriptoriaCLI/Commands/RunCommand.swift +++ b/Sources/ScriptoriaCLI/Commands/RunCommand.swift @@ -33,6 +33,9 @@ struct RunCommand: AsyncParsableCommand { @Flag(name: .long, help: "Disable steering input while agent is running") var noSteer: Bool = false + @Option(name: .long, help: "Send a scripted agent command (repeatable, supports /interrupt)") + var command: [String] = [] + func run() async throws { let config = Config.load() let store = ScriptStore(config: config) @@ -208,14 +211,11 @@ struct RunCommand: AsyncParsableCommand { guard let line = readLine(strippingNewline: true) else { break } - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { continue } do { - if trimmed == "/interrupt" { - try await session.interrupt() + guard let command = AgentCommandInput.parseCLI(line) else { continue } + try await send(command: command, to: session) + if case .interrupt = command { break - } else { - try await session.steer(trimmed) } } catch { FileHandle.standardError.write(Data("[steer-error] \(error.localizedDescription)\n".utf8)) @@ -224,6 +224,14 @@ struct RunCommand: AsyncParsableCommand { } } + for raw in command { + guard let command = AgentCommandInput.parseCLI(raw) else { continue } + try await send(command: command, to: session) + if case .interrupt = command { + break + } + } + let agentResult = try await session.waitForCompletion() steerTask?.cancel() @@ -292,4 +300,13 @@ struct RunCommand: AsyncParsableCommand { if text.count <= max { return text } return String(text.prefix(max)) + "\n\n[truncated]" } + + private func send(command: AgentCommandInput, to session: PostScriptAgentSession) async throws { + switch command { + case .steer(let text): + try await session.steer(text) + case .interrupt: + try await session.interrupt() + } + } } diff --git a/Sources/ScriptoriaCore/Execution/AgentCommandInput.swift b/Sources/ScriptoriaCore/Execution/AgentCommandInput.swift new file mode 100644 index 0000000..e472f82 --- /dev/null +++ b/Sources/ScriptoriaCore/Execution/AgentCommandInput.swift @@ -0,0 +1,31 @@ +import Foundation + +public enum AgentCommandMode: String, Codable, CaseIterable, Sendable { + case prompt + case interrupt +} + +public enum AgentCommandInput: Equatable, Sendable { + case steer(String) + case interrupt + + public static func parseCLI(_ input: String) -> AgentCommandInput? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.lowercased() == "/interrupt" { + return .interrupt + } + return .steer(trimmed) + } + + public static func from(mode: AgentCommandMode, input: String) -> AgentCommandInput? { + switch mode { + case .prompt: + return parseCLI(input) + case .interrupt: + return .interrupt + } + } +} + diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift index ef043c1..c99425e 100644 --- a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift @@ -271,6 +271,75 @@ struct ScriptoriaCLITests { } } + @Test("run command supports scripted steer commands") + func testRunCommandScriptedSteerCommands() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-agent-scripted-steer") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command" + ]) { + let scriptPath = try workspace.makeScript(name: "agent-steer.sh", content: "#!/bin/sh\necho ok\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "AgentSteer"]) + + let run = try runCLI( + arguments: [ + "run", "AgentSteer", + "--no-notify", + "--no-steer", + "--model", "gpt-test", + "--command", "focus only eslint fixes" + ], + timeout: 10 + ) + #expect(run.timedOut == false) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("steer:focus only eslint fixes")) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "AgentSteer")) + let latest = try #require(try store.fetchLatestAgentRun(scriptId: script.id)) + #expect(latest.status == .completed) + #expect(latest.finalMessage == "steer done") + } + } + } + + @Test("run command supports scripted interrupt command") + func testRunCommandScriptedInterruptCommand() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-agent-scripted-interrupt") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command" + ]) { + let scriptPath = try workspace.makeScript(name: "agent-interrupt.sh", content: "#!/bin/sh\necho ok\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "AgentInterrupt"]) + + let run = try runCLI( + arguments: [ + "run", "AgentInterrupt", + "--no-notify", + "--no-steer", + "--model", "gpt-test", + "--command", "/interrupt" + ], + timeout: 10 + ) + #expect(run.timedOut == false) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("Agent interrupted")) + + let store = ScriptStore.fromConfig() + try await store.load() + let script = try #require(store.get(title: "AgentInterrupt")) + let latest = try #require(try store.fetchLatestAgentRun(scriptId: script.id)) + #expect(latest.status == .interrupted) + } + } + } + @Test("memory summarize by script and task-id") func testMemorySummarizeCommand() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-memory") { workspace in diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift index a654b69..0877e53 100644 --- a/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift @@ -384,4 +384,15 @@ struct ScriptoriaCoreBehaviorTests { #expect(prompt.contains("Script STDOUT")) } } + + @Test("agent input command parsing for cli/gui") + func testAgentInputCommandParsing() { + #expect(AgentCommandInput.parseCLI(" fix only lint ") == .steer("fix only lint")) + #expect(AgentCommandInput.parseCLI("/interrupt") == .interrupt) + #expect(AgentCommandInput.parseCLI(" ") == nil) + + #expect(AgentCommandInput.from(mode: .prompt, input: "next step") == .steer("next step")) + #expect(AgentCommandInput.from(mode: .interrupt, input: "ignored") == .interrupt) + #expect(AgentCommandInput.from(mode: .prompt, input: " ") == nil) + } } From dd0d5efa1214eb127e70831a020afeac577ea5f3 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 18:17:05 +0800 Subject: [PATCH 08/15] test(agent): verify codex process exits after turn completion --- .../ScriptoriaCLITests.swift | 28 +++++++++++++++++++ Tests/ScriptoriaCoreTests/TestSupport.swift | 14 ++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift index c99425e..6bad9c6 100644 --- a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift @@ -243,6 +243,34 @@ struct ScriptoriaCLITests { } } + @Test("run command shuts down codex process after turn completed") + func testRunCommandShutsDownCodexProcessOnCompletion() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-agent-shutdown") { workspace in + let codexPath = try workspace.makeFakeCodex() + let pidFile = workspace.rootURL.appendingPathComponent("fake-codex.pid").path + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete", + "SCRIPTORIA_FAKE_CODEX_PID_FILE": pidFile + ]) { + let scriptPath = try workspace.makeScript(name: "agent-shutdown.sh", content: "#!/bin/sh\necho ok\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "AgentShutdown"]) + + let run = try runCLI( + arguments: ["run", "AgentShutdown", "--no-notify", "--no-steer", "--model", "gpt-test"], + timeout: 10 + ) + #expect(run.timedOut == false) + #expect(run.exitCode == 0) + + let pidText = try #require(try? String(contentsOfFile: pidFile, encoding: .utf8)) + let pid = try #require(Int32(pidText.trimmingCharacters(in: .whitespacesAndNewlines))) + waitForProcessToExit(pid, timeout: 3) + #expect(ProcessManager.isRunning(pid: pid) == false) + } + } + } + @Test("run command agent crash should fail fast") func testRunCommandAgentCrashFailsFast() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-agent-crash") { workspace in diff --git a/Tests/ScriptoriaCoreTests/TestSupport.swift b/Tests/ScriptoriaCoreTests/TestSupport.swift index ee8551a..5bb25c1 100644 --- a/Tests/ScriptoriaCoreTests/TestSupport.swift +++ b/Tests/ScriptoriaCoreTests/TestSupport.swift @@ -32,7 +32,8 @@ struct TestWorkspace { "SCRIPTORIA_CODEX_EXECUTABLE": nil, "SCRIPTORIA_FAKE_CODEX_MODE": nil, "SCRIPTORIA_FAKE_CODEX_THREAD_ID": nil, - "SCRIPTORIA_FAKE_CODEX_TURN_ID": nil + "SCRIPTORIA_FAKE_CODEX_TURN_ID": nil, + "SCRIPTORIA_FAKE_CODEX_PID_FILE": nil ] } @@ -93,6 +94,14 @@ struct TestWorkspace { mode = os.environ.get("SCRIPTORIA_FAKE_CODEX_MODE", "complete") thread_id = os.environ.get("SCRIPTORIA_FAKE_CODEX_THREAD_ID", "thread-test") turn_id = os.environ.get("SCRIPTORIA_FAKE_CODEX_TURN_ID", "turn-test") + pid_file = os.environ.get("SCRIPTORIA_FAKE_CODEX_PID_FILE") + + if pid_file: + try: + with open(pid_file, "w", encoding="utf-8") as f: + f.write(str(os.getpid())) + except Exception: + pass def send(payload): sys.stdout.write(json.dumps(payload) + "\n") @@ -297,7 +306,8 @@ func runCLI( "SCRIPTORIA_CODEX_EXECUTABLE", "SCRIPTORIA_FAKE_CODEX_MODE", "SCRIPTORIA_FAKE_CODEX_THREAD_ID", - "SCRIPTORIA_FAKE_CODEX_TURN_ID" + "SCRIPTORIA_FAKE_CODEX_TURN_ID", + "SCRIPTORIA_FAKE_CODEX_PID_FILE" ] { if let value = getenv(key).map({ String(cString: $0) }) { env[key] = value From 8090731c5e2aa511b2e20bb4e7122ffd823f6c18 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 18:40:00 +0800 Subject: [PATCH 09/15] docs(skill): prioritize scriptoria schedule for recurring tasks --- skills/scriptoria/SKILL.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/skills/scriptoria/SKILL.md b/skills/scriptoria/SKILL.md index f16de17..1402f57 100644 --- a/skills/scriptoria/SKILL.md +++ b/skills/scriptoria/SKILL.md @@ -6,13 +6,18 @@ description: > a task every hour", "list my scripts", or "set up a cron job". metadata: author: scriptoria - version: "0.1.0" + version: "0.1.1" --- # Scriptoria โ€” Automation Script Manager Manage, run, and schedule shell scripts on macOS via the `scriptoria` CLI. +## Scheduling Policy + +- For any request to create/update/enable/disable/remove a schedule, prefer `scriptoria schedule ...` commands. +- Do not default to direct `launchd` or `cron` file edits; only use them when the user explicitly requests low-level setup or when Scriptoria CLI cannot represent the requested schedule. + ## File Conventions When creating new scripts or skill files, always place them under the Scriptoria config directory: From 11e7be17303469e61bf377dec57a76f4115789d3 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 18:42:25 +0800 Subject: [PATCH 10/15] docs(skill): add installation guide, task fit, and agent support --- README.md | 64 +++++++++++++++++++++++++++----------- skills/scriptoria/SKILL.md | 24 ++++++++++++-- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 28a0fcc..7853334 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ Built with Swift, SwiftUI, SQLite ([GRDB](https://github.com/groue/GRDB.swift)), A full-featured command-line tool for terminal workflows and automation: ```bash -scriptoria add ./backup.sh --title "Backup" --tags "daily,infra" +scriptoria add ./backup.sh --title "Backup" --task-name "Daily Backup" --default-model gpt-5.3-codex --tags "daily,infra" scriptoria list --tag daily -scriptoria run "Backup" --notify +scriptoria run "Backup" --model gpt-5.3-codex --no-steer scriptoria schedule add "Backup" --daily 09:00 scriptoria search "deploy" scriptoria tags @@ -32,9 +32,9 @@ scriptoria config show | Command | Description | |---------|-------------| -| `add ` | Register a script (with title, description, interpreter, tags) | +| `add ` | Register a script (title/description/interpreter/tags/agent task/model defaults) | | `list` | List scripts (filter by `--tag`, `--favorites`, `--recent`) | -| `run ` | Execute a script, save run history | +| `run ` | Execute a script, optional post-script agent stage (`--model`, `--agent-prompt`, `--command`, `--skip-agent`) | | `search ` | Search by title, description, or tags | | `remove ` | Remove a script from the database | | `tags` | List all tags with script counts | @@ -46,18 +46,45 @@ scriptoria config show ### AI Agent Friendly -Scriptoria is designed to work seamlessly with AI coding agents like [Claude Code](https://claude.ai/claude-code). +Scriptoria includes a reusable [skill](skills/scriptoria/SKILL.md) and a provider-agnostic agent runtime, so coding agents can manage scripts end to end. -**Claude Code Skill** โ€” The project includes a [skill](skills/scriptoria/SKILL.md) that teaches Claude Code how to use Scriptoria. When the skill is active, Claude can: +#### Install the Scriptoria Skill (Codex) -- Write shell scripts and register them with `scriptoria add` -- Run scripts and inspect output via `scriptoria run` -- Set up automated schedules with `scriptoria schedule add` -- Search and manage your script library +```bash +# Install from local repo (recommended during development) +mkdir -p ~/.codex/skills/scriptoria +ln -sfn "$(pwd)/skills/scriptoria/SKILL.md" ~/.codex/skills/scriptoria/SKILL.md + +# Or install from a GitHub repo path +python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-github.py \ + --repo / \ + --path skills/scriptoria \ + --ref main +``` + +After installation, restart Codex so the new skill is loaded. + +#### Best-fit Tasks for This Skill + +- Create/register scripts: `scriptoria add ...` +- Run scripts and inspect output/errors: `scriptoria run ...`, `scriptoria logs ...` +- Configure recurring jobs: `scriptoria schedule add|list|enable|disable|remove ...` +- Search, classify, and clean up script inventory: `scriptoria search|tags|remove ...` +- Memory-oriented post-script workflows (task/workspace summaries): `scriptoria memory ...` + +This skill should prefer Scriptoria CLI scheduling commands over direct `launchd` or `cron` edits. + +#### Supported Coding Agents + +- **Codex (native)**: direct support via `codex app-server` +- **Claude (adapter mode)**: supported through a local adapter that exposes the same app-server JSON-RPC protocol +- **Kimi (adapter mode)**: supported through a local adapter with the same contract + +Runtime provider selection is done by executable path (`SCRIPTORIA_CODEX_EXECUTABLE`), keeping `ScriptoriaCore` provider-agnostic. Example agent workflow: + ```bash -# Claude can do this entire flow autonomously: cat > /tmp/health-check.sh << 'EOF' #!/bin/bash curl -sf https://example.com/health && echo "OK" || echo "FAIL" @@ -65,17 +92,16 @@ EOF chmod +x /tmp/health-check.sh scriptoria add /tmp/health-check.sh --title "Health Check" --tags "monitoring" -scriptoria run "Health Check" +scriptoria run "Health Check" --model gpt-5.3-codex --no-steer scriptoria schedule add "Health Check" --every 10 ``` -**Why it works well with agents:** +Why it works well with agents: -- All CLI commands are non-interactive โ€” no prompts, no TTY required -- Structured output with clear success/error indicators -- UUID-based script references for unambiguous identification -- Full CRUD via CLI โ€” agents never need the GUI -- Chainable commands for end-to-end automation in a single flow +- CLI flows are automation-friendly and mostly non-interactive +- Structured status and stored run history simplify agent follow-up +- UUID-based references reduce ambiguity +- Full lifecycle coverage is available from CLI (create, run, schedule, inspect, clean up) ## Architecture @@ -86,7 +112,7 @@ Scriptoria/ โ”‚ โ”œโ”€โ”€ ScriptoriaCLI/ # CLI tool (swift-argument-parser) โ”‚ โ””โ”€โ”€ ScriptoriaCore/ # Shared library (models, storage, execution, scheduling) โ”œโ”€โ”€ Tests/ -โ”œโ”€โ”€ skills/scriptoria/ # Claude Code skill definition +โ”œโ”€โ”€ skills/scriptoria/ # Scriptoria skill definition โ”œโ”€โ”€ CLAUDE.md # AI agent project context โ””โ”€โ”€ Package.swift ``` diff --git a/skills/scriptoria/SKILL.md b/skills/scriptoria/SKILL.md index 1402f57..01da478 100644 --- a/skills/scriptoria/SKILL.md +++ b/skills/scriptoria/SKILL.md @@ -18,6 +18,12 @@ Manage, run, and schedule shell scripts on macOS via the `scriptoria` CLI. - For any request to create/update/enable/disable/remove a schedule, prefer `scriptoria schedule ...` commands. - Do not default to direct `launchd` or `cron` file edits; only use them when the user explicitly requests low-level setup or when Scriptoria CLI cannot represent the requested schedule. +## Supported Coding Agents + +- Codex (native app-server mode) +- Claude (via local adapter exposing the same app-server JSON-RPC contract) +- Kimi (via local adapter exposing the same app-server JSON-RPC contract) + ## File Conventions When creating new scripts or skill files, always place them under the Scriptoria config directory: @@ -96,15 +102,29 @@ scriptoria add [options] | `--interpreter` | `-i` | `auto`, `bash`, `zsh`, `sh`, `node`, `python3`, `ruby`, `osascript`, `binary` | | `--tags` | | Comma-separated tags | | `--skill` | | Path to a skill file for AI agents | +| `--task-name` | | Task name for post-script agent runs and memory | +| `--default-model` | | Default model used by post-script agent | ### Run a script ```bash scriptoria run "Title" # By title scriptoria run 3A1F2B4C # By UUID prefix -scriptoria run "Title" --notify # Send macOS notification on finish +scriptoria run --id "3A1F2B4C-..." # By explicit UUID +scriptoria run "Title" --model gpt-5.3-codex --no-steer +scriptoria run "Title" --agent-prompt "Focus on failing logs first" +scriptoria run "Title" --command "Please continue with tests" --command "/interrupt" ``` +Common run flags: +- `--no-notify`: suppress completion notification +- `--scheduled`: scheduled mode (less output) +- `--model`: override post-script agent model +- `--agent-prompt`: append extra user instruction for agent stage +- `--skip-agent`: skip post-script agent stage +- `--no-steer`: disable interactive steering input +- `--command`: scripted steer/interrupt commands (repeatable, supports `/interrupt`) + ### List / Search / Remove ```bash @@ -146,7 +166,7 @@ scriptoria config set-dir ~/data # Change data directory ## Agent Workflow Tips - After `scriptoria add`, the printed UUID can be used for `run` / `schedule` commands. -- All commands are non-interactive โ€” safe for automation. +- For non-interactive automation, provide `--model` and `--no-steer` (or use `--scheduled`). - Exit code of `scriptoria run` matches the script's exit code. - Run history (stdout, stderr, exit code) is saved to the database. - Chain commands for a complete workflow: From bfc9f3867031778c168240450670dbf4363613d9 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 19:04:33 +0800 Subject: [PATCH 11/15] feat: default to codex model and add runtime model picker --- Sources/ScriptoriaApp/AppState.swift | 5 +- .../Views/Main/AddScriptSheet.swift | 12 +- .../Views/Main/ScriptDetailView.swift | 186 +++++++----- .../ScriptoriaCLI/Commands/AddCommand.swift | 2 +- .../ScriptoriaCLI/Commands/RunCommand.swift | 19 +- .../Execution/AgentRuntimeCatalog.swift | 276 ++++++++++++++++++ Sources/ScriptoriaCore/Models/Script.swift | 2 +- .../Storage/DatabaseManager.swift | 9 +- .../ScriptoriaCoreBehaviorTests.swift | 46 +++ 9 files changed, 446 insertions(+), 111 deletions(-) create mode 100644 Sources/ScriptoriaCore/Execution/AgentRuntimeCatalog.swift diff --git a/Sources/ScriptoriaApp/AppState.swift b/Sources/ScriptoriaApp/AppState.swift index 99f77ff..99ad8bd 100644 --- a/Sources/ScriptoriaApp/AppState.swift +++ b/Sources/ScriptoriaApp/AppState.swift @@ -389,15 +389,14 @@ final class AppState: ObservableObject { updateRunningState() } - private func resolveModel(script: Script, override: String?) -> String { + private func resolveModel(script _: Script, override: String?) -> String { if let override { let value = override.trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { return value } } - let defaultModel = script.defaultModel.trimmingCharacters(in: .whitespacesAndNewlines) - return defaultModel.isEmpty ? "gpt-5.3-codex" : defaultModel + return AgentRuntimeCatalog.defaultModel } private func readFileIfExists(path: String) -> String? { diff --git a/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift b/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift index a4e9965..8675751 100644 --- a/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift +++ b/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift @@ -11,7 +11,7 @@ struct AddScriptSheet: View { @State private var path = "" @State private var skill = "" @State private var taskName = "" - @State private var defaultModel = "" + @State private var defaultModel = AgentRuntimeCatalog.defaultModel @State private var interpreter: Interpreter = .auto @State private var tagsInput = "" @State private var isFavorite = false @@ -65,7 +65,7 @@ struct AddScriptSheet: View { } TextField("Task Name (for memory namespace)", text: $taskName) - TextField("Default Model (optional)", text: $defaultModel) + TextField("Default Model", text: $defaultModel) Picker("Interpreter", selection: $interpreter) { ForEach(Interpreter.allCases, id: \.self) { interp in @@ -111,7 +111,7 @@ struct AddScriptSheet: View { path: resolvedPath, skill: resolvedSkill, agentTaskName: taskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? title : taskName.trimmingCharacters(in: .whitespacesAndNewlines), - defaultModel: defaultModel.trimmingCharacters(in: .whitespacesAndNewlines), + defaultModel: AgentRuntimeCatalog.normalizeModel(defaultModel), interpreter: interpreter, tags: tags, isFavorite: isFavorite @@ -153,7 +153,7 @@ struct EditScriptSheet: View { self._path = State(initialValue: script.path) self._skill = State(initialValue: script.skill) self._taskName = State(initialValue: script.agentTaskName) - self._defaultModel = State(initialValue: script.defaultModel) + self._defaultModel = State(initialValue: AgentRuntimeCatalog.normalizeModel(script.defaultModel)) self._interpreter = State(initialValue: script.interpreter) self._tagsInput = State(initialValue: script.tags.joined(separator: ", ")) self._isFavorite = State(initialValue: script.isFavorite) @@ -201,7 +201,7 @@ struct EditScriptSheet: View { } TextField("Task Name (for memory namespace)", text: $taskName) - TextField("Default Model (optional)", text: $defaultModel) + TextField("Default Model", text: $defaultModel) Picker("Interpreter", selection: $interpreter) { ForEach(Interpreter.allCases, id: \.self) { interp in @@ -238,7 +238,7 @@ struct EditScriptSheet: View { updated.path = path updated.skill = resolvedSkill updated.agentTaskName = taskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? title : taskName.trimmingCharacters(in: .whitespacesAndNewlines) - updated.defaultModel = defaultModel.trimmingCharacters(in: .whitespacesAndNewlines) + updated.defaultModel = AgentRuntimeCatalog.normalizeModel(defaultModel) updated.interpreter = interpreter updated.tags = tags updated.isFavorite = isFavorite diff --git a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift index 625db49..4dc9091 100644 --- a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift +++ b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift @@ -6,8 +6,9 @@ struct ScriptDetailView: View { let script: Script @EnvironmentObject var appState: AppState @State private var showEditSheet = false - @State private var showRunSheet = false - @State private var runModelOverride = "" + @State private var runModelOverride = AgentRuntimeCatalog.defaultModel + @State private var runModelOptions: [String] = [AgentRuntimeCatalog.defaultModel] + @State private var runtimeSnapshot = AgentRuntimeCatalog.discover() @State private var runHistory: [ScriptRun] = [] @State private var selectedRun: ScriptRun? @State private var isAddingTag = false @@ -42,15 +43,23 @@ struct ScriptDetailView: View { .onChange(of: script.id) { _, _ in loadHistory() selectedRun = nil - runModelOverride = script.defaultModel + runModelOverride = AgentRuntimeCatalog.defaultModel + refreshRunModelOptions() Task { await appState.reloadSchedules() } } + .onChange(of: script.defaultModel) { _, _ in + refreshRunModelOptions() + } + .onReceive(appState.$scripts) { _ in + refreshRunModelOptions() + } .onChange(of: appState.currentOutput) { _, _ in loadHistory() } .onAppear { loadHistory() - runModelOverride = script.defaultModel + runModelOverride = AgentRuntimeCatalog.defaultModel + refreshRunModelOptions() Task { await appState.reloadSchedules() } } .toolbar { @@ -79,8 +88,7 @@ struct ScriptDetailView: View { .help("Stop script") } else { Button { - runModelOverride = script.defaultModel - showRunSheet = true + runWithSelectedModel() } label: { Image(systemName: "play.fill") .contentTransition(.symbolEffect(.replace)) @@ -93,18 +101,6 @@ struct ScriptDetailView: View { EditScriptSheet(script: script, isPresented: $showEditSheet) .environmentObject(appState) } - .sheet(isPresented: $showRunSheet) { - RunWithModelSheet( - scriptTitle: script.title, - defaultModel: script.defaultModel, - modelOverride: $runModelOverride, - isPresented: $showRunSheet - ) { model in - Task { - await appState.runScript(script, modelOverride: model) - } - } - } } // MARK: - Header @@ -136,28 +132,40 @@ struct ScriptDetailView: View { Spacer() - // Run/Stop button - if isRunning { - Button { - appState.stopScript(script.id) - } label: { - HStack(spacing: 6) { - Image(systemName: "stop.fill") - Text("Stop") + VStack(alignment: .trailing, spacing: 6) { + // Run/Stop button + if isRunning { + Button { + appState.stopScript(script.id) + } label: { + HStack(spacing: 6) { + Image(systemName: "stop.fill") + Text("Stop") + } } - } - .buttonStyle(RunButtonStyle(isRunning: isRunning)) - } else { - Button { - runModelOverride = script.defaultModel - showRunSheet = true - } label: { - HStack(spacing: 6) { - Image(systemName: "play.fill") - Text("Run") + .buttonStyle(RunButtonStyle(isRunning: isRunning)) + } else { + Button { + runWithSelectedModel() + } label: { + HStack(spacing: 6) { + Image(systemName: "play.fill") + Text("Run") + } + } + .buttonStyle(RunButtonStyle(isRunning: false)) + + Picker("", selection: $runModelOverride) { + ForEach(runModelOptions, id: \.self) { model in + Text(modelMenuLabel(for: model)).tag(model) + } } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) + .frame(minWidth: 220, alignment: .trailing) + .help(modelPickerHelp) } - .buttonStyle(RunButtonStyle(isRunning: false)) } } @@ -556,6 +564,68 @@ struct ScriptDetailView: View { newTagText = "" } + private func runWithSelectedModel() { + Task { + await appState.runScript(script, modelOverride: runModelOverride) + } + } + + private func refreshRunModelOptions() { + runtimeSnapshot = AgentRuntimeCatalog.discover() + + var options: [String] = [] + appendUniqueModel(into: &options, model: AgentRuntimeCatalog.defaultModel) + appendUniqueModel(into: &options, model: AgentRuntimeCatalog.normalizeModel(script.defaultModel)) + for model in runtimeSnapshot.models { + appendUniqueModel(into: &options, model: model) + } + for saved in appState.scripts.map(\.defaultModel) { + appendUniqueModel(into: &options, model: AgentRuntimeCatalog.normalizeModel(saved)) + } + + if options.isEmpty { + options = [AgentRuntimeCatalog.defaultModel] + } + runModelOptions = options + + if let existing = options.first(where: { $0.caseInsensitiveCompare(runModelOverride) == .orderedSame }) { + runModelOverride = existing + } else { + runModelOverride = options[0] + } + } + + private func appendUniqueModel(into options: inout [String], model: String) { + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if options.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { + return + } + options.append(trimmed) + } + + private func modelMenuLabel(for model: String) -> String { + let provider = AgentRuntimeCatalog.provider(forModel: model).displayName + if model.caseInsensitiveCompare(AgentRuntimeCatalog.defaultModel) == .orderedSame { + return "\(provider) / GPT-5.3-Codex" + } + return "\(provider) / \(model)" + } + + private var modelPickerHelp: String { + let configured = runtimeSnapshot.activeProvider + let providerName = configured?.provider.displayName ?? runtimeSnapshot.configuredProvider.displayName + let source = configured?.source ?? "default" + let detected = runtimeSnapshot.providers + .filter(\.isAvailable) + .map { $0.provider.displayName } + .joined(separator: ", ") + if detected.isEmpty { + return "Configured provider: \(providerName) (\(source)). No local agent executable detected." + } + return "Configured provider: \(providerName) (\(source)). Detected providers: \(detected)." + } + private func runStatusBadge(_ status: RunStatus) -> some View { let (icon, color, label): (String, Color, String) = switch status { case .success: ("checkmark.circle.fill", Theme.successColor, "Success") @@ -619,48 +689,6 @@ struct ScriptDetailView: View { } } -struct RunWithModelSheet: View { - let scriptTitle: String - let defaultModel: String - @Binding var modelOverride: String - @Binding var isPresented: Bool - let onRun: (String?) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Run Script") - .font(.headline) - Text(scriptTitle) - .font(.subheadline) - .foregroundStyle(.secondary) - - TextField("Model (leave blank to use default)", text: $modelOverride) - .textFieldStyle(.roundedBorder) - - if !defaultModel.isEmpty { - Text("Default: \(defaultModel)") - .font(.caption) - .foregroundStyle(.tertiary) - } - - HStack { - Spacer() - Button("Cancel") { - isPresented = false - } - Button("Run") { - let value = modelOverride.trimmingCharacters(in: .whitespacesAndNewlines) - onRun(value.isEmpty ? nil : value) - isPresented = false - } - .keyboardShortcut(.defaultAction) - } - } - .padding(20) - .frame(width: 420) - } -} - // MARK: - Stat Card struct StatCard: View { diff --git a/Sources/ScriptoriaCLI/Commands/AddCommand.swift b/Sources/ScriptoriaCLI/Commands/AddCommand.swift index 1fe8a68..f715bf1 100644 --- a/Sources/ScriptoriaCLI/Commands/AddCommand.swift +++ b/Sources/ScriptoriaCLI/Commands/AddCommand.swift @@ -73,7 +73,7 @@ struct AddCommand: AsyncParsableCommand { path: resolvedPath, skill: resolvedSkill, agentTaskName: finalTaskName, - defaultModel: defaultModel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + defaultModel: AgentRuntimeCatalog.normalizeModel(defaultModel), interpreter: interp, tags: tagList ) diff --git a/Sources/ScriptoriaCLI/Commands/RunCommand.swift b/Sources/ScriptoriaCLI/Commands/RunCommand.swift index e4702c0..1e92e9c 100644 --- a/Sources/ScriptoriaCLI/Commands/RunCommand.swift +++ b/Sources/ScriptoriaCLI/Commands/RunCommand.swift @@ -266,27 +266,12 @@ struct RunCommand: AsyncParsableCommand { !scheduled && !noSteer && isatty(fileno(stdin)) == 1 } - private func resolveModel(for script: Script) -> String { + private func resolveModel(for _: Script) -> String { if let model { let value = model.trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { return value } } - - let defaultModel = script.defaultModel.trimmingCharacters(in: .whitespacesAndNewlines) - if scheduled { - return defaultModel.isEmpty ? "gpt-5.3-codex" : defaultModel - } - - let fallback = defaultModel.isEmpty ? "gpt-5.3-codex" : defaultModel - if isatty(fileno(stdin)) == 1 { - print("Model [\(fallback)]: ", terminator: "") - if let input = readLine(strippingNewline: true)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !input.isEmpty { - return input - } - } - return fallback + return AgentRuntimeCatalog.defaultModel } private func readFileIfExists(path: String) -> String? { diff --git a/Sources/ScriptoriaCore/Execution/AgentRuntimeCatalog.swift b/Sources/ScriptoriaCore/Execution/AgentRuntimeCatalog.swift new file mode 100644 index 0000000..e448a4d --- /dev/null +++ b/Sources/ScriptoriaCore/Execution/AgentRuntimeCatalog.swift @@ -0,0 +1,276 @@ +import Foundation + +public enum AgentRuntimeCatalog { + public static let defaultModel = "gpt-5.3-codex" + + public enum Provider: String, CaseIterable, Sendable { + case codex + case claude + case kimi + case custom + + public var displayName: String { + switch self { + case .codex: + return "codex" + case .claude: + return "claude" + case .kimi: + return "kimi" + case .custom: + return "custom" + } + } + + public var defaultModel: String? { + switch self { + case .codex: + return AgentRuntimeCatalog.defaultModel + case .claude: + return "claude-sonnet" + case .kimi: + return "kimi-k2" + case .custom: + return nil + } + } + } + + public struct ProviderAvailability: Sendable, Equatable, Identifiable { + public let provider: Provider + public let executable: String + public let resolvedPath: String? + public let source: String + + public init( + provider: Provider, + executable: String, + resolvedPath: String?, + source: String + ) { + self.provider = provider + self.executable = executable + self.resolvedPath = resolvedPath + self.source = source + } + + public var id: String { + "\(provider.rawValue):\(executable)" + } + + public var isAvailable: Bool { + resolvedPath != nil + } + } + + public struct Snapshot: Sendable, Equatable { + public let configuredExecutable: String + public let configuredProvider: Provider + public let providers: [ProviderAvailability] + public let models: [String] + + public init( + configuredExecutable: String, + configuredProvider: Provider, + providers: [ProviderAvailability], + models: [String] + ) { + self.configuredExecutable = configuredExecutable + self.configuredProvider = configuredProvider + self.providers = providers + self.models = models + } + + public var activeProvider: ProviderAvailability? { + providers.first { $0.provider == configuredProvider } + } + } + + public static func normalizeModel(_ value: String?) -> String { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? defaultModel : trimmed + } + + public static func detectProvider(forExecutable executable: String) -> Provider { + let lower = URL(fileURLWithPath: executable).lastPathComponent.lowercased() + if lower.contains("claude") { + return .claude + } + if lower.contains("kimi") { + return .kimi + } + if lower.contains("codex") { + return .codex + } + return .custom + } + + public static func provider(forModel model: String) -> Provider { + let lower = model.lowercased() + if lower.contains("claude") { + return .claude + } + if lower.contains("kimi") { + return .kimi + } + if lower.contains("gpt") || lower.contains("codex") { + return .codex + } + return .custom + } + + public static func discover( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory(), + fileManager: FileManager = .default + ) -> Snapshot { + let configuredFromEnv = environment["SCRIPTORIA_CODEX_EXECUTABLE"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let configuredExecutable = (configuredFromEnv?.isEmpty == false) ? configuredFromEnv! : "codex" + let configuredProvider = detectProvider(forExecutable: configuredExecutable) + + var byProvider: [Provider: ProviderAvailability] = [:] + byProvider[configuredProvider] = ProviderAvailability( + provider: configuredProvider, + executable: configuredExecutable, + resolvedPath: resolveExecutable( + configuredExecutable, + environment: environment, + homeDirectory: homeDirectory, + fileManager: fileManager + ), + source: (configuredFromEnv?.isEmpty == false) ? "env:SCRIPTORIA_CODEX_EXECUTABLE" : "default" + ) + + let candidates: [(String, String)] = [ + ("codex", "PATH"), + ("claude-adapter", "PATH"), + ("kimi-adapter", "PATH"), + ("claude", "PATH"), + ("kimi", "PATH"), + ("\(homeDirectory)/.scriptoria/agents/claude-adapter", "~/.scriptoria/agents"), + ("\(homeDirectory)/.scriptoria/agents/kimi-adapter", "~/.scriptoria/agents"), + ] + + for (executable, source) in candidates { + let provider = detectProvider(forExecutable: executable) + guard provider != .custom else { continue } + guard let resolved = resolveExecutable( + executable, + environment: environment, + homeDirectory: homeDirectory, + fileManager: fileManager + ) else { + continue + } + + let found = ProviderAvailability( + provider: provider, + executable: executable, + resolvedPath: resolved, + source: source + ) + + if let existing = byProvider[provider] { + if !existing.isAvailable { + byProvider[provider] = found + } + } else { + byProvider[provider] = found + } + } + + var orderedProviders: [ProviderAvailability] = [] + if let configured = byProvider.removeValue(forKey: configuredProvider) { + orderedProviders.append(configured) + } + for provider in Provider.allCases where provider != configuredProvider { + if let found = byProvider.removeValue(forKey: provider) { + orderedProviders.append(found) + } + } + if !byProvider.isEmpty { + orderedProviders.append(contentsOf: byProvider.values.sorted { $0.provider.rawValue < $1.provider.rawValue }) + } + + var models: [String] = [defaultModel] + func appendModel(_ model: String?) { + guard let model else { return } + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if !models.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { + models.append(trimmed) + } + } + + appendModel(configuredProvider.defaultModel) + for found in orderedProviders where found.isAvailable { + appendModel(found.provider.defaultModel) + } + + return Snapshot( + configuredExecutable: configuredExecutable, + configuredProvider: configuredProvider, + providers: orderedProviders, + models: models + ) + } + + private static func resolveExecutable( + _ executable: String, + environment: [String: String], + homeDirectory: String, + fileManager: FileManager + ) -> String? { + let trimmed = executable.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let expanded = expandPath(trimmed, homeDirectory: homeDirectory) + if expanded.contains("/") { + return isExecutable(path: expanded, fileManager: fileManager) ? expanded : nil + } + + var searchPaths: [String] = [] + if let rawPath = environment["PATH"], !rawPath.isEmpty { + searchPaths.append(contentsOf: rawPath.split(separator: ":").map(String.init)) + } + searchPaths.append(contentsOf: [ + "\(homeDirectory)/.local/bin", + "\(homeDirectory)/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]) + + var visited = Set() + for dir in searchPaths { + let expandedDir = expandPath(dir, homeDirectory: homeDirectory) + guard !expandedDir.isEmpty else { continue } + if !visited.insert(expandedDir).inserted { continue } + + let candidate = URL(fileURLWithPath: expandedDir).appendingPathComponent(trimmed).path + if isExecutable(path: candidate, fileManager: fileManager) { + return candidate + } + } + + return nil + } + + private static func expandPath(_ path: String, homeDirectory: String) -> String { + if path.hasPrefix("~") { + return NSString(string: path).expandingTildeInPath + } + if path.hasPrefix("/") { + return path + } + return path + } + + private static func isExecutable(path: String, fileManager: FileManager) -> Bool { + fileManager.fileExists(atPath: path) && fileManager.isExecutableFile(atPath: path) + } +} diff --git a/Sources/ScriptoriaCore/Models/Script.swift b/Sources/ScriptoriaCore/Models/Script.swift index 1fe3cfa..64a66f7 100644 --- a/Sources/ScriptoriaCore/Models/Script.swift +++ b/Sources/ScriptoriaCore/Models/Script.swift @@ -27,7 +27,7 @@ public struct Script: Codable, Identifiable, Sendable { skill: String = "", agentTaskId: Int? = nil, agentTaskName: String = "", - defaultModel: String = "", + defaultModel: String = AgentRuntimeCatalog.defaultModel, interpreter: Interpreter = .auto, tags: [String] = [], isFavorite: Bool = false, diff --git a/Sources/ScriptoriaCore/Storage/DatabaseManager.swift b/Sources/ScriptoriaCore/Storage/DatabaseManager.swift index fcb110a..d76a524 100644 --- a/Sources/ScriptoriaCore/Storage/DatabaseManager.swift +++ b/Sources/ScriptoriaCore/Storage/DatabaseManager.swift @@ -762,7 +762,7 @@ public final class DatabaseManager: Sendable { try upsertScriptAgentProfileRow( scriptId: script.id, taskName: script.agentTaskName.isEmpty ? script.title : script.agentTaskName, - defaultModel: script.defaultModel, + defaultModel: AgentRuntimeCatalog.normalizeModel(script.defaultModel), db: db ) } @@ -779,7 +779,7 @@ public final class DatabaseManager: Sendable { ) let profile = profileRow.map(self.scriptAgentProfileFromRow) let taskName = profile?.taskName ?? (row["title"] as String) - let defaultModel = profile?.defaultModel ?? "" + let defaultModel = AgentRuntimeCatalog.normalizeModel(profile?.defaultModel) return Script( id: id, @@ -823,7 +823,7 @@ public final class DatabaseManager: Sendable { id: row["id"], scriptId: UUID(uuidString: row["scriptId"] as String)!, taskName: row["taskName"], - defaultModel: row["defaultModel"], + defaultModel: AgentRuntimeCatalog.normalizeModel(row["defaultModel"] as String), createdAt: row["createdAt"], updatedAt: row["updatedAt"] ) @@ -856,6 +856,7 @@ public final class DatabaseManager: Sendable { db: Database ) throws { let now = Date() + let normalizedDefaultModel = AgentRuntimeCatalog.normalizeModel(defaultModel) try db.execute( sql: """ INSERT INTO script_agent_profiles (scriptId, taskName, defaultModel, createdAt, updatedAt) @@ -865,7 +866,7 @@ public final class DatabaseManager: Sendable { defaultModel=excluded.defaultModel, updatedAt=excluded.updatedAt """, - arguments: [scriptId.uuidString, taskName, defaultModel, now, now] + arguments: [scriptId.uuidString, taskName, normalizedDefaultModel, now, now] ) } diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift index 0877e53..d12be51 100644 --- a/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift @@ -26,6 +26,52 @@ struct ScriptoriaCoreBehaviorTests { } } + @Test("agent runtime catalog detects providers from PATH") + func testAgentRuntimeCatalogProviderDiscovery() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-agent-catalog") { workspace in + _ = try workspace.makeExecutable(relativePath: "bin/codex", content: "#!/bin/sh\nexit 0\n") + _ = try workspace.makeExecutable(relativePath: "bin/claude-adapter", content: "#!/bin/sh\nexit 0\n") + _ = try workspace.makeExecutable(relativePath: "bin/kimi-adapter", content: "#!/bin/sh\nexit 0\n") + + let snapshot = AgentRuntimeCatalog.discover( + environment: ["PATH": workspace.rootURL.appendingPathComponent("bin").path], + homeDirectory: workspace.rootURL.path + ) + + #expect(snapshot.configuredProvider == .codex) + #expect(snapshot.activeProvider?.isAvailable == true) + #expect(snapshot.providers.contains(where: { $0.provider == .claude && $0.isAvailable })) + #expect(snapshot.providers.contains(where: { $0.provider == .kimi && $0.isAvailable })) + #expect(snapshot.models.contains(AgentRuntimeCatalog.defaultModel)) + #expect(snapshot.models.contains("claude-sonnet")) + #expect(snapshot.models.contains("kimi-k2")) + } + } + + @Test("agent runtime catalog honors configured executable override") + func testAgentRuntimeCatalogConfiguredExecutable() async throws { + try await withTestWorkspace(prefix: "scriptoria-core-agent-configured") { workspace in + let claudeAdapter = try workspace.makeExecutable( + relativePath: "agents/claude-adapter", + content: "#!/bin/sh\nexit 0\n" + ) + + let snapshot = AgentRuntimeCatalog.discover( + environment: [ + "SCRIPTORIA_CODEX_EXECUTABLE": claudeAdapter, + "PATH": workspace.rootURL.appendingPathComponent("bin").path + ], + homeDirectory: workspace.rootURL.path + ) + + #expect(snapshot.configuredProvider == .claude) + #expect(snapshot.activeProvider?.resolvedPath == claudeAdapter) + #expect(snapshot.models.contains("claude-sonnet")) + #expect(AgentRuntimeCatalog.normalizeModel(nil) == AgentRuntimeCatalog.defaultModel) + #expect(AgentRuntimeCatalog.normalizeModel(" ") == AgentRuntimeCatalog.defaultModel) + } + } + @Test("script store + agent profile + run storage") func testScriptStoreAndAgentPersistence() async throws { try await withTestWorkspace(prefix: "scriptoria-core-store") { workspace in From 184faac0b4b6c326a6123daddcaa3a2241861550 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 19:08:34 +0800 Subject: [PATCH 12/15] fix(ci): run tests on Swift 6 toolchain --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 298436e..b563ecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: test: name: Swift Test - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 @@ -17,6 +17,14 @@ jobs: - name: Show toolchain run: swift --version + - name: Ensure Swift 6+ + run: | + SWIFT_MAJOR="$(swift --version | awk '/Swift version/ { split($3, v, "."); print v[1] }')" + if [ "${SWIFT_MAJOR}" -lt 6 ]; then + echo "Swift 6+ is required by Package.swift (swift-tools-version: 6.0)." + exit 1 + fi + - name: Run full test suite run: swift test From b1fbc0873e4f2ed0b6bf4a4c08932fcab149b9b3 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 19:35:17 +0800 Subject: [PATCH 13/15] feat: add agent trigger mode with UI selector and run gating --- Sources/ScriptoriaApp/AppState.swift | 12 ++- .../Views/Main/AddScriptSheet.swift | 21 +++++ .../Views/Main/ScriptDetailView.swift | 12 +++ .../ScriptoriaCLI/Commands/RunCommand.swift | 20 +++-- .../Execution/AgentTriggerEvaluator.swift | 79 ++++++++++++++++ Sources/ScriptoriaCore/Models/Script.swift | 90 +++++++++++++++++++ .../Storage/DatabaseManager.swift | 21 +++-- .../ScriptoriaCLITests.swift | 52 +++++++++++ .../ScriptoriaCoreBehaviorTests.swift | 28 ++++++ 9 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 Sources/ScriptoriaCore/Execution/AgentTriggerEvaluator.swift diff --git a/Sources/ScriptoriaApp/AppState.swift b/Sources/ScriptoriaApp/AppState.swift index 99ad8bd..ea4feda 100644 --- a/Sources/ScriptoriaApp/AppState.swift +++ b/Sources/ScriptoriaApp/AppState.swift @@ -251,8 +251,16 @@ final class AppState: ObservableObject { await NotificationManager.shared.notifyRunComplete(result) } - // Post-script agent stage - await runAgentStage(script: script, scriptRun: runRecord, modelOverride: modelOverride) + guard result.status == .success else { return } + + switch AgentTriggerEvaluator.evaluate(script: script, scriptRun: runRecord) { + case .run: + await runAgentStage(script: script, scriptRun: runRecord, modelOverride: modelOverride) + case .skip(let reason): + currentOutput += "\n\n=== Agent Stage Skipped ===\n\(reason)\n" + case .invalid(let reason): + currentOutput += "\n\n[agent-trigger-error] \(reason)\n" + } } func stopScript(_ scriptId: UUID) { diff --git a/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift b/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift index 8675751..702f1e6 100644 --- a/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift +++ b/Sources/ScriptoriaApp/Views/Main/AddScriptSheet.swift @@ -12,6 +12,7 @@ struct AddScriptSheet: View { @State private var skill = "" @State private var taskName = "" @State private var defaultModel = AgentRuntimeCatalog.defaultModel + @State private var agentTriggerMode: AgentTriggerMode = .always @State private var interpreter: Interpreter = .auto @State private var tagsInput = "" @State private var isFavorite = false @@ -66,6 +67,14 @@ struct AddScriptSheet: View { TextField("Task Name (for memory namespace)", text: $taskName) TextField("Default Model", text: $defaultModel) + Picker("Agent Trigger", selection: $agentTriggerMode) { + ForEach(AgentTriggerMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + Text(agentTriggerMode.helperText) + .font(.caption2) + .foregroundStyle(.secondary) Picker("Interpreter", selection: $interpreter) { ForEach(Interpreter.allCases, id: \.self) { interp in @@ -112,6 +121,7 @@ struct AddScriptSheet: View { skill: resolvedSkill, agentTaskName: taskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? title : taskName.trimmingCharacters(in: .whitespacesAndNewlines), defaultModel: AgentRuntimeCatalog.normalizeModel(defaultModel), + agentTriggerMode: agentTriggerMode, interpreter: interpreter, tags: tags, isFavorite: isFavorite @@ -141,6 +151,7 @@ struct EditScriptSheet: View { @State private var skill: String @State private var taskName: String @State private var defaultModel: String + @State private var agentTriggerMode: AgentTriggerMode @State private var interpreter: Interpreter @State private var tagsInput: String @State private var isFavorite: Bool @@ -154,6 +165,7 @@ struct EditScriptSheet: View { self._skill = State(initialValue: script.skill) self._taskName = State(initialValue: script.agentTaskName) self._defaultModel = State(initialValue: AgentRuntimeCatalog.normalizeModel(script.defaultModel)) + self._agentTriggerMode = State(initialValue: script.agentTriggerMode) self._interpreter = State(initialValue: script.interpreter) self._tagsInput = State(initialValue: script.tags.joined(separator: ", ")) self._isFavorite = State(initialValue: script.isFavorite) @@ -202,6 +214,14 @@ struct EditScriptSheet: View { TextField("Task Name (for memory namespace)", text: $taskName) TextField("Default Model", text: $defaultModel) + Picker("Agent Trigger", selection: $agentTriggerMode) { + ForEach(AgentTriggerMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + Text(agentTriggerMode.helperText) + .font(.caption2) + .foregroundStyle(.secondary) Picker("Interpreter", selection: $interpreter) { ForEach(Interpreter.allCases, id: \.self) { interp in @@ -239,6 +259,7 @@ struct EditScriptSheet: View { updated.skill = resolvedSkill updated.agentTaskName = taskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? title : taskName.trimmingCharacters(in: .whitespacesAndNewlines) updated.defaultModel = AgentRuntimeCatalog.normalizeModel(defaultModel) + updated.agentTriggerMode = agentTriggerMode updated.interpreter = interpreter updated.tags = tags updated.isFavorite = isFavorite diff --git a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift index 4dc9091..77d70f2 100644 --- a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift +++ b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift @@ -342,6 +342,18 @@ struct ScriptDetailView: View { } .padding(.horizontal, 20) .padding(.bottom, 16) + + HStack(spacing: 8) { + Image(systemName: "switch.2") + .font(.caption) + .foregroundStyle(.tertiary) + Text("Agent Trigger: \(script.agentTriggerMode.displayName)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 20) + .padding(.bottom, 16) } } diff --git a/Sources/ScriptoriaCLI/Commands/RunCommand.swift b/Sources/ScriptoriaCLI/Commands/RunCommand.swift index 1e92e9c..ab57a70 100644 --- a/Sources/ScriptoriaCLI/Commands/RunCommand.swift +++ b/Sources/ScriptoriaCLI/Commands/RunCommand.swift @@ -130,12 +130,20 @@ struct RunCommand: AsyncParsableCommand { } if !skipAgent { - try await runAgentStage( - script: script, - scriptRun: runRecord, - store: store, - config: config - ) + switch AgentTriggerEvaluator.evaluate(script: script, scriptRun: runRecord) { + case .run: + try await runAgentStage( + script: script, + scriptRun: runRecord, + store: store, + config: config + ) + case .skip(let reason): + print("โญ Agent skipped: \(reason)") + case .invalid(let reason): + print("โŒ Agent trigger check failed: \(reason)") + throw ExitCode.failure + } } // Always notify unless --no-notify diff --git a/Sources/ScriptoriaCore/Execution/AgentTriggerEvaluator.swift b/Sources/ScriptoriaCore/Execution/AgentTriggerEvaluator.swift new file mode 100644 index 0000000..7ea5456 --- /dev/null +++ b/Sources/ScriptoriaCore/Execution/AgentTriggerEvaluator.swift @@ -0,0 +1,79 @@ +import Foundation + +public enum AgentTriggerDecision: Sendable { + case run + case skip(reason: String) + case invalid(reason: String) +} + +public enum AgentTriggerEvaluator { + public static func evaluate(script: Script, scriptRun: ScriptRun) -> AgentTriggerDecision { + evaluate(mode: script.agentTriggerMode, scriptRun: scriptRun) + } + + public static func evaluate(mode: AgentTriggerMode, scriptRun: ScriptRun) -> AgentTriggerDecision { + switch mode { + case .always: + return .run + case .preScriptTrue: + guard let parsed = parseBooleanResult(from: scriptRun.output) else { + return .invalid( + reason: "Expected script STDOUT last non-empty line to be true/false when trigger mode is preScriptTrue." + ) + } + if parsed { + return .run + } + return .skip(reason: "Pre-script result is false.") + } + } + + static func parseBooleanResult(from output: String) -> Bool? { + guard let token = lastNonEmptyLine(in: output) else { return nil } + if let value = parseBooleanToken(token) { + return value + } + + guard let data = token.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any] else { + return nil + } + + for key in ["triggerAgent", "agentTrigger", "shouldRunAgent", "result", "value"] { + if let value = dictionary[key] as? Bool { + return value + } + if let value = dictionary[key] as? String, + let parsed = parseBooleanToken(value) { + return parsed + } + if let value = dictionary[key] as? NSNumber { + if value.intValue == 0 { return false } + if value.intValue == 1 { return true } + } + } + return nil + } + + private static func lastNonEmptyLine(in text: String) -> String? { + for line in text.split(whereSeparator: \.isNewline).reversed() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return nil + } + + private static func parseBooleanToken(_ token: String) -> Bool? { + switch token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "true", "1", "yes", "y", "on": + return true + case "false", "0", "no", "n", "off": + return false + default: + return nil + } + } +} diff --git a/Sources/ScriptoriaCore/Models/Script.swift b/Sources/ScriptoriaCore/Models/Script.swift index 64a66f7..b64f1eb 100644 --- a/Sources/ScriptoriaCore/Models/Script.swift +++ b/Sources/ScriptoriaCore/Models/Script.swift @@ -10,6 +10,7 @@ public struct Script: Codable, Identifiable, Sendable { public var agentTaskId: Int? public var agentTaskName: String public var defaultModel: String + public var agentTriggerMode: AgentTriggerMode public var interpreter: Interpreter public var tags: [String] public var isFavorite: Bool @@ -28,6 +29,7 @@ public struct Script: Codable, Identifiable, Sendable { agentTaskId: Int? = nil, agentTaskName: String = "", defaultModel: String = AgentRuntimeCatalog.defaultModel, + agentTriggerMode: AgentTriggerMode = .always, interpreter: Interpreter = .auto, tags: [String] = [], isFavorite: Bool = false, @@ -45,6 +47,7 @@ public struct Script: Codable, Identifiable, Sendable { self.agentTaskId = agentTaskId self.agentTaskName = agentTaskName self.defaultModel = defaultModel + self.agentTriggerMode = agentTriggerMode self.interpreter = interpreter self.tags = tags self.isFavorite = isFavorite @@ -54,6 +57,93 @@ public struct Script: Codable, Identifiable, Sendable { self.lastRunStatus = lastRunStatus self.runCount = runCount } + + enum CodingKeys: String, CodingKey { + case id + case title + case description + case path + case skill + case agentTaskId + case agentTaskName + case defaultModel + case agentTriggerMode + case interpreter + case tags + case isFavorite + case createdAt + case updatedAt + case lastRunAt + case lastRunStatus + case runCount + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.title = try container.decode(String.self, forKey: .title) + self.description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" + self.path = try container.decode(String.self, forKey: .path) + self.skill = try container.decodeIfPresent(String.self, forKey: .skill) ?? "" + self.agentTaskId = try container.decodeIfPresent(Int.self, forKey: .agentTaskId) + self.agentTaskName = try container.decodeIfPresent(String.self, forKey: .agentTaskName) ?? "" + let decodedModel = try container.decodeIfPresent(String.self, forKey: .defaultModel) + self.defaultModel = AgentRuntimeCatalog.normalizeModel(decodedModel) + self.agentTriggerMode = try container.decodeIfPresent(AgentTriggerMode.self, forKey: .agentTriggerMode) ?? .always + let interpreterRaw = try container.decodeIfPresent(String.self, forKey: .interpreter) ?? Interpreter.auto.rawValue + self.interpreter = Interpreter(rawValue: interpreterRaw) ?? .auto + self.tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + self.isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false + self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() + self.updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt + self.lastRunAt = try container.decodeIfPresent(Date.self, forKey: .lastRunAt) + self.lastRunStatus = try container.decodeIfPresent(RunStatus.self, forKey: .lastRunStatus) + self.runCount = try container.decodeIfPresent(Int.self, forKey: .runCount) ?? 0 + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(title, forKey: .title) + try container.encode(description, forKey: .description) + try container.encode(path, forKey: .path) + try container.encode(skill, forKey: .skill) + try container.encodeIfPresent(agentTaskId, forKey: .agentTaskId) + try container.encode(agentTaskName, forKey: .agentTaskName) + try container.encode(defaultModel, forKey: .defaultModel) + try container.encode(agentTriggerMode, forKey: .agentTriggerMode) + try container.encode(interpreter.rawValue, forKey: .interpreter) + try container.encode(tags, forKey: .tags) + try container.encode(isFavorite, forKey: .isFavorite) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(updatedAt, forKey: .updatedAt) + try container.encodeIfPresent(lastRunAt, forKey: .lastRunAt) + try container.encodeIfPresent(lastRunStatus, forKey: .lastRunStatus) + try container.encode(runCount, forKey: .runCount) + } +} + +public enum AgentTriggerMode: String, Codable, Sendable, CaseIterable { + case always + case preScriptTrue + + public var displayName: String { + switch self { + case .always: + return "Always (on script success)" + case .preScriptTrue: + return "Only when pre-script is true" + } + } + + public var helperText: String { + switch self { + case .always: + return "Run post-script agent stage whenever the script exits successfully." + case .preScriptTrue: + return "Run agent only when script STDOUT last non-empty line is true." + } + } } /// Interpreter used to run the script diff --git a/Sources/ScriptoriaCore/Storage/DatabaseManager.swift b/Sources/ScriptoriaCore/Storage/DatabaseManager.swift index d76a524..3abf9ba 100644 --- a/Sources/ScriptoriaCore/Storage/DatabaseManager.swift +++ b/Sources/ScriptoriaCore/Storage/DatabaseManager.swift @@ -166,6 +166,14 @@ public final class DatabaseManager: Sendable { } } + migrator.registerMigration("v5") { db in + try db.alter(table: "scripts") { t in + t.add(column: "agentTriggerMode", .text) + .notNull() + .defaults(to: AgentTriggerMode.always.rawValue) + } + } + return migrator } @@ -274,13 +282,13 @@ public final class DatabaseManager: Sendable { updated.updatedAt = Date() try db.execute( sql: """ - UPDATE scripts SET title=?, description=?, path=?, skill=?, interpreter=?, + UPDATE scripts SET title=?, description=?, path=?, skill=?, interpreter=?, agentTriggerMode=?, isFavorite=?, createdAt=?, updatedAt=?, lastRunAt=?, lastRunStatus=?, runCount=? WHERE id=? """, arguments: [ updated.title, updated.description, updated.path, - updated.skill, updated.interpreter.rawValue, updated.isFavorite, + updated.skill, updated.interpreter.rawValue, updated.agentTriggerMode.rawValue, updated.isFavorite, updated.createdAt, updated.updatedAt, updated.lastRunAt, updated.lastRunStatus?.rawValue, updated.runCount, updated.id.uuidString @@ -742,12 +750,12 @@ public final class DatabaseManager: Sendable { private func insertScriptRow(_ script: Script, db: Database) throws { try db.execute( sql: """ - INSERT INTO scripts (id, title, description, path, skill, interpreter, isFavorite, createdAt, updatedAt, lastRunAt, lastRunStatus, runCount) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scripts (id, title, description, path, skill, interpreter, agentTriggerMode, isFavorite, createdAt, updatedAt, lastRunAt, lastRunStatus, runCount) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ script.id.uuidString, script.title, script.description, script.path, - script.skill, script.interpreter.rawValue, script.isFavorite, + script.skill, script.interpreter.rawValue, script.agentTriggerMode.rawValue, script.isFavorite, script.createdAt, script.updatedAt, script.lastRunAt, script.lastRunStatus?.rawValue, script.runCount @@ -780,6 +788,8 @@ public final class DatabaseManager: Sendable { let profile = profileRow.map(self.scriptAgentProfileFromRow) let taskName = profile?.taskName ?? (row["title"] as String) let defaultModel = AgentRuntimeCatalog.normalizeModel(profile?.defaultModel) + let triggerModeRaw: String? = row["agentTriggerMode"] + let triggerMode = triggerModeRaw.flatMap(AgentTriggerMode.init(rawValue:)) ?? .always return Script( id: id, @@ -790,6 +800,7 @@ public final class DatabaseManager: Sendable { agentTaskId: profile?.id, agentTaskName: taskName, defaultModel: defaultModel, + agentTriggerMode: triggerMode, interpreter: Interpreter(rawValue: row["interpreter"]) ?? .auto, tags: tags, isFavorite: row["isFavorite"], diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift index 6bad9c6..b17f8e3 100644 --- a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift @@ -243,6 +243,58 @@ struct ScriptoriaCLITests { } } + @Test("run command skips agent when pre-script output is false") + func testRunCommandAgentTriggerSkip() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-agent-skip") { workspace in + let scriptPath = try workspace.makeScript(name: "gate-false.sh", content: "#!/bin/sh\necho false\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "GateFalse"]) + + let store = ScriptStore.fromConfig() + try await store.load() + var script = try #require(store.get(title: "GateFalse")) + script.agentTriggerMode = .preScriptTrue + try await store.update(script) + + let run = try runCLI( + arguments: ["run", "GateFalse", "--no-notify", "--no-steer"], + extraEnvironment: ["SCRIPTORIA_CODEX_EXECUTABLE": "/tmp/scriptoria-codex-not-found"] + ) + + #expect(run.exitCode == 0) + #expect(run.stdout.contains("Agent skipped")) + #expect(run.stdout.contains("Starting agent task") == false) + #expect(try store.fetchLatestAgentRun(scriptId: script.id) == nil) + } + } + + @Test("run command starts agent when pre-script output is true") + func testRunCommandAgentTriggerRun() async throws { + try await withTestWorkspace(prefix: "scriptoria-cli-agent-run") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let scriptPath = try workspace.makeScript(name: "gate-true.sh", content: "#!/bin/sh\necho true\n") + _ = try runCLI(arguments: ["add", scriptPath, "--title", "GateTrue"]) + + let store = ScriptStore.fromConfig() + try await store.load() + var script = try #require(store.get(title: "GateTrue")) + script.agentTriggerMode = .preScriptTrue + try await store.update(script) + + let run = try runCLI(arguments: ["run", "GateTrue", "--no-notify", "--no-steer"], timeout: 20) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("Starting agent task")) + #expect(run.stdout.contains("agent delta")) + + let latest = try #require(try store.fetchLatestAgentRun(scriptId: script.id)) + #expect(latest.status == .completed) + } + } + } + @Test("run command shuts down codex process after turn completed") func testRunCommandShutsDownCodexProcessOnCompletion() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-agent-shutdown") { workspace in diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift index d12be51..6703a00 100644 --- a/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCoreBehaviorTests.swift @@ -404,6 +404,34 @@ struct ScriptoriaCoreBehaviorTests { } } + @Test("agent trigger evaluator supports pre-script true/false") + func testAgentTriggerEvaluator() async throws { + let trueRun = ScriptRun(scriptId: UUID(), scriptTitle: "t", status: .success, output: "checked\ntrue\n") + let falseRun = ScriptRun(scriptId: UUID(), scriptTitle: "t", status: .success, output: "checked\nfalse\n") + let invalidRun = ScriptRun(scriptId: UUID(), scriptTitle: "t", status: .success, output: "checked\nmaybe\n") + + switch AgentTriggerEvaluator.evaluate(mode: .preScriptTrue, scriptRun: trueRun) { + case .run: + break + default: + Issue.record("Expected run when pre-script output is true.") + } + + switch AgentTriggerEvaluator.evaluate(mode: .preScriptTrue, scriptRun: falseRun) { + case .skip: + break + default: + Issue.record("Expected skip when pre-script output is false.") + } + + switch AgentTriggerEvaluator.evaluate(mode: .preScriptTrue, scriptRun: invalidRun) { + case .invalid: + break + default: + Issue.record("Expected invalid when pre-script output is not parseable.") + } + } + @Test("post-script agent prompt and instruction builders") func testPostScriptAgentRunnerBehavior() async throws { try await withTestWorkspace(prefix: "scriptoria-core-agent") { workspace in From 5c8849616c93b97764689888fee731ebd97deb74 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 20:01:28 +0800 Subject: [PATCH 14/15] feat(app): improve agent trigger gate flow UI --- .../Views/Main/ScriptDetailView.swift | 428 +++++++++++++++--- 1 file changed, 353 insertions(+), 75 deletions(-) diff --git a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift index 77d70f2..bb4c4bd 100644 --- a/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift +++ b/Sources/ScriptoriaApp/Views/Main/ScriptDetailView.swift @@ -17,6 +17,7 @@ struct ScriptDetailView: View { @State private var agentCommandMode: AgentCommandMode = .prompt @State private var averageDuration: TimeInterval? @State private var isSummarizingMemory = false + @State private var selectedAgentTriggerMode: AgentTriggerMode = .always @Environment(\.colorScheme) var colorScheme var isRunning: Bool { @@ -45,11 +46,15 @@ struct ScriptDetailView: View { selectedRun = nil runModelOverride = AgentRuntimeCatalog.defaultModel refreshRunModelOptions() + selectedAgentTriggerMode = script.agentTriggerMode Task { await appState.reloadSchedules() } } .onChange(of: script.defaultModel) { _, _ in refreshRunModelOptions() } + .onChange(of: script.agentTriggerMode) { _, mode in + selectedAgentTriggerMode = mode + } .onReceive(appState.$scripts) { _ in refreshRunModelOptions() } @@ -60,6 +65,7 @@ struct ScriptDetailView: View { loadHistory() runModelOverride = AgentRuntimeCatalog.defaultModel refreshRunModelOptions() + selectedAgentTriggerMode = script.agentTriggerMode Task { await appState.reloadSchedules() } } .toolbar { @@ -250,77 +256,83 @@ struct ScriptDetailView: View { } .padding(20) - // Path - HStack(spacing: 8) { - Image(systemName: "folder") - .font(.caption) - .foregroundStyle(.tertiary) - Text(script.path) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - Spacer() - Button { - NSWorkspace.shared.selectFile(script.path, inFileViewerRootedAtPath: "") - } label: { - Image(systemName: "arrow.right.circle") - .font(.caption) - .foregroundStyle(.tertiary) - } - .buttonStyle(.plain) - .help("Reveal in Finder") - } - .padding(.horizontal, 20) - .padding(.bottom, script.skill.isEmpty ? 16 : 8) + agentFlowGateSection + .padding(.horizontal, 20) + .padding(.bottom, 16) + } + } - // Skill - if !script.skill.isEmpty { + var agentFlowGateSection: some View { + VStack(alignment: .leading, spacing: 10) { + Label("Agent Flow Gate", systemImage: "point.3.connected.trianglepath.dotted") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + AgentFlowStepRow( + step: 1, + title: "Script & Skill Input", + detail: script.path, + helper: inputGateHelperText, + status: inputGateStatus, + isLast: false + ) { HStack(spacing: 8) { - Image(systemName: "brain") - .font(.caption) - .foregroundStyle(.tertiary) - Text(script.skill) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - Spacer() Button { - NSWorkspace.shared.selectFile(script.skill, inFileViewerRootedAtPath: "") + revealInFinder(path: script.path) } label: { - Image(systemName: "arrow.right.circle") + Image(systemName: "folder") .font(.caption) - .foregroundStyle(.tertiary) } .buttonStyle(.plain) - .help("Reveal in Finder") - } - .padding(.horizontal, 20) - .padding(.bottom, 16) - } + .foregroundStyle(.secondary) + .help("Reveal script in Finder") - HStack(spacing: 8) { - Image(systemName: "target") - .font(.caption) - .foregroundStyle(.tertiary) - if let taskId = script.agentTaskId { - Text("[\(taskId)] \(script.agentTaskName)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } else { - Text(script.agentTaskName) - .font(.system(.caption, design: .monospaced)) + if !normalizedSkillPath.isEmpty { + Button { + revealInFinder(path: normalizedSkillPath) + } label: { + Image(systemName: "brain") + .font(.caption) + } + .buttonStyle(.plain) .foregroundStyle(.secondary) + .help("Reveal skill in Finder") + } } - if !script.defaultModel.isEmpty { - Text("ยท \(script.defaultModel)") - .font(.caption2) - .foregroundStyle(.tertiary) + } + + AgentFlowStepRow( + step: 2, + title: "Agent Trigger", + detail: selectedAgentTriggerMode.displayName, + helper: triggerGateHelperText, + status: triggerGateStatus, + isLast: false + ) { + Picker("", selection: agentTriggerPickerBinding) { + ForEach(AgentTriggerMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } } - Spacer() + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 230, alignment: .trailing) + } + + if selectedAgentTriggerMode == .preScriptTrue { + agentTriggerBranchesView + .padding(.leading, 32) + .padding(.bottom, 4) + } + + AgentFlowStepRow( + step: 3, + title: "Task Context", + detail: taskContextDetailText, + helper: taskContextHelperText, + status: taskContextGateStatus, + isLast: true + ) { Button { isSummarizingMemory = true Task { @@ -332,7 +344,7 @@ struct ScriptDetailView: View { ProgressView() .scaleEffect(0.7) } else { - Label("Summarize Workspace", systemImage: "doc.text.magnifyingglass") + Label("Summarize", systemImage: "doc.text.magnifyingglass") .font(.caption) } } @@ -340,20 +352,6 @@ struct ScriptDetailView: View { .foregroundStyle(.secondary) .help("Summarize task memories into workspace memory") } - .padding(.horizontal, 20) - .padding(.bottom, 16) - - HStack(spacing: 8) { - Image(systemName: "switch.2") - .font(.caption) - .foregroundStyle(.tertiary) - Text("Agent Trigger: \(script.agentTriggerMode.displayName)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, 20) - .padding(.bottom, 16) } } @@ -541,6 +539,162 @@ struct ScriptDetailView: View { averageDuration = appState.fetchAverageDuration(scriptId: script.id) } + private var normalizedTaskName: String { + let trimmed = script.agentTaskName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? script.title : trimmed + } + + private var normalizedSkillPath: String { + script.skill.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var inputGateStatus: AgentGateStatus { + let scriptPath = script.path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !scriptPath.isEmpty else { return .blocked } + guard fileExists(atPath: scriptPath) else { return .blocked } + guard !normalizedSkillPath.isEmpty else { return .ready } + return fileExists(atPath: normalizedSkillPath) ? .ready : .warning + } + + private var inputGateHelperText: String { + if normalizedSkillPath.isEmpty { + return "No skill file configured. Agent will run with workspace memory only." + } + if fileExists(atPath: normalizedSkillPath) { + return "Skill ready: \(normalizedSkillPath)" + } + return "Skill not found: \(normalizedSkillPath). Agent can still run, but without skill injection." + } + + private var taskContextGateStatus: AgentGateStatus { + normalizedTaskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .blocked : .ready + } + + private var taskContextDetailText: String { + var details: [String] = [] + if let taskId = script.agentTaskId { + details.append("[\(taskId)] \(normalizedTaskName)") + } else { + details.append(normalizedTaskName) + } + let model = AgentRuntimeCatalog.normalizeModel(script.defaultModel) + if !model.isEmpty { + details.append(model) + } + return details.joined(separator: " ยท ") + } + + private var taskContextHelperText: String { + "Task memory namespace is derived from this task name." + } + + private var latestPreScriptDecision: AgentTriggerDecision? { + guard let latestSuccess = runHistory.first(where: { $0.status == .success }) else { + return nil + } + return AgentTriggerEvaluator.evaluate(mode: .preScriptTrue, scriptRun: latestSuccess) + } + + private var triggerGateStatus: AgentGateStatus { + switch selectedAgentTriggerMode { + case .always: + return .ready + case .preScriptTrue: + // Conditional gate: this mode branches by script output and is intentionally shown as warning/yellow. + return .warning + } + } + + private var triggerGateHelperText: String { + switch selectedAgentTriggerMode { + case .always: + return selectedAgentTriggerMode.helperText + case .preScriptTrue: + switch latestPreScriptDecision { + case .run: + return "Conditional gate enabled. Latest successful run resolves to true." + case .skip: + return "Conditional gate enabled. Latest successful run resolves to false." + case .invalid: + return "Conditional gate enabled. Latest successful run is not parseable yet." + case nil: + return "Conditional gate enabled. Waiting for the first successful script output." + } + } + } + + private var agentTriggerBranchesView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + TriggerBranchCard( + branchLabel: "true", + actionText: "Run Agent stage", + detailText: "Start post-script agent execution", + color: Theme.successColor, + icon: "checkmark.circle.fill", + isHighlighted: isTrueBranchHighlighted + ) + TriggerBranchCard( + branchLabel: "false", + actionText: "Skip Agent stage", + detailText: "Do not start post-script agent", + color: Theme.warningColor, + icon: "minus.circle.fill", + isHighlighted: isFalseBranchHighlighted + ) + } + + if case .invalid(let reason) = latestPreScriptDecision { + Text("Latest parse warning: \(reason)") + .font(.caption2) + .foregroundStyle(Theme.warningColor.opacity(0.95)) + } + } + } + + private var isTrueBranchHighlighted: Bool { + if case .run = latestPreScriptDecision { + return true + } + return false + } + + private var isFalseBranchHighlighted: Bool { + if case .skip = latestPreScriptDecision { + return true + } + return false + } + + private var agentTriggerPickerBinding: Binding { + Binding( + get: { selectedAgentTriggerMode }, + set: { newMode in + selectedAgentTriggerMode = newMode + updateAgentTriggerMode(newMode) + } + ) + } + + private func updateAgentTriggerMode(_ mode: AgentTriggerMode) { + guard mode != script.agentTriggerMode else { return } + var updated = script + updated.agentTriggerMode = mode + Task { + await appState.updateScript(updated) + } + } + + private func fileExists(atPath path: String) -> Bool { + !path.isEmpty && FileManager.default.fileExists(atPath: path) + } + + private func revealInFinder(path: String) { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + NSWorkspace.shared.selectFile(trimmed, inFileViewerRootedAtPath: "") + } + private func removeTag(_ tag: String) { var updated = script updated.tags.removeAll { $0 == tag } @@ -701,6 +855,130 @@ struct ScriptDetailView: View { } } +private enum AgentGateStatus { + case ready + case warning + case blocked + + var color: Color { + switch self { + case .ready: + return Theme.successColor + case .warning: + return Theme.warningColor + case .blocked: + return Theme.failureColor + } + } + + var iconName: String { + switch self { + case .ready: + return "checkmark.circle.fill" + case .warning: + return "exclamationmark.triangle.fill" + case .blocked: + return "xmark.circle.fill" + } + } +} + +private struct AgentFlowStepRow: View { + let step: Int + let title: String + let detail: String + let helper: String + let status: AgentGateStatus + let isLast: Bool + @ViewBuilder var trailing: () -> Trailing + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(spacing: 0) { + ZStack { + Circle() + .fill(status.color.opacity(0.16)) + .frame(width: 20, height: 20) + Image(systemName: status.iconName) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(status.color) + } + + if !isLast { + Rectangle() + .fill(status.color.opacity(0.45)) + .frame(width: 2, height: 26) + .padding(.top, 4) + } + } + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(step). \(title)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + Spacer(minLength: 8) + trailing() + } + + Text(detail) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + + if !helper.isEmpty { + Text(helper) + .font(.caption2) + .foregroundStyle(status.color.opacity(0.9)) + .textSelection(.enabled) + } + } + .padding(.bottom, isLast ? 0 : 6) + } + } +} + +private struct TriggerBranchCard: View { + let branchLabel: String + let actionText: String + let detailText: String + let color: Color + let icon: String + let isHighlighted: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.caption2) + .foregroundStyle(color) + Text(branchLabel) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(color) + } + + Text(actionText) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + + Text(detailText) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(color.opacity(isHighlighted ? 0.18 : 0.1), in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(color.opacity(isHighlighted ? 0.8 : 0.4), lineWidth: isHighlighted ? 1.2 : 1) + ) + } +} + // MARK: - Stat Card struct StatCard: View { From 83e3961c52b6766e5b851173dc189761afbb4555 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 10 Mar 2026 20:07:46 +0800 Subject: [PATCH 15/15] docs(skill): document pre-script agent trigger gate --- skills/scriptoria/SKILL.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/skills/scriptoria/SKILL.md b/skills/scriptoria/SKILL.md index 01da478..d2f05d2 100644 --- a/skills/scriptoria/SKILL.md +++ b/skills/scriptoria/SKILL.md @@ -6,7 +6,7 @@ description: > a task every hour", "list my scripts", or "set up a cron job". metadata: author: scriptoria - version: "0.1.1" + version: "0.1.2" --- # Scriptoria โ€” Automation Script Manager @@ -183,6 +183,23 @@ scriptoria run "Health Check" scriptoria schedule add "Health Check" --every 15 ``` +## Pre-script Gate (Agent Trigger) + +When GUI `Agent Trigger` is set to `Only when pre-script is true`, Scriptoria evaluates the script output gate before running agent stage. + +Gate parser contract: +- Scriptoria reads the **last non-empty line of STDOUT**. +- Accepted values: + - `true` / `1` / `yes` / `on` -> run post-script agent stage + - `false` / `0` / `no` / `off` -> skip post-script agent stage +- If the last non-empty line is not parseable (or JSON with a recognized boolean field), trigger check is invalid and run is marked with trigger error. + +Recommended gate-script pattern: +```bash +# keep logs above... +echo "true" # or "false" as the final non-empty stdout line +``` + ## Troubleshooting - **CLI not found**: Rebuild and re-link: `swift build && sudo ln -sf "$(pwd)/.build/debug/scriptoria" /usr/local/bin/scriptoria`