Initial import

This commit is contained in:
slhx agent
2026-07-04 20:44:28 +02:00
commit 1ed0d9667d
6 changed files with 374 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
+55
View File
@@ -0,0 +1,55 @@
# AGENTS.md
## Overview
`pick` is a tiny compiled Zig CLI for selecting TSV fields from stdin. It exists
as an agent-safe replacement for the safe subset of `awk '{print $N}'` and
`cut -f` when allowlist policy needs argv-obvious behavior.
## How to build
```sh
zig build
```
## How to test
```sh
zig build test
```
## Constraints
- One source file: `pick.zig`.
- No runtime dependencies, no shell execution, no filesystem reads/writes in the
tool.
- stdout is TSV/plain records only; diagnostics go to stderr.
- No regex, expressions, arithmetic, sorting, paging, JSON, color, or hidden
state.
- This is an inspection primitive. Do not add mutation or policy workflow.
## 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 the durable behavior, update `REQUIREMENTS.md` first or stop and
ask for authority. When adding or changing rows, choose the ring by product
foundation, not implementation size: lower rings capture stable kernel
obligations and core contracts; higher rings compose on lower-ring obligations
and must not weaken, redefine, or bypass them. If one row mixes foundational and
optional behavior, split it before implementation.
If a change alters durable product obligations, acceptance behavior,
safety/recovery behavior, public interfaces, or verification duties, update
`REQUIREMENTS.md` in the same slice and run the relevant `redgate` checks. For
behavior intended to stick, write or update a failing BDD/TDD test, contract
test, or executable proof before implementation code; run it and record the RED
result. Implement only after the requirement row and RED proof exist. If RED
proof would be fake, unsafe, or disproportionate, state that exception before
implementation and use the strongest cheaper check.
Before final handoff, compare the user request and actual diff against
`REQUIREMENTS.md`. If there is no requirement impact, the handoff must include
exactly `REQUIREMENT IMPACT: none - <specific reason>`. If code, tests, or docs
changed but `REQUIREMENTS.md` did not, explicitly justify why no requirement row
changed.
+13
View File
@@ -0,0 +1,13 @@
# REQUIREMENTS
## pick
0 001 `pick` must be a compiled single-file Zig CLI with no runtime dependencies beyond the OS. [core]
0 002 `pick` must read TSV records from stdin and write selected TSV fields to stdout only. [stream]
0 003 `pick` must have no filesystem mutation, network access, shell execution, expression language, regex evaluation, hidden config, prompts, color, pager, JSON default, or daemon behavior. [agent-safe]
1 004 `pick FIELD...` must select 1-based numeric fields from headerless TSV input and preserve input record order.
1 005 `pick --header FIELD...` must resolve field names from the first TSV record, emit the selected header, and then emit selected fields from subsequent records.
1 006 Missing fields must emit empty TSV fields rather than failing a pipeline.
1 007 Usage errors must exit nonzero and write diagnostics to stderr, never stdout.
1 008 `zig build test` must prove numeric selection, header selection, missing fields, help/error stdout-stderr separation, CRLF input tolerance, invalid field rejection, and one forbidden near miss. [verification]
1 009 Project smoke tests must run under POSIX `sh` and exercise `pick` through pipe-oriented stdin/stdout/stderr behavior without leaving required generated artifacts. [posix]
1 010 `pick` must process stdin incrementally for pipeline-sized streams and must not print noisy diagnostics when downstream consumers close stdout early. [pipeline]
+16
View File
@@ -0,0 +1,16 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{ .name = "pick", .root_module = b.createModule(.{ .root_source_file = b.path("pick.zig"), .target = target, .optimize = optimize }) });
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 pick").dependOn(&run_cmd.step);
const smoke = b.addSystemCommand(&.{ "sh", "smoke.sh", "zig-out/bin/pick" });
smoke.setCwd(b.path("."));
smoke.step.dependOn(b.getInstallStep());
b.step("test", "Run smoke test").dependOn(&smoke.step);
}
+181
View File
@@ -0,0 +1,181 @@
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: pick [--header] FIELD...
\\
\\Select tab-separated fields from stdin. FIELD is a 1-based column number,
\\or a header name when --header is set. Output is TSV/plain records only.
\\
\\options:
\\ --header resolve FIELD names from the first input record
\\ --help show this help on stdout and exit 0
\\
\\stdout: selected records only
\\stderr: usage errors and diagnostics only
\\exits: 0 on success, nonzero on invalid fields or I/O failure
\\
\\No regex, expressions, arithmetic, shell, mutation, files, JSON, color,
\\prompts, config, network, daemon behavior, or hidden state.
\\
\\examples:
\\ printf 'path\\tsize\\nREADME\\t120\\n' | pick --header path size
\\ printf 'a\\tb\\n' | pick 2
\\
);
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 fieldAt(line: []const u8, index: usize) []const u8 {
var it = std.mem.splitScalar(u8, line, '\t');
var i: usize = 0;
while (it.next()) |field| : (i += 1) {
if (i == index) return field;
}
return "";
}
fn stripRecordNewlineCr(raw: []const u8) []const u8 {
if (raw.len > 0 and raw[raw.len - 1] == '\r') return raw[0 .. raw.len - 1];
return raw;
}
fn headerIndex(header: []const u8, name: []const u8, io: std.Io) usize {
var it = std.mem.splitScalar(u8, header, '\t');
var i: usize = 0;
while (it.next()) |field| : (i += 1) {
if (std.mem.eql(u8, field, name)) return i;
}
die(io, "pick: header not found: {s}\n", .{name});
}
fn writeAll(out: *std.Io.File.Writer, bytes: []const u8) std.Io.File.Writer.Error!void {
out.interface.writeAll(bytes) catch |err| switch (err) {
error.WriteFailed => return out.err.?,
};
}
fn writeByte(out: *std.Io.File.Writer, byte: u8) std.Io.File.Writer.Error!void {
out.interface.writeByte(byte) catch |err| switch (err) {
error.WriteFailed => return out.err.?,
};
}
fn writePicked(out: *std.Io.File.Writer, line: []const u8, cols: []const usize) !void {
for (cols, 0..) |col, i| {
if (i > 0) try writeByte(out, '\t');
try writeAll(out, fieldAt(line, col));
}
try writeByte(out, '\n');
}
fn handleLine(
allocator: std.mem.Allocator,
io: std.Io,
has_header: bool,
names: []const []const u8,
cols: *std.ArrayList(usize),
header_done: *bool,
out: *std.Io.File.Writer,
raw: []const u8,
) !void {
const line = stripRecordNewlineCr(raw);
if (has_header and !header_done.*) {
for (names) |name| try cols.append(allocator, headerIndex(line, name, io));
try writePicked(out, line, cols.items);
header_done.* = true;
} else {
try writePicked(out, line, cols.items);
}
}
fn pickStream(
allocator: std.mem.Allocator,
io: std.Io,
has_header: bool,
names: []const []const u8,
cols: *std.ArrayList(usize),
) !void {
var out_buf: [8192]u8 = undefined;
var out = std.Io.File.stdout().writer(io, &out_buf);
var carry = std.ArrayList(u8).empty;
defer carry.deinit(allocator);
var header_done = !has_header;
var buf: [8192]u8 = undefined;
while (true) {
const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch |err| die(io, "pick: stdin: {s}\n", .{@errorName(err)});
if (n == 0) break;
var start: usize = 0;
for (buf[0..n], 0..) |byte, i| {
if (byte != '\n') continue;
if (carry.items.len == 0) {
try handleLine(allocator, io, has_header, names, cols, &header_done, &out, buf[start..i]);
} else {
try carry.appendSlice(allocator, buf[start..i]);
try handleLine(allocator, io, has_header, names, cols, &header_done, &out, carry.items);
carry.clearRetainingCapacity();
}
start = i + 1;
}
if (start < n) try carry.appendSlice(allocator, buf[start..n]);
}
if (carry.items.len > 0) {
try handleLine(allocator, io, has_header, names, cols, &header_done, &out, carry.items);
} else if (has_header and !header_done) {
try handleLine(allocator, io, has_header, names, cols, &header_done, &out, "");
}
try out.flush();
}
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 has_header = false;
var names = std.ArrayList([]const u8).empty;
defer names.deinit(allocator);
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--help")) {
try usage(std.Io.File.stdout(), init.io);
return;
} else if (std.mem.eql(u8, arg, "--header")) {
has_header = true;
} else {
try names.append(allocator, arg);
}
}
if (names.items.len == 0) {
try usage(std.Io.File.stderr(), init.io);
std.process.exit(2);
}
var cols = std.ArrayList(usize).empty;
defer cols.deinit(allocator);
if (!has_header) {
for (names.items) |name| {
const n = std.fmt.parseInt(usize, name, 10) catch die(init.io, "pick: invalid field: {s}\n", .{name});
if (n == 0) die(init.io, "pick: fields are 1-based\n", .{});
try cols.append(allocator, n - 1);
}
}
pickStream(allocator, init.io, has_header, names.items, &cols) catch |err| switch (err) {
error.BrokenPipe => return,
else => die(init.io, "pick: output: {s}\n", .{@errorName(err)}),
};
}
+101
View File
@@ -0,0 +1,101 @@
#!/bin/sh
set -eu
tool=${1:-zig-out/bin/pick}
tmp=${TMPDIR:-.zig-cache}/pick-smoke.$$
mkdir -p "$tmp"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
# req: pick/008
# req: pick/009
# req: pick/010
assert_eq() {
want=$1
got=$2
cmp "$want" "$got"
}
assert_empty() {
file=$1
if [ -s "$file" ]; then
echo "pick: expected empty file: $file" >&2
exit 1
fi
}
assert_fails() {
if "$@"; then
echo "pick: command unexpectedly succeeded: $*" >&2
exit 1
fi
}
# Numeric field selection preserves records and stdout contains only selected TSV.
printf 'a\tb\tc\n1\t2\t3\n' | "$tool" 2 > "$tmp/pick-one.out"
printf 'b\n2\n' > "$tmp/pick-one.want"
assert_eq "$tmp/pick-one.want" "$tmp/pick-one.out"
# Header selection emits the selected header and selected data columns.
printf 'path\tsize\tkind\na\t10\tfile\n' | "$tool" --header path size > "$tmp/pick-header.out"
printf 'path\tsize\na\t10\n' > "$tmp/pick-header.want"
assert_eq "$tmp/pick-header.want" "$tmp/pick-header.out"
# Missing fields are empty fields, not pipeline failures.
printf 'a\tb\n1\n' | "$tool" 1 2 > "$tmp/pick-missing.out"
printf 'a\tb\n1\t\n' > "$tmp/pick-missing.want"
assert_eq "$tmp/pick-missing.want" "$tmp/pick-missing.out"
# CRLF input is accepted and normalized to LF records.
printf 'a\tb\r\n1\t2\r\n' | "$tool" 2 > "$tmp/pick-crlf.out"
printf 'b\n2\n' > "$tmp/pick-crlf.want"
assert_eq "$tmp/pick-crlf.want" "$tmp/pick-crlf.out"
# A record longer than the internal read buffer still streams correctly across
# chunk boundaries without whole-input buffering.
: > "$tmp/pick-long.in"
i=0
while [ "$i" -lt 9000 ]; do
printf x >> "$tmp/pick-long.in"
i=$((i + 1))
done
printf '\tb\n' >> "$tmp/pick-long.in"
"$tool" 2 < "$tmp/pick-long.in" > "$tmp/pick-long.out"
printf 'b\n' > "$tmp/pick-long.want"
assert_eq "$tmp/pick-long.want" "$tmp/pick-long.out"
# Help is stdout-only and successful.
"$tool" --help > "$tmp/pick-help.out" 2> "$tmp/pick-help.err"
grep -q '^usage: pick' "$tmp/pick-help.out"
assert_empty "$tmp/pick-help.err"
# Usage errors are stderr-only and nonzero.
assert_fails "$tool" > "$tmp/pick-noargs.out" 2> "$tmp/pick-noargs.err"
assert_empty "$tmp/pick-noargs.out"
grep -q '^usage: pick' "$tmp/pick-noargs.err"
assert_fails "$tool" 0 > "$tmp/pick-zero.out" 2> "$tmp/pick-zero.err"
assert_empty "$tmp/pick-zero.out"
grep -q 'fields are 1-based' "$tmp/pick-zero.err"
assert_fails "$tool" nope > "$tmp/pick-invalid.out" 2> "$tmp/pick-invalid.err"
assert_empty "$tmp/pick-invalid.out"
grep -q 'invalid field' "$tmp/pick-invalid.err"
# Forbidden near miss: option-looking regex/expression input is rejected as data,
# not interpreted as another command language.
assert_fails "$tool" --regex '.*' > "$tmp/pick-regex.out" 2> "$tmp/pick-regex.err"
assert_empty "$tmp/pick-regex.out"
grep -q 'invalid field' "$tmp/pick-regex.err"
# Head-style consumers may close stdout early; that must not pollute stderr.
i=0
{
while [ "$i" -lt 20000 ]; do
printf 'a\tb\n'
i=$((i + 1))
done
} | "$tool" 1 2> "$tmp/pick-head.err" | sed 1q > "$tmp/pick-head.out"
printf 'a\n' > "$tmp/pick-head.want"
assert_eq "$tmp/pick-head.want" "$tmp/pick-head.out"
assert_empty "$tmp/pick-head.err"