diff --git a/.gitignore b/.gitignore index 5ce9a82..d87530b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage/ # `ZFIN_HOME=examples/ zfin projections` immediately. !examples/**/*.srf scripts/ +.tmp/ diff --git a/AGENTS.md b/AGENTS.md index f1ebb44..8703db2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -308,6 +308,30 @@ Ask the user instead.** stop. You do not commit. You do not prepare a commit. You hand off the working tree and wait. +### NEVER write to `/tmp`. Use `$(cwd)/.tmp` instead. + +- **`/tmp` is off-limits for any temporary file the assistant creates.** + Not for scratch scripts, not for redirected stdout, not for `cp` + backups, not for "just for a second" experiments. The user's workflow + treats `/tmp` as their own space; assistant-created files there + pollute it across sessions and hide from `git status`. +- Use `$(cwd)/.tmp/` (a `.tmp` directory at the repository root) for + every transient file. `mkdir -p .tmp` if it doesn't exist; + `.gitignore` it (or rely on a project-level `.gitignore` rule that + catches it). Files in `.tmp` show up in `ls` next to the work, + surface in `git status` if the ignore rule is wrong, and get + cleaned up at the end of the task with a single `rm -rf .tmp/*`. +- This applies to: + - Bash redirections: `cmd > .tmp/out` not `cmd > /tmp/out`. + - Test fixture scratch files. + - `cp file /tmp/file.bak` for "I'll restore in a sec" backups — + use `cp file .tmp/file.bak` instead. + - Heredoc-created throwaway scripts to test a Zig snippet + (`zig run /tmp/foo.zig` → `zig run .tmp/foo.zig`). +- The ONE exception: temp files created by the system / by other + tools (mise installations, zig's own cache symlinks, etc.) that + legitimately use `/tmp`. Don't try to redirect those. + ### Documentation-file conventions - **`TODO.md` holds only what's still open.** Git already tracks what was diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index 55eb99a..70ab36f 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -25,6 +25,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{UnexpectedArg}, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 7e5ed15..4cacde0 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -2278,6 +2278,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{ UnexpectedArg, EmptyFile, NoAccountsFound, UnexpectedHeader }, }; pub fn parseArgs(_: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/cache.zig b/src/commands/cache.zig index 1335a3d..02211a6 100644 --- a/src/commands/cache.zig +++ b/src/commands/cache.zig @@ -33,6 +33,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{ MissingSubcommand, UnexpectedArg, UnknownSubcommand }, }; /// Data types to show in the stats table (skip candles_meta and meta — internal bookkeeping). diff --git a/src/commands/compare.zig b/src/commands/compare.zig index f7aa046..c6a6080 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -126,6 +126,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{ InvalidDate, MissingDateArg, PortfolioLoadFailed, SameDate, SnapshotNotFound, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index d84edae..844cd05 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -226,6 +226,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{ DuplicateEndpoint, InvalidArg, MissingOpenDate, PrepareFailed, ResolveFailed, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/divs.zig b/src/commands/divs.zig index 6fb3a00..d33cfbc 100644 --- a/src/commands/divs.zig +++ b/src/commands/divs.zig @@ -25,6 +25,7 @@ pub const meta: framework.Meta = .{ \\ zfin divs T # historical AT&T dividends \\ , + .user_errors = error{ MissingSymbol, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/earnings.zig b/src/commands/earnings.zig index e48c92b..5a3e14e 100644 --- a/src/commands/earnings.zig +++ b/src/commands/earnings.zig @@ -31,6 +31,7 @@ pub const meta: framework.Meta = .{ \\ zfin earnings AAPL | head -8 # last two years of quarters \\ , + .user_errors = error{ MissingSymbol, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/enrich.zig b/src/commands/enrich.zig index 8e5e147..880efd3 100644 --- a/src/commands/enrich.zig +++ b/src/commands/enrich.zig @@ -41,6 +41,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{ MissingArg, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/etf.zig b/src/commands/etf.zig index 4906075..1cfaabe 100644 --- a/src/commands/etf.zig +++ b/src/commands/etf.zig @@ -26,6 +26,7 @@ pub const meta: framework.Meta = .{ \\ zfin etf TQQQ # leveraged (warning surfaced) \\ , + .user_errors = error{ MissingSymbol, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/framework.zig b/src/commands/framework.zig index 28a3348..00f1ea6 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -17,6 +17,7 @@ //! .synopsis = "Show 1y/3y/5y/10y trailing returns", //! .help = "...", // multi-line; rendered by `--help` //! .uppercase_first_arg = true, +//! .user_errors = error{ MissingSymbol, UnexpectedArg }, //! }; //! //! pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs; @@ -125,8 +126,47 @@ pub const Meta = struct { /// optional opt-in; the `false` answer is just as load-bearing /// as `true`. uppercase_first_arg: bool, + + /// Error set listing the command's user-level errors — errors + /// where the command has already printed a useful message to + /// stderr and the dispatcher should just return exit 1 silently. + /// Anything NOT in this set propagates to Zig's panic handler + /// with a stack trace, signaling a genuine bug worth investigating. + /// + /// Examples: + /// - `error.MissingSymbol` (parseArgs printed "requires a symbol + /// argument") → user-level. + /// - `error.SnapshotNotFound` (run printed "No snapshot at or + /// before X") → user-level. + /// - `error.OutOfMemory`, `error.Unexpected*` → not user-level; + /// these should crash visibly so they don't get swallowed. + /// + /// No default — every command author must enumerate the errors + /// their `parseArgs` and `run` deliberately return as user + /// signals. Commands with no user-level errors (`version`) + /// declare `error{}`. Adding a new `return error.X` to a + /// command means you also add `X` here if it's user-level — + /// the explicit list IS the contract. + user_errors: type, }; +/// Returns true if `err` is a member of the comptime-known +/// `UserErrors` error set. Used by the framework dispatcher to +/// decide whether a `Module.run` error is a user-level signal +/// (return exit 1 silently) or an internal bug (propagate to +/// Zig's panic handler). +/// +/// Implemented as a comptime-unrolled equality chain over the +/// error set's variants. Cost is one comparison per variant; the +/// largest user-error set today is on the order of 10 variants, +/// so this is cheap. +pub fn isUserError(comptime UserErrors: type, err: anyerror) bool { + inline for (@typeInfo(UserErrors).error_set orelse return false) |variant| { + if (err == @field(anyerror, variant.name)) return true; + } + return false; +} + // ── Refresh policy ──────────────────────────────────────────── /// Cache-freshness policy for the invocation. Set via the global @@ -432,6 +472,7 @@ const ProbeModule = struct { .synopsis = "test probe", .help = "Usage: zfin probe\n", .uppercase_first_arg = false, + .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { @@ -481,6 +522,7 @@ test "printCommandHelp: appends newline when meta.help lacks one" { .synopsis = "x", .help = "no trailing newline", .uppercase_first_arg = false, + .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { return .{}; @@ -536,6 +578,7 @@ test "printGroupedUsage: groups in canonical order with headers + synopses" { .synopsis = "alpha synopsis", .help = "alpha help\n", .uppercase_first_arg = false, + .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { return .{}; @@ -550,6 +593,7 @@ test "printGroupedUsage: groups in canonical order with headers + synopses" { .synopsis = "beta synopsis", .help = "beta help\n", .uppercase_first_arg = false, + .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { return .{}; @@ -592,6 +636,7 @@ test "printGroupedUsage: omits empty groups" { .synopsis = "only synopsis", .help = "h\n", .uppercase_first_arg = false, + .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { return .{}; diff --git a/src/commands/history.zig b/src/commands/history.zig index 30c7681..373fa0d 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -83,6 +83,7 @@ pub const meta: framework.Meta = .{ \\DATE accepts YYYY-MM-DD or relative shortcuts (1W/1M/1Q/1Y). \\ , + .user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution }, }; pub const Error = error{ diff --git a/src/commands/lookup.zig b/src/commands/lookup.zig index 288a0c8..6dafa22 100644 --- a/src/commands/lookup.zig +++ b/src/commands/lookup.zig @@ -29,6 +29,7 @@ pub const meta: framework.Meta = .{ \\ zfin lookup 037833100 # → AAPL \\ , + .user_errors = error{ MissingCusip, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/milestones.zig b/src/commands/milestones.zig index 10bf7f3..8a78a81 100644 --- a/src/commands/milestones.zig +++ b/src/commands/milestones.zig @@ -62,6 +62,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{ InvalidStep, MissingStep, NoData, UnexpectedArg }, }; pub const RunError = error{ diff --git a/src/commands/options.zig b/src/commands/options.zig index fdd3a8b..dce8eb2 100644 --- a/src/commands/options.zig +++ b/src/commands/options.zig @@ -33,6 +33,7 @@ pub const meta: framework.Meta = .{ \\ zfin options AAPL --ntm 12 \\ , + .user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue, InvalidFlagValue }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/perf.zig b/src/commands/perf.zig index bc793d1..a5ca727 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -33,6 +33,7 @@ pub const meta: framework.Meta = .{ \\ zfin perf NVDA # individual stock \\ , + .user_errors = error{ MissingSymbol, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 1e0108e..5326651 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -30,6 +30,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{UnexpectedArg}, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 640883d..1d0151b 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -108,6 +108,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, MutuallyExclusive, NoSnapshot, PortfolioLoadFailed }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 5bad5db..0bf1a66 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -30,6 +30,7 @@ pub const meta: framework.Meta = .{ \\ zfin quote spy # symbols are case-insensitive \\ , + .user_errors = error{ MissingSymbol, UnexpectedArg }, }; /// Quote data extracted from the real-time API (or synthesized from candles). diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index 9da5e32..871bbc1 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -96,6 +96,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{ UnexpectedArg, BadMetadata, NoCommitBeforeDate, NoMetadata, PathMissingInRev, WriteFailed }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/splits.zig b/src/commands/splits.zig index 4e572f8..9a9f9ca 100644 --- a/src/commands/splits.zig +++ b/src/commands/splits.zig @@ -25,6 +25,7 @@ pub const meta: framework.Meta = .{ \\ zfin splits NVDA # 10:1 (2024-06-10), 4:1 (2021-07-20), ... \\ , + .user_errors = error{ MissingSymbol, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/commands/version.zig b/src/commands/version.zig index f24e689..c650026 100644 --- a/src/commands/version.zig +++ b/src/commands/version.zig @@ -33,6 +33,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, + .user_errors = error{UnexpectedArg}, }; /// Parse `--verbose`/`-v`. Unknown args produce an error on stderr. diff --git a/src/main.zig b/src/main.zig index 8a11a15..558bdef 100644 --- a/src/main.zig +++ b/src/main.zig @@ -376,8 +376,21 @@ fn runCli(init: std.process.Init) !u8 { try cmd_framework.normalizeFirstArg(allocator, cmd_args) else cmd_args; - const parsed = Module.parseArgs(&ctx, dispatched_args) catch return 1; - try Module.run(&ctx, parsed); + const parsed = Module.parseArgs(&ctx, dispatched_args) catch |err| { + // parseArgs errors: if the command declared this + // error as user-level, exit 1 silently (the + // command already printed a stderr message). + // Otherwise propagate so genuine bugs (OOM, etc.) + // surface with a stack trace. + if (cmd_framework.isUserError(Module.meta.user_errors, err)) return 1; + return err; + }; + Module.run(&ctx, parsed) catch |err| { + // Same treatment for run errors: user-level errors + // become exit 1; everything else propagates. + if (cmd_framework.isUserError(Module.meta.user_errors, err)) return 1; + return err; + }; try out.flush(); return 0; }