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
@@ -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
  };
@@ -0,0 +1,30 @@
1
+ import { strFlag } from '../args.mjs';
2
+ import { emitResult, fmtMoney } from '../render.mjs';
3
+
4
+ export default {
5
+ summary: 'collection value totals',
6
+ help: `bp value [--source usd|eur|tix]
7
+
8
+ Priced/unpriced counts and total value for a price source (default usd).`,
9
+ async run(ctx) {
10
+ const session = await ctx.makeSession();
11
+ const source = strFlag(ctx.flags, 'source') || 'usd';
12
+ const sc = await session.call('collection_value', { source });
13
+ return emitResult(ctx, sc, {
14
+ render: (out) => {
15
+ const get = (...k) => {
16
+ for (const key of k) if (sc?.[key] != null) return sc[key];
17
+ return undefined;
18
+ };
19
+ out.line(`source: ${get('source') ?? source}`);
20
+ const total = get('totalValue', 'total', 'value');
21
+ if (total != null) out.line(`total value: ${fmtMoney(total)}`);
22
+ const priced = get('pricedCount', 'priced');
23
+ const unpriced = get('unpricedCount', 'unpriced');
24
+ if (priced != null || unpriced != null) {
25
+ out.line(`priced: ${priced ?? '?'} unpriced: ${unpriced ?? '?'}`);
26
+ }
27
+ },
28
+ });
29
+ },
30
+ };
@@ -1,26 +1,34 @@
1
1
  import { loadCredentials } from '../store.mjs';
2
- import { authError } from '../errors.mjs';
2
+ import { READ_SCOPE, EXIT } from '../constants.mjs';
3
3
 
4
4
  export default {
5
- summary: 'show the current session',
6
- help: 'usage: bp whoami\n\nshows the api endpoint, granted scopes, and when you signed in.',
5
+ summary: 'show local auth status',
6
+ help: `bp whoami
7
+
8
+ Show your current authentication state. Does not contact the server.`,
7
9
  async run(ctx) {
8
- const { out, apiBase } = ctx;
10
+ const pat = (process.env.BIBLIOPLEX_TOKEN || '').trim();
11
+ if (pat) {
12
+ ctx.out.emit({ authenticated: true, method: 'token', origin: ctx.apiOrigin }, () =>
13
+ ctx.out.line('authenticated via BIBLIOPLEX_TOKEN (personal access token)'),
14
+ );
15
+ return EXIT.OK;
16
+ }
9
17
  const creds = loadCredentials();
10
- if (!creds?.accessToken) throw authError();
11
- const data = {
12
- apiBase,
13
- scope: creds.scope,
14
- canWrite: (creds.scope || '').split(/\s+/).includes('collection.write'),
15
- loggedInAt: creds.loggedInAt || null,
16
- accessExpiresAt: creds.accessExpiresAt ? new Date(creds.accessExpiresAt).toISOString() : null,
17
- };
18
- out.emit(data, () => {
19
- out.line('api: ' + apiBase);
20
- out.line('scope: ' + creds.scope);
21
- out.line('access: ' + (data.canWrite ? 'read + write' : 'read only'));
22
- if (creds.loggedInAt) out.line('since: ' + creds.loggedInAt);
23
- });
24
- return 0;
18
+ if (!creds?.accessToken && !creds?.refreshToken) {
19
+ ctx.out.emit({ authenticated: false }, () => ctx.out.line('not logged in — run `bp login`'));
20
+ return EXIT.OK;
21
+ }
22
+ ctx.out.emit(
23
+ {
24
+ authenticated: true,
25
+ method: 'oauth',
26
+ scope: creds.scope || READ_SCOPE,
27
+ origin: creds.origin || ctx.apiOrigin,
28
+ },
29
+ () =>
30
+ ctx.out.line(`logged in · ${creds.scope || READ_SCOPE} · ${creds.origin || ctx.apiOrigin}`),
31
+ );
32
+ return EXIT.OK;
25
33
  },
26
34
  };
package/src/constants.mjs CHANGED
@@ -3,17 +3,39 @@ import { join, dirname } from 'node:path';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
- const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
6
+ const pkg = JSON.parse(
7
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
8
+ );
7
9
 
8
10
  export const VERSION = pkg.version;
9
- export const DEFAULT_API_BASE = 'https://api.bensonperry.com';
10
- export const CLI_CLIENT_ID = 'biblioplex-cli';
11
+
12
+ // The Biblioplex API origin (MCP + OAuth live here). Override for local
13
+ // `wrangler dev` via BIBLIOPLEX_API_ORIGIN (e.g. http://localhost:8765).
14
+ export const DEFAULT_API_ORIGIN = 'https://biblioplex-api.bensonperry.com';
15
+
16
+ // Sent as client_name during dynamic OAuth client registration.
11
17
  export const CLIENT_LABEL = 'biblioplex-cli';
18
+
12
19
  export const READ_SCOPE = 'collection.read';
13
20
  export const WRITE_SCOPE = 'collection.write';
14
21
 
15
- // Process exit codes. 3 = auth so scripts can detect "needs `bp login`".
16
- export const EXIT = { OK: 0, ERROR: 1, USAGE: 2, AUTH: 3, RATE_LIMIT: 4 };
22
+ // Process exit codes the CLI's contract for scripts and agents:
23
+ // 0 ok
24
+ // 1 generic error
25
+ // 2 usage / parse error / needs input
26
+ // 3 auth — not logged in, or insufficient scope (run `bp login [--write]`)
27
+ // 4 not found
28
+ // 5 conflict — unsafe undo, or revision mismatch (re-run after re-preview)
29
+ // 75 temp-fail — rate limited or retryable server error (EX_TEMPFAIL)
30
+ export const EXIT = {
31
+ OK: 0,
32
+ ERROR: 1,
33
+ USAGE: 2,
34
+ AUTH: 3,
35
+ NOT_FOUND: 4,
36
+ CONFLICT: 5,
37
+ TEMPFAIL: 75,
38
+ };
17
39
 
18
40
  export function configDir() {
19
41
  if (process.env.BIBLIOPLEX_CONFIG_DIR) return process.env.BIBLIOPLEX_CONFIG_DIR;
@@ -21,5 +43,10 @@ export function configDir() {
21
43
  return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'biblioplex');
22
44
  }
23
45
 
24
- export function credentialsPath() { return join(configDir(), 'credentials.json'); }
25
- export function configPath() { return join(configDir(), 'config.json'); }
46
+ export function credentialsPath() {
47
+ return join(configDir(), 'credentials.json');
48
+ }
49
+
50
+ export function configPath() {
51
+ return join(configDir(), 'config.json');
52
+ }