commit d02544fefca552c12b7a431491570771129a51b6 Author: slhx agent Date: Sat Jul 4 20:44:28 2026 +0200 Initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d23ccf --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Zig build artifacts +.zig-cache/ +zig-out/ + +# Local editor/OS noise +*.swp +*~ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3502d95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md + +## Overview + +`have` is a tiny compiled Zig CLI that lists executable command names available +through `PATH`. It exists as an agent-safe availability primitive for comparing a +run-tool allowlist against what the local Unix system can actually execute. + +## How to build + +```sh +zig build +``` + +## How to test + +```sh +zig build test +``` + +## Constraints + +- One source file: `have.zig`. +- POSIX-shaped: inspect `PATH` directories and executable bits only. +- No package-manager knowledge, shell builtins, aliases, functions, login shells, + network access, mutation, config files, JSON default, color, pager, or daemon + behavior. +- stdout is one command name per line; diagnostics go to stderr. +- This is an inspection primitive. Keep OS/package inventory in separate tools if + it is ever needed. + +## Requirement Governance + +`REQUIREMENTS.md` is the requirement authority for this repository and is checked +with `redgate`. Before implementation work, read the applicable requirement row; +if none covers durable behavior, update `REQUIREMENTS.md` first or stop and ask. +For behavior intended to stick, add or update a failing smoke/contract proof +before implementation unless that would be fake or disproportionate. Before +handoff, compare the user request and diff against `REQUIREMENTS.md`; report +`REQUIREMENT IMPACT: updated ` or exactly +`REQUIREMENT IMPACT: none - `. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..d42287a --- /dev/null +++ b/REQUIREMENTS.md @@ -0,0 +1,13 @@ +# REQUIREMENTS + +## cli + +ring id requirement +0 001 `have` must be a compiled single-file Zig CLI with no runtime dependencies beyond the OS/libc boundary needed for POSIX executable checks. +0 002 `have` must inspect command availability through `PATH` only; it must not mutate files, execute discovered commands, invoke a shell, inspect package managers, access the network, read hidden config, prompt, page, colorize, or emit JSON by default. +1 003 With no arguments, `have` must stream executable file names discovered in `PATH`, one per stdout line, de-duplicated by first executable `PATH` occurrence without collecting the full listing before writing records. +1 004 With `NAME...` arguments, `have` must print only available command names found through `PATH`, exit 0 when all requested names are available, and exit nonzero when any requested name is missing or not a command name. +1 005 `have` must not report shell builtins, aliases, shell functions, directories, non-executable files, or package installation state unless they also exist as executable files in `PATH`. +1 006 Usage-error and runtime diagnostics must go to stderr and never corrupt stdout records. +1 007 `zig build test` must prove PATH listing, executable-bit filtering, first-occurrence de-duplication, query output, missing-command failure, help stdout behavior, and that inspected commands are not executed. +1 008 `have --help` and `have -h` must print manual text to stdout and exit 0 without emitting stderr. diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..50da9e8 --- /dev/null +++ b/build.zig @@ -0,0 +1,84 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const exe = b.addExecutable(.{ + .name = "have", + .root_module = b.createModule(.{ + .root_source_file = b.path("have.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + b.step("run", "Run have").dependOn(&run_cmd.step); + + const tests = b.addTest(.{ + .root_module = exe.root_module, + }); + const run_tests = b.addRunArtifact(tests); + + const smoke = b.addSystemCommand(&.{ "sh", "smoke.sh", b.getInstallPath(.bin, "have") }); + smoke.setCwd(b.path(".")); + smoke.step.dependOn(b.getInstallStep()); + + const contract = b.addSystemCommand(&.{ "sh", "-c", + \\set -eu + \\tool=$1 + \\root=.zig-cache/have-contract + \\rm -rf "$root" + \\mkdir -p "$root/a" "$root/b" "$root/dup-a" "$root/dup-b" "$root/filter" + \\cat > "$root/a/alpha" <<'SCRIPT' + \\#!/bin/sh + \\echo ran >> .zig-cache/have-contract/ran + \\SCRIPT + \\cat > "$root/b/beta" <<'SCRIPT' + \\#!/bin/sh + \\echo ran >> .zig-cache/have-contract/ran + \\SCRIPT + \\cat > "$root/dup-a/dup" <<'SCRIPT' + \\#!/bin/sh + \\echo ran >> .zig-cache/have-contract/ran + \\SCRIPT + \\cat > "$root/dup-b/dup" <<'SCRIPT' + \\#!/bin/sh + \\echo ran >> .zig-cache/have-contract/ran + \\SCRIPT + \\chmod +x "$root/a/alpha" "$root/b/beta" "$root/dup-a/dup" "$root/dup-b/dup" + \\printf nope > "$root/filter/not-exec" + \\mkdir "$root/filter/exec-dir" + \\chmod +x "$root/filter/exec-dir" + \\ln -s exec-dir "$root/filter/dir-link" + \\PATH="$root/a:$root/b" "$tool" > "$root/list.out" + \\test "$(sed -n '1p' "$root/list.out")" = alpha + \\test "$(sed -n '2p' "$root/list.out")" = beta + \\test ! -e "$root/ran" + \\PATH="$root/dup-a:$root/dup-b" "$tool" > "$root/dup.out" + \\test "$(grep -c '^dup$' "$root/dup.out")" = 1 + \\PATH="$root/filter" "$tool" > "$root/filter.out" + \\! grep -q '^not-exec$' "$root/filter.out" + \\! grep -q '^exec-dir$' "$root/filter.out" + \\! grep -q '^dir-link$' "$root/filter.out" + \\PATH="$root/a" "$tool" alpha > "$root/query.out" + \\grep -q '^alpha$' "$root/query.out" + \\test ! -e "$root/ran" + \\PATH="$root/a" "$tool" ./alpha > "$root/slash.out" && exit 1 || true + \\test ! -s "$root/slash.out" + \\"$tool" --help > "$root/help.out" 2> "$root/help.err" + \\grep -q '^usage:' "$root/help.out" + \\test ! -s "$root/help.err" + , "have-contract", b.getInstallPath(.bin, "have") }); + contract.setCwd(b.path(".")); + contract.step.dependOn(b.getInstallStep()); + + const test_step = b.step("test", "Run unit and smoke tests"); + test_step.dependOn(&run_tests.step); + test_step.dependOn(&smoke.step); + test_step.dependOn(&contract.step); +} diff --git a/have.zig b/have.zig new file mode 100644 index 0000000..8ae37d9 --- /dev/null +++ b/have.zig @@ -0,0 +1,136 @@ +const std = @import("std"); + +fn usage(file: std.Io.File, io: std.Io) !void { + var buf: [1024]u8 = undefined; + var w = file.writer(io, &buf); + try w.interface.writeAll( + \\usage: have [NAME...] + \\ + \\List executable command names found in PATH, one per line. + \\With NAME args, print the names that are available and exit 1 if any are missing. + \\This inspects PATH only; it does not know shell builtins, aliases, functions, or packages. + \\ + \\stdout: + \\ one command name per line + \\ + \\examples: + \\ have | grep '^zig$' + \\ have zig git curl + \\ + ); + try w.interface.flush(); +} + +fn die(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { + var buf: [512]u8 = undefined; + var w = std.Io.File.stderr().writer(io, &buf); + w.interface.print(fmt, args) catch {}; + w.interface.flush() catch {}; + std.process.exit(1); +} + +fn pathEnv() []const u8 { + const raw = std.c.getenv("PATH") orelse return ""; + return std.mem.span(raw); +} + +fn commandName(name: []const u8) bool { + return name.len != 0 and std.mem.indexOfScalar(u8, name, '/') == null; +} + +fn executableFileInDir(dir: std.Io.Dir, io: std.Io, name: []const u8) bool { + const stat = dir.statFile(io, name, .{}) catch return false; + if (stat.kind != .file) return false; + dir.access(io, name, .{ .execute = true }) catch return false; + return true; +} + +fn haveOne(io: std.Io, name: []const u8) bool { + if (!commandName(name)) return false; + + var dirs = std.mem.splitScalar(u8, pathEnv(), ':'); + while (dirs.next()) |dir_path| { + const actual_dir = if (dir_path.len == 0) "." else dir_path; + var dir = std.Io.Dir.cwd().openDir(io, actual_dir, .{}) catch continue; + defer dir.close(io); + if (executableFileInDir(dir, io, name)) return true; + } + return false; +} + +fn listDir(io: std.Io, writer: *std.Io.Writer, seen: *std.StringHashMap(void), dir_path: []const u8) !void { + var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return; + defer dir.close(io); + + var it = dir.iterate(); + while (try it.next(io)) |entry| { + if (!commandName(entry.name)) continue; + if (seen.contains(entry.name)) continue; + if (!executableFileInDir(dir, io, entry.name)) continue; + + const owned = try seen.allocator.dupe(u8, entry.name); + errdefer seen.allocator.free(owned); + try seen.put(owned, {}); + try writer.print("{s}\n", .{entry.name}); + } +} + +fn listAll(allocator: std.mem.Allocator, io: std.Io, writer: *std.Io.Writer) !void { + var seen = std.StringHashMap(void).init(allocator); + defer { + var keys = seen.keyIterator(); + while (keys.next()) |key| allocator.free(key.*); + seen.deinit(); + } + + var dirs = std.mem.splitScalar(u8, pathEnv(), ':'); + while (dirs.next()) |dir_path| { + const actual_dir = if (dir_path.len == 0) "." else dir_path; + try listDir(io, writer, &seen, actual_dir); + } +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + var args = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer args.deinit(); + _ = args.skip(); + + var out_buf: [8192]u8 = undefined; + var out = std.Io.File.stdout().writer(init.io, &out_buf); + + var names = std.ArrayList([]const u8).empty; + defer names.deinit(allocator); + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { + try usage(std.Io.File.stdout(), init.io); + return; + } + try names.append(allocator, arg); + } + + if (names.items.len == 0) { + listAll(allocator, init.io, &out.interface) catch |err| die(init.io, "have: {s}\n", .{@errorName(err)}); + try out.interface.flush(); + return; + } + + var missing = false; + for (names.items) |name| { + if (haveOne(init.io, name)) { + try out.interface.print("{s}\n", .{name}); + } else { + missing = true; + } + } + try out.interface.flush(); + if (missing) std.process.exit(1); +} + +test commandName { + try std.testing.expect(commandName("zig")); + try std.testing.expect(commandName(".")); + try std.testing.expect(!commandName("")); + try std.testing.expect(!commandName("./zig")); + try std.testing.expect(!commandName("bin/zig")); +} diff --git a/smoke.sh b/smoke.sh new file mode 100644 index 0000000..3eac694 --- /dev/null +++ b/smoke.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu +tool=${1:-zig-out/bin/have} +mkdir -p .zig-cache/have-smoke/bin +cat > .zig-cache/have-smoke/bin/ok <<'SCRIPT' +#!/bin/sh +exit 0 +SCRIPT +chmod +x .zig-cache/have-smoke/bin/ok +printf 'nope' > .zig-cache/have-smoke/bin/not-exec + +PATH=.zig-cache/have-smoke/bin "$tool" > .zig-cache/have-list.out +grep -q '^ok$' .zig-cache/have-list.out +if grep -q '^not-exec$' .zig-cache/have-list.out; then + echo 'have: listed non-executable file' >&2 + exit 1 +fi +PATH=.zig-cache/have-smoke/bin "$tool" ok missing > .zig-cache/have-query.out && { + echo 'have: query succeeded despite missing command' >&2 + exit 1 +} +grep -q '^ok$' .zig-cache/have-query.out +if grep -q '^missing$' .zig-cache/have-query.out; then + echo 'have: printed missing command' >&2 + exit 1 +fi + +"$tool" --help > .zig-cache/have-help.out 2> .zig-cache/have-help.err +grep -q '^usage: have \[NAME\.\.\.\]' .zig-cache/have-help.out +if [ -s .zig-cache/have-help.err ]; then + echo 'have: --help wrote stderr' >&2 + exit 1 +fi +"$tool" -h > .zig-cache/have-help-short.out 2> .zig-cache/have-help-short.err +grep -q '^usage: have \[NAME\.\.\.\]' .zig-cache/have-help-short.out +if [ -s .zig-cache/have-help-short.err ]; then + echo 'have: -h wrote stderr' >&2 + exit 1 +fi