biblioplex 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +23 -27
  2. package/src/__tests__/args.test.js +39 -0
  3. package/src/__tests__/csv.test.js +33 -0
  4. package/src/__tests__/mcp.test.js +84 -0
  5. package/src/__tests__/mutate.test.js +91 -0
  6. package/src/__tests__/render.test.js +103 -0
  7. package/src/args.mjs +29 -6
  8. package/src/cli.mjs +38 -25
  9. package/src/commands/add.mjs +30 -51
  10. package/src/commands/deck.mjs +13 -47
  11. package/src/commands/edit.mjs +26 -38
  12. package/src/commands/export.mjs +57 -41
  13. package/src/commands/history.mjs +24 -0
  14. package/src/commands/import.mjs +52 -80
  15. package/src/commands/index.mjs +44 -20
  16. package/src/commands/login.mjs +29 -38
  17. package/src/commands/logout.mjs +13 -10
  18. package/src/commands/ls.mjs +13 -25
  19. package/src/commands/move.mjs +15 -35
  20. package/src/commands/prices.mjs +21 -0
  21. package/src/commands/recover.mjs +53 -0
  22. package/src/commands/resolve.mjs +37 -0
  23. package/src/commands/rm.mjs +15 -32
  24. package/src/commands/search.mjs +25 -35
  25. package/src/commands/summary.mjs +25 -28
  26. package/src/commands/undo.mjs +13 -28
  27. package/src/commands/value.mjs +30 -0
  28. package/src/commands/whoami.mjs +27 -19
  29. package/src/constants.mjs +34 -7
  30. package/src/csv.mjs +71 -0
  31. package/src/errors.mjs +13 -2
  32. package/src/mcp.mjs +227 -0
  33. package/src/mutate.mjs +142 -67
  34. package/src/oauth.mjs +200 -62
  35. package/src/output.mjs +27 -18
  36. package/src/render.mjs +135 -30
  37. package/src/store.mjs +39 -23
  38. package/README.md +0 -92
  39. package/src/api.mjs +0 -110
  40. package/src/commands/container.mjs +0 -83
  41. package/src/commands/deckExport.mjs +0 -48
  42. package/src/commands/show.mjs +0 -33
  43. package/src/commands/tag.mjs +0 -40
  44. package/src/commands/writeHelpers.mjs +0 -82
  45. package/src/scryfall.mjs +0 -81
  46. package/src/snapshot.mjs +0 -77
  47. package/vendor/README.md +0 -20
  48. package/vendor/adapters.js +0 -443
  49. package/vendor/collection.js +0 -665
  50. package/vendor/deckExport.js +0 -219
  51. package/vendor/importMerge.js +0 -22
  52. package/vendor/importParsing.js +0 -119
  53. package/vendor/portableArchive.js +0 -151
  54. package/vendor/searchCore.js +0 -223
  55. package/vendor/state.js +0 -91
  56. package/vendor/storageSchema.js +0 -75
  57. package/vendor/syncOps.js +0 -188
package/package.json CHANGED
@@ -1,42 +1,38 @@
1
1
  {
2
2
  "name": "biblioplex",
3
- "version": "0.1.0",
4
- "description": "Command-line tool to manage your biblioplex Magic: The Gathering collection.",
3
+ "version": "0.2.0",
4
+ "description": "Manage your Biblioplex Magic: The Gathering collection from the terminal.",
5
+ "keywords": [
6
+ "mtg",
7
+ "magic-the-gathering",
8
+ "collection",
9
+ "deck",
10
+ "cli",
11
+ "biblioplex"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Benson Perry",
15
+ "homepage": "https://biblioplex.bensonperry.com",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/benson/biblioplex.git",
19
+ "directory": "apps/cli"
20
+ },
21
+ "bugs": "https://github.com/benson/biblioplex/issues",
5
22
  "type": "module",
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
6
26
  "bin": {
7
27
  "biblioplex": "bin/biblioplex.mjs",
8
28
  "bp": "bin/biblioplex.mjs"
9
29
  },
10
- "engines": {
11
- "node": ">=20"
12
- },
13
30
  "files": [
14
31
  "bin/",
15
32
  "src/",
16
- "vendor/",
17
33
  "README.md"
18
34
  ],
19
35
  "scripts": {
20
- "sync-vendor": "node scripts/sync-vendor.mjs",
21
- "test": "node --test __tests__/*.test.js",
22
- "prepublishOnly": "node scripts/sync-vendor.mjs && node --test __tests__/*.test.js"
23
- },
24
- "keywords": [
25
- "mtg",
26
- "magic-the-gathering",
27
- "collection",
28
- "cli",
29
- "biblioplex",
30
- "scryfall"
31
- ],
32
- "license": "MIT",
33
- "homepage": "https://biblioplex.bensonperry.com",
34
- "bugs": {
35
- "url": "https://github.com/benson/benson.github.io/issues"
36
- },
37
- "repository": {
38
- "type": "git",
39
- "url": "git+https://github.com/benson/benson.github.io.git",
40
- "directory": "mtgcollection/cli"
36
+ "test": "node --test \"src/**/__tests__/*.test.js\""
41
37
  }
42
38
  }
@@ -0,0 +1,39 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseArgs, boolFlag, strFlag, intFlag } from '../args.mjs';
4
+
5
+ test('parseArgs: positionals, booleans, =value, bare value', () => {
6
+ const { positionals, flags } = parseArgs([
7
+ 'add',
8
+ 'sol ring',
9
+ '--yes',
10
+ '--qty',
11
+ '2',
12
+ '--to=deck:breya',
13
+ ]);
14
+ assert.deepEqual(positionals, ['add', 'sol ring']);
15
+ assert.equal(flags.yes, true);
16
+ assert.equal(flags.qty, '2');
17
+ assert.equal(flags.to, 'deck:breya');
18
+ });
19
+
20
+ test('parseArgs: -- ends flag parsing', () => {
21
+ const { positionals } = parseArgs(['search', '--', '--not-a-flag']);
22
+ assert.deepEqual(positionals, ['search', '--not-a-flag']);
23
+ });
24
+
25
+ test('parseArgs: known boolean flags do not swallow the next token', () => {
26
+ const { positionals, flags } = parseArgs(['add', '--dry-run', 'sol ring']);
27
+ assert.equal(flags['dry-run'], true);
28
+ assert.deepEqual(positionals, ['add', 'sol ring']);
29
+ });
30
+
31
+ test('boolFlag / strFlag / intFlag', () => {
32
+ const { flags } = parseArgs(['x', '--json', '--limit', '5', '--name', 'bolt']);
33
+ assert.equal(boolFlag(flags, 'json'), true);
34
+ assert.equal(boolFlag(flags, 'missing'), false);
35
+ assert.equal(strFlag(flags, 'name'), 'bolt');
36
+ assert.equal(strFlag(flags, 'json'), null); // a boolean true is not a string value
37
+ assert.equal(intFlag(flags, 'limit'), 5);
38
+ assert.equal(intFlag(flags, 'missing', 9), 9);
39
+ });
@@ -0,0 +1,33 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { toCsv, fromCsv } from '../csv.mjs';
4
+
5
+ test('toCsv quotes fields with commas/quotes/newlines and joins arrays', () => {
6
+ const csv = toCsv(
7
+ [{ name: 'A, B', note: 'he said "hi"', tags: ['x', 'y'], qty: 2 }],
8
+ ['name', 'note', 'tags', 'qty'],
9
+ );
10
+ assert.equal(csv, 'name,note,tags,qty\n"A, B","he said ""hi""",x;y,2\n');
11
+ });
12
+
13
+ test('fromCsv parses header + quoted fields with embedded commas/quotes', () => {
14
+ const rows = fromCsv('name,note\n"A, B","he said ""hi"""\nplain,ok\n');
15
+ assert.deepEqual(rows, [
16
+ { name: 'A, B', note: 'he said "hi"' },
17
+ { name: 'plain', note: 'ok' },
18
+ ]);
19
+ });
20
+
21
+ test('round-trips a typical import shape', () => {
22
+ const original = [
23
+ { setCode: 'lea', cn: '161', finish: 'nonfoil', qty: '1', location: 'box:red' },
24
+ { setCode: 'cmm', cn: '2', finish: 'foil', qty: '3', location: 'deck:breya' },
25
+ ];
26
+ const parsed = fromCsv(toCsv(original));
27
+ assert.deepEqual(parsed, original);
28
+ });
29
+
30
+ test('fromCsv ignores blank trailing lines', () => {
31
+ assert.equal(fromCsv('a,b\n1,2\n\n').length, 1);
32
+ assert.equal(fromCsv('').length, 0);
33
+ });
@@ -0,0 +1,84 @@
1
+ import { test, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createSession } from '../mcp.mjs';
6
+ import { EXIT } from '../constants.mjs';
7
+
8
+ const realFetch = globalThis.fetch;
9
+ afterEach(() => {
10
+ globalThis.fetch = realFetch;
11
+ delete process.env.BIBLIOPLEX_TOKEN;
12
+ delete process.env.BIBLIOPLEX_CONFIG_DIR;
13
+ });
14
+
15
+ function jsonResponse(obj, status = 200) {
16
+ return new Response(JSON.stringify(obj), {
17
+ status,
18
+ headers: { 'content-type': 'application/json' },
19
+ });
20
+ }
21
+
22
+ test('PAT session: posts to /mcp with Bearer + tools/call, returns structuredContent', async () => {
23
+ const calls = [];
24
+ globalThis.fetch = async (url, opts) => {
25
+ calls.push({ url, opts });
26
+ return jsonResponse({
27
+ jsonrpc: '2.0',
28
+ id: 1,
29
+ result: { structuredContent: { rows: [{ name: 'X' }] } },
30
+ });
31
+ };
32
+ process.env.BIBLIOPLEX_TOKEN = 'pat-xyz';
33
+ const s = await createSession({ origin: 'https://api.test' });
34
+ const sc = await s.call('query_collection', { from: 'inventory' });
35
+ assert.deepEqual(sc, { rows: [{ name: 'X' }] });
36
+ assert.equal(calls[0].url, 'https://api.test/mcp');
37
+ assert.equal(calls[0].opts.headers.Authorization, 'Bearer pat-xyz');
38
+ const body = JSON.parse(calls[0].opts.body);
39
+ assert.equal(body.method, 'tools/call');
40
+ assert.equal(body.params.name, 'query_collection');
41
+ assert.deepEqual(body.params.arguments, { from: 'inventory' });
42
+ });
43
+
44
+ test('PAT session: HTTP 401 -> auth error (exit AUTH), no retry', async () => {
45
+ let n = 0;
46
+ globalThis.fetch = async () => {
47
+ n += 1;
48
+ return jsonResponse({ error: 'unauthorized' }, 401);
49
+ };
50
+ process.env.BIBLIOPLEX_TOKEN = 'bad';
51
+ const s = await createSession({ origin: 'https://api.test' });
52
+ await assert.rejects(s.call('query_collection', {}), (e) => e.code === EXIT.AUTH);
53
+ assert.equal(n, 1);
54
+ });
55
+
56
+ test('JSON-RPC -32003 -> scope error (exit AUTH) mentioning write', async () => {
57
+ globalThis.fetch = async () =>
58
+ jsonResponse({
59
+ jsonrpc: '2.0',
60
+ id: 1,
61
+ error: {
62
+ code: -32003,
63
+ message: 'insufficient scope',
64
+ data: { requiredScope: 'collection.write' },
65
+ },
66
+ });
67
+ process.env.BIBLIOPLEX_TOKEN = 'pat';
68
+ const s = await createSession({ origin: 'https://api.test' });
69
+ await assert.rejects(
70
+ s.call('mutate_inventory', {}),
71
+ (e) => e.code === EXIT.AUTH && /write/.test(e.message),
72
+ );
73
+ });
74
+
75
+ test('no PAT and no stored creds -> auth error before any network', async () => {
76
+ let fetched = false;
77
+ globalThis.fetch = async () => {
78
+ fetched = true;
79
+ return jsonResponse({});
80
+ };
81
+ process.env.BIBLIOPLEX_CONFIG_DIR = join(tmpdir(), 'bp-empty-test-dir-xyz');
82
+ await assert.rejects(createSession({ origin: 'https://api.test' }), (e) => e.code === EXIT.AUTH);
83
+ assert.equal(fetched, false);
84
+ });
@@ -0,0 +1,91 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { runMutation } from '../mutate.mjs';
4
+ import { EXIT } from '../constants.mjs';
5
+ import { CliError } from '../errors.mjs';
6
+
7
+ function fakeOut(json = false) {
8
+ return {
9
+ json,
10
+ emit(d, fn) {
11
+ if (json) this.emitted = d;
12
+ else if (fn) fn();
13
+ return undefined;
14
+ },
15
+ line() {},
16
+ info() {},
17
+ };
18
+ }
19
+
20
+ function recSession(applyBehavior) {
21
+ const calls = [];
22
+ let applyCount = 0;
23
+ return {
24
+ calls,
25
+ call: async (tool, args) => {
26
+ calls.push({ tool, args: { ...args } });
27
+ if (tool === 'mutate_inventory' && args.dryRun) {
28
+ return {
29
+ status: 'preview',
30
+ previews: [{ summary: 'preview' }],
31
+ idempotencyKey: 'idem-1',
32
+ expectedRevision: 7,
33
+ };
34
+ }
35
+ if (tool === 'mutate_inventory') {
36
+ applyCount += 1;
37
+ return applyBehavior ? applyBehavior(applyCount) : { status: 'applied' };
38
+ }
39
+ return {};
40
+ },
41
+ };
42
+ }
43
+
44
+ const ctx = (flags, out) => ({ out, flags });
45
+
46
+ test('preview is dryRun; apply reuses idempotencyKey + expectedRevision and omits dryRun', async () => {
47
+ const s = recSession();
48
+ await runMutation(ctx({ yes: true, json: true }, fakeOut(true)), s, {
49
+ operation: 'add',
50
+ scryfallId: 'x',
51
+ });
52
+ assert.deepEqual(
53
+ s.calls.map((c) => c.tool + (c.args.dryRun ? ':dry' : '')),
54
+ ['mutate_inventory:dry', 'mutate_inventory'],
55
+ );
56
+ assert.equal(s.calls[1].args.idempotencyKey, 'idem-1');
57
+ assert.equal(s.calls[1].args.expectedRevision, 7);
58
+ assert.equal(s.calls[1].args.dryRun, undefined);
59
+ });
60
+
61
+ test('--dry-run stops before apply', async () => {
62
+ const s = recSession();
63
+ await runMutation(ctx({ 'dry-run': true, json: true }, fakeOut(true)), s, {
64
+ operation: 'add',
65
+ scryfallId: 'x',
66
+ });
67
+ assert.equal(s.calls.filter((c) => c.tool === 'mutate_inventory' && !c.args.dryRun).length, 0);
68
+ });
69
+
70
+ test('--json refuses to apply without --yes (exit USAGE), nothing applied', async () => {
71
+ const s = recSession();
72
+ await assert.rejects(
73
+ runMutation(ctx({ json: true }, fakeOut(true)), s, { operation: 'add', scryfallId: 'x' }),
74
+ (e) => e instanceof CliError && e.code === EXIT.USAGE,
75
+ );
76
+ assert.equal(s.calls.filter((c) => c.tool === 'mutate_inventory' && !c.args.dryRun).length, 0);
77
+ });
78
+
79
+ test('409 conflict re-previews then re-applies', async () => {
80
+ const s = recSession((n) => {
81
+ if (n === 1)
82
+ throw new CliError('conflict', EXIT.CONFLICT, { data: { recoveryStrategy: 're_preview' } });
83
+ return { status: 'applied' };
84
+ });
85
+ await runMutation(ctx({ yes: true, json: true }, fakeOut(true)), s, {
86
+ operation: 'add',
87
+ scryfallId: 'x',
88
+ });
89
+ assert.equal(s.calls.filter((c) => c.tool === 'mutate_inventory' && c.args.dryRun).length, 2);
90
+ assert.equal(s.calls.filter((c) => c.tool === 'mutate_inventory' && !c.args.dryRun).length, 2);
91
+ });
@@ -0,0 +1,103 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { pickRows, fmtMoney, emitResult, renderTable, CONTAINER_COLUMNS } from '../render.mjs';
4
+ import { EXIT } from '../constants.mjs';
5
+
6
+ function fakeOut(json = false) {
7
+ return {
8
+ json,
9
+ _lines: [],
10
+ _tables: [],
11
+ emitted: undefined,
12
+ emit(d, fn) {
13
+ if (json) this.emitted = d;
14
+ else if (fn) fn();
15
+ },
16
+ line(s = '') {
17
+ this._lines.push(s);
18
+ },
19
+ info() {},
20
+ table(c, r) {
21
+ this._tables.push({ c, r });
22
+ },
23
+ };
24
+ }
25
+
26
+ test('pickRows finds the first array among known keys', () => {
27
+ assert.deepEqual(pickRows({ rows: [1, 2] }), [1, 2]);
28
+ assert.deepEqual(pickRows({ containers: [{ a: 1 }] }), [{ a: 1 }]);
29
+ assert.deepEqual(pickRows([3, 4]), [3, 4]);
30
+ assert.deepEqual(pickRows({ nope: 1 }), []);
31
+ });
32
+
33
+ test('fmtMoney', () => {
34
+ assert.equal(fmtMoney(12.5), '$12.50');
35
+ assert.equal(fmtMoney(null), '—');
36
+ assert.equal(fmtMoney('3.2'), '$3.20');
37
+ });
38
+
39
+ test('emitResult maps error statuses to exit codes', () => {
40
+ const ctx = { out: fakeOut() };
41
+ assert.throws(
42
+ () => emitResult(ctx, { status: 'parse_error', message: 'bad' }),
43
+ (e) => e.code === EXIT.USAGE,
44
+ );
45
+ assert.throws(
46
+ () => emitResult(ctx, { status: 'not_found' }),
47
+ (e) => e.code === EXIT.NOT_FOUND,
48
+ );
49
+ assert.throws(
50
+ () => emitResult(ctx, { status: 'unsafe' }),
51
+ (e) => e.code === EXIT.CONFLICT,
52
+ );
53
+ });
54
+
55
+ test('emitResult emits raw structuredContent under --json', () => {
56
+ const ctx = { out: fakeOut(true) };
57
+ const sc = { rows: [{ name: 'X' }], hasMore: false };
58
+ emitResult(ctx, sc, { columns: [] });
59
+ assert.deepEqual(ctx.out.emitted, sc);
60
+ });
61
+
62
+ test('renderTable keeps present columns and formats money/location', () => {
63
+ const out = fakeOut();
64
+ renderTable(
65
+ out,
66
+ [{ qty: 1, name: 'Bolt', location: { type: 'box', name: 'red' }, price: 2 }],
67
+ [
68
+ { key: 'qty', align: 'right' },
69
+ { key: 'name' },
70
+ { key: 'location', header: 'loc', loc: true },
71
+ { key: 'price', money: true },
72
+ { key: 'absent' },
73
+ ],
74
+ );
75
+ const { c, r } = out._tables[0];
76
+ assert.deepEqual(
77
+ c.map((x) => x.header),
78
+ ['qty', 'name', 'loc', 'price'],
79
+ ); // 'absent' column dropped (no data)
80
+ assert.deepEqual(r[0], ['1', 'Bolt', 'box:red', '$2.00']);
81
+ });
82
+
83
+ test('container columns read cards/value from stats, not deckListCount', () => {
84
+ const out = fakeOut();
85
+ renderTable(
86
+ out,
87
+ [
88
+ {
89
+ name: 'bulk pile',
90
+ type: 'box',
91
+ stats: { unique: 161, total: 195, value: 242.3 },
92
+ deckListCount: 0,
93
+ },
94
+ ],
95
+ CONTAINER_COLUMNS,
96
+ );
97
+ const { c, r } = out._tables[0];
98
+ assert.deepEqual(
99
+ c.map((x) => x.header),
100
+ ['name', 'type', 'cards', 'value'],
101
+ );
102
+ assert.deepEqual(r[0], ['bulk pile', 'box', '195', '$242.30']);
103
+ });
package/src/args.mjs CHANGED
@@ -4,9 +4,25 @@
4
4
  // or the next token looks like another flag. `--` ends flag parsing.
5
5
 
6
6
  const BOOLEAN_FLAGS = new Set([
7
- 'json', 'yes', 'y', 'help', 'h', 'version', 'v',
8
- 'csv', 'table', 'dry-run', 'no-browser', 'no-color', 'all', 'force', 'verbose',
9
- 'desc', 'no-resolve', 'archive', 'write',
7
+ 'json',
8
+ 'yes',
9
+ 'y',
10
+ 'help',
11
+ 'h',
12
+ 'version',
13
+ 'v',
14
+ 'csv',
15
+ 'table',
16
+ 'dry-run',
17
+ 'no-browser',
18
+ 'no-color',
19
+ 'all',
20
+ 'force',
21
+ 'verbose',
22
+ 'desc',
23
+ 'no-resolve',
24
+ 'archive',
25
+ 'write',
10
26
  ]);
11
27
 
12
28
  const SHORT_ALIASES = { y: 'yes', h: 'help', v: 'version' };
@@ -24,12 +40,19 @@ export function parseArgs(argv) {
24
40
  i += 1;
25
41
  continue;
26
42
  }
27
- if (tok === '--') { noMoreFlags = true; i += 1; continue; }
43
+ if (tok === '--') {
44
+ noMoreFlags = true;
45
+ i += 1;
46
+ continue;
47
+ }
28
48
 
29
49
  let raw = tok.startsWith('--') ? tok.slice(2) : tok.slice(1);
30
50
  let value;
31
51
  const eq = raw.indexOf('=');
32
- if (eq !== -1) { value = raw.slice(eq + 1); raw = raw.slice(0, eq); }
52
+ if (eq !== -1) {
53
+ value = raw.slice(eq + 1);
54
+ raw = raw.slice(0, eq);
55
+ }
33
56
 
34
57
  const name = SHORT_ALIASES[raw] || raw;
35
58
 
@@ -66,7 +89,7 @@ export function strFlag(flags, ...names) {
66
89
  }
67
90
 
68
91
  export function boolFlag(flags, ...names) {
69
- return names.some(n => flags[n] === true || flags[n] === 'true');
92
+ return names.some((n) => flags[n] === true || flags[n] === 'true');
70
93
  }
71
94
 
72
95
  export function intFlag(flags, name, fallback = null) {
package/src/cli.mjs CHANGED
@@ -1,14 +1,17 @@
1
1
  import { parseArgs, boolFlag, strFlag } from './args.mjs';
2
2
  import { createOutput } from './output.mjs';
3
3
  import { CliError } from './errors.mjs';
4
- import { EXIT, VERSION, DEFAULT_API_BASE } from './constants.mjs';
5
- import { loadCredentials, saveCredentials, loadConfig } from './store.mjs';
6
- import { Session } from './api.mjs';
4
+ import { EXIT, VERSION, DEFAULT_API_ORIGIN } from './constants.mjs';
5
+ import { loadConfig } from './store.mjs';
7
6
  import { commands, commandOrder } from './commands/index.mjs';
8
7
 
9
- function resolveApiBase(flags) {
10
- const base = strFlag(flags, 'api') || process.env.BIBLIOPLEX_API_BASE || loadConfig().apiBase || DEFAULT_API_BASE;
11
- return base.replace(/\/+$/, '');
8
+ function resolveApiOrigin(flags) {
9
+ const origin =
10
+ strFlag(flags, 'api') ||
11
+ process.env.BIBLIOPLEX_API_ORIGIN ||
12
+ loadConfig().apiOrigin ||
13
+ DEFAULT_API_ORIGIN;
14
+ return origin.replace(/\/+$/, '');
12
15
  }
13
16
 
14
17
  function printHelp(out) {
@@ -16,19 +19,22 @@ function printHelp(out) {
16
19
  out.line('');
17
20
  out.line('usage: bp <command> [options]');
18
21
  out.line('');
19
- out.line('commands:');
20
- for (const name of commandOrder) {
21
- out.line(' ' + name.padEnd(12) + commands[name].summary);
22
+ if (commandOrder.length) {
23
+ out.line('commands:');
24
+ for (const name of commandOrder) {
25
+ out.line(' ' + name.padEnd(12) + commands[name].summary);
26
+ }
27
+ out.line('');
22
28
  }
23
- out.line('');
24
29
  out.line('global options:');
25
- out.line(' --json machine-readable output ({ok,data}|{ok,error})');
26
- out.line(' --no-color disable colored output');
27
- out.line(' --api <url> override the API base url');
28
- out.line(' --help show help (also: bp <command> --help)');
29
- out.line(' --version print the version');
30
+ out.line(' --json machine-readable output ({ok,data}|{ok,error})');
31
+ out.line(' --no-color disable colored output');
32
+ out.line(' --api <origin> override the API origin (or set BIBLIOPLEX_API_ORIGIN)');
33
+ out.line(' --help show help (also: bp <command> --help)');
34
+ out.line(' --version print the version');
30
35
  out.line('');
31
- out.line('start with: bp login');
36
+ out.line('auth: run `bp login`, or set BIBLIOPLEX_TOKEN to a personal access');
37
+ out.line('token for headless / CI use.');
32
38
  }
33
39
 
34
40
  export async function run(argv) {
@@ -36,13 +42,18 @@ export async function run(argv) {
36
42
  const out = createOutput({ json: boolFlag(flags, 'json'), color: !boolFlag(flags, 'no-color') });
37
43
  const command = positionals[0];
38
44
 
39
- if (!command) {
40
- if (boolFlag(flags, 'version')) { out.line(VERSION); return EXIT.OK; }
45
+ if (!command || command === 'help') {
46
+ if (boolFlag(flags, 'version')) {
47
+ out.line(VERSION);
48
+ return EXIT.OK;
49
+ }
41
50
  printHelp(out);
42
51
  return EXIT.OK;
43
52
  }
44
- if (command === 'help') { printHelp(out); return EXIT.OK; }
45
- if (boolFlag(flags, 'version')) { out.line(VERSION); return EXIT.OK; }
53
+ if (boolFlag(flags, 'version')) {
54
+ out.line(VERSION);
55
+ return EXIT.OK;
56
+ }
46
57
 
47
58
  const cmd = commands[command];
48
59
  if (!cmd) {
@@ -58,11 +69,13 @@ export async function run(argv) {
58
69
  out,
59
70
  flags,
60
71
  args: positionals.slice(1),
61
- apiBase: resolveApiBase(flags),
62
- makeSession() {
63
- const creds = loadCredentials();
64
- if (!creds?.accessToken) throw new CliError('not logged in — run `bp login`', EXIT.AUTH);
65
- return new Session({ base: this.apiBase, credentials: creds, persist: saveCredentials });
72
+ apiOrigin: resolveApiOrigin(flags),
73
+ // Lazily build an MCP session. Uses BIBLIOPLEX_TOKEN (a personal access
74
+ // token) when set, otherwise the stored OAuth credentials. Imported on
75
+ // demand so `help`/`version` work without auth wiring.
76
+ async makeSession({ write = false } = {}) {
77
+ const { createSession } = await import('./mcp.mjs');
78
+ return createSession({ origin: this.apiOrigin, requireWrite: write, out: this.out });
66
79
  },
67
80
  };
68
81
 
@@ -1,57 +1,36 @@
1
- import { resolvePrinting, cardToFields } from '../scryfall.mjs';
2
- import { normalizeCollectionEntry } from '../../vendor/collection.js';
3
- import { mergeIntoCollection } from '../../vendor/importMerge.js';
4
- import { applyMutation } from '../mutate.mjs';
5
- import { strFlag, intFlag, boolFlag } from '../args.mjs';
6
- import { requireWrite, parseLocationFlag, ensureContainer, persistUndo, printWrite } from './writeHelpers.mjs';
7
- import { usageError, CliError } from '../errors.mjs';
1
+ import { intFlag, strFlag } from '../args.mjs';
2
+ import { usageError } from '../errors.mjs';
3
+ import { runMutation, resolveOnePrinting } from '../mutate.mjs';
8
4
 
9
5
  export default {
10
6
  summary: 'add a card to your collection',
11
- help: [
12
- 'usage: bp add <name> [--set xxx --cn 123] [--finish foil] [--condition lightly_played]',
13
- ' [--qty 2] [--location "deck:breya"] [--tags trade,wishlist] [--dry-run]',
14
- ' bp add --scryfall-id <id> [...]',
15
- '',
16
- 'resolves the printing on scryfall (give --set + --cn to pin an exact printing),',
17
- 'then adds it to your cloud collection. coalesces with an identical existing stack.',
18
- ].join('\n'),
19
- async run(ctx) {
20
- const { out, flags, args } = ctx;
21
- const session = ctx.makeSession();
22
- requireWrite(session);
23
-
24
- const scryfallId = strFlag(flags, 'scryfall-id', 'id');
25
- const set = strFlag(flags, 'set', 's');
26
- const cn = strFlag(flags, 'cn');
27
- const name = args.join(' ').trim();
28
- if (!scryfallId && !(set && cn) && !name) throw usageError('usage: bp add <name> [--set --cn] | --scryfall-id <id>');
29
-
30
- const finish = strFlag(flags, 'finish') || 'normal';
31
- const card = await resolvePrinting({ scryfallId, set, cn, name });
32
- if (!card) throw new CliError("couldn't find that printing — try specifying --set and --cn");
7
+ help: `bp add <name> [--set CODE] [--cn NUMBER] [--finish foil|nonfoil|etched] [--qty N] [--to LOCATION] [--tag a,b] [--yes] [--dry-run]
33
8
 
34
- const tags = strFlag(flags, 'tags', 'tag');
35
- const entry = normalizeCollectionEntry({
36
- ...cardToFields(card, finish),
37
- finish,
38
- qty: intFlag(flags, 'qty', 1) || 1,
39
- condition: strFlag(flags, 'condition', 'cond') || 'near_mint',
40
- language: strFlag(flags, 'lang', 'language') || 'en',
41
- location: parseLocationFlag(strFlag(flags, 'location', 'loc', 'to')),
42
- tags: tags ? tags.split(',').map(s => s.trim()).filter(Boolean) : [],
43
- }, { preserveResolvedFields: true });
44
-
45
- const result = await applyMutation(session, (draft) => {
46
- ensureContainer(draft, entry.location);
47
- draft.app.collection = mergeIntoCollection(draft.app.collection, [entry]);
48
- return { added: { name: entry.name, set: entry.setCode.toUpperCase(), cn: entry.cn, finish: entry.finish, qty: entry.qty } };
49
- }, { dryRun: boolFlag(flags, 'dry-run') });
50
-
51
- persistUndo(result);
52
- printWrite(out, result, () => out.info(
53
- out.c.green('✓ added') + ` ${entry.qty}x ${entry.name} (${entry.setCode.toUpperCase()} ${entry.cn}${entry.finish !== 'normal' ? ' ' + entry.finish : ''})`
54
- + (entry.location ? ` → ${entry.location.type}:${entry.location.name}` : '')));
55
- return 0;
9
+ Resolves a printing, then adds it. For an exact printing use --set + --cn.
10
+ Examples:
11
+ bp add "sol ring" --to deck:breya
12
+ bp add --set sld --cn 2144 --finish foil --qty 2`,
13
+ async run(ctx) {
14
+ const session = await ctx.makeSession({ write: true });
15
+ const query = ctx.args.join(' ').trim();
16
+ const setCode = strFlag(ctx.flags, 'set');
17
+ const cn = strFlag(ctx.flags, 'cn');
18
+ const finish = strFlag(ctx.flags, 'finish');
19
+ if (!query && !(setCode && cn)) {
20
+ throw usageError('usage: bp add <name> (or --set CODE --cn NUMBER)');
21
+ }
22
+ const { scryfallId } = await resolveOnePrinting(session, { query, setCode, cn, finish });
23
+ const args = { operation: 'add', scryfallId, qty: intFlag(ctx.flags, 'qty', 1) };
24
+ if (finish) args.finish = finish;
25
+ const to = strFlag(ctx.flags, 'to');
26
+ if (to) args.location = to;
27
+ const tagStr = strFlag(ctx.flags, 'tag', 'tags');
28
+ if (tagStr) {
29
+ args.tags = tagStr
30
+ .split(',')
31
+ .map((s) => s.trim())
32
+ .filter(Boolean);
33
+ }
34
+ return runMutation(ctx, session, args, { verb: 'add' });
56
35
  },
57
36
  };