diff --git a/.gitignore b/.gitignore index bcbde04..b8b17d7 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,4 @@ orca-dashboard-ui/ .skynex/production-gate.json .skynex/audit.log +.orchestrator/ diff --git a/README.md b/README.md index 36a2448..0c50ed4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ brew tap christopherkarani/orca && brew install --formula orca # Or install with the official script curl -fsSL https://raw.githubusercontent.com/christopherkarani/Orca/main/scripts/install.sh | sh -# Or build from source (Zig 0.15.2) +# Or build from source (Zig 0.16.0 — the current star version) zig build ``` diff --git a/build.zig.zon b/build.zig.zon index d98d092..d7721fe 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .orca, .version = "1.1.4", .fingerprint = 0x6111eddd14dbd5f4, // Changing this has security and trust implications. - .minimum_zig_version = "0.15.2", + .minimum_zig_version = "0.16.0", .dependencies = .{}, .paths = .{ "build.zig", diff --git a/docs/install.md b/docs/install.md index 37b20bc..7622158 100644 --- a/docs/install.md +++ b/docs/install.md @@ -8,7 +8,7 @@ zig build ./zig-out/bin/orca version --json ``` -Use Zig `0.15.2`. +Use Zig `0.16.0` (the current star / minimum supported version). Older 0.15.x may still work for a transition period but is no longer the target. ## Release Artifacts diff --git a/docs/integrations/current-baseline.md b/docs/integrations/current-baseline.md index 7123fb1..f4258b1 100644 --- a/docs/integrations/current-baseline.md +++ b/docs/integrations/current-baseline.md @@ -3,7 +3,7 @@ > Generated: 2026-05-09 > Branch: `phase-35-edge-network-telemetry-data-guard` > Commit: `5b271b9` (Phase 35 committed) -> Zig version: 0.15.2 +> Zig version: 0.16.0 (star) > Version: 1.1.0 --- diff --git a/docs/quickstart.md b/docs/quickstart.md index 2ed091c..1570aba 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -10,7 +10,7 @@ zig build ./zig-out/bin/orca version --json ``` -The repository is pinned to Zig `0.15.2`. Release installs are covered in [install.md](install.md). +The repository is pinned to Zig `0.16.0` (the new star version). Release installs are covered in [install.md](install.md). ## 2. Initialize A Policy diff --git a/src/cli/doctor.zig b/src/cli/doctor.zig index 7c4145e..021b9e0 100644 --- a/src/cli/doctor.zig +++ b/src/cli/doctor.zig @@ -70,11 +70,16 @@ const IntegrationContext = struct { }; pub fn command(argv: []const []const u8, stdout: anytype, stderr: anytype) !u8 { + var verbose = false; for (argv) |arg| { if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { _ = try help.writeCommand(stdout, "doctor"); return exit_codes.success; } + if (std.mem.eql(u8, arg, "-v") or std.mem.eql(u8, arg, "--verbose")) { + verbose = true; + continue; + } try stderr.print("orca doctor: unknown option '{s}'.\n", .{arg}); return exit_codes.usage; } @@ -90,12 +95,59 @@ pub fn command(argv: []const []const u8, stdout: anytype, stderr: anytype) !u8 { return exit_codes.general; }; defer context.deinit(); - try writeReport(stdout, os, backend_report, context); + try writeReport(stdout, os, backend_report, context, verbose); return exit_codes.success; } -fn writeReport(stdout: anytype, os: core.platform.Os, backend_report: sandbox.backend.ReportSet, context: IntegrationContext) !void { +fn writeReport(stdout: anytype, os: core.platform.Os, backend_report: sandbox.backend.ReportSet, context: IntegrationContext, verbose: bool) !void { try stdout.writeAll("Orca Doctor\n\n"); + + // Phase 3: always compute and print the one-line summary + var active_count: usize = 0; + var limited_count: usize = 0; + var unavailable_count: usize = 0; + for (doctor_capabilities) |item| { + if (item.feature) |f| { + const lvl = backend_report.get(f).level; + switch (lvl) { + .active => active_count += 1, + .limited, .partial, .observe_only, .wrapper_only => limited_count += 1, + .unavailable, .unsupported, .failed => unavailable_count += 1, + } + } else if (item.capability) |cap| { + const st = core.platform.reportCapability(os, cap).state; + switch (st) { + .active => active_count += 1, + .limited, .partial => limited_count += 1, + .unavailable => unavailable_count += 1, + // other states (if any) fall through as limited for summary purposes + else => limited_count += 1, + } + } + } + + const policy_status = if (!context.policy_present) + "no policy" + else if (!context.policy_valid) + "policy invalid" + else + "policy valid"; + + try stdout.print("Summary: {s} · {d} active · {d} limited · {d} unavailable · {s}\n\n", .{ + os.toString(), + active_count, + limited_count, + unavailable_count, + policy_status, + }); + + if (!verbose) { + // Brief mode: summary + recommendations only (Phase 3 default) + try writeRecommendations(stdout, context); + return; + } + + // Verbose: full previous report try stdout.print("OS: {s}\n", .{os.toString()}); try stdout.print("Version: {s}\n\n", .{cli.version}); try writeIntegrationReport(stdout, context); @@ -404,7 +456,7 @@ test "doctor can render Linux backend details from an injected report" { var context = try testContext(std.testing.allocator, .{}); defer context.deinit(); - try writeReport(stdout_stream.writer(), .linux, report, context); + try writeReport(stdout_stream.writer(), .linux, report, context, true); const written = stdout_stream.getWritten(); try std.testing.expect(std.mem.indexOf(u8, written, "Linux backend:") != null); @@ -422,7 +474,7 @@ test "doctor can render macOS backend details from an injected report" { var context = try testContext(std.testing.allocator, .{}); defer context.deinit(); - try writeReport(stdout_stream.writer(), .macos, report, context); + try writeReport(stdout_stream.writer(), .macos, report, context, true); const written = stdout_stream.getWritten(); try std.testing.expect(std.mem.indexOf(u8, written, "macOS backend:") != null); @@ -445,7 +497,7 @@ test "doctor can render Windows backend details from an injected report" { var context = try testContext(std.testing.allocator, .{}); defer context.deinit(); - try writeReport(stdout_stream.writer(), .windows, report, context); + try writeReport(stdout_stream.writer(), .windows, report, context, true); const written = stdout_stream.getWritten(); try std.testing.expect(std.mem.indexOf(u8, written, "Windows backend:") != null); @@ -483,7 +535,7 @@ test "doctor detects valid policy in current workspace" { var context = try collectIntegrationContextAt(std.testing.allocator, tmp_path); defer context.deinit(); - try writeReport(stdout_stream.writer(), core.platform.detectOs(), sandbox.backend.detect(core.platform.detectOs()), context); + try writeReport(stdout_stream.writer(), core.platform.detectOs(), sandbox.backend.detect(core.platform.detectOs()), context, true); try std.testing.expect(std.mem.indexOf(u8, stdout_stream.getWritten(), ".orca/policy.yaml: present and valid") != null); try std.testing.expect(std.mem.indexOf(u8, stdout_stream.getWritten(), "git repository: detected") != null); try std.testing.expectEqualStrings("", stderr_stream.getWritten()); @@ -512,7 +564,7 @@ test "doctor reports invalid policy clearly without printing synthetic secrets" var context = try collectIntegrationContextAt(std.testing.allocator, tmp_path); defer context.deinit(); - try writeReport(stdout_stream.writer(), core.platform.detectOs(), sandbox.backend.detect(core.platform.detectOs()), context); + try writeReport(stdout_stream.writer(), core.platform.detectOs(), sandbox.backend.detect(core.platform.detectOs()), context, true); const output = stdout_stream.getWritten(); try std.testing.expect(std.mem.indexOf(u8, output, ".orca/policy.yaml: invalid") != null); try std.testing.expect(std.mem.indexOf(u8, output, "UnsupportedPolicyMode") != null); @@ -548,3 +600,44 @@ fn testContext(allocator: std.mem.Allocator, options: TestContextOptions) !Integ .redteam_fixtures_present = true, }; } + +// --------------------------------------------------------------------------- +// Phase 3: doctor summary line + --verbose / -v (TDD tests written FIRST) +// Default (no flag) is now brief (Summary + recommendations only). +// --verbose restores the previous full dense report. +// --------------------------------------------------------------------------- + +test "doctor default is brief (summary + recommendations, no full Capabilities dump)" { + var stdout_buf: [8192]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 command(&.{}, stdout_stream.writer(), stderr_stream.writer()); + try std.testing.expectEqual(exit_codes.success, code); + + const output = stdout_stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, "Summary:") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "active") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Recommendations") != null or std.mem.indexOf(u8, output, "Next steps") != null or std.mem.indexOf(u8, output, "orca ") != null); + // Brief mode should not dump the full 9+ capability lines or backend details + try std.testing.expect(std.mem.indexOf(u8, output, "Capabilities:") == null or output.len < 800); + try std.testing.expectEqualStrings("", stderr_stream.getWritten()); +} + +test "doctor --verbose prints full report (Capabilities and backend details)" { + var stdout_buf: [8192]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 command(&.{ "--verbose" }, stdout_stream.writer(), stderr_stream.writer()); + try std.testing.expectEqual(exit_codes.success, code); + + const output = stdout_stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, "Summary:") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Capabilities:") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Backend:") != null or std.mem.indexOf(u8, output, "Linux backend:") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "process supervision:") != null); + try std.testing.expectEqualStrings("", stderr_stream.getWritten()); +} diff --git a/src/cli/help.zig b/src/cli/help.zig index 397c86b..72026e0 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -74,6 +74,23 @@ pub const commands = [_]CommandInfo{ "After setup, run 'orca run -- ' for immediate protection.", }, }, + .{ + .name = "quickstart", + .summary = "One-command onboarding: doctor, init, setup", + .usage = "orca quickstart [--auto] [--preset ]", + .category = .getting_started, + .examples = &.{ + "orca quickstart", + "orca quickstart --auto", + "orca quickstart --preset strict-local", + }, + .details = &.{ + "Runs doctor → init (if needed) → setup in one command.", + "On interactive terminals, setup runs in guided mode.", + "Use --auto for non-interactive environments (CI, scripts).", + "Use --preset to choose a policy preset (default: generic-agent).", + }, + }, .{ .name = "env", .summary = "Print shell environment for Orca", @@ -153,10 +170,11 @@ pub const commands = [_]CommandInfo{ "Local workspace .orca/ directories are not removed automatically;", "run 'find . -type d -name .orca' to locate them manually.", } }, - .{ .name = "replay", .summary = "Replay an audit session", .usage = "orca replay [--session ] [--json] [--only denied] [--verify]", .category = .core_workflow, .examples = &.{ + .{ .name = "replay", .summary = "Replay an audit session", .usage = "orca replay [--list] [--session ] [--json] [--only denied] [--verify]", .category = .core_workflow, .examples = &.{ + "orca replay --list", "orca replay --session last", "orca replay --session 2026-05-29-abc123", - }, .details = &.{"Reads .orca session artifacts, renders a timeline, and can verify the event hash chain."} }, + }, .details = &.{"Reads .orca session artifacts, renders a timeline, and can verify the event hash chain. Use --list to enumerate past sessions (or run bare `orca replay` with none)."} }, .{ .name = "diff", .summary = "Show pending file changes", diff --git a/src/cli/interactive.zig b/src/cli/interactive.zig index 9b5bbc1..1bb1b84 100644 --- a/src/cli/interactive.zig +++ b/src/cli/interactive.zig @@ -30,8 +30,18 @@ pub fn runMultiSelect( stdout: anytype, stdin: anytype, ) !MultiSelectResult { - _ = stdout; - _ = stdin; + // Print detected items (MVP numbered prompt, Phase 3) + try stdout.writeAll("\nDetected agent hosts:\n"); + for (items, 0..) |item, i| { + const marker = if (item.checked) "[x]" else "[ ]"; + try stdout.print(" {s} {d}) {s}\n", .{ marker, i + 1, item.label }); + } + + try stdout.writeAll("\nEnter numbers to integrate (e.g. 1 3), or 'all', or 'none':\n> "); + + var buf: [256]u8 = undefined; + const n = try stdin.read(&buf); + const input = std.mem.trimRight(u8, buf[0..n], "\r\n"); // Use ArrayList + errdefer for safe partial-init cleanup on dupe failure. // This guarantees zero leaks even if the Nth dupe fails after earlier successes. @@ -52,7 +62,7 @@ pub fn runMultiSelect( try list.append(allocator, .{ .label = owned_label, - .checked = true, // Phase 0 default: everything selected + .checked = item.checked, // start with incoming defaults .id = owned_id, }); } @@ -61,10 +71,43 @@ pub fn runMultiSelect( // list is now empty (toOwnedSlice takes ownership); the errdefer above will not run on success. list = .empty; // prevent double-free in errdefer if somehow reached - return .{ - .items = owned, - .confirmed = true, - }; + if (std.mem.eql(u8, input, "all")) { + for (owned) |*item| item.checked = true; + return .{ .items = owned, .confirmed = true }; + } + + if (std.mem.eql(u8, input, "none")) { + for (owned) |*item| item.checked = false; + return .{ .items = owned, .confirmed = true }; + } + + if (input.len == 0) { + // Empty input: confirm whatever defaults were provided + return .{ .items = owned, .confirmed = true }; + } + + // Parse space-separated numbers — explicit list means "select exactly these" + for (owned) |*item| item.checked = false; + var any_selected = false; + var it = std.mem.splitScalar(u8, input, ' '); + while (it.next()) |token| { + const trimmed = std.mem.trim(u8, token, " \t"); + if (trimmed.len == 0) continue; + const num = std.fmt.parseInt(usize, trimmed, 10) catch continue; + if (num >= 1 and num <= owned.len) { + owned[num - 1].checked = true; + any_selected = true; + } + } + + if (!any_selected) { + // All garbage / no valid numbers: fall back to incoming defaults + for (items, 0..) |item, i| { + owned[i].checked = item.checked; + } + } + + return .{ .items = owned, .confirmed = true }; } /// Frees memory owned by a MultiSelectResult. @@ -100,8 +143,8 @@ test "interactive: runMultiSelect Phase 0 stub returns all items checked and con const allocator = std.testing.allocator; const input = [_]SelectionItem{ - .{ .label = "Hermes", .id = "hermes" }, - .{ .label = "Claude Code", .id = "claude" }, + .{ .label = "Hermes", .id = "hermes", .checked = true }, + .{ .label = "Claude Code", .id = "claude", .checked = true }, }; var stdout_buf: [256]u8 = undefined; @@ -147,7 +190,7 @@ test "interactive: deinitMultiSelectResult frees memory cleanly" { }; // Use simple fixed buffers instead of null_* (Zig 0.15 Io model) - var out_buf: [64]u8 = undefined; + var out_buf: [256]u8 = undefined; var in_buf: [64]u8 = undefined; var out = std.io.fixedBufferStream(&out_buf); var in_ = std.io.fixedBufferStream(&in_buf); @@ -170,7 +213,7 @@ test "interactive: runMultiSelect does not leak on allocation failure mid-initia .{ .label = "HostTwo", .id = "two" }, }; - var out_buf: [64]u8 = undefined; + var out_buf: [256]u8 = undefined; var in_buf: [64]u8 = undefined; var out = std.io.fixedBufferStream(&out_buf); var in_ = std.io.fixedBufferStream(&in_buf); @@ -294,3 +337,140 @@ test "askConfirm accepts Y/YES case variations" { const result = try askConfirm(out.writer(), &in_reader, "Test?", false); try std.testing.expectEqual(true, result); } + +// --------------------------------------------------------------------------- +// Phase 3: real numbered multi-select prompt (TDD tests written FIRST) +// Replaces the Phase 0 stub. Simple line-based (no raw mode). Robust across +// terminals. Accepts "1 3", "all", "none", or empty (keeps incoming defaults). +// --------------------------------------------------------------------------- + +test "runMultiSelect renders list and parses selection" { + const allocator = std.testing.allocator; + var stdout_buf: [1024]u8 = undefined; + const stdin_buf = "1 3\n"; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stdin_stream = std.io.fixedBufferStream(stdin_buf); + + const items = &[_]SelectionItem{ + .{ .label = "claude", .checked = true }, + .{ .label = "codex", .checked = true }, + .{ .label = "opencode", .checked = false }, + }; + + var result = try runMultiSelect( + allocator, + items, + stdout_stream.writer(), + stdin_stream.reader(), + ); + defer deinitMultiSelectResult(&result, allocator); + + try std.testing.expect(result.confirmed); + try std.testing.expect(result.items[0].checked); // claude (1) + try std.testing.expect(!result.items[1].checked); // codex (not listed) + try std.testing.expect(result.items[2].checked); // opencode (3) + + const output = stdout_stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, "Detected agent hosts:") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "1) claude") != null); +} + +test "runMultiSelect all selects everything" { + const allocator = std.testing.allocator; + var stdout_buf: [512]u8 = undefined; + const stdin_buf = "all\n"; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stdin_stream = std.io.fixedBufferStream(stdin_buf); + + const items = &[_]SelectionItem{ + .{ .label = "claude", .checked = false }, + .{ .label = "codex", .checked = false }, + }; + + var result = try runMultiSelect( + allocator, + items, + stdout_stream.writer(), + stdin_stream.reader(), + ); + defer deinitMultiSelectResult(&result, allocator); + + try std.testing.expect(result.confirmed); + try std.testing.expect(result.items[0].checked); + try std.testing.expect(result.items[1].checked); +} + +test "runMultiSelect none clears defaults" { + const allocator = std.testing.allocator; + var stdout_buf: [512]u8 = undefined; + const stdin_buf = "none\n"; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stdin_stream = std.io.fixedBufferStream(stdin_buf); + + const items = &[_]SelectionItem{ + .{ .label = "claude", .checked = true }, + .{ .label = "codex", .checked = true }, + }; + + var result = try runMultiSelect( + allocator, + items, + stdout_stream.writer(), + stdin_stream.reader(), + ); + defer deinitMultiSelectResult(&result, allocator); + + try std.testing.expect(result.confirmed); + try std.testing.expect(!result.items[0].checked); + try std.testing.expect(!result.items[1].checked); +} + +test "runMultiSelect empty input keeps defaults" { + const allocator = std.testing.allocator; + var stdout_buf: [512]u8 = undefined; + const stdin_buf = "\n"; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stdin_stream = std.io.fixedBufferStream(stdin_buf); + + const items = &[_]SelectionItem{ + .{ .label = "claude", .checked = true }, + .{ .label = "codex", .checked = false }, + }; + + var result = try runMultiSelect( + allocator, + items, + stdout_stream.writer(), + stdin_stream.reader(), + ); + defer deinitMultiSelectResult(&result, allocator); + + try std.testing.expect(result.confirmed); + try std.testing.expect(result.items[0].checked); + try std.testing.expect(!result.items[1].checked); +} + +test "runMultiSelect garbage input falls back to defaults" { + const allocator = std.testing.allocator; + var stdout_buf: [512]u8 = undefined; + const stdin_buf = "xyz abc\n"; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stdin_stream = std.io.fixedBufferStream(stdin_buf); + + const items = &[_]SelectionItem{ + .{ .label = "claude", .checked = true }, + .{ .label = "codex", .checked = false }, + }; + + var result = try runMultiSelect( + allocator, + items, + stdout_stream.writer(), + stdin_stream.reader(), + ); + defer deinitMultiSelectResult(&result, allocator); + + try std.testing.expect(result.confirmed); + try std.testing.expect(result.items[0].checked); + try std.testing.expect(!result.items[1].checked); +} diff --git a/src/cli/mod.zig b/src/cli/mod.zig index dbaafe8..2057708 100644 --- a/src/cli/mod.zig +++ b/src/cli/mod.zig @@ -32,6 +32,7 @@ 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 quickstart = @import("quickstart.zig"); pub const child_process = @import("child_process.zig"); pub const style = @import("style.zig"); @@ -177,6 +178,7 @@ pub fn runWithCwd(cwd: std.fs.Dir, argv: []const []const u8, stdout: anytype, st if (std.mem.eql(u8, command, "completions")) return completions.command(argv[1..], stdout, stderr); if (std.mem.eql(u8, command, "shim")) return shim.command(argv[1..], stdout, stderr); if (std.mem.eql(u8, command, "plugin")) return plugin.command(argv[1..], stdout, stderr); + if (std.mem.eql(u8, command, "quickstart")) return quickstart.command(cwd, argv[1..], stdout, stderr); if (std.mem.eql(u8, command, "setup")) return setup.command(cwd, argv[1..], stdout, stderr); if (std.mem.eql(u8, command, "decide")) return decide.command(argv[1..], stdout, stderr); if (std.mem.eql(u8, command, "hook")) return hook.command(argv[1..], stdout, stderr); @@ -486,6 +488,50 @@ test "plugin help and disable re-enable messaging de-emphasize --yes in favor of try std.testing.expectEqualStrings("", stderr_stream.getWritten()); } +test "quickstart dispatch runs and prints steps" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const stdout_buf: [4096]u8 = undefined; + const stderr_buf: [256]u8 = undefined; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stderr_stream = std.io.fixedBufferStream(&stderr_buf); + + const code = try runWithCwd(tmp.dir, &.{"quickstart"}, stdout_stream.writer(), stderr_stream.writer()); + try std.testing.expectEqual(exit_codes.success, code); + + const output = stdout_stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, "Orca Quickstart") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Step 1: Checking your system") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Step 2: Creating your first policy") != null or std.mem.indexOf(u8, output, "Policy already exists") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Step 3: Setting up") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "You're all set!") != null); +} + +test "quickstart skips init when policy exists" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath(".orca"); + { + const f = try tmp.dir.createFile(".orca/policy.yaml", .{}); + defer f.close(); + try f.writeAll("version: 1\nmode: ask\n"); + } + + const stdout_buf: [4096]u8 = undefined; + const stderr_buf: [256]u8 = undefined; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stderr_stream = std.io.fixedBufferStream(&stderr_buf); + + const code = try runWithCwd(tmp.dir, &.{"quickstart"}, stdout_stream.writer(), stderr_stream.writer()); + try std.testing.expectEqual(exit_codes.success, code); + + const output = stdout_stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, "Policy already exists. Skipping init") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "You're all set!") != null); +} + // writeInstallEnv — the trustworthy, layout-aware activation printer for installers, // Homebrew post-install, npm wrappers, power users, and `eval "$(orca env)"`. // Uses the running binary's actual location (selfExePath) so custom prefixes diff --git a/src/cli/quickstart.zig b/src/cli/quickstart.zig new file mode 100644 index 0000000..a548593 --- /dev/null +++ b/src/cli/quickstart.zig @@ -0,0 +1,99 @@ +const std = @import("std"); + +const cli = @import("mod.zig"); +const exit_codes = @import("exit_codes.zig"); +const help = @import("help.zig"); +const doctor = @import("doctor.zig"); +const init = @import("init.zig"); +const setup = @import("setup.zig"); + +pub fn command(cwd: std.fs.Dir, argv: []const []const u8, stdout: anytype, stderr: anytype) !u8 { + var preset: ?[]const u8 = null; + var auto = false; + + var index: usize = 0; + while (index < argv.len) : (index += 1) { + const arg = argv[index]; + if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { + _ = try help.writeCommand(stdout, "quickstart"); + return exit_codes.success; + } + if (std.mem.eql(u8, arg, "--auto")) { + auto = true; + continue; + } + if (std.mem.eql(u8, arg, "--preset")) { + index += 1; + if (index >= argv.len) { + try stderr.writeAll("orca quickstart: --preset requires a preset name.\n"); + return exit_codes.usage; + } + preset = argv[index]; + continue; + } + try stderr.print("orca quickstart: unknown option '{s}'.\n", .{arg}); + return exit_codes.usage; + } + + try stdout.writeAll("🚀 Orca Quickstart\n"); + try stdout.writeAll("==================\n\n"); + + // Step 1: Doctor (always run for visibility) + try stdout.writeAll("→ Step 1: Checking your system...\n"); + const doctor_code = try doctor.command(&.{}, stdout, stderr); + if (doctor_code != exit_codes.success) { + try stdout.writeAll("\n⚠️ Doctor found issues. Please fix them and re-run quickstart.\n"); + return doctor_code; + } + try stdout.writeAll("✓ System check complete.\n\n"); + + // Step 2: Init (skip if policy already present at cwd) + const policy_exists = blk: { + cwd.access(".orca/policy.yaml", .{}) catch break :blk false; + break :blk true; + }; + + if (!policy_exists) { + try stdout.writeAll("→ Step 2: Creating your first policy...\n"); + const init_argv = if (preset) |p| + &[_][]const u8{ "--preset", p } + else + &[_][]const u8{}; + const init_code = try init.command(cwd, init_argv, stdout, stderr); + if (init_code != exit_codes.success) { + try stdout.writeAll("\n⚠️ Policy creation failed.\n"); + return init_code; + } + try stdout.writeAll("✓ Policy created.\n\n"); + } else { + try stdout.writeAll("→ Step 2: Policy already exists. Skipping init.\n\n"); + } + + // Step 3: Setup + // We deliberately do *not* duplicate TTY detection here. + // We pass the user's explicit --auto intent (or lack thereof) down to setup. + // setup owns the decision: on TTY with no --auto it will enter guided mode. + const setup_argv = if (auto) + &[_][]const u8{"--auto"} + else + &[_][]const u8{}; + try stdout.writeAll("→ Step 3: Setting up agent host integrations...\n"); + const setup_code = try setup.command(cwd, setup_argv, stdout, stderr); + if (setup_code != exit_codes.success) { + try stdout.writeAll("\n⚠️ Setup finished with warnings. You may need to run `orca setup` manually.\n"); + // Do not fail the whole quickstart for plugin-level issues (per spec) + } else { + try stdout.writeAll("✓ Setup complete.\n\n"); + } + + // Celebration + concrete next steps + try stdout.writeAll("🎉 You're all set!\n"); + try stdout.writeAll("\nStart protecting your sessions:\n"); + try stdout.writeAll(" orca run -- \n"); + try stdout.writeAll("\nUseful next steps:\n"); + try stdout.writeAll(" orca doctor Check system status\n"); + try stdout.writeAll(" orca replay Review past sessions\n"); + try stdout.writeAll(" orca help run Learn about running commands\n"); + + return exit_codes.success; +} diff --git a/src/cli/replay.zig b/src/cli/replay.zig index 5985175..b3277bc 100644 --- a/src/cli/replay.zig +++ b/src/cli/replay.zig @@ -11,6 +11,7 @@ const ReplayCliOptions = struct { json: bool = false, only_denied: bool = false, verify: bool = false, + list: bool = false, }; pub fn command(argv: []const []const u8, stdout: anytype, stderr: anytype) !u8 { @@ -20,6 +21,10 @@ pub fn command(argv: []const []const u8, stdout: anytype, stderr: anytype) !u8 { else => return err, }; + if (options.list) { + return listSessions(stdout, stderr); + } + var gpa_state: std.heap.GeneralPurposeAllocator(.{}) = .init; defer _ = gpa_state.deinit(); const allocator = gpa_state.allocator(); @@ -36,8 +41,8 @@ pub fn command(argv: []const []const u8, stdout: anytype, stderr: anytype) !u8 { .verify = options.verify, }) catch |err| switch (err) { error.FileNotFound => { - try stderr.writeAll("orca replay: session not found.\n"); - return exit_codes.general; + // Phase 3: graceful fallback to listing instead of hard error + return listSessions(stdout, stderr); }, error.HashVerificationFailed => { const session_dir_path = sessionDirPathForError(allocator, workspace_root, options.session) catch null; @@ -92,6 +97,8 @@ fn parseOptions(argv: []const []const u8, stdout: anytype, stderr: anytype) !Rep return error.Usage; } options.only_denied = true; + } else if (std.mem.eql(u8, arg, "--list")) { + options.list = true; } else { try stderr.print("orca replay: unknown option '{s}'.\n", .{arg}); return error.Usage; @@ -112,6 +119,47 @@ fn sessionDirPathForError(allocator: std.mem.Allocator, workspace_root: []const return try std.fs.path.join(allocator, &.{ workspace_root, ".orca", "sessions", session_id }); } +fn listSessions(stdout: anytype, stderr: anytype) !u8 { + _ = stderr; + var gpa_state: std.heap.GeneralPurposeAllocator(.{}) = .init; + defer _ = gpa_state.deinit(); + const allocator = gpa_state.allocator(); + + const workspace_root = try std.process.getCwdAlloc(allocator); + defer allocator.free(workspace_root); + + const sessions_dir = try std.fs.path.join(allocator, &.{ workspace_root, ".orca", "sessions" }); + defer allocator.free(sessions_dir); + + var dir = std.fs.cwd().openDir(sessions_dir, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => { + try stdout.writeAll("No sessions found. Run `orca run -- ` to create one.\n"); + return exit_codes.success; + }, + else => return err, + }; + defer dir.close(); + + try stdout.writeAll("SESSION ID\n"); + + var count: usize = 0; + var iter = dir.iterate(); + while (try iter.next()) |entry| { + if (entry.kind != .directory) continue; + try stdout.print("{s}\n", .{entry.name}); + count += 1; + } + + if (count == 0) { + try stdout.writeAll("\nNo sessions found. Run `orca run -- ` to create one.\n"); + } else { + try stdout.print("\n{d} session(s) found.\n", .{count}); + try stdout.writeAll("Run `orca replay --session ` to view a session.\n"); + } + + return exit_codes.success; +} + test "replay rejects invalid --only value" { var stdout_buf: [512]u8 = undefined; var stderr_buf: [512]u8 = undefined; @@ -122,3 +170,37 @@ test "replay rejects invalid --only value" { try std.testing.expectEqual(exit_codes.usage, code); try std.testing.expect(std.mem.indexOf(u8, stderr_stream.getWritten(), "--only") != null); } + +// --------------------------------------------------------------------------- +// Phase 3: --list and graceful no-sessions fallback for bare "orca replay" +// (TDD tests written FIRST) +// --------------------------------------------------------------------------- + +test "replay --list succeeds (empty or populated)" { + var stdout_buf: [1024]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 command(&.{ "--list" }, stdout_stream.writer(), stderr_stream.writer()); + try std.testing.expectEqual(exit_codes.success, code); + // Either lists sessions or prints the friendly empty message — both OK + const out = stdout_stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, out, "No sessions") != null or std.mem.indexOf(u8, out, "SESSION") != null or out.len > 0); +} + +test "replay with no args and no sessions lists instead of erroring" { + var stdout_buf: [1024]u8 = undefined; + var stderr_buf: [256]u8 = undefined; + var stdout_stream = std.io.fixedBufferStream(&stdout_buf); + var stderr_stream = std.io.fixedBufferStream(&stderr_buf); + + // In a clean test env there is no .orca/sessions in cwd + const code = try command(&.{}, stdout_stream.writer(), stderr_stream.writer()); + try std.testing.expectEqual(exit_codes.success, code); + + const output = stdout_stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, "No sessions found") != null or std.mem.indexOf(u8, output, "orca run") != null); + // Friendly message must be on stdout (not the old hard error on stderr) + try std.testing.expect(std.mem.indexOf(u8, stderr_stream.getWritten(), "not found") == null); +} diff --git a/src/cli/setup.zig b/src/cli/setup.zig index 7355934..df042dd 100644 --- a/src/cli/setup.zig +++ b/src/cli/setup.zig @@ -72,7 +72,7 @@ pub fn command(cwd: std.fs.Dir, argv: []const []const u8, stdout: anytype, stder if (!plugin.fileExistsAbsolute(policy_path)) { try stdout.writeAll("Policy not found. Initializing...\n"); - const init_argv = &[_][]const u8{ "--preset", preset, "--quiet" }; + const init_argv = &[_][]const u8{ "--preset", preset }; const code = init.command(cwd, init_argv, stdout, stderr) catch |err| { try stderr.print("orca setup: policy init failed: {s}\n", .{@errorName(err)}); return exit_codes.general; @@ -191,7 +191,7 @@ fn runGuidedSetup(cwd: std.fs.Dir, stdout: anytype, stderr: anytype) !u8 { 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" }; + const init_argv = &[_][]const u8{ "--preset", "generic-agent" }; const init_code = try init.command(cwd, init_argv, stdout, stderr); if (init_code != exit_codes.success) { try stderr.print("orca setup: policy init returned non-success code {d}\n", .{init_code}); diff --git a/src/main.zig b/src/main.zig index 857cc1b..1097b26 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,6 +2,16 @@ const std = @import("std"); const builtin = @import("builtin"); const orca = @import("orca"); +// ============================================================================== +// Zig 0.16.0 Star Migration Status +// ============================================================================== +// - build.zig.zon now declares .minimum_zig_version = "0.16.0" +// - 0.16 is the new star / primary supported version. +// - The classic `pub fn main() !void` is kept below for buildability during +// the transition. The target 0.16 form (using std.process.Init + std.Io) +// is documented in the 0.16 evolution plan. +// ============================================================================== + pub fn main() !void { if (builtin.os.tag == .windows) { setupWindowsConsole(); @@ -19,11 +29,7 @@ pub fn main() !void { var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer); - // Prime the color decision once at true CLI startup, before any command - // dispatch or warm output. The module-level cache in style.zig is populated - // as a side-effect; all subsequent maybeColor/useColor calls hit the fast - // cached path. The existing call in cli.runWithCwd remains as a fallback - // for library/direct usage. + // Prime the color decision once at true CLI startup. _ = orca.cli.style.useColor(stdout_writer); const shim_alias = if (builtin.os.tag == .windows) orca.intercept.commands.shimAliasFromExecutablePath(argv[0]) else null;