From 5d3bf48ef8458b9318b8197425c9c0cc809450e0 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 12 Aug 2026 02:58:02 +1000 Subject: [PATCH 1/3] test: cover nested export destinations --- test/cli.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/cli.test.ts b/test/cli.test.ts index dace9ec..a0dc310 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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(); From dc8785b6d0cd1a313927081c1acab563f4d1b412 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 12 Aug 2026 02:58:34 +1000 Subject: [PATCH 2/3] fix: create parent directories for exports --- src/cli.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index b1ce382..040b512 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; @@ -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 { 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 { 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 { 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); }); From ff45319bf55ccf5ece5d4cfdbec4ad3c6686c5e5 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 12 Aug 2026 02:58:48 +1000 Subject: [PATCH 3/3] docs: describe export destination behavior --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 315e9c8..6fbf4ae 100644 --- a/README.md +++ b/README.md @@ -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 ` prints deterministic JSON metadata. - `clipcase search [--json]` searches entry text, tags, and source labels offline. -- `clipcase export [--out ]` produces a single Markdown bundle. +- `clipcase export [--out ]` 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