@rankwrangler/cli 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -8,18 +8,20 @@ npm install -g @rankwrangler/cli
8
8
  rw auth set ak_...
9
9
  rw product get B0DV53VS61
10
10
  rw product get B0DV53VS61 --shortName
11
- rw product search "retro gardening shirt"
11
+ rw product search "retro gardening shirt" --maxBsr 300000
12
+ rw keyword results "retro gardening shirt"
12
13
  rw product history B0DV53VS61 --bucket week
13
14
  rw keyword get "retro gardening shirt"
14
15
  rw keyword search "retro gardening"
15
16
  rw keyword history "retro gardening shirt" --rangeDays 90
16
17
  ```
17
18
 
18
- The public command surface is Product `get/search/history`, keyword `get/search/history`, auth,
19
- config, version, and changelog. Product `get/history` and keyword reads wait for policy-current
20
- data and expose no refresh control; Product search retains its separate search contract. Search
21
- data contains `keyword`, `searchedAt`, and compact results with `organicSearchPlacement`. Output is
22
- a JSON `{ ok, data }` envelope; failures use `{ ok: false, error }` on stderr.
19
+ The public command surface is Product `get/search/history`, keyword `get/search/history/results`,
20
+ auth, config, version, and changelog. Product search reads the collected database immediately and
21
+ supports filters, sorting, and cursor pagination. Keyword results returns dated Amazon search
22
+ results with placement and accepts `--refresh` to replace the observed Search run.
23
+ Product `get/history` and other keyword reads use server-owned freshness policy.
24
+ Output is a JSON `{ ok, data }` envelope; failures use `{ ok: false, error }` on stderr.
23
25
  The CLI does not expose Catalog, Operation, provider status, or polling commands.
24
26
  `--shortName` adds a nullable image-informed `listing.shortName` to a single Product get, waiting
25
27
  up to 20 seconds for generation and returning `null` if it is still running.
package/dist/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## v0.11.0 - 2026-10-23
6
+
7
+ ### Changed
8
+
9
+ - Breaking: `product.search` is now a query over the collected catalog, with optional text,
10
+ structured filters, screening metrics, sorting and cursor pagination. It returns
11
+ `items`, `nextCursor` and dated keyword coverage without waiting for external collection.
12
+ - Preserve the previous external search capability as `keyword.results`, a mutation accepting
13
+ `term` and optional `refresh`, with dated Product placements in the existing response shape.
14
+ - Use the same catalog filtering and sorting in the dashboard, CLI and MCP. Dashboard filters
15
+ apply to the full stored catalog rather than only loaded pages.
16
+
17
+ ### Added
18
+
19
+ - Prepare native text-search indexes and a verified official-brand registry. Research search hides
20
+ known registry matches by default while retaining unknown brands. The registry starts empty.
21
+
22
+ - Add adaptive keyword checks, bounded SP-API pagination and dated collection coverage. General
23
+ searches record interest and return immediately; collection runs independently in the background.
24
+ - Protect Keepa capacity with durable request reservations, measured costs and separate work budgets.
25
+ Admit new recurring history selectively while preserving every existing Product's eligibility.
26
+ - Add operator collection status and active/admitted refresh counts.
27
+
5
28
  ## v0.10.0 - 2026-10-20
6
29
 
7
30
  ### Added
@@ -1,8 +1,14 @@
1
1
  import { parseIntegerOption } from './cli-options.js';
2
2
  const KEYWORD_MARKETPLACE_ID = 'ATVPDKIKX0DER';
3
3
  export const runKeywordCommand = async (command, client, options, fail) => {
4
- if (options.refresh) {
5
- fail('INVALID_INPUT', '--refresh is not supported for keyword reads');
4
+ if (command.verb !== 'results' && options.refresh) {
5
+ fail('INVALID_INPUT', '--refresh is only supported for keyword results');
6
+ }
7
+ if (command.verb === 'results') {
8
+ return await client.keyword.results.mutate({
9
+ term: requireKeyword(command.args, fail, 'keyword results'),
10
+ refresh: Boolean(options.refresh),
11
+ });
6
12
  }
7
13
  if (command.verb === 'get') {
8
14
  return await runKeywordGet(command.args, client, fail);
@@ -2,6 +2,28 @@ import type { CliConfig } from './cli-config';
2
2
  import { type HistoryBucket, type HistoryMetricAlias } from './history-options';
3
3
  type CliFail = (code: string, message: string, details?: unknown) => never;
4
4
  export interface CliOptionValues {
5
+ textFields?: string;
6
+ excludeWord?: string[];
7
+ brand?: string[];
8
+ excludeBrand?: string[];
9
+ categoryId?: string[];
10
+ facet?: string[];
11
+ includeOfficialBrands?: boolean;
12
+ merch?: string;
13
+ listingStatus?: string;
14
+ sort?: string;
15
+ minBsr?: string;
16
+ maxBsr?: string;
17
+ minPrice?: string;
18
+ maxPrice?: string;
19
+ minMonthlySold?: string;
20
+ maxMonthlySold?: string;
21
+ updatedAfter?: string;
22
+ updatedBefore?: string;
23
+ availableAfter?: string;
24
+ availableBefore?: string;
25
+ discoveredAfter?: string;
26
+ discoveredBefore?: string;
5
27
  baseUrl?: string;
6
28
  marketplace?: string;
7
29
  metrics?: string;
@@ -1,7 +1,8 @@
1
+ import { runProductSearch } from './cli-product-search.js';
1
2
  import { parseIntegerOption, requireAsins, requireMarketplaceId, requireSingleAsin, resolveHistoryBucket, resolveHistoryMetrics, resolveHistoryWindow, } from './cli-options.js';
2
3
  export const runProductCommand = async (command, client, config, options, fail) => {
3
- if (command.verb !== 'search' && options.refresh) {
4
- fail('INVALID_INPUT', '--refresh is only supported for product search');
4
+ if (options.refresh) {
5
+ fail('INVALID_INPUT', '--refresh is only supported for keyword results');
5
6
  }
6
7
  if (command.verb !== 'get' && options.shortName) {
7
8
  fail('INVALID_INPUT', '--shortName is only supported for product get');
@@ -10,7 +11,7 @@ export const runProductCommand = async (command, client, config, options, fail)
10
11
  return await runProductGet(command.args, client, config, options, fail);
11
12
  }
12
13
  if (command.verb === 'search') {
13
- return await runProductSearch(command.args, client, options, fail);
14
+ return await runProductSearch(command.args, client, config, options, fail);
14
15
  }
15
16
  if (command.verb === 'history') {
16
17
  return await runProductHistory(command.args, client, config, options, fail);
@@ -36,16 +37,6 @@ const runProductGet = async (args, client, config, options, fail) => {
36
37
  products: asins.map(asin => ({ marketplaceId, asin })),
37
38
  });
38
39
  };
39
- const runProductSearch = async (args, client, options, fail) => {
40
- const term = args.join(' ').trim();
41
- if (!term) {
42
- fail('INVALID_INPUT', 'product search requires a keyword');
43
- }
44
- return await client.product.search.mutate({
45
- term,
46
- refresh: Boolean(options.refresh),
47
- });
48
- };
49
40
  const runProductHistory = async (args, client, config, options, fail) => {
50
41
  const asin = requireSingleAsin(args, fail, 'product history');
51
42
  const historyOptions = resolveHistoryOptions(options, fail);
@@ -0,0 +1,6 @@
1
+ import type { createRankWranglerClient } from '@rankwrangler/http-client';
2
+ import type { CliConfig } from './cli-config';
3
+ import { type CliOptionValues } from './cli-options';
4
+ type CliFail = (code: string, message: string, details?: unknown) => never;
5
+ export declare const runProductSearch: (args: string[], client: ReturnType<typeof createRankWranglerClient>, config: CliConfig, options: CliOptionValues, fail: CliFail) => Promise<unknown>;
6
+ export {};
@@ -0,0 +1,99 @@
1
+ import { requireMarketplaceId } from './cli-options.js';
2
+ export const runProductSearch = async (args, client, config, options, fail) => {
3
+ const term = args.join(' ').trim();
4
+ const merch = choice(options.merch, ['true', 'false', 'unknown', 'all'], 'merch', fail);
5
+ return await client.product.search.query({
6
+ ...(term ? { term } : {}),
7
+ marketplaceIds: [requireMarketplaceId(options, config)],
8
+ textFields: choice(options.textFields, ['all', 'title'], 'textFields', fail),
9
+ excludeWords: options.excludeWord,
10
+ brands: options.brand,
11
+ excludeBrands: options.excludeBrand,
12
+ categoryIds: options.categoryId?.map(value => integer(value, 'categoryId', 1, fail)),
13
+ facets: options.facet?.map(value => {
14
+ const separator = value.indexOf('=');
15
+ if (separator < 1 || separator === value.length - 1) {
16
+ fail('INVALID_INPUT', '--facet must be facet=name');
17
+ }
18
+ return {
19
+ facet: value.slice(0, separator),
20
+ name: value.slice(separator + 1),
21
+ };
22
+ }),
23
+ includeOfficialBrands: options.includeOfficialBrands,
24
+ isMerchListing: merch === undefined
25
+ ? undefined
26
+ : merch === 'unknown' || merch === 'all'
27
+ ? merch
28
+ : merch === 'true',
29
+ amazonListingStatus: choice(options.listingStatus, ['active', 'deleted', 'all'], 'listingStatus', fail),
30
+ salesRank: range(options.minBsr, options.maxBsr, 'Bsr', fail),
31
+ price: range(options.minPrice, options.maxPrice, 'Price', fail),
32
+ monthlySold: range(options.minMonthlySold, options.maxMonthlySold, 'MonthlySold', fail),
33
+ firstAvailableAt: dates(options.availableAfter, options.availableBefore, 'available', fail),
34
+ updatedAt: dates(options.updatedAfter, options.updatedBefore, 'updated', fail),
35
+ discoveredAt: dates(options.discoveredAfter, options.discoveredBefore, 'discovered', fail),
36
+ sort: choice(options.sort, [
37
+ 'relevance',
38
+ 'salesRankAsc',
39
+ 'salesRankDesc',
40
+ 'firstAvailableAtDesc',
41
+ 'firstAvailableAtAsc',
42
+ 'updatedAtAsc',
43
+ 'updatedAtDesc',
44
+ 'discoveredAtDesc',
45
+ 'priceAsc',
46
+ 'priceDesc',
47
+ 'monthlySoldDesc',
48
+ ], 'sort', fail),
49
+ limit: options.limit === undefined ? undefined : integer(options.limit, 'limit', 1, fail, 100),
50
+ cursor: options.cursor,
51
+ });
52
+ };
53
+ const choice = (value, choices, name, fail) => {
54
+ if (value === undefined)
55
+ return undefined;
56
+ const match = choices.find(candidate => candidate === value);
57
+ if (match === undefined)
58
+ fail('INVALID_INPUT', `--${name} must be ${choices.join(', ')}`);
59
+ return match;
60
+ };
61
+ const integer = (value, name, min, fail, max = Number.MAX_SAFE_INTEGER) => {
62
+ const parsed = Number(value);
63
+ if (!value.trim() || !Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
64
+ fail('INVALID_INPUT', `--${name} must be an integer between ${min} and ${max}`);
65
+ }
66
+ return parsed;
67
+ };
68
+ const range = (min, max, name, fail) => {
69
+ if (min === undefined && max === undefined)
70
+ return undefined;
71
+ const bounds = {
72
+ ...(min === undefined ? {} : { min: integer(min, `min${name}`, 0, fail) }),
73
+ ...(max === undefined ? {} : { max: integer(max, `max${name}`, 0, fail) }),
74
+ };
75
+ if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) {
76
+ fail('INVALID_INPUT', `--min${name} must not exceed --max${name}`);
77
+ }
78
+ return bounds;
79
+ };
80
+ const dates = (after, before, name, fail) => {
81
+ if (after === undefined && before === undefined)
82
+ return undefined;
83
+ const parse = (value) => {
84
+ if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?Z)?$/.test(value) ||
85
+ !Number.isFinite(new Date(value).getTime()) ||
86
+ new Date(value).toISOString().slice(0, 10) !== value.slice(0, 10)) {
87
+ fail('INVALID_INPUT', `--${name}After and --${name}Before require ISO dates`);
88
+ }
89
+ return new Date(value).toISOString();
90
+ };
91
+ const bounds = {
92
+ after: after === undefined ? undefined : parse(after),
93
+ before: before === undefined ? undefined : parse(before),
94
+ };
95
+ if (bounds.after && bounds.before && bounds.after > bounds.before) {
96
+ fail('INVALID_INPUT', `--${name}After must not exceed --${name}Before`);
97
+ }
98
+ return bounds;
99
+ };
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ const SUPPORTED_COMMANDS = new Set([
17
17
  'keyword:get',
18
18
  'keyword:search',
19
19
  'keyword:history',
20
+ 'keyword:results',
20
21
  'auth:status',
21
22
  'auth:set',
22
23
  'auth:clear',
@@ -43,6 +44,28 @@ const { positionals, values } = parseArgs({
43
44
  cursor: { type: 'string' },
44
45
  refresh: { type: 'boolean' },
45
46
  shortName: { type: 'boolean' },
47
+ textFields: { type: 'string' },
48
+ excludeWord: { type: 'string', multiple: true },
49
+ brand: { type: 'string', multiple: true },
50
+ excludeBrand: { type: 'string', multiple: true },
51
+ categoryId: { type: 'string', multiple: true },
52
+ facet: { type: 'string', multiple: true },
53
+ includeOfficialBrands: { type: 'boolean' },
54
+ merch: { type: 'string' },
55
+ listingStatus: { type: 'string' },
56
+ sort: { type: 'string' },
57
+ minBsr: { type: 'string' },
58
+ maxBsr: { type: 'string' },
59
+ minPrice: { type: 'string' },
60
+ maxPrice: { type: 'string' },
61
+ minMonthlySold: { type: 'string' },
62
+ maxMonthlySold: { type: 'string' },
63
+ updatedAfter: { type: 'string' },
64
+ updatedBefore: { type: 'string' },
65
+ availableAfter: { type: 'string' },
66
+ availableBefore: { type: 'string' },
67
+ discoveredAfter: { type: 'string' },
68
+ discoveredBefore: { type: 'string' },
46
69
  stdin: { type: 'boolean' },
47
70
  },
48
71
  allowPositionals: true,
@@ -122,7 +145,9 @@ const resolveCommandOrFail = (inputPositionals) => {
122
145
  if (command) {
123
146
  return command;
124
147
  }
125
- fail('UNKNOWN_COMMAND', 'Unknown command', { command: inputPositionals.join(' ') });
148
+ fail('UNKNOWN_COMMAND', 'Unknown command', {
149
+ command: inputPositionals.join(' '),
150
+ });
126
151
  throw new Error('unreachable');
127
152
  };
128
153
  const isSupportedCommand = (command) => SUPPORTED_COMMANDS.has(`${command.resource}:${command.verb}`);
package/dist/usage.js CHANGED
@@ -16,11 +16,12 @@ export const printUsage = (paths) => {
16
16
  'COMMANDS',
17
17
  ' changelog',
18
18
  ' product get <ASIN...>',
19
- ' product search <keyword>',
19
+ ' product search [text]',
20
20
  ' product history <ASIN>',
21
21
  ' keyword get <keyword>',
22
22
  ' keyword search <text>',
23
23
  ' keyword history <keyword>',
24
+ ' keyword results <keyword>',
24
25
  ' auth status',
25
26
  ' auth set [apiKey]',
26
27
  ' auth set --stdin',
@@ -38,7 +39,7 @@ export const printUsage = (paths) => {
38
39
  ' --version Print the CLI version',
39
40
  ' --baseUrl <origin> Override API origin',
40
41
  ` -m, --marketplace <id> Override marketplace (default: ${DEFAULT_MARKETPLACE_ID})`,
41
- ' --refresh Product search refresh (separate Search contract)',
42
+ ' --refresh Replace the Search run for keyword results',
42
43
  ' --shortName Generate a design name for one Merch Product',
43
44
  ' --metrics <list> Product history metrics: salesRank,price (default: salesRank,price)',
44
45
  ' --bucket <unit> Product history buckets: auto,day,week,month (default: auto)',
@@ -47,7 +48,22 @@ export const printUsage = (paths) => {
47
48
  ' --startAt <ISO> Product history range start (ISO date/time)',
48
49
  ' --endAt <ISO> Product history range end (ISO date/time)',
49
50
  ' --limit <N> Result limit',
50
- ' --cursor <N> Keyword search offset (default: 0)',
51
+ ' --cursor <value> Product search cursor or keyword search offset',
52
+ ' --sort <order> Product search order (default: relevance)',
53
+ ' --textFields <scope> Product search text: all,title (default: all)',
54
+ ' --minBsr/--maxBsr <N> Product sales-rank bounds',
55
+ ' --minPrice/--maxPrice <N> Product price bounds in minor currency units',
56
+ ' --minMonthlySold/--maxMonthlySold <N> Product demand bounds',
57
+ ' --availableAfter/--availableBefore <ISO> First-available bounds',
58
+ ' --discoveredAfter/--discoveredBefore <ISO> Discovery bounds',
59
+ ' --updatedAfter/--updatedBefore <ISO> Product update bounds',
60
+ ' --brand/--excludeBrand <name> Repeat for brand filters',
61
+ ' --excludeWord <word> Repeat for excluded words',
62
+ ' --categoryId <N> Repeat for category filters',
63
+ ' --facet <facet=name> Repeat for facet filters',
64
+ ' --merch <value> true,false,unknown,all (default: true)',
65
+ ' --listingStatus <value> active,deleted,all (default: active)',
66
+ ' --includeOfficialBrands Include known official brands in Product search',
51
67
  ' --stdin Read auth secret from standard input',
52
68
  '',
53
69
  'FILES',
@@ -66,7 +82,8 @@ export const printUsage = (paths) => {
66
82
  ' printf %s "$MERCHBASE_API_KEY" | rw auth set --stdin',
67
83
  ` ${API_KEY_ENV_VAR}=ak_... rw product get B0DV53VS61`,
68
84
  ' rw product get B0DV53VS61 B012345678',
69
- ' rw product search "retro gardening shirt"',
85
+ ' rw product search "retro gardening shirt" --maxBsr 300000 --sort salesRankAsc',
86
+ ' rw keyword results "retro gardening shirt"',
70
87
  ' rw product history B0DV53VS61 --bucket week',
71
88
  ' rw keyword get "retro gardening shirt"',
72
89
  ' rw keyword search "retro gardening" --limit 10',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankwrangler/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Official RankWrangler command line interface",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@merchbaseco/access": "0.4.0",
33
- "@rankwrangler/http-client": "^0.10.0",
33
+ "@rankwrangler/http-client": "^0.11.0",
34
34
  "keytar": "7.9.0"
35
35
  },
36
36
  "devDependencies": {