diff --git a/bin/shotkit.js b/bin/shotkit.js index ffc116a..a159f61 100644 --- a/bin/shotkit.js +++ b/bin/shotkit.js @@ -26,6 +26,12 @@ async function main() { process.stdout.write(USAGE); return; } + if (opts.errors.length) { + const msg = opts.errors.join('; '); + if (opts.json) process.stderr.write(JSON.stringify({ ok: false, error: msg, code: 2 }) + '\n'); + else console.error(`[shotkit] ${msg}\n\n${USAGE}`); + process.exit(2); + } const cwd = path.resolve(process.cwd(), opts.path || '.'); const configPath = resolveConfigPath(opts.config, cwd); if (!configPath || !fs.existsSync(configPath)) { diff --git a/src/cli.js b/src/cli.js index a4d1a0c..4926ecb 100644 --- a/src/cli.js +++ b/src/cli.js @@ -41,6 +41,7 @@ Exit codes: 0 ok · 1 runtime failure · 2 usage / no config found function parseArgs(argv) { const opts = { scenes: [], + errors: [], noVideo: false, noBuild: false, liveGt: false, @@ -53,8 +54,26 @@ function parseArgs(argv) { }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === '--scene') opts.scenes.push(...(argv[++i] || '').split(',').filter(Boolean)); - else if (a === '--config') opts.config = argv[++i]; + if (a === '--scene') { + const value = argv[++i]; + if (!value || value.startsWith('-')) { + opts.errors.push('--scene requires a scene name'); + if (value && value.startsWith('-')) i--; + } else { + const scenes = value.split(',').filter(Boolean); + if (scenes.length) opts.scenes.push(...scenes); + else opts.errors.push('--scene requires a scene name'); + } + } + else if (a === '--config') { + const value = argv[++i]; + if (!value || value.startsWith('-')) { + opts.errors.push('--config requires a config path'); + if (value && value.startsWith('-')) i--; + } else { + opts.config = value; + } + } else if (a === '--json') opts.json = true; else if (a === '--mp4') opts.mp4 = true; else if (a === '--no-video') opts.noVideo = true; @@ -63,6 +82,8 @@ function parseArgs(argv) { else if (a === '--freeze') opts.freeze = true; else if (a === '-h' || a === '--help') opts.help = true; else if (!a.startsWith('-') && opts.path === null) opts.path = a; + else if (!a.startsWith('-')) opts.errors.push(`unexpected positional argument: ${a}`); + else opts.errors.push(`unknown option: ${a}`); } return opts; } diff --git a/test/cli.test.js b/test/cli.test.js index f05deab..856e520 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -5,7 +5,7 @@ const { parseArgs, resolveConfigPath, USAGE } = require('../src/cli'); describe('parseArgs', () => { test('defaults', () => { - expect(parseArgs([])).toMatchObject({ scenes: [], json: false, noVideo: false, help: false, path: null }); + expect(parseArgs([])).toMatchObject({ scenes: [], errors: [], json: false, noVideo: false, help: false, path: null }); }); test('positional path + flags', () => { @@ -19,14 +19,22 @@ describe('parseArgs', () => { expect(parseArgs(['--scene', 'a,b', '--scene', 'c']).scenes).toEqual(['a', 'b', 'c']); }); + test('usage errors are explicit for missing values and unknown options', () => { + expect(parseArgs(['--scene']).errors).toEqual(['--scene requires a scene name']); + expect(parseArgs(['--config', '--json']).errors).toEqual(['--config requires a config path']); + expect(parseArgs(['--wat']).errors).toEqual(['unknown option: --wat']); + }); + test('--config consumes its value (not mistaken for the positional)', () => { const o = parseArgs(['--config', 'x.js', 'repo']); expect(o.config).toBe('x.js'); expect(o.path).toBe('repo'); }); - test('only the first non-flag token becomes the path', () => { - expect(parseArgs(['a', 'b']).path).toBe('a'); + test('only one positional path is accepted', () => { + const opts = parseArgs(['a', 'b']); + expect(opts.path).toBe('a'); + expect(opts.errors).toEqual(['unexpected positional argument: b']); }); test('USAGE documents the agent contract', () => { diff --git a/test/package-boundary.test.js b/test/package-boundary.test.js new file mode 100644 index 0000000..6cbbc9c --- /dev/null +++ b/test/package-boundary.test.js @@ -0,0 +1,20 @@ +const pkg = require('../package.json'); + +describe('npm package boundary', () => { + test('ships the public capture and handoff surface only', () => { + expect(pkg.files).toEqual([ + 'src', + 'bin', + 'skills/capture', + 'docs/handoff-conventions.md', + 'schemas', + ]); + }); + + test('keeps repo-internal research and application planning out of the tarball', () => { + expect(pkg.files).not.toContain('skills/research-to-product-fit'); + expect(pkg.files).not.toContain('research-runs'); + expect(pkg.files).not.toContain('examples'); + expect(pkg.files).not.toContain('docs'); + }); +});