|
#!/usr/bin/env bun |
|
import * as p from "@clack/prompts"; |
|
import color from "picocolors"; |
|
|
|
// ============================================================================ |
|
// Types |
|
// ============================================================================ |
|
|
|
interface Command { |
|
name: string; |
|
description: string; |
|
arguments?: Argument[]; |
|
options?: Option[]; |
|
usage_examples: string[]; |
|
} |
|
|
|
interface Argument { |
|
name: string; |
|
description: string; |
|
required: boolean; |
|
} |
|
|
|
interface Option { |
|
name: string; |
|
alias?: string; |
|
description: string; |
|
type: "string" | "number" | "boolean"; |
|
required?: boolean; |
|
} |
|
|
|
interface CLISchema { |
|
name: string; |
|
version: string; |
|
description: string; |
|
commands: Command[]; |
|
} |
|
|
|
// ============================================================================ |
|
// CLI Schema (single source of truth) |
|
// ============================================================================ |
|
|
|
const CLI_SCHEMA: CLISchema = { |
|
name: "kv-cli", |
|
version: "1.0.0", |
|
description: "A CLI for interacting with the KV store", |
|
commands: [ |
|
{ |
|
name: "get", |
|
description: "Retrieve a value by key from the KV store", |
|
arguments: [ |
|
{ |
|
name: "key", |
|
description: "The key to retrieve", |
|
required: true, |
|
}, |
|
], |
|
options: [ |
|
{ |
|
name: "format", |
|
alias: "f", |
|
description: "Output format (json, text)", |
|
type: "string", |
|
}, |
|
], |
|
usage_examples: [ |
|
"kv-cli get my-key", |
|
"kv-cli get user:123 --format json", |
|
], |
|
}, |
|
{ |
|
name: "put", |
|
description: "Store a key-value pair in the KV store", |
|
arguments: [ |
|
{ |
|
name: "key", |
|
description: "The key to store", |
|
required: true, |
|
}, |
|
{ |
|
name: "value", |
|
description: "The value to store", |
|
required: true, |
|
}, |
|
], |
|
options: [ |
|
{ |
|
name: "ttl", |
|
alias: "t", |
|
description: "Time-to-live in seconds", |
|
type: "number", |
|
}, |
|
{ |
|
name: "metadata", |
|
alias: "m", |
|
description: "JSON metadata to attach to the key", |
|
type: "string", |
|
}, |
|
], |
|
usage_examples: [ |
|
'kv-cli put my-key "my value"', |
|
'kv-cli put session:abc "data" --ttl 3600', |
|
'kv-cli put user:123 "data" --metadata \'{"role":"admin"}\'', |
|
], |
|
}, |
|
{ |
|
name: "list", |
|
description: "List all keys in the KV store", |
|
options: [ |
|
{ |
|
name: "limit", |
|
alias: "l", |
|
description: "Maximum number of keys to return", |
|
type: "number", |
|
}, |
|
{ |
|
name: "cursor", |
|
alias: "c", |
|
description: "Pagination cursor for next page", |
|
type: "string", |
|
}, |
|
], |
|
usage_examples: ["kv-cli list", "kv-cli list --limit 100"], |
|
}, |
|
{ |
|
name: "filter", |
|
description: "Filter keys based on criteria", |
|
options: [ |
|
{ |
|
name: "beforeDate", |
|
alias: "b", |
|
description: "Filter keys created before this date (ISO 8601)", |
|
type: "string", |
|
}, |
|
{ |
|
name: "afterDate", |
|
alias: "a", |
|
description: "Filter keys created after this date (ISO 8601)", |
|
type: "string", |
|
}, |
|
{ |
|
name: "prefix", |
|
alias: "p", |
|
description: "Filter keys by prefix", |
|
type: "string", |
|
}, |
|
{ |
|
name: "limit", |
|
alias: "l", |
|
description: "Maximum number of keys to return", |
|
type: "number", |
|
}, |
|
], |
|
usage_examples: [ |
|
"kv-cli filter --prefix user:", |
|
"kv-cli filter --afterDate 2024-01-01 --limit 50", |
|
"kv-cli filter --beforeDate 2024-06-01 --afterDate 2024-01-01 --prefix logs:", |
|
], |
|
}, |
|
], |
|
}; |
|
|
|
// ============================================================================ |
|
// Stubbed API calls |
|
// ============================================================================ |
|
|
|
async function kvGet( |
|
key: string, |
|
_options: { format?: string } |
|
): Promise<{ key: string; value: string; metadata?: Record<string, unknown> }> { |
|
// Stub: simulate API latency |
|
await new Promise((r) => setTimeout(r, 200)); |
|
return { |
|
key, |
|
value: `<value for ${key}>`, |
|
metadata: { createdAt: new Date().toISOString() }, |
|
}; |
|
} |
|
|
|
async function kvPut( |
|
key: string, |
|
value: string, |
|
_options: { ttl?: number; metadata?: string } |
|
): Promise<{ success: boolean; key: string }> { |
|
await new Promise((r) => setTimeout(r, 200)); |
|
return { success: true, key }; |
|
} |
|
|
|
async function kvList(_options: { |
|
limit?: number; |
|
cursor?: string; |
|
}): Promise<{ keys: string[]; cursor?: string }> { |
|
await new Promise((r) => setTimeout(r, 200)); |
|
return { |
|
keys: ["user:123", "user:456", "session:abc", "config:app"], |
|
cursor: undefined, |
|
}; |
|
} |
|
|
|
async function kvFilter(_options: { |
|
beforeDate?: string; |
|
afterDate?: string; |
|
prefix?: string; |
|
limit?: number; |
|
}): Promise<{ keys: string[]; count: number }> { |
|
await new Promise((r) => setTimeout(r, 200)); |
|
return { |
|
keys: ["user:123", "user:456"], |
|
count: 2, |
|
}; |
|
} |
|
|
|
// ============================================================================ |
|
// CLI Output Helpers |
|
// ============================================================================ |
|
|
|
function printYamlSchema(): void { |
|
const indent = (level: number) => " ".repeat(level); |
|
|
|
console.log(`name: ${CLI_SCHEMA.name}`); |
|
console.log(`version: ${CLI_SCHEMA.version}`); |
|
console.log(`description: ${CLI_SCHEMA.description}`); |
|
console.log(`commands:`); |
|
|
|
for (const cmd of CLI_SCHEMA.commands) { |
|
console.log(`${indent(1)}- name: ${cmd.name}`); |
|
console.log(`${indent(2)}description: ${cmd.description}`); |
|
|
|
if (cmd.arguments?.length) { |
|
console.log(`${indent(2)}arguments:`); |
|
for (const arg of cmd.arguments) { |
|
console.log(`${indent(3)}- name: ${arg.name}`); |
|
console.log(`${indent(4)}description: ${arg.description}`); |
|
console.log(`${indent(4)}required: ${arg.required}`); |
|
} |
|
} |
|
|
|
if (cmd.options?.length) { |
|
console.log(`${indent(2)}options:`); |
|
for (const opt of cmd.options) { |
|
console.log(`${indent(3)}- name: --${opt.name}`); |
|
if (opt.alias) console.log(`${indent(4)}alias: -${opt.alias}`); |
|
console.log(`${indent(4)}description: ${opt.description}`); |
|
console.log(`${indent(4)}type: ${opt.type}`); |
|
if (opt.required) console.log(`${indent(4)}required: ${opt.required}`); |
|
} |
|
} |
|
|
|
console.log(`${indent(2)}usage_examples:`); |
|
for (const ex of cmd.usage_examples) { |
|
console.log(`${indent(3)}- ${ex}`); |
|
} |
|
} |
|
} |
|
|
|
function printHelp(): void { |
|
console.log(` |
|
${color.bold("kv-cli")} - ${CLI_SCHEMA.description} |
|
|
|
${color.bold("Usage:")} |
|
kv-cli <command> [options] |
|
|
|
${color.bold("Commands:")} |
|
${color.cyan("get")} <key> Retrieve a value by key |
|
${color.cyan("put")} <key> <value> Store a key-value pair |
|
${color.cyan("list")} List all keys |
|
${color.cyan("filter")} Filter keys based on criteria |
|
|
|
${color.bold("Options:")} |
|
--help, -h Show help |
|
--version, -v Show version |
|
|
|
Run ${color.cyan("kv-cli <command> --help")} for command-specific help. |
|
`); |
|
} |
|
|
|
function printCommandHelp(cmd: Command): void { |
|
const args = cmd.arguments?.map((a) => (a.required ? `<${a.name}>` : `[${a.name}]`)).join(" ") ?? ""; |
|
|
|
console.log(` |
|
${color.bold(cmd.name)} - ${cmd.description} |
|
|
|
${color.bold("Usage:")} |
|
kv-cli ${cmd.name} ${args} |
|
`); |
|
|
|
if (cmd.arguments?.length) { |
|
console.log(`${color.bold("Arguments:")}`); |
|
for (const arg of cmd.arguments) { |
|
console.log(` ${color.cyan(arg.name)}${arg.required ? " (required)" : ""} ${arg.description}`); |
|
} |
|
console.log(); |
|
} |
|
|
|
if (cmd.options?.length) { |
|
console.log(`${color.bold("Options:")}`); |
|
for (const opt of cmd.options) { |
|
const alias = opt.alias ? `-${opt.alias}, ` : " "; |
|
console.log(` ${alias}--${opt.name} ${opt.description}`); |
|
} |
|
console.log(); |
|
} |
|
|
|
console.log(`${color.bold("Examples:")}`); |
|
for (const ex of cmd.usage_examples) { |
|
console.log(` ${color.dim("$")} ${ex}`); |
|
} |
|
console.log(); |
|
} |
|
|
|
// ============================================================================ |
|
// Argument Parsing |
|
// ============================================================================ |
|
|
|
interface ParsedArgs { |
|
command?: string; |
|
args: string[]; |
|
options: Record<string, string | boolean | number>; |
|
} |
|
|
|
function parseArgs(argv: string[]): ParsedArgs { |
|
const result: ParsedArgs = { args: [], options: {} }; |
|
let i = 0; |
|
|
|
while (i < argv.length) { |
|
const arg = argv[i]!; |
|
|
|
if (arg.startsWith("--")) { |
|
const key = arg.slice(2); |
|
const next = argv[i + 1]; |
|
if (next && !next.startsWith("-")) { |
|
result.options[key] = next; |
|
i += 2; |
|
} else { |
|
result.options[key] = true; |
|
i++; |
|
} |
|
} else if (arg.startsWith("-") && arg.length === 2) { |
|
const alias = arg.slice(1); |
|
const next = argv[i + 1]; |
|
// Resolve alias to full name |
|
const fullName = resolveAlias(alias); |
|
if (next && !next.startsWith("-")) { |
|
result.options[fullName] = next; |
|
i += 2; |
|
} else { |
|
result.options[fullName] = true; |
|
i++; |
|
} |
|
} else if (!result.command) { |
|
result.command = arg; |
|
i++; |
|
} else { |
|
result.args.push(arg); |
|
i++; |
|
} |
|
} |
|
|
|
return result; |
|
} |
|
|
|
function resolveAlias(alias: string): string { |
|
const aliasMap: Record<string, string> = { |
|
h: "help", |
|
v: "version", |
|
f: "format", |
|
t: "ttl", |
|
m: "metadata", |
|
l: "limit", |
|
c: "cursor", |
|
b: "beforeDate", |
|
a: "afterDate", |
|
p: "prefix", |
|
}; |
|
return aliasMap[alias] ?? alias; |
|
} |
|
|
|
// ============================================================================ |
|
// Interactive Mode |
|
// ============================================================================ |
|
|
|
async function runInteractive(): Promise<void> { |
|
console.clear(); |
|
p.intro(color.bgCyan(color.black(" kv-cli "))); |
|
|
|
const command = await p.select({ |
|
message: "What would you like to do?", |
|
options: CLI_SCHEMA.commands.map((cmd) => ({ |
|
value: cmd.name, |
|
label: cmd.name, |
|
hint: cmd.description, |
|
})), |
|
}); |
|
|
|
if (p.isCancel(command)) { |
|
p.cancel("Operation cancelled."); |
|
process.exit(0); |
|
} |
|
|
|
switch (command) { |
|
case "get": |
|
await runInteractiveGet(); |
|
break; |
|
case "put": |
|
await runInteractivePut(); |
|
break; |
|
case "list": |
|
await runInteractiveList(); |
|
break; |
|
case "filter": |
|
await runInteractiveFilter(); |
|
break; |
|
} |
|
|
|
p.outro(color.green("Done!")); |
|
} |
|
|
|
async function runInteractiveGet(): Promise<void> { |
|
const key = await p.text({ |
|
message: "Enter the key to retrieve:", |
|
placeholder: "user:123", |
|
validate: (v) => (!v ? "Key is required" : undefined), |
|
}); |
|
|
|
if (p.isCancel(key)) { |
|
p.cancel("Operation cancelled."); |
|
process.exit(0); |
|
} |
|
|
|
const format = await p.select({ |
|
message: "Output format:", |
|
options: [ |
|
{ value: "json", label: "JSON" }, |
|
{ value: "text", label: "Plain text" }, |
|
], |
|
}); |
|
|
|
if (p.isCancel(format)) { |
|
p.cancel("Operation cancelled."); |
|
process.exit(0); |
|
} |
|
|
|
const s = p.spinner(); |
|
s.start("Fetching value..."); |
|
|
|
const result = await kvGet(key as string, { format }); |
|
|
|
s.stop("Value retrieved"); |
|
|
|
if (format === "json") { |
|
p.note(JSON.stringify(result, null, 2), "Result"); |
|
} else { |
|
p.note(result.value, key); |
|
} |
|
} |
|
|
|
async function runInteractivePut(): Promise<void> { |
|
const inputs = await p.group({ |
|
key: () => |
|
p.text({ |
|
message: "Enter the key:", |
|
placeholder: "user:123", |
|
validate: (v) => (!v ? "Key is required" : undefined), |
|
}), |
|
value: () => |
|
p.text({ |
|
message: "Enter the value:", |
|
placeholder: '{"name": "John"}', |
|
validate: (v) => (!v ? "Value is required" : undefined), |
|
}), |
|
ttl: () => |
|
p.text({ |
|
message: "TTL in seconds (optional):", |
|
placeholder: "3600", |
|
}), |
|
}); |
|
|
|
if (p.isCancel(inputs)) { |
|
p.cancel("Operation cancelled."); |
|
process.exit(0); |
|
} |
|
|
|
const s = p.spinner(); |
|
s.start("Storing value..."); |
|
|
|
const result = await kvPut(inputs.key, inputs.value, { |
|
ttl: inputs.ttl ? parseInt(inputs.ttl, 10) : undefined, |
|
}); |
|
|
|
s.stop(result.success ? "Value stored" : "Failed to store value"); |
|
} |
|
|
|
async function runInteractiveList(): Promise<void> { |
|
const limit = await p.text({ |
|
message: "Maximum keys to return (optional):", |
|
placeholder: "100", |
|
}); |
|
|
|
if (p.isCancel(limit)) { |
|
p.cancel("Operation cancelled."); |
|
process.exit(0); |
|
} |
|
|
|
const s = p.spinner(); |
|
s.start("Listing keys..."); |
|
|
|
const result = await kvList({ |
|
limit: limit ? parseInt(limit, 10) : undefined, |
|
}); |
|
|
|
s.stop(`Found ${result.keys.length} keys`); |
|
p.note(result.keys.join("\n"), "Keys"); |
|
} |
|
|
|
async function runInteractiveFilter(): Promise<void> { |
|
const inputs = await p.group({ |
|
prefix: () => |
|
p.text({ |
|
message: "Key prefix (optional):", |
|
placeholder: "user:", |
|
}), |
|
afterDate: () => |
|
p.text({ |
|
message: "Created after date (optional):", |
|
placeholder: "2024-01-01", |
|
}), |
|
beforeDate: () => |
|
p.text({ |
|
message: "Created before date (optional):", |
|
placeholder: "2024-12-31", |
|
}), |
|
limit: () => |
|
p.text({ |
|
message: "Maximum keys to return (optional):", |
|
placeholder: "100", |
|
}), |
|
}); |
|
|
|
if (p.isCancel(inputs)) { |
|
p.cancel("Operation cancelled."); |
|
process.exit(0); |
|
} |
|
|
|
const s = p.spinner(); |
|
s.start("Filtering keys..."); |
|
|
|
const result = await kvFilter({ |
|
prefix: inputs.prefix || undefined, |
|
afterDate: inputs.afterDate || undefined, |
|
beforeDate: inputs.beforeDate || undefined, |
|
limit: inputs.limit ? parseInt(inputs.limit, 10) : undefined, |
|
}); |
|
|
|
s.stop(`Found ${result.count} keys`); |
|
p.note(result.keys.join("\n"), "Filtered Keys"); |
|
} |
|
|
|
// ============================================================================ |
|
// Command Execution (non-interactive) |
|
// ============================================================================ |
|
|
|
async function executeCommand(parsed: ParsedArgs): Promise<void> { |
|
const cmd = CLI_SCHEMA.commands.find((c) => c.name === parsed.command); |
|
|
|
if (parsed.options.help && cmd) { |
|
if (process.stdout.isTTY) { |
|
printCommandHelp(cmd); |
|
} else { |
|
printYamlSchema(); // full schema for machine consumption |
|
} |
|
return; |
|
} |
|
|
|
switch (parsed.command) { |
|
case "get": { |
|
const key = parsed.args[0]; |
|
if (!key) { |
|
console.error("Error: key is required"); |
|
process.exit(1); |
|
} |
|
const result = await kvGet(key, { format: parsed.options.format as string }); |
|
console.log(JSON.stringify(result, null, 2)); |
|
break; |
|
} |
|
|
|
case "put": { |
|
const [key, value] = parsed.args; |
|
if (!key || !value) { |
|
console.error("Error: key and value are required"); |
|
process.exit(1); |
|
} |
|
const result = await kvPut(key, value, { |
|
ttl: parsed.options.ttl ? Number(parsed.options.ttl) : undefined, |
|
metadata: parsed.options.metadata as string, |
|
}); |
|
console.log(JSON.stringify(result, null, 2)); |
|
break; |
|
} |
|
|
|
case "list": { |
|
const result = await kvList({ |
|
limit: parsed.options.limit ? Number(parsed.options.limit) : undefined, |
|
cursor: parsed.options.cursor as string, |
|
}); |
|
console.log(JSON.stringify(result, null, 2)); |
|
break; |
|
} |
|
|
|
case "filter": { |
|
const result = await kvFilter({ |
|
beforeDate: parsed.options.beforeDate as string, |
|
afterDate: parsed.options.afterDate as string, |
|
prefix: parsed.options.prefix as string, |
|
limit: parsed.options.limit ? Number(parsed.options.limit) : undefined, |
|
}); |
|
console.log(JSON.stringify(result, null, 2)); |
|
break; |
|
} |
|
|
|
default: |
|
console.error(`Unknown command: ${parsed.command}`); |
|
process.exit(1); |
|
} |
|
} |
|
|
|
// ============================================================================ |
|
// Main Entry Point |
|
// ============================================================================ |
|
|
|
async function main(): Promise<void> { |
|
const args = process.argv.slice(2); |
|
const parsed = parseArgs(args); |
|
const isTTY = process.stdout.isTTY; |
|
|
|
// --version |
|
if (parsed.options.version) { |
|
console.log(CLI_SCHEMA.version); |
|
return; |
|
} |
|
|
|
// No command provided |
|
if (!parsed.command) { |
|
// --help or no args in non-TTY: full YAML schema for machine consumption |
|
if (!isTTY || parsed.options.help) { |
|
if (parsed.options.help && isTTY) { |
|
printHelp(); |
|
} else { |
|
printYamlSchema(); |
|
} |
|
return; |
|
} |
|
// Interactive mode |
|
await runInteractive(); |
|
return; |
|
} |
|
|
|
// Execute the specified command (handles command-specific --help) |
|
await executeCommand(parsed); |
|
} |
|
|
|
main().catch((err) => { |
|
console.error(err); |
|
process.exit(1); |
|
}); |