tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

commit bc801af9e3635c64d212ad96485a3ad15b793406
parent ca289ff53182b010eed2dfea185d5fbbdd9aba9f
Author: mtmn <miro@haravara.org>
Date:   Thu, 24 Sep 2026 23:52:11 +0200

pmn: add pass edit and map to C-e

Diffstat:
Mpmn/README.md | 6++++++
Mpmn/build.zig | 12++++++++++++
Mpmn/src/main.zig | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
3 files changed, 127 insertions(+), 18 deletions(-)

diff --git a/pmn/README.md b/pmn/README.md @@ -18,3 +18,9 @@ zig build -Doptimize=ReleaseSafe ``` > Set `PASSWORD_STORE_DIR` if your password store is not at the default `~/.password-store` path. + +## Key bindings + +- Enter: show the highlighted password (or create one from the typed query), then copy it to the clipboard. +- Ctrl-e: edit the highlighted password in `$EDITOR` (or create a new entry from the typed query). Exit without touching the clipboard. +- Esc: quit without doing anything. diff --git a/pmn/build.zig b/pmn/build.zig @@ -13,4 +13,16 @@ pub fn build(b: *std.Build) void { }), }); b.installArtifact(exe); + + const exe_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + const run_exe_tests = b.addRunArtifact(exe_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_tests.step); } diff --git a/pmn/src/main.zig b/pmn/src/main.zig @@ -32,8 +32,18 @@ pub fn main(init: std.process.Init) !void { try paths.append(alloc, entry.path); } - const selected = try fzf(init, alloc, paths.items) orelse return; - defer alloc.free(selected); + const pick = try fzf(init, alloc, paths.items) orelse return; + defer pick.deinit(alloc); + + if (std.mem.eql(u8, pick.key, "ctrl-e")) { + const target = if (pick.selected.len > 0) pick.selected else pick.query; + if (target.len == 0) return; + try edit(init, target); + return; + } + + const selected = if (pick.selected.len > 0) pick.selected else pick.query; + if (selected.len == 0) return; const exists = blk: { for (passwords.items) |item| { @@ -91,13 +101,13 @@ fn collect(alloc: std.mem.Allocator, io: std.Io, base_dir: []const u8, sub_path: const accessed = blk: { const file = std.Io.Dir.openFileAbsolute(io, file_path, .{}) catch |err| { - std.debug.print("Failed to stat {s}: {}\n", .{ file_path, err }); + std.log.warn("failed to open {s}: {}", .{ file_path, err }); break :blk @as(i128, 0); }; defer file.close(io); const file_stat = file.stat(io) catch |err| { - std.debug.print("Failed to stat {s}: {}\n", .{ file_path, err }); + std.log.warn("failed to stat {s}: {}", .{ file_path, err }); break :blk @as(i128, 0); }; @@ -116,9 +126,40 @@ fn collect(alloc: std.mem.Allocator, io: std.Io, base_dir: []const u8, sub_path: } } -fn fzf(init: std.process.Init, alloc: std.mem.Allocator, items: []const []const u8) !?[]u8 { +const Pick = struct { + query: []const u8, + key: []const u8, + selected: []const u8, + raw: []u8, + + fn deinit(self: Pick, alloc: std.mem.Allocator) void { + alloc.free(self.raw); + } +}; + +const PickParts = struct { + query: []const u8, + key: []const u8, + selected: []const u8, +}; + +fn parseFzfOutput(output: []const u8) ?PickParts { + var lines = [3][]const u8{ "", "", "" }; + var it = std.mem.splitScalar(u8, output, '\n'); + var i: usize = 0; + while (i < lines.len) : (i += 1) { + const line = it.next() orelse break; + lines[i] = std.mem.trim(u8, line, " \n\r\t"); + } + + if (lines[0].len == 0 and lines[1].len == 0 and lines[2].len == 0) return null; + + return .{ .query = lines[0], .key = lines[1], .selected = lines[2] }; +} + +fn fzf(init: std.process.Init, alloc: std.mem.Allocator, items: []const []const u8) !?Pick { var child = try std.process.spawn(init.io, .{ - .argv = &.{ "fzf", "--exact", "--print-query" }, + .argv = &.{ "fzf", "--exact", "--print-query", "--expect=ctrl-e" }, .stdin = .pipe, .stdout = .pipe, .stderr = .inherit, @@ -137,22 +178,72 @@ fn fzf(init: std.process.Init, alloc: std.mem.Allocator, items: []const []const } const output = try read(init.io, alloc, &child); - defer alloc.free(output); + errdefer alloc.free(output); const term = try child.wait(init.io); - if (term != .exited) return null; + if (term != .exited) { + alloc.free(output); + return null; + } - const trimmed = std.mem.trim(u8, output, " \n\r\t"); - if (trimmed.len == 0) return null; + const parts = parseFzfOutput(output) orelse { + alloc.free(output); + return null; + }; - if (std.mem.findScalar(u8, trimmed, '\n')) |idx| { - const selected = std.mem.trim(u8, trimmed[idx + 1 ..], " \n\r\t"); - if (selected.len > 0) return try alloc.dupe(u8, selected); - const query = std.mem.trim(u8, trimmed[0..idx], " \n\r\t"); - if (query.len > 0) return try alloc.dupe(u8, query); - } + return .{ + .query = parts.query, + .key = parts.key, + .selected = parts.selected, + .raw = output, + }; +} + +fn edit(init: std.process.Init, target: []const u8) !void { + var child = try std.process.spawn(init.io, .{ + .argv = &.{ "pass", "edit", target }, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }); + + const term = try child.wait(init.io); + // `pass edit` exits non-zero with "Password unchanged." when the editor + // saved no changes. That is not a failure here. + if (term != .exited) return error.FailedExit; +} + +test "parseFzfOutput query only" { + const parts = parseFzfOutput("foo\n") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("foo", parts.query); + try std.testing.expectEqualStrings("", parts.key); + try std.testing.expectEqualStrings("", parts.selected); +} + +test "parseFzfOutput query key selection" { + const parts = parseFzfOutput("foo\nctrl-e\nbar\n") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("foo", parts.query); + try std.testing.expectEqualStrings("ctrl-e", parts.key); + try std.testing.expectEqualStrings("bar", parts.selected); +} + +test "parseFzfOutput enter with selection" { + const parts = parseFzfOutput("foo\n\nbar\n") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("foo", parts.query); + try std.testing.expectEqualStrings("", parts.key); + try std.testing.expectEqualStrings("bar", parts.selected); +} + +test "parseFzfOutput empty query ctrl-e" { + const parts = parseFzfOutput("\nctrl-e\n\n") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("", parts.query); + try std.testing.expectEqualStrings("ctrl-e", parts.key); + try std.testing.expectEqualStrings("", parts.selected); +} - return try alloc.dupe(u8, trimmed); +test "parseFzfOutput empty" { + try std.testing.expect(parseFzfOutput("\n\n\n") == null); + try std.testing.expect(parseFzfOutput("") == null); } fn run(init: std.process.Init, alloc: std.mem.Allocator, args: []const []const u8) ![]u8 { @@ -215,7 +306,7 @@ fn store(init: std.process.Init, alloc: std.mem.Allocator, name: []const u8) ![] errdefer alloc.free(password); inner(init, alloc, name, password) catch |err| { - std.debug.print("Failed to store password: {}\n", .{err}); + std.log.err("failed to store password: {}", .{err}); return err; }; return password;