|
| 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