biblioplex 0.1.1 → 0.2.1

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/README.md +44 -70
  2. package/package.json +17 -27
  3. package/src/__tests__/args.test.js +39 -0
  4. package/src/__tests__/csv.test.js +33 -0
  5. package/src/__tests__/mcp.test.js +84 -0
  6. package/src/__tests__/mutate.test.js +91 -0
  7. package/src/__tests__/render.test.js +103 -0
  8. package/src/args.mjs +29 -6
  9. package/src/cli.mjs +38 -25
  10. package/src/commands/add.mjs +30 -51
  11. package/src/commands/deck.mjs +13 -47
  12. package/src/commands/edit.mjs +26 -38
  13. package/src/commands/export.mjs +57 -41
  14. package/src/commands/history.mjs +24 -0
  15. package/src/commands/import.mjs +52 -80
  16. package/src/commands/index.mjs +44 -20
  17. package/src/commands/login.mjs +29 -38
  18. package/src/commands/logout.mjs +13 -10
  19. package/src/commands/ls.mjs +13 -25
  20. package/src/commands/move.mjs +15 -35
  21. package/src/commands/prices.mjs +21 -0
  22. package/src/commands/recover.mjs +53 -0
  23. package/src/commands/resolve.mjs +37 -0
  24. package/src/commands/rm.mjs +15 -32
  25. package/src/commands/search.mjs +25 -35
  26. package/src/commands/summary.mjs +25 -28
  27. package/src/commands/undo.mjs +13 -28
  28. package/src/commands/value.mjs +30 -0
  29. package/src/commands/whoami.mjs +27 -19
  30. package/src/constants.mjs +34 -7
  31. package/src/csv.mjs +71 -0
  32. package/src/errors.mjs +13 -2
  33. package/src/mcp.mjs +227 -0
  34. package/src/mutate.mjs +142 -67
  35. package/src/oauth.mjs +200 -62
  36. package/src/output.mjs +27 -18
  37. package/src/render.mjs +135 -30
  38. package/src/store.mjs +39 -23
  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
@@ -1,40 +1,64 @@
1
- import login from './login.mjs';
2
- import logout from './logout.mjs';
3
- import whoami from './whoami.mjs';
4
- import search from './search.mjs';
1
+ // Command registry. Each command is { summary, help?, run(ctx) }.
5
2
  import summary from './summary.mjs';
3
+ import search from './search.mjs';
6
4
  import ls from './ls.mjs';
7
- import show from './show.mjs';
8
5
  import deck from './deck.mjs';
6
+ import value from './value.mjs';
7
+ import prices from './prices.mjs';
8
+ import history from './history.mjs';
9
+ import resolve from './resolve.mjs';
10
+ import exportCmd from './export.mjs';
11
+ import importCmd from './import.mjs';
9
12
  import add from './add.mjs';
10
13
  import rm from './rm.mjs';
11
14
  import move from './move.mjs';
12
15
  import edit from './edit.mjs';
13
- import tag from './tag.mjs';
14
- import container from './container.mjs';
15
16
  import undo from './undo.mjs';
16
- import importCmd from './import.mjs';
17
- import exportCmd from './export.mjs';
17
+ import recover from './recover.mjs';
18
+ import login from './login.mjs';
19
+ import logout from './logout.mjs';
20
+ import whoami from './whoami.mjs';
18
21
 
19
- // Registry. commandOrder controls help-listing order.
20
22
  export const commands = {
21
- login,
22
- logout,
23
- whoami,
24
- search,
25
23
  summary,
24
+ search,
26
25
  ls,
27
- show,
28
26
  deck,
27
+ value,
28
+ prices,
29
+ history,
30
+ resolve,
31
+ export: exportCmd,
32
+ import: importCmd,
29
33
  add,
30
34
  rm,
31
35
  move,
32
36
  edit,
33
- tag,
34
- container,
35
37
  undo,
36
- import: importCmd,
37
- export: exportCmd,
38
+ recover,
39
+ login,
40
+ logout,
41
+ whoami,
38
42
  };
39
43
 
40
- export const commandOrder = Object.keys(commands);
44
+ export const commandOrder = [
45
+ 'summary',
46
+ 'search',
47
+ 'ls',
48
+ 'deck',
49
+ 'value',
50
+ 'prices',
51
+ 'history',
52
+ 'resolve',
53
+ 'export',
54
+ 'import',
55
+ 'add',
56
+ 'rm',
57
+ 'move',
58
+ 'edit',
59
+ 'undo',
60
+ 'recover',
61
+ 'login',
62
+ 'logout',
63
+ 'whoami',
64
+ ];
@@ -1,48 +1,39 @@
1
- import { login as oauthLogin } from '../oauth.mjs';
1
+ import { login } from '../oauth.mjs';
2
2
  import { saveCredentials } from '../store.mjs';
3
- import { Session } from '../api.mjs';
4
- import { READ_SCOPE, WRITE_SCOPE } from '../constants.mjs';
3
+ import { READ_SCOPE, WRITE_SCOPE, EXIT } from '../constants.mjs';
5
4
  import { boolFlag } from '../args.mjs';
6
- import { loadSnapshot } from '../mutate.mjs';
7
- import { summarize, collectionOf } from '../snapshot.mjs';
8
5
 
9
6
  export default {
10
- summary: 'sign in to your collection',
11
- help: [
12
- 'usage: bp login [--write] [--no-browser]',
13
- '',
14
- 'opens your browser once to authorize the cli, then stores a refresh token',
15
- 'locally so future commands stay signed in (~30 days).',
16
- '',
17
- ' --write also request permission to make changes (add/edit/import)',
18
- ' --no-browser print the url to open manually instead of launching a browser',
19
- ].join('\n'),
20
- async run(ctx) {
21
- const { out, flags, apiBase } = ctx;
22
- const scope = boolFlag(flags, 'write') ? `${READ_SCOPE} ${WRITE_SCOPE}` : READ_SCOPE;
23
- const tokens = await oauthLogin({ base: apiBase, scope, out, noBrowser: boolFlag(flags, 'no-browser') });
7
+ summary: 'sign in via your browser',
8
+ help: `bp login [--write] [--no-browser]
9
+
10
+ Authenticate via your browser (OAuth 2.0 + PKCE) and store credentials locally.
11
+ Requests read-only access by default; --write also allows modifying your
12
+ collection. --no-browser just prints the URL to open manually (useful over SSH).
24
13
 
25
- const creds = {
14
+ For headless / CI use, skip login and set BIBLIOPLEX_TOKEN to a personal access
15
+ token (created in the Biblioplex web app) instead.`,
16
+ async run(ctx) {
17
+ const scope = boolFlag(ctx.flags, 'write') ? `${READ_SCOPE} ${WRITE_SCOPE}` : READ_SCOPE;
18
+ const { tokens, clientId, tokenEndpoint } = await login({
19
+ origin: ctx.apiOrigin,
20
+ scope,
21
+ out: ctx.out,
22
+ noBrowser: boolFlag(ctx.flags, 'no-browser'),
23
+ });
24
+ const granted = tokens.scope || scope;
25
+ saveCredentials({
26
26
  accessToken: tokens.access_token,
27
27
  refreshToken: tokens.refresh_token,
28
- accessExpiresAt: Date.now() + (Number(tokens.expires_in) || 3600) * 1000,
29
- scope: tokens.scope || scope,
30
- apiBase,
31
- loggedInAt: new Date().toISOString(),
32
- };
33
- saveCredentials(creds);
34
-
35
- let stats = null;
36
- try {
37
- const session = new Session({ base: apiBase, credentials: creds, persist: saveCredentials });
38
- const { snapshot } = await loadSnapshot(session);
39
- stats = summarize(collectionOf(snapshot));
40
- } catch { /* confirmation is best-effort */ }
41
-
42
- out.emit({ loggedIn: true, scope: creds.scope, collection: stats }, () => {
43
- out.info(out.c.green('✓ signed in') + ' — scope: ' + creds.scope);
44
- if (stats) out.info(`cloud collection: ${stats.unique} unique · ${stats.total} cards · $${stats.value}`);
28
+ expiresAt: Date.now() + (tokens.expires_in ? tokens.expires_in * 1000 : 3_600_000),
29
+ scope: granted,
30
+ clientId,
31
+ tokenEndpoint,
32
+ origin: ctx.apiOrigin,
45
33
  });
46
- return 0;
34
+ ctx.out.emit({ status: 'signed_in', scope: granted, origin: ctx.apiOrigin }, () =>
35
+ ctx.out.line(`signed in (${granted})`),
36
+ );
37
+ return EXIT.OK;
47
38
  },
48
39
  };
@@ -1,16 +1,19 @@
1
- import { loadCredentials, clearCredentials } from '../store.mjs';
2
- import { revokeToken } from '../oauth.mjs';
1
+ import { clearCredentials } from '../store.mjs';
2
+ import { EXIT } from '../constants.mjs';
3
3
 
4
4
  export default {
5
- summary: 'sign out and revoke the session',
6
- help: 'usage: bp logout\n\nrevokes the stored tokens server-side and deletes the local credentials file.',
5
+ summary: 'sign out (clears local credentials)',
6
+ help: `bp logout
7
+
8
+ Remove locally stored credentials. Biblioplex has no server-side token
9
+ revocation, so existing tokens remain valid until they expire (access ~1h,
10
+ refresh ~30d). To kill a credential sooner, revoke the relevant personal access
11
+ token in the Biblioplex web app.`,
7
12
  async run(ctx) {
8
- const { out, apiBase } = ctx;
9
- const creds = loadCredentials();
10
- if (creds?.refreshToken) await revokeToken({ base: apiBase, token: creds.refreshToken });
11
- if (creds?.accessToken) await revokeToken({ base: apiBase, token: creds.accessToken });
12
13
  clearCredentials();
13
- out.emit({ loggedOut: true }, () => out.info(out.c.green('✓ signed out')));
14
- return 0;
14
+ ctx.out.emit({ status: 'signed_out' }, () =>
15
+ ctx.out.line('signed out (local credentials cleared)'),
16
+ );
17
+ return EXIT.OK;
15
18
  },
16
19
  };
@@ -1,31 +1,19 @@
1
- import { loadSnapshot } from '../mutate.mjs';
2
- import { listContainers } from '../snapshot.mjs';
3
- import { usageError } from '../errors.mjs';
4
-
5
- // binder/box are legacy aliases for the unified container type.
6
- const TYPES = { decks: 'deck', deck: 'deck', containers: 'container', container: 'container', binders: 'container', boxes: 'container', binder: 'container', box: 'container' };
1
+ import { emitResult, INVENTORY_COLUMNS, CONTAINER_COLUMNS } from '../render.mjs';
7
2
 
8
3
  export default {
9
- summary: 'list containers (decks / containers)',
10
- help: 'usage: bp ls [decks|containers]\n\nlists your decks and storage containers with card counts and value. with no argument, lists all.',
4
+ summary: 'list containers and their cards',
5
+ help: `bp ls [container]
6
+
7
+ With no argument, lists your containers (decks, boxes, binders). With a
8
+ container name, lists the cards stored in it.`,
11
9
  async run(ctx) {
12
- const { out, args } = ctx;
13
- let type = null;
14
- if (args[0]) {
15
- type = TYPES[args[0].toLowerCase()];
16
- if (!type) throw usageError(`unknown container type: ${args[0]} (use decks or containers)`);
10
+ const session = await ctx.makeSession();
11
+ const name = ctx.args.join(' ').trim();
12
+ if (!name) {
13
+ const sc = await session.call('query_collection', { from: 'containers' });
14
+ return emitResult(ctx, sc, { columns: CONTAINER_COLUMNS });
17
15
  }
18
- const session = ctx.makeSession();
19
- const { snapshot } = await loadSnapshot(session);
20
- const rows = listContainers(snapshot, type);
21
-
22
- out.emit({ containers: rows }, () => {
23
- if (!rows.length) { out.info('no containers.'); return; }
24
- out.table(
25
- [{ header: 'type' }, { header: 'name' }, { header: 'cards', align: 'right' }, { header: 'value', align: 'right' }],
26
- rows.map(r => [r.type, r.name, String(r.total), '$' + r.value.toFixed(2)]),
27
- );
28
- });
29
- return 0;
16
+ const sc = await session.call('query_collection', { from: 'inventory', location: name });
17
+ return emitResult(ctx, sc, { columns: INVENTORY_COLUMNS });
30
18
  },
31
19
  };
@@ -1,41 +1,21 @@
1
- import { collectionKey } from '../../vendor/collection.js';
2
- import { applyMutation } from '../mutate.mjs';
3
- import { strFlag, boolFlag } from '../args.mjs';
4
- import { requireWrite, selectorFrom, requireStacks, parseLocationFlag, ensureContainer, persistUndo, printWrite } from './writeHelpers.mjs';
1
+ import { intFlag, strFlag } from '../args.mjs';
5
2
  import { usageError } from '../errors.mjs';
3
+ import { runMutation } from '../mutate.mjs';
6
4
 
7
5
  export default {
8
- summary: 'move a card to a different container',
9
- help: [
10
- 'usage: bp move <name> --to "deck:breya" [--board sideboard] [--set --cn --finish ...] [--all] [--dry-run]',
11
- '',
12
- 'changes a stack\'s location. --to accepts "deck:x", "container:x", or a bare name',
13
- '(treated as a container). --board sets the deck board (main/sideboard/maybe).',
14
- ].join('\n'),
15
- async run(ctx) {
16
- const { out, flags, args } = ctx;
17
- const session = ctx.makeSession();
18
- requireWrite(session);
19
- const to = parseLocationFlag(strFlag(flags, 'to'));
20
- if (!to) throw usageError('move requires --to <container>');
21
- const board = strFlag(flags, 'board');
22
- const sel = selectorFrom(flags, args);
23
-
24
- const result = await applyMutation(session, (draft) => {
25
- const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
26
- ensureContainer(draft, to);
27
- const keys = new Set(stacks.map(collectionKey));
28
- for (const e of draft.app.collection) {
29
- if (!keys.has(collectionKey(e))) continue;
30
- e.location = { type: to.type, name: to.name };
31
- if (to.type === 'deck') e.deckBoard = board || e.deckBoard || 'main';
32
- else delete e.deckBoard;
33
- }
34
- return { moved: stacks.length, to: to.type + ':' + to.name };
35
- }, { dryRun: boolFlag(flags, 'dry-run') });
6
+ summary: 'move cards between locations',
7
+ help: `bp move <itemKey> --to LOCATION [--qty N] [--yes] [--dry-run]
36
8
 
37
- persistUndo(result);
38
- printWrite(out, result, () => out.info(out.c.green('✓ moved') + ` ${result.meta.moved} stack(s) → ${result.meta.to}`));
39
- return 0;
9
+ Move an inventory row to another location (e.g. deck:breya, box:red).
10
+ Get the itemKey from \`bp search ... --json\`.`,
11
+ async run(ctx) {
12
+ const itemKey = ctx.args[0] || strFlag(ctx.flags, 'item');
13
+ const to = strFlag(ctx.flags, 'to');
14
+ if (!itemKey || !to) throw usageError('usage: bp move <itemKey> --to LOCATION [--qty N]');
15
+ const session = await ctx.makeSession({ write: true });
16
+ const args = { operation: 'move', itemKey, toLocation: to };
17
+ const qty = intFlag(ctx.flags, 'qty');
18
+ if (qty != null) args.qty = qty;
19
+ return runMutation(ctx, session, args, { verb: 'move' });
40
20
  },
41
21
  };
@@ -0,0 +1,21 @@
1
+ import { strFlag } from '../args.mjs';
2
+ import { usageError } from '../errors.mjs';
3
+ import { emitResult } from '../render.mjs';
4
+
5
+ export default {
6
+ summary: 'price snapshots for a printing',
7
+ help: `bp prices <scryfallId> [--finish foil|nonfoil|etched]
8
+
9
+ Per-source price snapshots for one owned printing (by Scryfall id).
10
+ Find ids with: bp search "..." --json`,
11
+ async run(ctx) {
12
+ const scryfallId = ctx.args[0];
13
+ if (!scryfallId) throw usageError('usage: bp prices <scryfallId> [--finish ...]');
14
+ const session = await ctx.makeSession();
15
+ const args = { scryfallId };
16
+ const finish = strFlag(ctx.flags, 'finish');
17
+ if (finish) args.finish = finish;
18
+ const sc = await session.call('card_prices', args);
19
+ return emitResult(ctx, sc);
20
+ },
21
+ };
@@ -0,0 +1,53 @@
1
+ import { EXIT } from '../constants.mjs';
2
+ import { usageError } from '../errors.mjs';
3
+ import { emitResult } from '../render.mjs';
4
+ import { confirm } from '../mutate.mjs';
5
+
6
+ export default {
7
+ summary: 'list / preview / restore recovery points',
8
+ help: `bp recover list
9
+ bp recover preview <id>
10
+ bp recover restore <id> [--yes]
11
+
12
+ Recovery points are server-side snapshots taken automatically before each
13
+ change — your safety net if undo isn't enough.`,
14
+ async run(ctx) {
15
+ const sub = ctx.args[0] || 'list';
16
+
17
+ if (sub === 'list') {
18
+ const session = await ctx.makeSession();
19
+ const sc = await session.call('list_mcp_recovery_points', {});
20
+ return emitResult(ctx, sc, {
21
+ columns: [
22
+ { key: 'id', header: 'id' },
23
+ { key: 'createdAt', header: 'when' },
24
+ { key: 'summary', header: 'summary' },
25
+ ],
26
+ });
27
+ }
28
+
29
+ const id = ctx.args[1];
30
+ if (!id) throw usageError(`usage: bp recover ${sub} <id>`);
31
+
32
+ if (sub === 'preview') {
33
+ const session = await ctx.makeSession({ write: true });
34
+ const sc = await session.call('restore_mcp_recovery_point', { id, dryRun: true });
35
+ return emitResult(ctx, sc);
36
+ }
37
+
38
+ if (sub === 'restore') {
39
+ const session = await ctx.makeSession({ write: true });
40
+ const preview = await session.call('restore_mcp_recovery_point', { id, dryRun: true });
41
+ ctx.out.line('restore preview:');
42
+ ctx.out.line(' • ' + (preview.summary || preview.message || JSON.stringify(preview)));
43
+ if (!(await confirm(ctx, `restore recovery point ${id}?`))) {
44
+ ctx.out.info('aborted.');
45
+ return EXIT.OK;
46
+ }
47
+ const sc = await session.call('restore_mcp_recovery_point', { id });
48
+ return ctx.out.emit(sc, () => ctx.out.line('restored.')) ?? EXIT.OK;
49
+ }
50
+
51
+ throw usageError('usage: bp recover list | preview <id> | restore <id>');
52
+ },
53
+ };
@@ -0,0 +1,37 @@
1
+ import { strFlag } from '../args.mjs';
2
+ import { usageError } from '../errors.mjs';
3
+ import { emitResult } from '../render.mjs';
4
+
5
+ export default {
6
+ summary: 'resolve a Scryfall printing',
7
+ help: `bp resolve <query> [--set CODE] [--cn NUMBER] [--finish ...]
8
+
9
+ Resolve a card to concrete printings (for adding/replacing). Examples:
10
+ bp resolve "lightning bolt"
11
+ bp resolve --set sld --cn 2144`,
12
+ async run(ctx) {
13
+ const session = await ctx.makeSession();
14
+ const args = {};
15
+ const query = ctx.args.join(' ').trim();
16
+ if (query) args.query = query;
17
+ const set = strFlag(ctx.flags, 'set');
18
+ const cn = strFlag(ctx.flags, 'cn');
19
+ const finish = strFlag(ctx.flags, 'finish');
20
+ if (set) args.setCode = set;
21
+ if (cn) args.cn = cn;
22
+ if (finish) args.finish = finish;
23
+ if (!query && !(set && cn)) {
24
+ throw usageError('usage: bp resolve <query> (or --set CODE --cn NUMBER)');
25
+ }
26
+ const sc = await session.call('resolve_printing', args);
27
+ return emitResult(ctx, sc, {
28
+ columns: [
29
+ { key: 'name', header: 'name' },
30
+ { key: 'setCode', header: 'set' },
31
+ { key: 'cn', header: 'cn' },
32
+ { key: 'finish', header: 'finish' },
33
+ { key: 'price', header: 'price', align: 'right', money: true },
34
+ ],
35
+ });
36
+ },
37
+ };
@@ -1,37 +1,20 @@
1
- import { collectionKey } from '../../vendor/collection.js';
2
- import { applyMutation } from '../mutate.mjs';
3
- import { boolFlag, intFlag } from '../args.mjs';
4
- import { requireWrite, selectorFrom, requireStacks, persistUndo, printWrite } from './writeHelpers.mjs';
1
+ import { intFlag, strFlag } from '../args.mjs';
2
+ import { usageError } from '../errors.mjs';
3
+ import { runMutation } from '../mutate.mjs';
5
4
 
6
5
  export default {
7
- summary: 'remove a card (stack) from your collection',
8
- help: [
9
- 'usage: bp rm <name> [--set --cn --finish --condition --location] [--qty n] [--all] [--dry-run]',
10
- '',
11
- 'removes whole matching stacks. --qty n removes only n copies. --all removes every',
12
- 'matching stack when a name is ambiguous.',
13
- ].join('\n'),
14
- async run(ctx) {
15
- const { out, flags, args } = ctx;
16
- const session = ctx.makeSession();
17
- requireWrite(session);
18
- const sel = selectorFrom(flags, args);
19
- const removeQty = intFlag(flags, 'qty', null);
20
-
21
- const result = await applyMutation(session, (draft) => {
22
- const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
23
- const keys = new Set(stacks.map(collectionKey));
24
- if (removeQty != null) {
25
- for (const e of draft.app.collection) if (keys.has(collectionKey(e))) e.qty = Math.max(0, (parseInt(e.qty, 10) || 0) - removeQty);
26
- draft.app.collection = draft.app.collection.filter(e => (parseInt(e.qty, 10) || 0) > 0);
27
- } else {
28
- draft.app.collection = draft.app.collection.filter(e => !keys.has(collectionKey(e)));
29
- }
30
- return { removed: stacks.length };
31
- }, { dryRun: boolFlag(flags, 'dry-run') });
6
+ summary: 'remove cards from your collection',
7
+ help: `bp rm <itemKey> [--qty N] [--yes] [--dry-run]
32
8
 
33
- persistUndo(result);
34
- printWrite(out, result, () => out.info(out.c.green('✓ removed') + ` ${result.meta.removed} stack(s)`));
35
- return 0;
9
+ Delete an inventory row. Get its itemKey from \`bp search ... --json\`.
10
+ Without --qty, removes the whole row.`,
11
+ async run(ctx) {
12
+ const itemKey = ctx.args[0] || strFlag(ctx.flags, 'item');
13
+ if (!itemKey) throw usageError('usage: bp rm <itemKey> [--qty N]');
14
+ const session = await ctx.makeSession({ write: true });
15
+ const args = { operation: 'delete', itemKey };
16
+ const qty = intFlag(ctx.flags, 'qty');
17
+ if (qty != null) args.qty = qty;
18
+ return runMutation(ctx, session, args, { verb: 'remove' });
36
19
  },
37
20
  };
@@ -1,42 +1,32 @@
1
- import { loadSnapshot } from '../mutate.mjs';
2
- import { runQuery, collectionOf } from '../snapshot.mjs';
3
- import { cardRow, CARD_COLUMNS, cardsToCsv } from '../render.mjs';
4
- import { strFlag, intFlag, boolFlag } from '../args.mjs';
1
+ import { intFlag, strFlag, boolFlag } from '../args.mjs';
2
+ import { emitResult, INVENTORY_COLUMNS } from '../render.mjs';
5
3
 
6
4
  export default {
7
- summary: 'search your collection (app query syntax)',
8
- help: [
9
- 'usage: bp search <query> [--sort field] [--desc] [--limit n] [--csv|--json]',
10
- '',
11
- 'uses the same query grammar as the web app, e.g.:',
12
- ' bp search "t:creature c:rg cmc<=3 -t:legendary"',
13
- ' bp search rare f:foil --sort price --desc',
14
- '',
15
- 'fields: t/type c/color ci/identity cmc/mv o/oracle r/rarity s/set f/finish',
16
- ' loc/location tag cond/condition lang qty (prefix with - to negate)',
17
- 'sort: name set cn finish rarity condition location qty price cmc',
18
- ].join('\n'),
19
- async run(ctx) {
20
- const { out, flags, args } = ctx;
21
- const session = ctx.makeSession();
22
- const { snapshot } = await loadSnapshot(session);
23
- const query = args.join(' ');
24
- const sort = strFlag(flags, 'sort') || 'name';
25
- const dir = boolFlag(flags, 'desc') ? 'desc' : (strFlag(flags, 'dir') || 'asc');
5
+ summary: 'search your inventory',
6
+ help: `bp search [query] [--limit N] [--sort FIELD] [--desc] [--cursor C]
26
7
 
27
- let list = runQuery(collectionOf(snapshot), query, { sort, dir });
28
- const limit = intFlag(flags, 'limit', null);
29
- if (limit != null) list = list.slice(0, limit);
8
+ Query physical inventory with Biblioplex's SQL-ish expression. Examples:
9
+ bp search "cmc=1 colors=blue"
10
+ bp search "finish=foil order by price desc" --limit 10
11
+ bp search 'select name,price from inventory where oracleText like "draw a card"'
30
12
 
31
- if (boolFlag(flags, 'csv')) {
32
- out.raw(cardsToCsv(list, strFlag(flags, 'format') || 'canonical'));
33
- return 0;
13
+ Fields: name,setCode,finish,condition,qty,location,tags,typeLine,setName,cmc,
14
+ colors,colorIdentity,oracleText,price,totalValue.`,
15
+ async run(ctx) {
16
+ const session = await ctx.makeSession();
17
+ const args = { from: 'inventory' };
18
+ const query = ctx.args.join(' ').trim();
19
+ if (query) args.query = query;
20
+ const limit = intFlag(ctx.flags, 'limit');
21
+ if (limit != null) args.limit = limit;
22
+ const sort = strFlag(ctx.flags, 'sort');
23
+ if (sort) {
24
+ args.sortBy = sort;
25
+ args.sortDirection = boolFlag(ctx.flags, 'desc') ? 'desc' : 'asc';
34
26
  }
35
- out.emit({ count: list.length, cards: list }, () => {
36
- if (!list.length) { out.info('no cards match.'); return; }
37
- out.table(CARD_COLUMNS, list.map(cardRow));
38
- out.info(out.c.dim(`${list.length} stack${list.length === 1 ? '' : 's'}`));
39
- });
40
- return 0;
27
+ const cursor = strFlag(ctx.flags, 'cursor');
28
+ if (cursor) args.cursor = cursor;
29
+ const sc = await session.call('query_collection', args);
30
+ return emitResult(ctx, sc, { columns: INVENTORY_COLUMNS });
41
31
  },
42
32
  };
@@ -1,34 +1,31 @@
1
- import { loadSnapshot } from '../mutate.mjs';
2
- import { collectionOf, containersOf, summarize } from '../snapshot.mjs';
1
+ import { emitResult, renderTable, fmtMoney, CONTAINER_COLUMNS } from '../render.mjs';
3
2
 
4
3
  export default {
5
- summary: 'collection totals and top-value cards',
6
- help: 'usage: bp summary [--json]\n\nshows unique stacks, total cards, estimated value, container count, and your most valuable cards.',
7
- async run(ctx) {
8
- const { out } = ctx;
9
- const session = ctx.makeSession();
10
- const { snapshot } = await loadSnapshot(session);
11
- const collection = collectionOf(snapshot);
12
- const stats = summarize(collection);
13
- const containers = Object.values(containersOf(snapshot));
14
- const top = collection
15
- .filter(c => typeof c.price === 'number')
16
- .map(c => ({ name: c.resolvedName || c.name, set: (c.setCode || '').toUpperCase(), qty: c.qty, price: c.price, value: Math.round(c.price * (c.qty || 1) * 100) / 100 }))
17
- .sort((a, b) => b.value - a.value)
18
- .slice(0, 10);
4
+ summary: 'collection overview (counts, value, containers)',
5
+ help: `bp summary
19
6
 
20
- out.emit({ ...stats, containers: containers.length, topValue: top }, () => {
21
- out.line(out.c.bold('collection summary'));
22
- out.line(` unique stacks : ${stats.unique}`);
23
- out.line(` total cards : ${stats.total}`);
24
- out.line(` est. value : $${stats.value.toFixed(2)}`);
25
- out.line(` containers : ${containers.length}`);
26
- if (top.length) {
27
- out.line('');
28
- out.line(out.c.bold('top value'));
29
- for (const c of top) out.line(` $${c.value.toFixed(2).padStart(8)} ${c.name} (${c.set})${c.qty > 1 ? ' x' + c.qty : ''}`);
30
- }
7
+ One-shot overview: totals, recent changes, and your containers. Use --json for
8
+ the full structured payload.`,
9
+ async run(ctx) {
10
+ const session = await ctx.makeSession();
11
+ const sc = await session.call('get_collection_overview', {});
12
+ return emitResult(ctx, sc, {
13
+ render: (out) => {
14
+ const s = sc.summary || sc.overview || sc;
15
+ const get = (...keys) => {
16
+ for (const k of keys) if (s?.[k] != null) return s[k];
17
+ return undefined;
18
+ };
19
+ const unique = get('uniqueCount', 'unique', 'uniqueCards');
20
+ const total = get('totalCount', 'total', 'totalCards', 'count');
21
+ const value = get('totalValue', 'value');
22
+ out.line(`unique ${unique ?? '?'} · total ${total ?? '?'} · value ${fmtMoney(value)}`);
23
+ if (Array.isArray(sc.containers) && sc.containers.length) {
24
+ out.line('');
25
+ out.line('containers:');
26
+ renderTable(out, sc.containers, CONTAINER_COLUMNS);
27
+ }
28
+ },
31
29
  });
32
- return 0;
33
30
  },
34
31
  };
@@ -1,34 +1,19 @@
1
- import { collectionKey } from '../../vendor/collection.js';
2
- import { applyMutation } from '../mutate.mjs';
3
- import { loadUndo, clearUndo } from '../store.mjs';
4
- import { requireWrite, printWrite } from './writeHelpers.mjs';
1
+ import { EXIT } from '../constants.mjs';
5
2
  import { CliError } from '../errors.mjs';
6
3
 
7
4
  export default {
8
- summary: 'undo the last change made by this cli',
9
- help: 'usage: bp undo\n\nrestores exactly the cards and containers your last cli write touched.',
10
- async run(ctx) {
11
- const { out } = ctx;
12
- const session = ctx.makeSession();
13
- requireWrite(session);
14
- const record = loadUndo();
15
- if (!record) throw new CliError('nothing to undo');
16
-
17
- const result = await applyMutation(session, (draft) => {
18
- for (const [key, before] of Object.entries(record.collection || {})) {
19
- draft.app.collection = draft.app.collection.filter(e => collectionKey(e) !== key);
20
- if (before) draft.app.collection.push(before);
21
- }
22
- if (Object.keys(record.containers || {}).length && !draft.app.containers) draft.app.containers = {};
23
- for (const [key, before] of Object.entries(record.containers || {})) {
24
- if (before) draft.app.containers[key] = before;
25
- else delete draft.app.containers[key];
26
- }
27
- return { undone: true };
28
- });
5
+ summary: 'undo your last collection change',
6
+ help: `bp undo
29
7
 
30
- clearUndo();
31
- printWrite(out, result, () => out.info(out.c.green('✓ undone')));
32
- return 0;
8
+ Undo the most recent change you made via the CLI/MCP. Only works if it is still
9
+ the newest change; otherwise use \`bp recover\`.`,
10
+ async run(ctx) {
11
+ const session = await ctx.makeSession({ write: true });
12
+ const sc = await session.call('undo_last_mcp_change', {});
13
+ if (sc?.status === 'not_found') throw new CliError('nothing to undo', EXIT.NOT_FOUND, sc);
14
+ if (sc?.status === 'unsafe') {
15
+ throw new CliError('not the newest change — use `bp recover` instead', EXIT.CONFLICT, sc);
16
+ }
17
+ return ctx.out.emit(sc, () => ctx.out.line('undone.')) ?? EXIT.OK;
33
18
  },
34
19
  };