Skip to content

Commit b9227cf

Browse files
lilhammerfunclaude
andcommitted
refactor: flatten CLI from nested subcommands to 7 verbs
- Replace prompt/bundle namespaces with flat verbs: ls, add, rm, show, set, get, pub - Auto-detect prompt vs bundle by ref type (hex hash → prompt, name → bundle) - Add -Q/--quiet-git flag to suppress git output across all commands - Replace anytype with concrete *std.io.Writer for all writer parameters - Rewrite top-level help to concise format with per-command -h for details - Bump version to 0.11.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6e49f3f commit b9227cf

24 files changed

Lines changed: 3484 additions & 3607 deletions

DESIGN.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,9 @@ Prompt files contain **pure content only** — no frontmatter or embedded metada
251251
Metadata is stored in `prompts/index.json` and can be updated after registration without changing the prompt file (and thus without changing its hash):
252252

253253
```bash
254-
clumsies prompt register file.md --desc "Git conventions" --cat conduct/git
255-
clumsies prompt update <hash> --desc "Updated description"
256-
clumsies prompt update <hash> --cat conduct/writing/zh
254+
clumsies add file.md --desc "Git conventions" --cat conduct/git
255+
clumsies set <hash> --desc "Updated description"
256+
clumsies set <hash> --cat conduct/writing/zh
257257
```
258258

259259
This separation ensures prompt content hashes remain stable across protocol changes.

README.md

Lines changed: 44 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,50 @@ Platforms: `darwin-arm64`, `darwin-x86_64`, `linux-arm64`, `linux-x86_64`
6060

6161
The CLI exists mostly to keep us honest. If this idea can't survive real workflows, it probably isn't worth much.
6262

63-
### Main Commands (manage .prompts/)
63+
### Registry Commands
64+
65+
```bash
66+
# Configure registry (one-time setup)
67+
clumsies config set registry git@github.com:org/prompt-registry.git
68+
69+
# List prompts and bundles
70+
clumsies ls # List prompts
71+
clumsies ls -b # List bundles
72+
clumsies ls -c conduct # Filter by category
73+
74+
# Register prompts
75+
clumsies add <file> -c conduct -d "Code style rules"
76+
clumsies add file1.md file2.md -c command # Batch register
77+
78+
# Show content (auto-detects prompt vs bundle)
79+
clumsies show a1b2c3 # Show prompt by hash prefix
80+
clumsies show my-bundle # Show bundle by name
81+
clumsies show my-bundle --meta # Show bundle meta-prompt
82+
83+
# Update metadata or content
84+
clumsies set a1b2c3 -n new_name # Rename prompt
85+
clumsies set a1b2c3 -c new-cat --all # Rename category across all prompts
86+
clumsies set a1b2c3 -f new-file.md # Replace prompt content
87+
clumsies set my-bundle --add ./conduct # Add files to bundle
88+
clumsies set my-bundle --rm-prompt a1b2c3 # Remove prompt from bundle
89+
clumsies set my-bundle --meta CLAUDE.md # Update bundle meta-prompt
90+
91+
# Import to local .prompts/
92+
clumsies get a1b2c3 # Import prompt by hash
93+
clumsies get my-bundle # Import full bundle
94+
clumsies get a1b2 a3c4 -c conduct # Import by hash + category
95+
96+
# Publish bundle
97+
clumsies pub CLAUDE.md ./conduct ./command
98+
99+
# Remove from registry
100+
clumsies rm a1b2c3 # Remove prompt
101+
clumsies rm my-bundle # Remove bundle
102+
```
103+
104+
Type detection: hex-only refs → prompt hash prefix; otherwise → bundle name.
105+
106+
### Workspace Commands (manage .prompts/)
64107

65108
```bash
66109
# Clone existing prompts
@@ -80,54 +123,6 @@ clumsies status
80123
clumsies log
81124
```
82125

83-
### Bundle Commands (manage bundles in registry)
84-
85-
```bash
86-
# Configure registry (one-time setup)
87-
clumsies config set registry git@github.com:org/prompt-registry.git
88-
89-
# List and show bundles
90-
clumsies bundle list
91-
clumsies bundle show <name>
92-
93-
# Import bundle to .prompts/
94-
clumsies bundle import <name> [--remote-url <url>]
95-
96-
# Register bundle from meta-prompt file and directories
97-
# Bundle name comes from frontmatter in meta-prompt file
98-
clumsies bundle register <meta-prompt-file> <dirs...>
99-
clumsies bundle register CLAUDE.md ./conduct ./command
100-
101-
# Update bundle contents
102-
clumsies bundle update <name> --add <files...>
103-
clumsies bundle update <name> --rm <hash...>
104-
clumsies bundle update <name> --meta <file>
105-
106-
# Remove bundle(s)
107-
clumsies bundle rm <name>...
108-
```
109-
110-
### Prompt Commands (manage prompts in registry)
111-
112-
```bash
113-
# List and show prompts
114-
clumsies prompt list
115-
clumsies prompt show <hash>
116-
117-
# Register prompt to registry (metadata via flags, not frontmatter)
118-
clumsies prompt register <file> [--desc "..."] [--cat "..."]
119-
120-
# Update prompt metadata (without changing content/hash)
121-
clumsies prompt update <hash> [--desc "..."] [--cat "..."]
122-
123-
# Import prompt(s) to local .prompts/
124-
clumsies prompt import <hash>...
125-
clumsies prompt import --cat <category>... # Import by category (prefix match)
126-
127-
# Remove prompt(s)
128-
clumsies prompt rm <hash>...
129-
```
130-
131126
### Configuration
132127

133128
```bash

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
.{
22
.fingerprint = 0xa21731978d142f78,
33
.name = .clumsies,
4-
.version = "0.10.0",
4+
.version = "0.11.0",
55
.paths = .{
66
"build.zig",
77
"build.zig.zon",

src/commands/add_cmd.zig

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
const std = @import("std");
2+
const fs = std.fs;
3+
const git = @import("../git.zig");
4+
const commands = @import("commands.zig");
5+
const spinner = @import("../spinner.zig");
6+
7+
const Color = commands.Color;
8+
const P = commands.P;
9+
const GitOutput = commands.GitOutput;
10+
const printGitOutputRaw = commands.printGitOutputRaw;
11+
const stripSequencePrefix = commands.stripSequencePrefix;
12+
const hexEncode = commands.hexEncode;
13+
const MAX_FILE_SIZE = commands.MAX_FILE_SIZE;
14+
const ensureRegistry = commands.ensureRegistry;
15+
const hasFrontmatter = commands.hasFrontmatter;
16+
const stripFrontmatter = commands.stripFrontmatter;
17+
18+
pub fn run(stdout: *std.io.Writer, stderr: *std.io.Writer, allocator: std.mem.Allocator, args: []const []const u8) !void {
19+
var file_paths: std.ArrayListUnmanaged([]const u8) = .empty;
20+
defer file_paths.deinit(allocator);
21+
var desc_flag: ?[]const u8 = null;
22+
var cat_flag: ?[]const u8 = null;
23+
var sync: bool = false;
24+
var quiet_git: bool = false;
25+
26+
var i: usize = 0;
27+
while (i < args.len) : (i += 1) {
28+
const arg = args[i];
29+
if (std.mem.eql(u8, arg, "-Q") or std.mem.eql(u8, arg, "--quiet-git")) {
30+
quiet_git = true;
31+
} else if (std.mem.eql(u8, arg, "--desc") or std.mem.eql(u8, arg, "-d")) {
32+
if (i + 1 < args.len) {
33+
i += 1;
34+
desc_flag = args[i];
35+
} else {
36+
try stderr.print("{s}{s}{s}Error:{s} --desc requires a value\n", .{ P, Color.bold, Color.red, Color.reset });
37+
return;
38+
}
39+
} else if (std.mem.eql(u8, arg, "--cat") or std.mem.eql(u8, arg, "-c")) {
40+
if (i + 1 < args.len) {
41+
i += 1;
42+
cat_flag = args[i];
43+
} else {
44+
try stderr.print("{s}{s}{s}Error:{s} --cat requires a value\n", .{ P, Color.bold, Color.red, Color.reset });
45+
return;
46+
}
47+
} else if (std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--sync")) {
48+
sync = true;
49+
} else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) {
50+
try printHelp(stdout);
51+
return;
52+
} else if (std.mem.startsWith(u8, arg, "-")) {
53+
try stderr.print("{s}{s}{s}Error:{s} Unknown flag: {s}\n", .{ P, Color.bold, Color.red, Color.reset, arg });
54+
try printHelp(stderr);
55+
return;
56+
} else {
57+
try file_paths.append(allocator, arg);
58+
}
59+
}
60+
61+
if (file_paths.items.len == 0) {
62+
try stderr.print("{s}{s}{s}Error:{s} File(s) required\n", .{ P, Color.bold, Color.red, Color.reset });
63+
try printHelp(stderr);
64+
return;
65+
}
66+
67+
const registry_path = ensureRegistry(stdout, stderr, allocator, sync, quiet_git) catch return;
68+
defer allocator.free(registry_path);
69+
70+
const cwd = std.process.getCwdAlloc(allocator) catch {
71+
try stderr.print("{s}{s}{s}Error:{s} Could not determine current directory\n\n", .{ P, Color.bold, Color.red, Color.reset });
72+
return;
73+
};
74+
defer allocator.free(cwd);
75+
76+
const prompts_dir = try std.fs.path.join(allocator, &.{ registry_path, "prompts" });
77+
defer allocator.free(prompts_dir);
78+
fs.cwd().makePath(prompts_dir) catch {};
79+
80+
var registered: usize = 0;
81+
82+
for (file_paths.items) |file_path| {
83+
if (try registerOne(stdout, stderr, allocator, registry_path, prompts_dir, cwd, file_path, desc_flag, cat_flag)) {
84+
registered += 1;
85+
}
86+
}
87+
88+
if (registered == 0) return;
89+
90+
// Commit and push
91+
var sp = spinner.init(stdout, "Registering prompt(s)");
92+
sp.start();
93+
94+
var add_output: GitOutput = .{};
95+
defer add_output.deinit(allocator);
96+
git.addAll(allocator, registry_path, &add_output) catch {};
97+
98+
const commit_msg = if (registered == 1) "Add prompt" else "Add prompts";
99+
var commit_output: GitOutput = .{};
100+
defer commit_output.deinit(allocator);
101+
git.commit(allocator, registry_path, commit_msg, &commit_output) catch {};
102+
103+
var git_output: GitOutput = .{};
104+
defer git_output.deinit(allocator);
105+
106+
git.push(allocator, registry_path, &git_output) catch {
107+
sp.fail();
108+
printGitOutputRaw(&git_output, quiet_git);
109+
try stderr.print("{s}{s}{s}Warning:{s} Saved locally but failed to push to remote\n", .{ P, Color.bold, Color.orange, Color.reset });
110+
return;
111+
};
112+
sp.succeed();
113+
printGitOutputRaw(&git_output, quiet_git);
114+
115+
if (registered == 1) {
116+
try stdout.print("{s}{s}{s}✓{s} Registered prompt in registry\n\n", .{ P, Color.bold, Color.green, Color.reset });
117+
} else {
118+
try stdout.print("{s}{s}{s}✓{s} Registered {d} prompts in registry\n\n", .{ P, Color.bold, Color.green, Color.reset, registered });
119+
}
120+
}
121+
122+
fn registerOne(stdout: *std.io.Writer, stderr: *std.io.Writer, allocator: std.mem.Allocator, _: []const u8, prompts_dir: []const u8, cwd: []const u8, file_path: []const u8, desc_flag: ?[]const u8, cat_flag: ?[]const u8) !bool {
123+
const abs_path = if (std.fs.path.isAbsolute(file_path))
124+
try allocator.dupe(u8, file_path)
125+
else
126+
try std.fs.path.join(allocator, &.{ cwd, file_path });
127+
defer allocator.free(abs_path);
128+
129+
const file = fs.openFileAbsolute(abs_path, .{}) catch {
130+
try stderr.print("{s}{s}{s}Error:{s} Could not open file: {s}\n", .{ P, Color.bold, Color.red, Color.reset, file_path });
131+
return false;
132+
};
133+
defer file.close();
134+
135+
const raw_content = file.readToEndAlloc(allocator, MAX_FILE_SIZE) catch {
136+
try stderr.print("{s}{s}{s}Error:{s} Failed to read file: {s}\n", .{ P, Color.bold, Color.red, Color.reset, file_path });
137+
return false;
138+
};
139+
defer allocator.free(raw_content);
140+
141+
const had_frontmatter = hasFrontmatter(raw_content);
142+
const content = stripFrontmatter(raw_content);
143+
144+
if (had_frontmatter) {
145+
try stdout.print("{s}{s}Stripped frontmatter from {s}{s}\n", .{ P, Color.dim, file_path, Color.reset });
146+
}
147+
148+
// Compute SHA-256 hash
149+
var hash: [32]u8 = undefined;
150+
std.crypto.hash.sha2.Sha256.hash(content, &hash, .{});
151+
var hash_hex: [64]u8 = undefined;
152+
hexEncode(&hash, &hash_hex);
153+
154+
// Extract file extension and name
155+
const basename = std.fs.path.basename(file_path);
156+
const ext_idx = std.mem.lastIndexOf(u8, basename, ".");
157+
const format = if (ext_idx) |idx| basename[idx + 1 ..] else "md";
158+
const name_end = ext_idx orelse basename.len;
159+
const raw_name = basename[0..name_end];
160+
const name = stripSequencePrefix(raw_name);
161+
const description = desc_flag orelse "-";
162+
const prompt_category = cat_flag orelse "conduct";
163+
164+
// Copy file to registry
165+
const dest_path = try std.fs.path.join(allocator, &.{ prompts_dir, &hash_hex });
166+
defer allocator.free(dest_path);
167+
168+
if (fs.openFileAbsolute(dest_path, .{})) |existing| {
169+
existing.close();
170+
try stdout.print("{s}{s}{s}!{s} Already exists: {s} ({s})\n", .{ P, Color.bold, Color.orange, Color.reset, name, hash_hex[0..8] });
171+
return false;
172+
} else |_| {}
173+
174+
const dest_file = fs.createFileAbsolute(dest_path, .{}) catch {
175+
try stderr.print("{s}{s}{s}Error:{s} Failed to create file in registry\n", .{ P, Color.bold, Color.red, Color.reset });
176+
return false;
177+
};
178+
defer dest_file.close();
179+
dest_file.writeAll(content) catch {
180+
try stderr.print("{s}{s}{s}Error:{s} Failed to write file\n", .{ P, Color.bold, Color.red, Color.reset });
181+
return false;
182+
};
183+
184+
// Update index.json
185+
const index_path = try std.fs.path.join(allocator, &.{ prompts_dir, "index.json" });
186+
defer allocator.free(index_path);
187+
188+
var existing_prompts: std.ArrayListUnmanaged(u8) = .{};
189+
defer existing_prompts.deinit(allocator);
190+
191+
if (fs.openFileAbsolute(index_path, .{})) |idx_file| {
192+
const idx_content = idx_file.readToEndAlloc(allocator, MAX_FILE_SIZE) catch {
193+
idx_file.close();
194+
try stderr.print("{s}{s}{s}Error:{s} Failed to read index\n", .{ P, Color.bold, Color.red, Color.reset });
195+
return false;
196+
};
197+
idx_file.close();
198+
defer allocator.free(idx_content);
199+
200+
if (std.json.parseFromSlice(std.json.Value, allocator, idx_content, .{})) |parsed| {
201+
defer parsed.deinit();
202+
if (parsed.value.object.get("prompts")) |prompts| {
203+
try existing_prompts.appendSlice(allocator, "{\n \"prompts\": [");
204+
for (prompts.array.items, 0..) |item, idx| {
205+
if (idx > 0) try existing_prompts.appendSlice(allocator, ",");
206+
const item_hash = if (item.object.get("hash")) |h| h.string else continue;
207+
const item_name = if (item.object.get("name")) |n| n.string else "-";
208+
const item_desc = if (item.object.get("description")) |d| d.string else "-";
209+
const item_format = if (item.object.get("format")) |f| f.string else "md";
210+
const item_created = if (item.object.get("created_at")) |c| c.string else "0";
211+
const item_category = if (item.object.get("category")) |p| p.string else "conduct";
212+
213+
const entry = try std.fmt.allocPrint(allocator, "\n {{\n \"hash\": \"{s}\",\n \"name\": \"{s}\",\n \"description\": \"{s}\",\n \"format\": \"{s}\",\n \"category\": \"{s}\",\n \"created_at\": \"{s}\"\n }}", .{ item_hash, item_name, item_desc, item_format, item_category, item_created });
214+
defer allocator.free(entry);
215+
try existing_prompts.appendSlice(allocator, entry);
216+
}
217+
}
218+
} else |_| {}
219+
} else |_| {
220+
try existing_prompts.appendSlice(allocator, "{\n \"prompts\": [");
221+
}
222+
223+
const timestamp = std.time.timestamp();
224+
const new_entry = try std.fmt.allocPrint(allocator, "{s}\n {{\n \"hash\": \"{s}\",\n \"name\": \"{s}\",\n \"description\": \"{s}\",\n \"format\": \"{s}\",\n \"category\": \"{s}\",\n \"created_at\": \"{d}\"\n }}\n ]\n}}\n", .{
225+
if (existing_prompts.items.len > 20) "," else "",
226+
hash_hex,
227+
name,
228+
description,
229+
format,
230+
prompt_category,
231+
timestamp,
232+
});
233+
defer allocator.free(new_entry);
234+
try existing_prompts.appendSlice(allocator, new_entry);
235+
236+
const idx_out = fs.createFileAbsolute(index_path, .{}) catch {
237+
try stderr.print("{s}{s}{s}Error:{s} Failed to write index\n", .{ P, Color.bold, Color.red, Color.reset });
238+
return false;
239+
};
240+
defer idx_out.close();
241+
idx_out.writeAll(existing_prompts.items) catch {
242+
try stderr.print("{s}{s}{s}Error:{s} Failed to write index\n", .{ P, Color.bold, Color.red, Color.reset });
243+
return false;
244+
};
245+
246+
try stdout.print("{s} Hash: {s}{s}{s} Name: {s}\n", .{ P, Color.cyan, hash_hex[0..8], Color.reset, name });
247+
return true;
248+
}
249+
250+
fn printHelp(out: *std.io.Writer) !void {
251+
try out.print("{s}Usage: {s}clumsies add <file>... [-c <cat>] [-d <desc>] [-s]{s}\n\n", .{ P, Color.cyan, Color.reset });
252+
try out.print("{s}Register prompt(s) to registry.\n\n", .{P});
253+
try out.print("{s}Options:\n", .{P});
254+
try out.print("{s} {s}-c, --cat{s} <cat> Category (default: conduct)\n", .{ P, Color.cyan, Color.reset });
255+
try out.print("{s} {s}-d, --desc{s} <desc> Description\n", .{ P, Color.cyan, Color.reset });
256+
try out.print("{s} {s}-s, --sync{s} Sync registry before command\n", .{ P, Color.cyan, Color.reset });
257+
try out.print("{s} {s}-Q, --quiet-git{s} Suppress git output\n", .{ P, Color.cyan, Color.reset });
258+
try out.print("{s} {s}-h, --help{s} Show this help\n\n", .{ P, Color.cyan, Color.reset });
259+
}

0 commit comments

Comments
 (0)