Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Linux and Windows users can pipe text on stdin. `--clipboard` uses `pbpaste`, `x
- `clipcase list [--json]` prints case name, entry count, updated timestamp, and title.
- `clipcase show <case>` prints deterministic JSON metadata.
- `clipcase search <query> [--json]` searches entry text, tags, and source labels offline.
- `clipcase export <case> [--out <file>]` produces a single Markdown bundle.
- `clipcase export <case> [--out <file>]` produces a single Markdown bundle. Without `--out` it writes the bundle to stdout; with `--out` it creates missing parent directories and writes the file.

## Storage format

Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { ClipcaseError } from './errors.js';
import { loadConfig, writeConfig } from './config.js';
import { addEntry, createCase, ensureStore, exportCase, listCases, loadCase, searchCases } from './storage.js';
Expand Down Expand Up @@ -51,6 +52,6 @@ function flagAll(parsed: Parsed, name: string): string[] { return (parsed.flags.
function booleanFlag(parsed: Parsed, name: string): boolean { return flag(parsed, name) === 'true'; }
async function readStdin(): Promise<string> { if (process.stdin.isTTY) return ''; const chunks: Buffer[] = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks).toString('utf8'); }
function readClipboard(): string { const cmd = process.platform === 'darwin' ? 'pbpaste' : process.platform === 'win32' ? 'powershell.exe' : 'xclip'; const args = process.platform === 'win32' ? ['-NoProfile', '-Command', 'Get-Clipboard'] : process.platform === 'linux' ? ['-selection', 'clipboard', '-o'] : []; const result = spawnSync(cmd, args, { encoding: 'utf8' }); if (result.status !== 0) throw new ClipcaseError('Clipboard read failed; pipe text on stdin instead.', 4); return result.stdout; }
export async function run(argv = process.argv.slice(2)): Promise<void> { const parsed = parse(argv); if (!parsed.command || booleanFlag(parsed, 'help') || parsed.command === 'help') { console.log(usage()); return; } if (parsed.command === 'init') { const target = await writeConfig(flag(parsed, 'storage') ?? '.clipcase'); const config = await loadConfig(); await ensureStore(config.storageDir); console.log(`Initialized ${target}`); return; } const config = await loadConfig(); await ensureStore(config.storageDir); switch (parsed.command) { case 'new': { const name = parsed.positionals[0]; const meta = await createCase(config.storageDir, name, flag(parsed, 'title')); console.log(`Created case ${meta.name}`); break; } case 'add': { const name = parsed.positionals[0]; const text = booleanFlag(parsed, 'clipboard') ? readClipboard() : await readStdin(); if (!text.trim()) throw new ClipcaseError('No input text supplied on stdin or clipboard.'); const entry = await addEntry(config.storageDir, { caseName: name, text, source: flag(parsed, 'source') ?? 'stdin', tags: flagAll(parsed, 'tag'), allowSecret: booleanFlag(parsed, 'allow-secret') }); console.log(`Added ${entry.id} to ${entry.caseName}`); break; } case 'list': { const cases = await listCases(config.storageDir); if (booleanFlag(parsed, 'json')) console.log(JSON.stringify(cases, null, 2)); else for (const meta of cases) console.log(`${meta.name}\t${meta.entries.length}\t${meta.updatedAt}\t${meta.title}`); break; } case 'show': { const name = parsed.positionals[0]; console.log(JSON.stringify(await loadCase(config.storageDir, name), null, 2)); break; } case 'search': { const query = parsed.positionals.join(' '); const results = await searchCases(config.storageDir, query); if (booleanFlag(parsed, 'json')) console.log(JSON.stringify(results, null, 2)); else for (const result of results) console.log(`${result.caseName}\t${result.entry.id}\t${result.entry.source}\t${result.preview}`); break; } case 'export': { const name = parsed.positionals[0]; const body = await exportCase(config.storageDir, name); const out = flag(parsed, 'out'); if (out) { await fs.writeFile(out, body); console.log(`Exported ${name} to ${out}`); } else process.stdout.write(body); break; } }
export async function run(argv = process.argv.slice(2)): Promise<void> { const parsed = parse(argv); if (!parsed.command || booleanFlag(parsed, 'help') || parsed.command === 'help') { console.log(usage()); return; } if (parsed.command === 'init') { const target = await writeConfig(flag(parsed, 'storage') ?? '.clipcase'); const config = await loadConfig(); await ensureStore(config.storageDir); console.log(`Initialized ${target}`); return; } const config = await loadConfig(); await ensureStore(config.storageDir); switch (parsed.command) { case 'new': { const name = parsed.positionals[0]; const meta = await createCase(config.storageDir, name, flag(parsed, 'title')); console.log(`Created case ${meta.name}`); break; } case 'add': { const name = parsed.positionals[0]; const text = booleanFlag(parsed, 'clipboard') ? readClipboard() : await readStdin(); if (!text.trim()) throw new ClipcaseError('No input text supplied on stdin or clipboard.'); const entry = await addEntry(config.storageDir, { caseName: name, text, source: flag(parsed, 'source') ?? 'stdin', tags: flagAll(parsed, 'tag'), allowSecret: booleanFlag(parsed, 'allow-secret') }); console.log(`Added ${entry.id} to ${entry.caseName}`); break; } case 'list': { const cases = await listCases(config.storageDir); if (booleanFlag(parsed, 'json')) console.log(JSON.stringify(cases, null, 2)); else for (const meta of cases) console.log(`${meta.name}\t${meta.entries.length}\t${meta.updatedAt}\t${meta.title}`); break; } case 'show': { const name = parsed.positionals[0]; console.log(JSON.stringify(await loadCase(config.storageDir, name), null, 2)); break; } case 'search': { const query = parsed.positionals.join(' '); const results = await searchCases(config.storageDir, query); if (booleanFlag(parsed, 'json')) console.log(JSON.stringify(results, null, 2)); else for (const result of results) console.log(`${result.caseName}\t${result.entry.id}\t${result.entry.source}\t${result.preview}`); break; } case 'export': { const name = parsed.positionals[0]; const body = await exportCase(config.storageDir, name); const out = flag(parsed, 'out'); if (out) { await fs.mkdir(path.dirname(out), { recursive: true }); await fs.writeFile(out, body); console.log(`Exported ${name} to ${out}`); } else process.stdout.write(body); break; } }
}
run().catch((error: unknown) => { if (error instanceof ClipcaseError) { console.error(error.message); process.exit(error.exitCode); } console.error(error); process.exit(1); });
12 changes: 12 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ describe('clipcase CLI', () => {
assert.match(run(['export', 'bug-login'], cwd), /expired cookie causes redirect failure/);
});

it('creates missing parent directories for an export destination', async () => {
const cwd = await tmp();

run(['init'], cwd);
run(['new', 'nested-export'], cwd);
run(['add', 'nested-export'], cwd, 'nested export content\n');

const destination = path.join('nested', 'case.md');
assert.equal(run(['export', 'nested-export', '--out', destination], cwd), `Exported nested-export to ${destination}\n`);
assert.match(await fs.readFile(path.join(cwd, destination), 'utf8'), /nested export content/);
});

it('captures repeated identical input without overwriting an entry', async () => {
const cwd = await tmp();

Expand Down
Loading