Initial import

This commit is contained in:
slhx agent
2026-07-04 20:44:28 +02:00
commit d02544fefc
6 changed files with 321 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Zig build artifacts
.zig-cache/
zig-out/
# Local editor/OS noise
*.swp
*~
.DS_Store
+41
View File
@@ -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 <rows>` or exactly
`REQUIREMENT IMPACT: none - <specific reason>`.
+13
View File
@@ -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.
+84
View File
@@ -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);
}
+136
View File
@@ -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"));
}
+39
View File
@@ -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