biblioplex 0.1.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 (47) hide show
  1. package/README.md +92 -0
  2. package/bin/biblioplex.mjs +9 -0
  3. package/package.json +42 -0
  4. package/src/api.mjs +110 -0
  5. package/src/args.mjs +77 -0
  6. package/src/cli.mjs +76 -0
  7. package/src/commands/add.mjs +57 -0
  8. package/src/commands/container.mjs +83 -0
  9. package/src/commands/deck.mjs +52 -0
  10. package/src/commands/deckExport.mjs +48 -0
  11. package/src/commands/edit.mjs +45 -0
  12. package/src/commands/export.mjs +56 -0
  13. package/src/commands/import.mjs +94 -0
  14. package/src/commands/index.mjs +40 -0
  15. package/src/commands/login.mjs +48 -0
  16. package/src/commands/logout.mjs +16 -0
  17. package/src/commands/ls.mjs +31 -0
  18. package/src/commands/move.mjs +41 -0
  19. package/src/commands/rm.mjs +37 -0
  20. package/src/commands/search.mjs +42 -0
  21. package/src/commands/show.mjs +33 -0
  22. package/src/commands/summary.mjs +34 -0
  23. package/src/commands/tag.mjs +40 -0
  24. package/src/commands/undo.mjs +34 -0
  25. package/src/commands/whoami.mjs +26 -0
  26. package/src/commands/writeHelpers.mjs +82 -0
  27. package/src/constants.mjs +25 -0
  28. package/src/errors.mjs +17 -0
  29. package/src/mutate.mjs +76 -0
  30. package/src/oauth.mjs +129 -0
  31. package/src/output.mjs +79 -0
  32. package/src/pkce.mjs +16 -0
  33. package/src/render.mjs +35 -0
  34. package/src/scryfall.mjs +81 -0
  35. package/src/snapshot.mjs +77 -0
  36. package/src/store.mjs +53 -0
  37. package/vendor/README.md +20 -0
  38. package/vendor/adapters.js +443 -0
  39. package/vendor/collection.js +665 -0
  40. package/vendor/deckExport.js +219 -0
  41. package/vendor/importMerge.js +22 -0
  42. package/vendor/importParsing.js +119 -0
  43. package/vendor/portableArchive.js +151 -0
  44. package/vendor/searchCore.js +223 -0
  45. package/vendor/state.js +91 -0
  46. package/vendor/storageSchema.js +75 -0
  47. package/vendor/syncOps.js +188 -0
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # biblioplex cli
2
+
3
+ manage your [biblioplex](https://biblioplex.bensonperry.com) magic: the gathering
4
+ collection from the terminal — search, edit, import, and export, all against your
5
+ live cloud collection.
6
+
7
+ cloud-first: every command reads and writes the same collection the web app uses,
8
+ so the cli and the website stay perfectly in sync.
9
+
10
+ ## install
11
+
12
+ requires **node 20+**.
13
+
14
+ ```sh
15
+ npm install -g biblioplex # then run `biblioplex` or `bp`
16
+ # or, no install:
17
+ npx biblioplex login
18
+ ```
19
+
20
+ > the global command is `biblioplex`. it also installs a short `bp` alias; if some
21
+ > other tool already owns `bp` on your machine, just use `biblioplex` (or add your
22
+ > own alias).
23
+
24
+ ## sign in
25
+
26
+ ```sh
27
+ bp login # read-only (search/export)
28
+ bp login --write # also allow edits/imports
29
+ ```
30
+
31
+ `bp login` opens your browser once to authorize the cli, then stores a refresh
32
+ token locally so you stay signed in for ~30 days. `bp logout` revokes it.
33
+
34
+ on a headless box: `bp login --no-browser` prints a url to open elsewhere.
35
+
36
+ ## examples
37
+
38
+ ```sh
39
+ # search with the same query grammar as the web app
40
+ bp search "t:creature c:rg cmc<=3 -t:legendary"
41
+ bp search rare f:foil --sort price --desc --limit 20
42
+
43
+ # totals and your most valuable cards
44
+ bp summary
45
+
46
+ # containers
47
+ bp ls decks
48
+ bp show "deck:breya"
49
+ bp deck show breya
50
+ bp deck export breya --preset moxfield > breya.txt
51
+
52
+ # edits (need `bp login --write`)
53
+ bp add "Sol Ring" --set c21 --cn 263 --qty 2 --location "box:bulk"
54
+ bp edit "Sol Ring" --condition lightly_played
55
+ bp move "Sol Ring" --to "deck:breya"
56
+ bp tag add trade "Sol Ring"
57
+ bp rm "Sol Ring"
58
+ bp undo # revert the last cli change
59
+
60
+ # bulk import / export (cloud stays the source of truth)
61
+ bp import moxfield-export.csv # auto-detects the format
62
+ bp export "f:foil" --format moxfield > foils.txt
63
+ bp export --archive --output backup.json
64
+ ```
65
+
66
+ ambiguous card names list the matching stacks; narrow with
67
+ `--set/--cn/--finish/--condition/--location`, or apply to all with `--all`.
68
+
69
+ ## scripting / agents
70
+
71
+ every command accepts `--json` and prints a stable envelope to stdout:
72
+
73
+ ```json
74
+ { "ok": true, "data": { ... } }
75
+ { "ok": false, "error": { "message": "..." } }
76
+ ```
77
+
78
+ diagnostics go to stderr, so `--json` stdout is always a single json document.
79
+ exit codes: `0` ok · `1` error · `2` usage · `3` auth (run `bp login`) · `4` rate-limited.
80
+
81
+ ## configuration
82
+
83
+ - credentials: `~/.config/biblioplex/credentials.json` (mode 600)
84
+ - override the api endpoint: `--api <url>` or `BIBLIOPLEX_API_BASE`
85
+ - override the config dir: `BIBLIOPLEX_CONFIG_DIR`
86
+
87
+ ## notes
88
+
89
+ - zero runtime dependencies (node built-ins only).
90
+ - card search/sort, csv adapters, and the collection model are shared verbatim
91
+ with the web app (see `vendor/`), so behavior matches exactly.
92
+ - macOS and linux are supported in this release.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.mjs';
3
+
4
+ run(process.argv.slice(2))
5
+ .then((code) => process.exit(code))
6
+ .catch((err) => {
7
+ process.stderr.write('fatal: ' + (err?.stack || err?.message || String(err)) + '\n');
8
+ process.exit(1);
9
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "biblioplex",
3
+ "version": "0.1.0",
4
+ "description": "Command-line tool to manage your biblioplex Magic: The Gathering collection.",
5
+ "type": "module",
6
+ "bin": {
7
+ "biblioplex": "bin/biblioplex.mjs",
8
+ "bp": "bin/biblioplex.mjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=20"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "src/",
16
+ "vendor/",
17
+ "README.md"
18
+ ],
19
+ "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"
41
+ }
42
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,110 @@
1
+ // Authenticated transport to the biblioplex worker.
2
+ // - reads: GET /sync/bootstrap (full snapshot)
3
+ // - writes: POST /sync/push (granular ops built locally from a diff)
4
+ // - tools: POST /mcp JSON-RPC (search_card_printings, preview_*/apply, undo)
5
+ // Refreshes the access token proactively (near expiry) and reactively (on 401),
6
+ // persisting rotated tokens. The token is sent only in the Authorization header.
7
+ import { refreshTokens } from './oauth.mjs';
8
+ import { CliError, authError, rateLimitError } from './errors.mjs';
9
+ import { CLIENT_LABEL } from './constants.mjs';
10
+
11
+ export class Session {
12
+ constructor({ base, credentials, persist, fetchImpl = fetch }) {
13
+ this.base = base;
14
+ this.creds = credentials;
15
+ this.persist = persist || (() => {});
16
+ this.fetchImpl = fetchImpl;
17
+ this._rpcId = 0;
18
+ }
19
+
20
+ get scopes() { return (this.creds?.scope || '').split(/\s+/).filter(Boolean); }
21
+ hasScope(scope) { return this.scopes.includes(scope); }
22
+
23
+ async _refresh() {
24
+ if (!this.creds?.refreshToken) throw authError('session expired — run `bp login`');
25
+ this._absorb(await refreshTokens({ base: this.base, refreshToken: this.creds.refreshToken, fetchImpl: this.fetchImpl }));
26
+ }
27
+
28
+ _absorb(tokens) {
29
+ this.creds = {
30
+ ...this.creds,
31
+ accessToken: tokens.access_token,
32
+ refreshToken: tokens.refresh_token || this.creds.refreshToken,
33
+ accessExpiresAt: Date.now() + (Number(tokens.expires_in) || 3600) * 1000,
34
+ scope: tokens.scope || this.creds.scope,
35
+ };
36
+ this.persist(this.creds);
37
+ }
38
+
39
+ async _authedFetch(path, opts = {}, retried = false) {
40
+ if (!this.creds?.accessToken) throw authError();
41
+ if (!retried && this.creds.accessExpiresAt && this.creds.accessExpiresAt - Date.now() < 30000) {
42
+ await this._refresh();
43
+ }
44
+ const headers = { ...(opts.headers || {}), Authorization: 'Bearer ' + this.creds.accessToken };
45
+ const res = await this.fetchImpl(this.base + path, { ...opts, headers });
46
+ if (res.status === 401 && !retried) { await this._refresh(); return this._authedFetch(path, opts, true); }
47
+ if (res.status === 401) throw authError('session expired — run `bp login`');
48
+ if (res.status === 403) {
49
+ const body = await res.json().catch(() => ({}));
50
+ const msg = body.error === 'insufficient_scope'
51
+ ? 'this session lacks write access — run `bp login --write`'
52
+ : (body.error || 'forbidden');
53
+ throw new CliError(msg, 1, body);
54
+ }
55
+ if (res.status === 429) throw rateLimitError();
56
+ return res;
57
+ }
58
+
59
+ async bootstrap() {
60
+ const res = await this._authedFetch('/sync/bootstrap');
61
+ if (!res.ok) throw new CliError('could not load collection (' + res.status + ')');
62
+ return res.json();
63
+ }
64
+
65
+ async push({ ops, baseRevision }) {
66
+ const res = await this._authedFetch('/sync/push', {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({ clientId: CLIENT_LABEL, baseRevision, requireBaseRevision: true, ops }),
70
+ });
71
+ if (res.status === 409) {
72
+ const data = await res.json().catch(() => ({}));
73
+ const err = new CliError('the cloud collection changed since this command started', 1, data);
74
+ err.conflict = true;
75
+ throw err;
76
+ }
77
+ if (!res.ok) {
78
+ const data = await res.json().catch(() => ({}));
79
+ throw new CliError('push failed: ' + (data.error || res.status), 1, data);
80
+ }
81
+ return res.json();
82
+ }
83
+
84
+ async mcp(method, params = {}) {
85
+ const id = ++this._rpcId;
86
+ const res = await this._authedFetch('/mcp', {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
89
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
90
+ });
91
+ if (!res.ok) throw new CliError('mcp request failed (' + res.status + ')');
92
+ const data = await res.json().catch(() => ({}));
93
+ if (data.error) {
94
+ if (data.error.code === -32003 || /insufficient_scope/.test(data.error.message || '')) {
95
+ throw new CliError('this session lacks write access — run `bp login --write`', 1, data.error);
96
+ }
97
+ throw new CliError(data.error.message || 'mcp error', 1, data.error);
98
+ }
99
+ return data.result;
100
+ }
101
+
102
+ async callTool(name, args = {}) {
103
+ const result = await this.mcp('tools/call', { name, arguments: args });
104
+ if (result?.isError) {
105
+ const text = (result.content || []).map(c => c.text).filter(Boolean).join(' ');
106
+ throw new CliError(text || ('tool ' + name + ' failed'), 1, result);
107
+ }
108
+ return result?.structuredContent ?? result ?? null;
109
+ }
110
+ }
package/src/args.mjs ADDED
@@ -0,0 +1,77 @@
1
+ // Tiny, dependency-free argv parser.
2
+ // bp <command> [subcommand] [positionals...] [--flag value] [--flag=value] [--bool]
3
+ // A flag consumes the next token as its value unless the flag is a known boolean
4
+ // or the next token looks like another flag. `--` ends flag parsing.
5
+
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',
10
+ ]);
11
+
12
+ const SHORT_ALIASES = { y: 'yes', h: 'help', v: 'version' };
13
+
14
+ export function parseArgs(argv) {
15
+ const positionals = [];
16
+ const flags = {};
17
+ let i = 0;
18
+ let noMoreFlags = false;
19
+
20
+ while (i < argv.length) {
21
+ const tok = argv[i];
22
+ if (noMoreFlags || tok === '-' || !tok.startsWith('-')) {
23
+ positionals.push(tok);
24
+ i += 1;
25
+ continue;
26
+ }
27
+ if (tok === '--') { noMoreFlags = true; i += 1; continue; }
28
+
29
+ let raw = tok.startsWith('--') ? tok.slice(2) : tok.slice(1);
30
+ let value;
31
+ const eq = raw.indexOf('=');
32
+ if (eq !== -1) { value = raw.slice(eq + 1); raw = raw.slice(0, eq); }
33
+
34
+ const name = SHORT_ALIASES[raw] || raw;
35
+
36
+ if (value !== undefined) {
37
+ flags[name] = value;
38
+ i += 1;
39
+ continue;
40
+ }
41
+ if (BOOLEAN_FLAGS.has(raw) || BOOLEAN_FLAGS.has(name)) {
42
+ flags[name] = true;
43
+ i += 1;
44
+ continue;
45
+ }
46
+ const next = argv[i + 1];
47
+ if (next === undefined || (next.startsWith('-') && next !== '-')) {
48
+ flags[name] = true;
49
+ i += 1;
50
+ } else {
51
+ flags[name] = next;
52
+ i += 2;
53
+ }
54
+ }
55
+
56
+ return { positionals, flags };
57
+ }
58
+
59
+ // Coerce a flag that may be a string or boolean into a trimmed string or null.
60
+ export function strFlag(flags, ...names) {
61
+ for (const n of names) {
62
+ const v = flags[n];
63
+ if (typeof v === 'string') return v;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export function boolFlag(flags, ...names) {
69
+ return names.some(n => flags[n] === true || flags[n] === 'true');
70
+ }
71
+
72
+ export function intFlag(flags, name, fallback = null) {
73
+ const v = flags[name];
74
+ if (v == null || v === true) return fallback;
75
+ const n = parseInt(v, 10);
76
+ return Number.isFinite(n) ? n : fallback;
77
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,76 @@
1
+ import { parseArgs, boolFlag, strFlag } from './args.mjs';
2
+ import { createOutput } from './output.mjs';
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';
7
+ import { commands, commandOrder } from './commands/index.mjs';
8
+
9
+ function resolveApiBase(flags) {
10
+ const base = strFlag(flags, 'api') || process.env.BIBLIOPLEX_API_BASE || loadConfig().apiBase || DEFAULT_API_BASE;
11
+ return base.replace(/\/+$/, '');
12
+ }
13
+
14
+ function printHelp(out) {
15
+ out.line('biblioplex — manage your magic: the gathering collection from the terminal');
16
+ out.line('');
17
+ out.line('usage: bp <command> [options]');
18
+ out.line('');
19
+ out.line('commands:');
20
+ for (const name of commandOrder) {
21
+ out.line(' ' + name.padEnd(12) + commands[name].summary);
22
+ }
23
+ out.line('');
24
+ 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('');
31
+ out.line('start with: bp login');
32
+ }
33
+
34
+ export async function run(argv) {
35
+ const { positionals, flags } = parseArgs(argv);
36
+ const out = createOutput({ json: boolFlag(flags, 'json'), color: !boolFlag(flags, 'no-color') });
37
+ const command = positionals[0];
38
+
39
+ if (!command) {
40
+ if (boolFlag(flags, 'version')) { out.line(VERSION); return EXIT.OK; }
41
+ printHelp(out);
42
+ return EXIT.OK;
43
+ }
44
+ if (command === 'help') { printHelp(out); return EXIT.OK; }
45
+ if (boolFlag(flags, 'version')) { out.line(VERSION); return EXIT.OK; }
46
+
47
+ const cmd = commands[command];
48
+ if (!cmd) {
49
+ out.error(new CliError(`unknown command: ${command} (try \`bp help\`)`, EXIT.USAGE));
50
+ return EXIT.USAGE;
51
+ }
52
+ if (boolFlag(flags, 'help')) {
53
+ out.line(cmd.help || cmd.summary);
54
+ return EXIT.OK;
55
+ }
56
+
57
+ const ctx = {
58
+ out,
59
+ flags,
60
+ 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 });
66
+ },
67
+ };
68
+
69
+ try {
70
+ return (await cmd.run(ctx)) ?? EXIT.OK;
71
+ } catch (err) {
72
+ out.error(err);
73
+ if (boolFlag(flags, 'verbose') && err?.stack && !(err instanceof CliError)) out.info(err.stack);
74
+ return err instanceof CliError ? err.code : EXIT.ERROR;
75
+ }
76
+ }
@@ -0,0 +1,57 @@
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';
8
+
9
+ export default {
10
+ 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");
33
+
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;
56
+ },
57
+ };
@@ -0,0 +1,83 @@
1
+ import { locationKey } from '../../vendor/collection.js';
2
+ import { applyMutation } from '../mutate.mjs';
3
+ import { parseContainerRef } from '../snapshot.mjs';
4
+ import { boolFlag } from '../args.mjs';
5
+ import { requireWrite, parseLocationFlag, ensureContainer, persistUndo, printWrite } from './writeHelpers.mjs';
6
+ import { usageError, CliError } from '../errors.mjs';
7
+
8
+ function resolveContainer(draft, ref) {
9
+ const containers = draft.app.containers || {};
10
+ const loc = parseLocationFlag(ref);
11
+ if (loc && containers[loc.type + ':' + loc.name]) return { key: loc.type + ':' + loc.name, container: containers[loc.type + ':' + loc.name] };
12
+ const name = parseContainerRef(ref).name;
13
+ const matches = Object.entries(containers).filter(([, c]) => c.name === name);
14
+ if (!matches.length) throw new CliError(`no container "${ref}"`);
15
+ if (matches.length > 1) throw new CliError(`"${ref}" is ambiguous — use type:name (${matches.map(([k]) => k).join(', ')})`);
16
+ return { key: matches[0][0], container: matches[0][1] };
17
+ }
18
+
19
+ export default {
20
+ summary: 'create / rename / delete a container',
21
+ help: [
22
+ 'usage: bp container create <type:name> (type = deck|container)',
23
+ ' bp container rename <type:name> <new-name>',
24
+ ' bp container delete <type:name> [--force] (--force unfiles its cards)',
25
+ '',
26
+ 'legacy "binder:"/"box:" prefixes are accepted and treated as containers.',
27
+ ].join('\n'),
28
+ async run(ctx) {
29
+ const { out, flags, args } = ctx;
30
+ const session = ctx.makeSession();
31
+ requireWrite(session);
32
+ const [sub, ref, ...rest] = args;
33
+
34
+ if (sub === 'create') {
35
+ const loc = parseLocationFlag(ref);
36
+ if (!loc) throw usageError('usage: bp container create <type:name>');
37
+ const result = await applyMutation(session, (draft) => { ensureContainer(draft, loc); return { created: loc.type + ':' + loc.name }; }, { dryRun: boolFlag(flags, 'dry-run') });
38
+ persistUndo(result);
39
+ printWrite(out, result, () => out.info(out.c.green('✓ created') + ` ${loc.type}:${loc.name}`));
40
+ return 0;
41
+ }
42
+
43
+ if (sub === 'rename') {
44
+ const newName = parseContainerRef(rest.join(' ')).name;
45
+ if (!ref || !newName) throw usageError('usage: bp container rename <type:name> <new-name>');
46
+ const result = await applyMutation(session, (draft) => {
47
+ const { key, container } = resolveContainer(draft, ref);
48
+ const newKey = container.type + ':' + newName;
49
+ if (draft.app.containers[newKey]) throw new CliError(`a ${container.type} named "${newName}" already exists`);
50
+ const renamed = { ...container, name: newName };
51
+ // Keep the deck's display title in sync if it tracked the old name.
52
+ if (renamed.deck && (!renamed.deck.title || renamed.deck.title === container.name)) {
53
+ renamed.deck = { ...renamed.deck, title: newName };
54
+ }
55
+ draft.app.containers[newKey] = renamed;
56
+ delete draft.app.containers[key];
57
+ for (const e of draft.app.collection) if (locationKey(e.location) === key) e.location = { type: container.type, name: newName };
58
+ return { renamed: key, to: newKey };
59
+ }, { dryRun: boolFlag(flags, 'dry-run') });
60
+ persistUndo(result);
61
+ printWrite(out, result, () => out.info(out.c.green('✓ renamed') + ` ${result.meta.renamed} → ${result.meta.to}`));
62
+ return 0;
63
+ }
64
+
65
+ if (sub === 'delete') {
66
+ const result = await applyMutation(session, (draft) => {
67
+ const { key, container } = resolveContainer(draft, ref);
68
+ const cards = draft.app.collection.filter(e => locationKey(e.location) === key);
69
+ if (cards.length && !boolFlag(flags, 'force')) {
70
+ throw new CliError(`${container.type}:${container.name} holds ${cards.length} stack(s) — pass --force to delete and unfile them`);
71
+ }
72
+ for (const e of draft.app.collection) if (locationKey(e.location) === key) { e.location = null; delete e.deckBoard; }
73
+ delete draft.app.containers[key];
74
+ return { deleted: key, unfiled: cards.length };
75
+ }, { dryRun: boolFlag(flags, 'dry-run') });
76
+ persistUndo(result);
77
+ printWrite(out, result, () => out.info(out.c.green('✓ deleted') + ` ${result.meta.deleted}` + (result.meta.unfiled ? ` (unfiled ${result.meta.unfiled})` : '')));
78
+ return 0;
79
+ }
80
+
81
+ throw usageError('usage: bp container <create|rename|delete> <type:name>');
82
+ },
83
+ };
@@ -0,0 +1,52 @@
1
+ import { loadSnapshot } from '../mutate.mjs';
2
+ import { findContainer, containerCards } from '../snapshot.mjs';
3
+ import { cardRow, CARD_COLUMNS } from '../render.mjs';
4
+ import { usageError, CliError } from '../errors.mjs';
5
+ import { runDeckExport } from './deckExport.mjs';
6
+
7
+ const BOARDS = ['main', 'sideboard', 'maybe'];
8
+
9
+ async function deckShow(ctx, name) {
10
+ const { out } = ctx;
11
+ if (!name) throw usageError('usage: bp deck show <name>');
12
+ const session = ctx.makeSession();
13
+ const { snapshot } = await loadSnapshot(session);
14
+ const { matches } = findContainer(snapshot, 'deck:' + name);
15
+ const decks = matches.filter(m => m.type === 'deck');
16
+ if (!decks.length) throw new CliError(`no deck named "${name}"`);
17
+
18
+ const deck = decks[0];
19
+ const cards = containerCards(snapshot, deck);
20
+ const boards = { main: [], sideboard: [], maybe: [] };
21
+ for (const c of cards) (boards[c.deckBoard] || boards.main).push(c);
22
+
23
+ out.emit({ deck: { name: deck.name, meta: deck.deck || null }, boards }, () => {
24
+ out.line(out.c.bold('deck: ' + deck.name));
25
+ const meta = deck.deck || {};
26
+ if (meta.format) out.line(' format: ' + meta.format);
27
+ if (meta.commander?.name) out.line(' commander: ' + meta.commander.name);
28
+ for (const b of BOARDS) {
29
+ const list = boards[b];
30
+ if (!list.length) continue;
31
+ const n = list.reduce((s, c) => s + (parseInt(c.qty, 10) || 0), 0);
32
+ out.line('');
33
+ out.line(out.c.bold(`${b} (${n})`));
34
+ out.table(CARD_COLUMNS, list.map(cardRow));
35
+ }
36
+ });
37
+ return 0;
38
+ }
39
+
40
+ export default {
41
+ summary: 'inspect or export a deck',
42
+ help: [
43
+ 'usage: bp deck show <name>',
44
+ ' bp deck export <name> [--preset plain|moxfield|arena|mtgo|csv|json] [--boards main,sideboard,maybe]',
45
+ ].join('\n'),
46
+ async run(ctx) {
47
+ const [sub, ...rest] = ctx.args;
48
+ if (sub === 'show') return deckShow(ctx, rest.join(' '));
49
+ if (sub === 'export') return runDeckExport(ctx, rest);
50
+ throw usageError('usage: bp deck <show|export> <name>');
51
+ },
52
+ };
@@ -0,0 +1,48 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { loadSnapshot } from '../mutate.mjs';
3
+ import { findContainer, containerCards } from '../snapshot.mjs';
4
+ import { buildDeckExport } from '../../vendor/deckExport.js';
5
+ import { strFlag } from '../args.mjs';
6
+ import { usageError, CliError } from '../errors.mjs';
7
+
8
+ // `bp deck export <name> [--preset ...] [--boards a,b] [--output file]`
9
+ export async function runDeckExport(ctx, args) {
10
+ const { out, flags } = ctx;
11
+ const name = args.join(' ');
12
+ if (!name) throw usageError('usage: bp deck export <name> [--preset plain|moxfield|arena|mtgo|csv|json]');
13
+
14
+ const session = ctx.makeSession();
15
+ const { snapshot } = await loadSnapshot(session);
16
+ const { matches } = findContainer(snapshot, 'deck:' + name);
17
+ const decks = matches.filter(m => m.type === 'deck');
18
+ if (!decks.length) throw new CliError(`no deck named "${name}"`);
19
+
20
+ const deck = decks[0];
21
+ const cards = containerCards(snapshot, deck);
22
+ const preset = strFlag(flags, 'preset') || 'plain';
23
+ const boardsFlag = strFlag(flags, 'boards');
24
+ const boards = boardsFlag ? boardsFlag.split(',').map(s => s.trim()).filter(Boolean) : undefined;
25
+ const dm = deck.deck || {};
26
+ // buildDeckExport expects commander/partner as plain name strings; the app
27
+ // stores them as { name, scryfallId, ... } objects.
28
+ const meta = {
29
+ title: deck.name,
30
+ format: dm.format,
31
+ commander: dm.commander?.name || (typeof dm.commander === 'string' ? dm.commander : undefined),
32
+ partner: dm.partner?.name || (typeof dm.partner === 'string' ? dm.partner : undefined),
33
+ };
34
+
35
+ const result = buildDeckExport(cards, meta, { preset, ...(boards ? { boards } : {}) });
36
+ for (const w of result.warnings || []) out.info(out.c.yellow('! ' + w));
37
+
38
+ const file = strFlag(flags, 'output', 'o');
39
+ if (file) {
40
+ writeFileSync(file, result.body.endsWith('\n') ? result.body : result.body + '\n');
41
+ out.emit({ file, filename: result.filename, preset }, () => out.info(`wrote ${file}`));
42
+ } else if (out.json) {
43
+ out.emit({ preset, filename: result.filename, body: result.body, warnings: result.warnings });
44
+ } else {
45
+ out.raw(result.body);
46
+ }
47
+ return 0;
48
+ }