diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7d1e702..fd53080 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -190,10 +190,9 @@ try { Write-Host "ORCA_RESOURCE_ROOT=$CurrentLink" Ensure-ResourceRootEntry $CurrentLink Write-Host "Next steps:" - Write-Host " $destination version" - Write-Host " $destination doctor" - Write-Host " $destination init --preset generic-agent" - Write-Host " $destination plugin install hermes --yes" + Write-Host " $destination setup" + Write-Host "" + Write-Host " (Guided interactive host selection is now the default on interactive terminals)" } finally { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/src/cli/disable.zig b/src/cli/disable.zig index 78bc6b4..4d54205 100644 --- a/src/cli/disable.zig +++ b/src/cli/disable.zig @@ -109,7 +109,7 @@ pub fn command(argv: []const []const u8, stdout: anytype, stderr: anytype) !u8 { if (any_action) { try stdout.writeAll("Orca plugins have been disabled.\n"); try stdout.writeAll("Orca binary and policy files remain in place.\n"); - try stdout.writeAll("Re-enable with: orca plugin install --yes\n"); + try stdout.writeAll("Re-enable with: orca setup (guided) or orca plugin install \n"); } else { try stdout.writeAll("No Orca plugins were found to disable.\n"); } diff --git a/src/cli/help.zig b/src/cli/help.zig index 8f87de0..651368c 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -32,11 +32,14 @@ pub const commands = [_]CommandInfo{ }, .{ .name = "setup", - .summary = "Unified bootstrap: detect hosts, init policy, install plugins", + .summary = "Guided post-install setup for agent host integrations", .usage = "orca setup [--auto] [--preset ]", .details = &.{ - "Detects installed agent hosts, initializes a policy if missing, installs missing plugins, and runs smoke tests.", - "Use --auto for non-interactive mode. Use --preset to choose a policy preset (default: generic-agent).", + "On interactive terminals (TTY), `orca setup` with no flags launches guided host selection: a simple numbered list of detected agents. Toggle choices, confirm with 'c' — no `--yes` needed for the happy path.", + "The selector is line-based for broad terminal compatibility. Full arrow+spacebar support is planned for a future phase.", + "Use --auto (or --yes alias) for fully automatic non-interactive mode (scripts, CI, headless). This path is 100% unchanged in behavior.", + "Use --preset to choose a policy preset (default: generic-agent).", + "After setup, run 'orca run -- ' for immediate protection.", }, }, .{ @@ -85,7 +88,7 @@ pub const commands = [_]CommandInfo{ "OpenClaw: runs 'openclaw plugins uninstall orca-openclaw-plugin'", "Hermes: runs 'hermes plugins disable orca' and removes ~/.hermes/plugins/orca/", "Codex / Claude: removes known plugin paths (host-managed install locations).", - "Re-enable later with: orca plugin install --yes", + "Re-enable later with: orca setup (guided) or orca plugin install ", } }, .{ .name = "uninstall", .summary = "Uninstall Orca from this machine", .usage = "orca uninstall [--plugins-only] [--keep-config] [--yes]", .details = &.{ "Completely removes Orca and its integrations from the machine.", @@ -139,6 +142,8 @@ pub const commands = [_]CommandInfo{ " orca plugin manifest [codex|claude|opencode|openclaw|hermes|all] [--json]", " orca plugin install [codex|claude|opencode|openclaw|hermes|all] [--dry-run] [--path ] [--yes]", " orca plugin mcp-server [--help]", + "Primary onboarding path: run `orca setup` (guided interactive selection on TTY terminals).", + "`plugin install --yes` is retained for scripting, CI, and non-interactive use cases.", "Plugin commands are safe by default: install defaults to --dry-run, doctor does not print secrets,", "and mcp-server is currently a documented stub that does not start a real server.", } }, diff --git a/src/cli/interactive.zig b/src/cli/interactive.zig new file mode 100644 index 0000000..58be8f9 --- /dev/null +++ b/src/cli/interactive.zig @@ -0,0 +1,233 @@ +const std = @import("std"); + +/// Represents a single selectable item in a multi-select checkbox list. +/// Used by guided flows (e.g. host selection after install). +pub const SelectionItem = struct { + /// Human-readable label (e.g. "Hermes", "Claude Code") + label: []const u8, + /// Whether the item is currently selected/checked. + checked: bool = false, + /// Optional stable identifier (e.g. "hermes", "claude"). + id: ?[]const u8 = null, +}; + +/// Result returned after a multi-select interaction completes. +pub const MultiSelectResult = struct { + /// The final state of all items presented to the user. + items: []SelectionItem, + /// True if the user confirmed the selection (e.g. pressed Enter). + /// False if the user canceled (e.g. Esc / q). + confirmed: bool, +}; + +/// High-level entry point for a checkbox-style multi-select. +/// Phase 1 implementation: line-based interactive selector (works in all terminals). +/// User can type numbers to toggle items, then 'c' to confirm or 'q' to cancel. +/// Full raw-mode (arrows + spacebar) can be layered on top later. +pub fn runMultiSelect( + allocator: std.mem.Allocator, + items: []const SelectionItem, + stdout: anytype, + stdin: anytype, +) !MultiSelectResult { + const owned = try allocator.alloc(SelectionItem, items.len); + errdefer allocator.free(owned); + + var initialized: usize = 0; + errdefer { + for (owned[0..initialized]) |*it| { + allocator.free(it.label); + if (it.id) |id| allocator.free(id); + } + } + + for (items, 0..) |item, i| { + const label = try allocator.dupe(u8, item.label); + errdefer allocator.free(label); + + const maybe_id = if (item.id) |id_str| try allocator.dupe(u8, id_str) else null; + errdefer if (maybe_id) |id_str| allocator.free(id_str); + + owned[i] = .{ + .label = label, + .checked = item.checked, + .id = maybe_id, + }; + initialized = i + 1; + } + + const stdin_file = std.fs.File.stdin(); + const is_interactive = stdin_file.isTty(); + + if (!is_interactive) { + // Non-interactive: return current state as confirmed (safe default for scripts) + return .{ + .items = owned, + .confirmed = true, + }; + } + + // Simple line-based interactive loop + while (true) { + try stdout.writeAll("\nSelect hosts to integrate with Orca (toggle by number, c=confirm, q=cancel):\n\n"); + + for (owned, 0..) |item, i| { + const checkbox = if (item.checked) "[x]" else "[ ]"; + try stdout.print(" {d}. {s} {s}\n", .{ i + 1, checkbox, item.label }); + } + + try stdout.writeAll("\n> "); + + var buf: [128]u8 = undefined; + const n = try stdin.read(&buf); + const input = std.mem.trimRight(u8, buf[0..n], "\r\n "); + + if (input.len == 0) continue; + + if (std.mem.eql(u8, input, "c") or std.mem.eql(u8, input, "C")) { + return .{ + .items = owned, + .confirmed = true, + }; + } + if (std.mem.eql(u8, input, "q") or std.mem.eql(u8, input, "Q")) { + return .{ + .items = owned, + .confirmed = false, + }; + } + + // Try to parse as number to toggle + const num = std.fmt.parseInt(usize, input, 10) catch { + try stdout.writeAll(" (invalid input — enter a number, 'c', or 'q')\n"); + continue; + }; + + if (num >= 1 and num <= owned.len) { + owned[num - 1].checked = !owned[num - 1].checked; + } else { + try stdout.writeAll(" (number out of range)\n"); + } + } +} + +/// Frees memory owned by a MultiSelectResult. +pub fn deinitMultiSelectResult(result: *MultiSelectResult, allocator: std.mem.Allocator) void { + for (result.items) |item| { + allocator.free(item.label); + if (item.id) |id| allocator.free(id); + } + allocator.free(result.items); + result.* = undefined; +} + +/// Pure helper: returns a new slice with only the checked items (labels only). +/// Useful for logging / summaries in guided flows. +pub fn getSelectedLabels(allocator: std.mem.Allocator, items: []const SelectionItem) ![][]const u8 { + var list: std.ArrayList([]const u8) = .empty; + defer list.deinit(allocator); + + for (items) |item| { + if (item.checked) { + const owned = try allocator.dupe(u8, item.label); + try list.append(allocator, owned); + } + } + return list.toOwnedSlice(allocator); +} + +// --------------------------------------------------------------------------- +// Phase 0 tests (TDD style - these will be expanded in later phases) +// --------------------------------------------------------------------------- + +test "interactive: runMultiSelect Phase 0 stub returns all items checked and confirmed" { + const allocator = std.testing.allocator; + + const input = [_]SelectionItem{ + .{ .label = "Hermes", .id = "hermes" }, + .{ .label = "Claude Code", .id = "claude" }, + }; + + var stdout_buf: [256]u8 = undefined; + var stdin_buf: [256]u8 = undefined; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stdin_stream = std.io.fixedBufferStream(&stdin_buf); + + var result = try runMultiSelect(allocator, &input, stdout_stream.writer(), stdin_stream.reader()); + defer deinitMultiSelectResult(&result, allocator); + + try std.testing.expectEqual(true, result.confirmed); + try std.testing.expectEqual(@as(usize, 2), result.items.len); + // In non-TTY path we now respect the input checked state (better semantics) + try std.testing.expectEqual(false, result.items[0].checked); // input had default false + try std.testing.expectEqual(false, result.items[1].checked); + try std.testing.expectEqualStrings("Hermes", result.items[0].label); +} + +test "interactive: getSelectedLabels returns only checked items" { + const allocator = std.testing.allocator; + + const items = [_]SelectionItem{ + .{ .label = "OpenCode", .checked = true }, + .{ .label = "Codex", .checked = false }, + .{ .label = "OpenClaw", .checked = true }, + }; + + const labels = try getSelectedLabels(allocator, &items); + defer { + for (labels) |l| allocator.free(l); + allocator.free(labels); + } + + try std.testing.expectEqual(@as(usize, 2), labels.len); + try std.testing.expectEqualStrings("OpenCode", labels[0]); + try std.testing.expectEqualStrings("OpenClaw", labels[1]); +} + +test "interactive: deinitMultiSelectResult frees memory cleanly" { + const allocator = std.testing.allocator; + + const input = [_]SelectionItem{ + .{ .label = "Test Host", .id = "test" }, + }; + + // Use simple fixed buffers instead of null_* (Zig 0.15 Io model) + var out_buf: [64]u8 = undefined; + var in_buf: [64]u8 = undefined; + var out = std.io.fixedBufferStream(&out_buf); + var in_ = std.io.fixedBufferStream(&in_buf); + + var result = try runMultiSelect(allocator, &input, out.writer(), in_.reader()); + deinitMultiSelectResult(&result, allocator); + + // Reaching here without leaks (under testing allocator) means deinit works. + try std.testing.expect(true); +} + +// TDD test for allocator safety on error paths (was RED, now GREEN after errdefer). +// Uses an isolated GPA + FailingAllocator so we can directly assert zero leaked bytes +// even when runMultiSelect returns an error after partial initialization. +test "interactive: runMultiSelect never leaks on allocation failure during item construction" { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + defer _ = gpa.deinit(); // will assert no leaks at test end + + var failing_state = std.testing.FailingAllocator.init(gpa.allocator(), .{ .fail_index = 2 }); + const allocator = failing_state.allocator(); + + const input = [_]SelectionItem{ + .{ .label = "Hermes", .id = "hermes" }, + .{ .label = "Claude Code", .id = "claude" }, + }; + + var in_buf: [64]u8 = undefined; + var in_ = std.io.fixedBufferStream(&in_buf); + var out_buf: [256]u8 = undefined; + var out = std.io.fixedBufferStream(&out_buf); + + const result = runMultiSelect(allocator, &input, out.writer(), in_.reader()); + try std.testing.expectError(error.OutOfMemory, result); + + // The errdefer in runMultiSelect must have released every partial dupe + the owned slice. + // If any bytes remain live in the GPA, the defer _ = gpa.deinit() below will panic + // (and the test will fail). Reaching here with no panic = GREEN. +} diff --git a/src/cli/mod.zig b/src/cli/mod.zig index b4eac9b..fc7e6e5 100644 --- a/src/cli/mod.zig +++ b/src/cli/mod.zig @@ -30,6 +30,7 @@ pub const ci = @import("ci.zig"); pub const demo = @import("demo.zig"); pub const disable = @import("disable.zig"); pub const uninstall = @import("uninstall.zig"); +pub const interactive = @import("interactive.zig"); pub const version = build_options.version; @@ -270,3 +271,43 @@ test "run dispatch launches child command" { try std.testing.expect(std.mem.indexOf(u8, stdout_stream.getWritten(), "Orca session ended: exit code 0") != null); try std.testing.expectEqualStrings("", stderr_stream.getWritten()); } + +// --------------------------------------------------------------------------- +// Phase 3 TDD tests: messaging and help text updates for guided onboarding +// These tests are written FIRST (RED). They will fail until help text is updated +// to describe the new default guided behavior and de-emphasize --yes. +// --------------------------------------------------------------------------- + +test "setup help describes guided interactive default on TTY and de-emphasizes --auto for primary path" { + var stdout_buf: [2048]u8 = undefined; + var stderr_buf: [256]u8 = undefined; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stderr_stream = std.io.fixedBufferStream(&stderr_buf); + + const code = try run(&.{ "help", "setup" }, stdout_stream.writer(), stderr_stream.writer()); + try std.testing.expectEqual(exit_codes.success, code); + + const output = stdout_stream.getWritten(); + // New Phase 3 messaging: guided is default on interactive terminals + try std.testing.expect(std.mem.indexOf(u8, output, "guided") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "interactive") != null or std.mem.indexOf(u8, output, "TTY") != null or std.mem.indexOf(u8, output, "terminal") != null); + // Still documents the non-interactive escape hatch + try std.testing.expect(std.mem.indexOf(u8, output, "--auto") != null or std.mem.indexOf(u8, output, "non-interactive") != null); + try std.testing.expectEqualStrings("", stderr_stream.getWritten()); +} + +test "plugin help and disable re-enable messaging de-emphasize --yes in favor of setup" { + var stdout_buf: [2048]u8 = undefined; + var stderr_buf: [256]u8 = undefined; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stderr_stream = std.io.fixedBufferStream(&stderr_buf); + + const code = try run(&.{ "help", "plugin" }, stdout_stream.writer(), stderr_stream.writer()); + try std.testing.expectEqual(exit_codes.success, code); + + const output = stdout_stream.getWritten(); + // Phase 3: primary onboarding path is `orca setup`; --yes remains for scripts + try std.testing.expect(std.mem.indexOf(u8, output, "setup") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "guided") != null or std.mem.indexOf(u8, output, "interactive") != null); + try std.testing.expectEqualStrings("", stderr_stream.getWritten()); +} diff --git a/src/cli/plugin.zig b/src/cli/plugin.zig index 70d7f59..b255359 100644 --- a/src/cli/plugin.zig +++ b/src/cli/plugin.zig @@ -407,35 +407,35 @@ fn writeDoctorPlain(stdout: anytype, report: PluginDoctorReport, target: DoctorT try stdout.writeAll("\nPlugin directories:\n"); try stdout.print(" integrations/common: {s}\n", .{if (report.plugin_directories.common) "found" else "missing"}); - if (!report.plugin_directories.common) try stdout.writeAll(" → Fix: orca plugin install all --yes\n"); + if (!report.plugin_directories.common) try stdout.writeAll(" → Fix: orca setup or orca plugin install all\n"); try stdout.print(" integrations/codex-plugin: {s}\n", .{if (report.plugin_directories.codex) "found" else "missing"}); - if (!report.plugin_directories.codex) try stdout.writeAll(" → Fix: orca plugin install codex --yes\n"); + if (!report.plugin_directories.codex) try stdout.writeAll(" → Fix: orca setup or orca plugin install codex\n"); try stdout.print(" integrations/claude-code-plugin: {s}\n", .{if (report.plugin_directories.claude) "found" else "missing"}); - if (!report.plugin_directories.claude) try stdout.writeAll(" → Fix: orca plugin install claude --yes\n"); + if (!report.plugin_directories.claude) try stdout.writeAll(" → Fix: orca setup or orca plugin install claude\n"); try stdout.print(" integrations/opencode-plugin: {s}\n", .{if (report.plugin_directories.opencode) "found" else "missing"}); - if (!report.plugin_directories.opencode) try stdout.writeAll(" → Fix: orca plugin install opencode --yes\n"); + if (!report.plugin_directories.opencode) try stdout.writeAll(" → Fix: orca setup or orca plugin install opencode\n"); try stdout.print(" integrations/openclaw-plugin: {s}\n", .{if (report.plugin_directories.openclaw) "found" else "missing"}); - if (!report.plugin_directories.openclaw) try stdout.writeAll(" → Fix: orca plugin install openclaw --yes\n"); + if (!report.plugin_directories.openclaw) try stdout.writeAll(" → Fix: orca setup or orca plugin install openclaw\n"); try stdout.print(" integrations/hermes-plugin: {s}\n", .{if (report.plugin_directories.hermes) "found" else "missing"}); - if (!report.plugin_directories.hermes) try stdout.writeAll(" → Fix: orca plugin install hermes --yes\n"); + if (!report.plugin_directories.hermes) try stdout.writeAll(" → Fix: orca setup or orca plugin install hermes\n"); try stdout.writeAll("\nHost binaries:\n"); try stdout.print(" codex: {s}\n", .{if (report.host_binaries.codex) "found in PATH" else "not found"}); - if (!report.host_binaries.codex) try stdout.writeAll(" → Fix: orca plugin install codex --yes\n"); + if (!report.host_binaries.codex) try stdout.writeAll(" → Fix: orca setup or orca plugin install codex\n"); try stdout.print(" claude: {s}\n", .{if (report.host_binaries.claude) "found in PATH" else "not found"}); - if (!report.host_binaries.claude) try stdout.writeAll(" → Fix: orca plugin install claude --yes\n"); + if (!report.host_binaries.claude) try stdout.writeAll(" → Fix: orca setup or orca plugin install claude\n"); try stdout.print(" opencode: {s}\n", .{if (report.host_binaries.opencode) "found in PATH" else "not found"}); - if (!report.host_binaries.opencode) try stdout.writeAll(" → Fix: orca plugin install opencode --yes\n"); + if (!report.host_binaries.opencode) try stdout.writeAll(" → Fix: orca setup or orca plugin install opencode\n"); try stdout.print(" openclaw: {s}\n", .{if (report.host_binaries.openclaw) "found in PATH" else "not found"}); - if (!report.host_binaries.openclaw) try stdout.writeAll(" → Fix: orca plugin install openclaw --yes\n"); + if (!report.host_binaries.openclaw) try stdout.writeAll(" → Fix: orca setup or orca plugin install openclaw\n"); try stdout.print(" hermes: {s}\n", .{if (report.host_binaries.hermes) "found in PATH" else "not found"}); - if (!report.host_binaries.hermes) try stdout.writeAll(" → Fix: orca plugin install hermes --yes\n"); + if (!report.host_binaries.hermes) try stdout.writeAll(" → Fix: orca setup or orca plugin install hermes\n"); try stdout.writeAll("\nMarketplace files:\n"); try stdout.print(" .agents/plugins/marketplace.json: {s}\n", .{if (report.marketplace.codex_marketplace) "present" else "missing"}); - if (!report.marketplace.codex_marketplace) try stdout.writeAll(" → Fix: orca plugin install codex --yes\n"); + if (!report.marketplace.codex_marketplace) try stdout.writeAll(" → Fix: orca setup or orca plugin install codex\n"); try stdout.print(" .claude-plugin/marketplace.json: {s}\n", .{if (report.marketplace.claude_marketplace) "present" else "missing"}); - if (!report.marketplace.claude_marketplace) try stdout.writeAll(" → Fix: orca plugin install claude --yes\n"); + if (!report.marketplace.claude_marketplace) try stdout.writeAll(" → Fix: orca setup or orca plugin install claude\n"); try stdout.writeAll("\nPlatform:\n"); try stdout.print(" {s}\n", .{report.platform_summary}); @@ -453,13 +453,13 @@ fn writeDoctorPlain(stdout: anytype, report: PluginDoctorReport, target: DoctorT .codex => { try stdout.writeAll("\nCodex plugin status:\n"); try stdout.print(" host binary: {s}\n", .{if (report.host_binaries.codex) "detected" else "not detected"}); - if (!report.host_binaries.codex) try stdout.writeAll(" → Fix: install Codex and re-run orca plugin install codex --yes\n"); + if (!report.host_binaries.codex) try stdout.writeAll(" → Fix: install Codex and re-run orca setup or orca plugin install codex\n"); try stdout.print(" bundled plugin directory: {s}\n", .{if (report.plugin_directories.codex) "present" else "missing"}); if (!report.plugin_directories.codex) try stdout.writeAll(" → Fix: install Orca runtime assets or set ORCA_RESOURCE_ROOT\n"); try stdout.print(" user plugin registration: {s}\n", .{if (report.marketplace.codex_user_plugin) "installed" else "missing"}); - if (!report.marketplace.codex_user_plugin) try stdout.writeAll(" → Fix: orca plugin install codex --yes\n"); + if (!report.marketplace.codex_user_plugin) try stdout.writeAll(" → Fix: orca setup or orca plugin install codex\n"); try stdout.print(" marketplace file: {s}\n", .{if (report.marketplace.codex_marketplace) "present" else "missing"}); - if (!report.marketplace.codex_marketplace) try stdout.writeAll(" → Fix: orca plugin install codex --yes\n"); + if (!report.marketplace.codex_marketplace) try stdout.writeAll(" → Fix: orca setup or orca plugin install codex\n"); try stdout.print(" bundled plugin manifest: {s}\n", .{if (report.marketplace.codex_plugin_manifest) "present" else "missing"}); if (!report.marketplace.codex_plugin_manifest) try stdout.writeAll(" → Fix: install Orca runtime assets or set ORCA_RESOURCE_ROOT\n"); try stdout.writeAll(" install: use 'orca plugin install codex --dry-run' to preview\n"); @@ -467,13 +467,13 @@ fn writeDoctorPlain(stdout: anytype, report: PluginDoctorReport, target: DoctorT .claude => { try stdout.writeAll("\nClaude Code plugin status:\n"); try stdout.print(" host binary: {s}\n", .{if (report.host_binaries.claude) "detected" else "not detected"}); - if (!report.host_binaries.claude) try stdout.writeAll(" → Fix: install Claude Code and re-run orca plugin install claude --yes\n"); + if (!report.host_binaries.claude) try stdout.writeAll(" → Fix: install Claude Code and re-run orca setup or orca plugin install claude\n"); try stdout.print(" bundled plugin directory: {s}\n", .{if (report.plugin_directories.claude) "present" else "missing"}); if (!report.plugin_directories.claude) try stdout.writeAll(" → Fix: install Orca runtime assets or set ORCA_RESOURCE_ROOT\n"); try stdout.print(" user plugin registration: {s}\n", .{if (report.marketplace.claude_user_plugin) "installed" else "missing"}); - if (!report.marketplace.claude_user_plugin) try stdout.writeAll(" → Fix: orca plugin install claude --yes\n"); + if (!report.marketplace.claude_user_plugin) try stdout.writeAll(" → Fix: orca setup or orca plugin install claude\n"); try stdout.print(" marketplace file: {s}\n", .{if (report.marketplace.claude_marketplace) "present" else "missing"}); - if (!report.marketplace.claude_marketplace) try stdout.writeAll(" → Fix: orca plugin install claude --yes\n"); + if (!report.marketplace.claude_marketplace) try stdout.writeAll(" → Fix: orca setup or orca plugin install claude\n"); try stdout.print(" bundled plugin manifest: {s}\n", .{if (report.marketplace.claude_plugin_manifest) "present" else "missing"}); if (!report.marketplace.claude_plugin_manifest) try stdout.writeAll(" → Fix: install Orca runtime assets or set ORCA_RESOURCE_ROOT\n"); try stdout.writeAll(" install: use 'orca plugin install claude --dry-run' to preview\n"); @@ -481,26 +481,26 @@ fn writeDoctorPlain(stdout: anytype, report: PluginDoctorReport, target: DoctorT .opencode => { try stdout.writeAll("\nOpenCode plugin status:\n"); try stdout.print(" host binary: {s}\n", .{if (report.host_binaries.opencode) "detected" else "not detected"}); - if (!report.host_binaries.opencode) try stdout.writeAll(" → Fix: orca plugin install opencode --yes\n"); + if (!report.host_binaries.opencode) try stdout.writeAll(" → Fix: orca setup or orca plugin install opencode\n"); try stdout.print(" plugin directory: {s}\n", .{if (report.plugin_directories.opencode) "present" else "not yet created"}); - if (!report.plugin_directories.opencode) try stdout.writeAll(" → Fix: orca plugin install opencode --yes\n"); + if (!report.plugin_directories.opencode) try stdout.writeAll(" → Fix: orca setup or orca plugin install opencode\n"); try stdout.print(" project plugin path (.opencode/plugins/orca.ts): {s}\n", .{if (report.opencode_paths.project_plugin_exists) "exists" else "not found"}); - if (!report.opencode_paths.project_plugin_exists) try stdout.writeAll(" → Fix: orca plugin install opencode --yes\n"); + if (!report.opencode_paths.project_plugin_exists) try stdout.writeAll(" → Fix: orca setup or orca plugin install opencode\n"); try stdout.print(" global plugin path (~/.config/opencode/plugins/orca.ts): {s}\n", .{if (report.opencode_paths.global_plugin_exists) "exists" else "not found"}); - if (!report.opencode_paths.global_plugin_exists) try stdout.writeAll(" → Fix: orca plugin install opencode --yes\n"); + if (!report.opencode_paths.global_plugin_exists) try stdout.writeAll(" → Fix: orca setup or orca plugin install opencode\n"); try stdout.writeAll(" install: use 'orca plugin install opencode --dry-run' to preview\n"); try stdout.writeAll(" note: OpenCode plugin uses TypeScript hooks, not a manifest file\n"); }, .openclaw => { try stdout.writeAll("\nOpenClaw plugin status:\n"); try stdout.print(" host binary: {s}\n", .{if (report.host_binaries.openclaw) "detected" else "not detected"}); - if (!report.host_binaries.openclaw) try stdout.writeAll(" → Fix: install OpenClaw and re-run orca plugin install openclaw --yes\n"); + if (!report.host_binaries.openclaw) try stdout.writeAll(" → Fix: install OpenClaw and re-run orca setup or orca plugin install openclaw\n"); try stdout.print(" bundled plugin directory: {s}\n", .{if (report.plugin_directories.openclaw) "present" else "missing"}); if (!report.plugin_directories.openclaw) try stdout.writeAll(" → Fix: install Orca runtime assets or set ORCA_RESOURCE_ROOT\n"); try stdout.print(" host plugin installed: {s}\n", .{if (report.openclaw_paths.host_plugin_installed) "yes" else "no"}); - if (!report.openclaw_paths.host_plugin_installed) try stdout.writeAll(" → Fix: orca plugin install openclaw --yes\n"); + if (!report.openclaw_paths.host_plugin_installed) try stdout.writeAll(" → Fix: orca setup or orca plugin install openclaw\n"); try stdout.print(" host plugin manifest (openclaw.plugin.json): {s}\n", .{if (report.openclaw_paths.plugin_manifest_exists) "exists" else "not found"}); - if (!report.openclaw_paths.plugin_manifest_exists) try stdout.writeAll(" → Fix: orca plugin install openclaw --yes\n"); + if (!report.openclaw_paths.plugin_manifest_exists) try stdout.writeAll(" → Fix: orca setup or orca plugin install openclaw\n"); try stdout.print(" host package.json: {s}\n", .{if (report.openclaw_paths.package_json_exists) "exists" else "not found"}); try stdout.print(" host source (src/index.ts): {s}\n", .{if (report.openclaw_paths.source_exists) "exists" else "not found"}); try stdout.print(" detection note: {s}\n", .{report.openclaw_paths.detection_note}); @@ -510,17 +510,17 @@ fn writeDoctorPlain(stdout: anytype, report: PluginDoctorReport, target: DoctorT .hermes => { try stdout.writeAll("\nHermes plugin status:\n"); try stdout.print(" host binary: {s}\n", .{if (report.host_binaries.hermes) "detected" else "not detected"}); - if (!report.host_binaries.hermes) try stdout.writeAll(" → Fix: orca plugin install hermes --yes\n"); + if (!report.host_binaries.hermes) try stdout.writeAll(" → Fix: orca setup or orca plugin install hermes\n"); try stdout.print(" plugin directory: {s}\n", .{if (report.plugin_directories.hermes) "present" else "not yet created"}); - if (!report.plugin_directories.hermes) try stdout.writeAll(" → Fix: orca plugin install hermes --yes\n"); + if (!report.plugin_directories.hermes) try stdout.writeAll(" → Fix: orca setup or orca plugin install hermes\n"); try stdout.print(" repo plugin.yaml: {s}\n", .{if (report.hermes_paths.repo_manifest_exists) "exists" else "not found"}); - if (!report.hermes_paths.repo_manifest_exists) try stdout.writeAll(" → Fix: orca plugin install hermes --yes\n"); + if (!report.hermes_paths.repo_manifest_exists) try stdout.writeAll(" → Fix: orca setup or orca plugin install hermes\n"); try stdout.print(" repo __init__.py: {s}\n", .{if (report.hermes_paths.repo_source_exists) "exists" else "not found"}); - if (!report.hermes_paths.repo_source_exists) try stdout.writeAll(" → Fix: orca plugin install hermes --yes\n"); + if (!report.hermes_paths.repo_source_exists) try stdout.writeAll(" → Fix: orca setup or orca plugin install hermes\n"); try stdout.print(" user plugin path (~/.hermes/plugins/orca/plugin.yaml): {s}\n", .{if (report.hermes_paths.user_manifest_exists) "exists" else "not found"}); - if (!report.hermes_paths.user_manifest_exists) try stdout.writeAll(" → Fix: orca plugin install hermes --yes\n"); + if (!report.hermes_paths.user_manifest_exists) try stdout.writeAll(" → Fix: orca setup or orca plugin install hermes\n"); try stdout.print(" config references plugin: {s}\n", .{if (report.hermes_paths.config_references_plugin) "yes" else "unknown/no"}); - if (!report.hermes_paths.config_references_plugin) try stdout.writeAll(" → Fix: orca plugin install hermes --yes\n"); + if (!report.hermes_paths.config_references_plugin) try stdout.writeAll(" → Fix: orca setup or orca plugin install hermes\n"); try stdout.print(" hook smoke test (pre_tool_call): {s}\n", .{if (report.hermes_hook_smoke_passed) "passed" else "FAILED"}); if (!report.hermes_hook_smoke_passed) try stdout.writeAll(" → Fix: upgrade Orca (./scripts/install-orca-plugin.sh hermes) or set ORCA_BIN to a build with Hermes host support\n"); try stdout.writeAll(" install: use 'orca plugin install hermes --dry-run' to preview\n"); @@ -964,12 +964,13 @@ fn installCommand(argv: []const []const u8, stdout: anytype, stderr: anytype) !u \\ orca plugin install opencode --scope project|global [--dry-run|--yes] \\ orca plugin install [--yes] \\ + \\Primary flow: `orca setup` (guided, interactive on TTY). --yes for scripts/CI only. \\Options: \\ --dry-run Preview changes without mutating host config (default) \\ --all-detected Only install for hosts found in PATH \\ --path Use a custom plugin path instead of the default \\ --scope OpenCode install scope: project|global (default: project) - \\ --yes Skip confirmation prompt (use with care) + \\ --yes Skip confirmation prompt (use with care for non-TTY) \\ ); return exit_codes.success; @@ -1076,11 +1077,26 @@ fn installCommand(argv: []const []const u8, stdout: anytype, stderr: anytype) !u .openclaw => &[_]InstallTarget{.openclaw}, .hermes => &[_]InstallTarget{.hermes}, .all => if (all_detected) blk: { - if (binaryInPath(allocator, "codex")) { detected_targets[detected_count] = .codex; detected_count += 1; } - if (binaryInPath(allocator, "claude")) { detected_targets[detected_count] = .claude; detected_count += 1; } - if (binaryInPath(allocator, "opencode")) { detected_targets[detected_count] = .opencode; detected_count += 1; } - if (binaryInPath(allocator, "openclaw")) { detected_targets[detected_count] = .openclaw; detected_count += 1; } - if (binaryInPath(allocator, "hermes")) { detected_targets[detected_count] = .hermes; detected_count += 1; } + if (binaryInPath(allocator, "codex")) { + detected_targets[detected_count] = .codex; + detected_count += 1; + } + if (binaryInPath(allocator, "claude")) { + detected_targets[detected_count] = .claude; + detected_count += 1; + } + if (binaryInPath(allocator, "opencode")) { + detected_targets[detected_count] = .opencode; + detected_count += 1; + } + if (binaryInPath(allocator, "openclaw")) { + detected_targets[detected_count] = .openclaw; + detected_count += 1; + } + if (binaryInPath(allocator, "hermes")) { + detected_targets[detected_count] = .hermes; + detected_count += 1; + } break :blk detected_targets[0..detected_count]; } else &[_]InstallTarget{ .codex, .claude, .opencode, .openclaw, .hermes }, }; diff --git a/src/cli/setup.zig b/src/cli/setup.zig index 017d749..a1ff39d 100644 --- a/src/cli/setup.zig +++ b/src/cli/setup.zig @@ -5,6 +5,7 @@ const exit_codes = @import("exit_codes.zig"); const help = @import("help.zig"); const init = @import("init.zig"); const plugin = @import("plugin.zig"); +const interactive = @import("interactive.zig"); pub fn command(cwd: std.fs.Dir, argv: []const []const u8, stdout: anytype, stderr: anytype) !u8 { var auto = false; @@ -44,6 +45,11 @@ pub fn command(cwd: std.fs.Dir, argv: []const []const u8, stdout: anytype, stder } if (!auto) { + // New guided path: on TTY, offer interactive host selection + const stdin_file = std.fs.File.stdin(); + if (stdin_file.isTty()) { + return runGuidedSetup(cwd, stdout, stderr); + } _ = try help.writeCommand(stdout, "setup"); return exit_codes.success; } @@ -135,12 +141,12 @@ pub fn command(cwd: std.fs.Dir, argv: []const []const u8, stdout: anytype, stder if (!any_detected) { try stdout.writeAll("\nNo agent hosts detected in PATH.\n"); - try stdout.writeAll("Install a supported host and run 'orca setup --auto' again.\n"); + try stdout.writeAll("Install a supported host and run 'orca setup --auto' (non-interactive) again.\n"); } if (failure_count > 0) { try stdout.print("\nSetup finished with {d} failure(s).\n", .{failure_count}); - try stdout.writeAll("Review the messages above and re-run 'orca setup --auto' after fixing blockers.\n"); + try stdout.writeAll("Review the messages above and re-run 'orca setup --auto' (non-interactive) after fixing blockers.\n"); return exit_codes.general; } @@ -160,3 +166,94 @@ fn runChild(allocator: std.mem.Allocator, argv: []const []const u8) !u8 { else => 255, }; } + +/// Guided / interactive setup path (new default on TTY when no --auto). +/// Uses the new interactive multi-select for host choice. +fn runGuidedSetup(cwd: std.fs.Dir, stdout: anytype, stderr: anytype) !u8 { + var gpa_state: std.heap.GeneralPurposeAllocator(.{}) = .init; + defer _ = gpa_state.deinit(); + const allocator = gpa_state.allocator(); + + try stdout.writeAll("Orca Guided Setup\n\n"); + try stdout.writeAll("Detecting installed agent hosts...\n"); + + const workspace_root = supervisor.resolveWorkspaceRoot(allocator, null, ".") catch try allocator.dupe(u8, "."); + defer allocator.free(workspace_root); + + // Policy init first (quiet) + const policy_path = try std.fs.path.join(allocator, &.{ workspace_root, ".orca", "policy.yaml" }); + defer allocator.free(policy_path); + + if (!plugin.fileExistsAbsolute(policy_path)) { + try stdout.writeAll("No policy found. Creating with generic-agent preset...\n"); + const init_argv = &[_][]const u8{ "--preset", "generic-agent", "--quiet" }; + _ = try init.command(cwd, init_argv, stdout, stderr); + } + + // Detect hosts using existing infrastructure + const hosts = &[_][]const u8{ "codex", "claude", "opencode", "openclaw", "hermes" }; + var doctor_report = try plugin.collectPluginDoctorReport(allocator); + defer plugin.deinitPluginDoctorReport(&doctor_report, allocator); + + var detected_list: std.ArrayList([]const u8) = .empty; + defer detected_list.deinit(allocator); + + for (hosts) |h| { + if (plugin.binaryInPath(allocator, h)) { + try detected_list.append(allocator, h); + } + } + + if (detected_list.items.len == 0) { + try stdout.writeAll("\nNo supported agent hosts detected in PATH.\n"); + try stdout.writeAll("You can still use `orca run -- ` for protection.\n"); + return exit_codes.success; + } + + // Build selection items for the interactive module + var selection_items = try allocator.alloc(interactive.SelectionItem, detected_list.items.len); + defer allocator.free(selection_items); + + for (detected_list.items, 0..) |h, i| { + selection_items[i] = .{ + .label = h, + .checked = true, // default: wire everything + .id = h, + }; + } + + const stdin_file = std.fs.File.stdin(); + + // Delegate to the shared interactive module. Pass the raw File for the + // stdin parameter (the module performs its own TTY check on global stdin + // and expects a type with a .read method, as used in its existing tests). + var result = try interactive.runMultiSelect(allocator, selection_items, stdout, stdin_file); + defer interactive.deinitMultiSelectResult(&result, allocator); + + if (!result.confirmed) { + try stdout.writeAll("\nSetup canceled by user.\n"); + return exit_codes.success; + } + + // Perform installs for selected hosts using existing logic + var any_installed = false; + for (result.items) |item| { + if (!item.checked) continue; + + try stdout.print("\nIntegrating with {s}...\n", .{item.label}); + const install_argv = &[_][]const u8{ "plugin", "install", item.label, "--yes" }; + const code = try runChild(allocator, install_argv); + if (code == 0) { + any_installed = true; + } + } + + if (any_installed) { + try stdout.writeAll("\nGuided setup complete.\n"); + } else { + try stdout.writeAll("\nNo new integrations were added.\n"); + } + + try stdout.writeAll("Run 'orca doctor' or 'orca run -- ' to get started.\n"); + return exit_codes.success; +} diff --git a/src/dashboard/mod.zig b/src/dashboard/mod.zig index b9cf598..deda19d 100644 --- a/src/dashboard/mod.zig +++ b/src/dashboard/mod.zig @@ -379,8 +379,8 @@ fn writePluginCardJson( } else { try writeStringArray(writer, &.{ "orca init --preset generic-agent", - "orca plugin install hermes --yes", - "hermes plugins enable orca", + "orca setup", + "orca plugin doctor hermes", "orca plugin doctor hermes", "orca run -- hermes", });