driggsby 0.7.5 → 0.8.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
@@ -117,9 +117,19 @@ counterparty's name, or tags on a set of transactions. `rules describe`
117
117
  prints the rule format and the process. `rules preview` shows exactly what a
118
118
  new or changed rule would do and returns the confirm token `rules save`
119
119
  needs to apply it; renaming, pausing, or resuming a rule needs its
120
- `rule_ref` and no preview. `rules delete` needs `--yes`. Every action prints JSON. If you
121
- signed in with an earlier version of the CLI, run
122
- `npx driggsby@latest login` once more to use rules.
120
+ `rule_ref` and no preview. `rules delete` needs `--yes`. Every action prints
121
+ JSON.
122
+
123
+ ## Items
124
+
125
+ `npx driggsby@latest items` saves and removes the items behind your charges:
126
+ what an order or a receipt actually bought, each in a category, so Driggsby
127
+ counts a charge by what was in it. `items describe` prints the item format
128
+ and the process. `items save --params-file order.json` saves the items of up
129
+ to 25 charges (saving a charge again replaces its items); each charge is
130
+ saved or refused on its own, and the JSON result says which and why.
131
+ `items delete` needs `--yes`. Read items back with
132
+ `npx driggsby@latest query query_item_sql --sql "..."`.
123
133
 
124
134
  ## License
125
135
 
@@ -0,0 +1,24 @@
1
+ // Argv parsing for `driggsby items <ACTION> [--params <JSON> | --params-file
2
+ // <PATH>] [--yes]`: the rules command's grammar (args-rules.ts) over the
3
+ // item tools.
4
+ import { parseActionCommand } from "./args-rules.js";
5
+ import {} from "./args-shared.js";
6
+ import { ITEMS_HELP } from "./help.js";
7
+ import { isItemAction, ITEM_ACTIONS } from "./items/item-actions.js";
8
+ const ITEMS_SPEC = {
9
+ name: "items",
10
+ article: "an",
11
+ usage: "Usage: npx driggsby@latest items <ACTION> [--params <JSON>] [--yes]",
12
+ help: ITEMS_HELP,
13
+ actions: ITEM_ACTIONS,
14
+ isAction: isItemAction,
15
+ describeAction: "describe",
16
+ deleteAction: "delete",
17
+ deleteNeedsYes: "error: removing a charge's items can't be undone — Spending counts it whole\n" +
18
+ "again. To remove them, run the same command again with --yes.",
19
+ exampleParams: `'{"transaction_refs":["txn_..."]}'`,
20
+ };
21
+ export function parseItems(argv) {
22
+ const parsed = parseActionCommand(argv, ITEMS_SPEC);
23
+ return parsed.kind === "action" ? { kind: "items", action: parsed.action, params: parsed.params, yes: parsed.yes } : parsed;
24
+ }
@@ -1,7 +1,7 @@
1
1
  // Argv parsing for `driggsby query <TOOL> [--sql <SQL>] [--params <JSON>]`.
2
2
  // Same behavior contract as the rest of the tree: help on -h/--help, usage
3
3
  // errors exit 2, echoed argv is sanitized. The tool is checked against the
4
- // allowlist here so a typo is a usage error naming the twelve, not a
4
+ // allowlist here so a typo is a usage error naming the thirteen, not a
5
5
  // server round trip.
6
6
  import { helpCommand, unexpectedArgument } from "./args-shared.js";
7
7
  import { didYouMean } from "./clap-suggestions.js";
@@ -9,9 +9,9 @@ import { CliError } from "./cli-error.js";
9
9
  import { APP_TOOL_ALLOWLIST } from "./dev/tool-allowlist.js";
10
10
  import { QUERY_HELP } from "./help.js";
11
11
  import { quotedForTerminal, wrapNames } from "./terminal-text.js";
12
- // The two tools that take SQL: --sql applies only to these, and a retry
12
+ // The tools that take SQL: --sql applies only to these, and a retry
13
13
  // line names --sql only for them.
14
- export const SQL_TOOLS = new Set(["query_cash_sql", "query_investment_sql"]);
14
+ export const SQL_TOOLS = new Set(["query_cash_sql", "query_investment_sql", "query_item_sql"]);
15
15
  const QUERY_USAGE = "Usage: npx driggsby@latest query <TOOL> [--sql <SQL>] [--params <JSON>]";
16
16
  export function parseQuery(argv) {
17
17
  let tool = null;
@@ -114,7 +114,7 @@ function unknownTool(tool) {
114
114
  `${wrapNames([...APP_TOOL_ALLOWLIST], " ")}\n\n${tip}${QUERY_USAGE}\n\nFor more information, try '--help'.`, 2);
115
115
  }
116
116
  function sqlForNonSqlTool(tool) {
117
- return new CliError(`error: '--sql' only applies to query_cash_sql or query_investment_sql;\n` +
117
+ return new CliError(`error: '--sql' only applies to query_cash_sql, query_investment_sql, or query_item_sql;\n` +
118
118
  `'${tool}' takes no SQL.\n\n` +
119
119
  `${QUERY_USAGE}\n\nFor more information, try '--help'.`, 2);
120
120
  }
@@ -12,11 +12,28 @@ import { RULES_HELP } from "./help.js";
12
12
  import { isRuleAction, RULE_ACTIONS } from "./rules/rule-actions.js";
13
13
  import { quotedForTerminal } from "./terminal-text.js";
14
14
  const RULES_USAGE = "Usage: npx driggsby@latest rules <ACTION> [--params <JSON>] [--yes]";
15
+ const RULES_SPEC = {
16
+ name: "rules",
17
+ article: "a",
18
+ usage: RULES_USAGE,
19
+ help: RULES_HELP,
20
+ actions: RULE_ACTIONS,
21
+ isAction: isRuleAction,
22
+ describeAction: "describe",
23
+ deleteAction: "delete",
24
+ deleteNeedsYes: "error: deleting a rule can't be undone — its categories and tags come off every\n" +
25
+ "transaction it covers. To delete it, run the same command again with --yes.",
26
+ exampleParams: `'{"rule_ref":"rule_..."}'`,
27
+ };
15
28
  // A saved rule with learned patterns and its answers is a few KB; this
16
29
  // bounds a mistaken path to something like a log file.
17
30
  export const MAX_PARAMS_FILE_BYTES = 1_048_576;
18
31
  const MAX_PARAMS_FILE_MB = String(MAX_PARAMS_FILE_BYTES / 1_048_576);
19
32
  export function parseRules(argv) {
33
+ const parsed = parseActionCommand(argv, RULES_SPEC);
34
+ return parsed.kind === "action" ? { kind: "rules", action: parsed.action, params: parsed.params, yes: parsed.yes } : parsed;
35
+ }
36
+ export function parseActionCommand(argv, spec) {
20
37
  let actionToken = null;
21
38
  let source = null;
22
39
  let yes = false;
@@ -28,24 +45,24 @@ export function parseRules(argv) {
28
45
  continue;
29
46
  }
30
47
  if (!optionsEnded && (token === "-h" || token === "--help")) {
31
- return helpCommand(RULES_HELP);
48
+ return helpCommand(spec.help);
32
49
  }
33
50
  if (!optionsEnded && token === "--yes") {
34
51
  yes = true;
35
52
  continue;
36
53
  }
37
54
  if (!optionsEnded && token.startsWith("--yes=")) {
38
- throw unexpectedFlagValue("--yes", token.slice("--yes=".length), RULES_USAGE);
55
+ throw unexpectedFlagValue("--yes", token.slice("--yes=".length), spec.usage);
39
56
  }
40
57
  const flag = !optionsEnded ? paramsFlag(token) : null;
41
58
  if (flag !== null) {
42
59
  if (source !== null) {
43
- throw usageError("error: give the params once, with either --params or --params-file");
60
+ throw usageError("error: give the params once, with either --params or --params-file", spec);
44
61
  }
45
62
  const attached = token.length > flag.length;
46
63
  const value = attached ? token.slice(flag.length + 1) : argv[index + 1];
47
64
  if (value === undefined || value === "" || (!attached && value.length > 1 && value.startsWith("-"))) {
48
- throw usageError(`error: a value is required for '${flag}' but none was supplied`);
65
+ throw usageError(`error: a value is required for '${flag}' but none was supplied`, spec);
49
66
  }
50
67
  source = flag === "--params" ? { flag, json: value } : { flag, path: value };
51
68
  index += attached ? 0 : 1;
@@ -55,26 +72,25 @@ export function parseRules(argv) {
55
72
  actionToken = token;
56
73
  continue;
57
74
  }
58
- throw unexpectedArgument(token, RULES_USAGE);
75
+ throw unexpectedArgument(token, spec.usage);
59
76
  }
60
77
  if (actionToken === null) {
61
- throw usageError("error: the following required arguments were not provided:\n <ACTION>");
78
+ throw usageError("error: the following required arguments were not provided:\n <ACTION>", spec);
62
79
  }
63
- if (!isRuleAction(actionToken)) {
64
- throw unknownAction(actionToken);
80
+ if (!spec.isAction(actionToken)) {
81
+ throw unknownAction(actionToken, spec);
65
82
  }
66
83
  const action = actionToken;
67
- if (action === "describe" && source !== null) {
68
- throw usageError("error: rules describe takes no params");
84
+ if (action === spec.describeAction && source !== null) {
85
+ throw usageError(`error: ${spec.name} ${spec.describeAction} takes no params`, spec);
69
86
  }
70
- if (action === "delete" && !yes) {
71
- throw usageError("error: deleting a rule can't be undone — its categories and tags come off every\n" +
72
- "transaction it covers. To delete it, run the same command again with --yes.");
87
+ if (action === spec.deleteAction && !yes) {
88
+ throw usageError(spec.deleteNeedsYes, spec);
73
89
  }
74
- if (action !== "delete" && yes) {
75
- throw usageError("error: '--yes' only applies to rules delete");
90
+ if (action !== spec.deleteAction && yes) {
91
+ throw usageError(`error: '--yes' only applies to ${spec.name} ${spec.deleteAction}`, spec);
76
92
  }
77
- return { kind: "rules", action, params: source === null ? {} : resolveParams(source), yes };
93
+ return { kind: "action", action, params: source === null ? {} : resolveParams(source, spec), yes };
78
94
  }
79
95
  // "--params", "--params=…", "--params-file", "--params-file=…" → the flag.
80
96
  function paramsFlag(token) {
@@ -85,13 +101,13 @@ function paramsFlag(token) {
85
101
  }
86
102
  return null;
87
103
  }
88
- function resolveParams(source) {
104
+ function resolveParams(source, spec) {
89
105
  if (source.flag === "--params") {
90
- return parseParamsObject(source.json, "--params", "\nFor JSON that's hard to quote in this shell, save it to a file and pass\n--params-file <PATH> instead.");
106
+ return parseParamsObject(source.json, "--params", "\nFor JSON that's hard to quote in this shell, save it to a file and pass\n--params-file <PATH> instead.", spec);
91
107
  }
92
- return parseParamsObject(readParamsFile(source.path), "--params-file", "");
108
+ return parseParamsObject(readParamsFile(source.path, spec), "--params-file", "", spec);
93
109
  }
94
- function parseParamsObject(rawValue, flag, hint) {
110
+ function parseParamsObject(rawValue, flag, hint, spec) {
95
111
  let parsed;
96
112
  try {
97
113
  parsed = JSON.parse(rawValue);
@@ -100,7 +116,7 @@ function parseParamsObject(rawValue, flag, hint) {
100
116
  parsed = null;
101
117
  }
102
118
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
103
- throw usageError(`error: invalid value for '${flag}':\n'${flag}' must be a JSON object, like '{"rule_ref":"rule_..."}'${hint}`);
119
+ throw usageError(`error: invalid value for '${flag}':\n'${flag}' must be a JSON object, like ${spec.exampleParams}${hint}`, spec);
104
120
  }
105
121
  return parsed;
106
122
  }
@@ -110,7 +126,7 @@ function parseParamsObject(rawValue, flag, hint) {
110
126
  // read). Special files are refused before they are opened, since opening a
111
127
  // FIFO blocks; on POSIX the open is also non-blocking, so a FIFO swapped in
112
128
  // after the check is refused by the fstat instead of hanging.
113
- function readParamsFile(path) {
129
+ function readParamsFile(path, spec) {
114
130
  let bytes;
115
131
  try {
116
132
  if (!statSync(path).isFile()) {
@@ -119,10 +135,10 @@ function readParamsFile(path) {
119
135
  bytes = readAtMost(path, MAX_PARAMS_FILE_BYTES + 1);
120
136
  }
121
137
  catch (error) {
122
- throw usageError(`error: couldn't read the --params-file: ${paramsFileProblem(error)}`);
138
+ throw usageError(`error: couldn't read the --params-file: ${paramsFileProblem(error)}`, spec);
123
139
  }
124
140
  if (bytes.length > MAX_PARAMS_FILE_BYTES) {
125
- throw usageError(`error: couldn't read the --params-file: it's over ${MAX_PARAMS_FILE_MB} MB`);
141
+ throw usageError(`error: couldn't read the --params-file: it's over ${MAX_PARAMS_FILE_MB} MB`, spec);
126
142
  }
127
143
  return decodeParamsFile(bytes);
128
144
  }
@@ -172,11 +188,11 @@ function decodeParamsFile(bytes) {
172
188
  const text = bytes.toString("utf8");
173
189
  return text.startsWith("\uFEFF") ? text.slice(1) : text;
174
190
  }
175
- function unknownAction(token) {
176
- const similar = didYouMean(token, RULE_ACTIONS);
191
+ function unknownAction(token, spec) {
192
+ const similar = didYouMean(token, spec.actions);
177
193
  const tip = similar === undefined ? "" : `\n\n tip: a similar action exists: '${similar}'`;
178
- return usageError(`error: ${quotedForTerminal(token, 60)} isn't a rules action. Actions: ${RULE_ACTIONS.join(", ")}.${tip}`);
194
+ return usageError(`error: ${quotedForTerminal(token, 60)} isn't ${spec.article} ${spec.name} action. Actions: ${spec.actions.join(", ")}.${tip}`, spec);
179
195
  }
180
- function usageError(firstLines) {
181
- return new CliError(`${firstLines}\n\n${RULES_USAGE}\n\nFor more information, try '--help'.`, 2);
196
+ function usageError(firstLines, spec) {
197
+ return new CliError(`${firstLines}\n\n${spec.usage}\n\nFor more information, try '--help'.`, 2);
182
198
  }
@@ -4,6 +4,7 @@
4
4
  // terminal through an echoed argument, tip, or usage line.
5
5
  import { CliError } from "./cli-error.js";
6
6
  import {} from "./mcp-setup/known-client.js";
7
+ import {} from "./items/item-actions.js";
7
8
  import {} from "./rules/rule-actions.js";
8
9
  import { sanitizeForTerminal } from "./terminal-text.js";
9
10
  export function helpCommand(text) {
package/dist/args.js CHANGED
@@ -13,6 +13,7 @@ import { parseInit } from "./args-init.js";
13
13
  import { parseLogin } from "./args-login.js";
14
14
  import { parseMcpSetup } from "./args-mcp-setup.js";
15
15
  import { parseQuery } from "./args-query.js";
16
+ import { parseItems } from "./args-items.js";
16
17
  import { parseRules } from "./args-rules.js";
17
18
  import { removeDashesTip, similarArgumentTip, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
18
19
  import { didYouMean } from "./clap-suggestions.js";
@@ -25,7 +26,7 @@ const MCP_USAGE = "Usage: npx driggsby@latest mcp <COMMAND>";
25
26
  const ROOT_LEVEL = {
26
27
  helpText: ROOT_HELP,
27
28
  usage: ROOT_USAGE,
28
- subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions", "delete", "query", "rules"],
29
+ subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions", "delete", "query", "rules", "items"],
29
30
  longFlags: ["help", "version"],
30
31
  subcommandFlags: [
31
32
  { name: "mcp", longFlags: ["help"] },
@@ -39,6 +40,7 @@ const ROOT_LEVEL = {
39
40
  { name: "delete", longFlags: ["yes", "help"] },
40
41
  { name: "query", longFlags: ["sql", "params", "help"] },
41
42
  { name: "rules", longFlags: ["params", "params-file", "yes", "help"] },
43
+ { name: "items", longFlags: ["params", "params-file", "yes", "help"] },
42
44
  ],
43
45
  usagePrefix: "npx driggsby@latest",
44
46
  hasVersion: true,
@@ -110,6 +112,8 @@ function dispatchSubcommand(name, rest) {
110
112
  return parseQuery(rest);
111
113
  case "rules":
112
114
  return parseRules(rest);
115
+ case "items":
116
+ return parseItems(rest);
113
117
  case "setup":
114
118
  return parseMcpSetup(rest);
115
119
  default:
@@ -13,6 +13,7 @@ export const APP_TOOL_ALLOWLIST = new Set([
13
13
  "list_recurring_transactions",
14
14
  "query_cash_sql",
15
15
  "query_investment_sql",
16
+ "query_item_sql",
16
17
  "search_cash_transactions",
17
18
  "search_investment_activity",
18
19
  ]);
package/dist/help.js CHANGED
@@ -22,6 +22,7 @@ Commands:
22
22
  delete Permanently remove a deployed app.
23
23
  query Run one Driggsby data tool and print its result as JSON.
24
24
  rules Create, change, and delete your transaction rules.
25
+ items Save and remove the items behind your charges.
25
26
 
26
27
  Options:
27
28
  -h, --help Print help
@@ -38,6 +39,7 @@ Examples:
38
39
  npx driggsby@latest deploy
39
40
  npx driggsby@latest query get_overview
40
41
  npx driggsby@latest rules describe
42
+ npx driggsby@latest items describe
41
43
  `;
42
44
  export const INIT_HELP = `Create a new Driggsby app in a new folder.
43
45
 
@@ -61,8 +63,8 @@ export const LOGIN_HELP = `Sign in to Driggsby and save an app token on this mac
61
63
 
62
64
  This opens a Driggsby approval page in your browser. Approving it issues an
63
65
  app token that lets this machine read your Driggsby data, deploy Driggsby
64
- apps, and manage your transaction rules. The token is saved locally for
65
- other driggsby commands and is never printed.
66
+ apps, and manage your transaction rules and items. The token is saved
67
+ locally for other driggsby commands and is never printed.
66
68
 
67
69
  If you approve in a browser on another computer, the page shows a code
68
70
  instead. Paste it where login is waiting, or finish with --code.
@@ -180,8 +182,8 @@ Arguments:
180
182
  ${QUERY_TOOL_LINES}
181
183
 
182
184
  Options:
183
- --sql <SQL> The SQL for query_cash_sql or query_investment_sql.
184
- Overrides a sql key given in --params.
185
+ --sql <SQL> The SQL for query_cash_sql, query_investment_sql, or
186
+ query_item_sql. Overrides a sql key given in --params.
185
187
  --params <JSON> Other params as a JSON object, for example
186
188
  '{"history_type":"liabilities"}'.
187
189
  -h, --help Print help
@@ -230,6 +232,43 @@ Examples:
230
232
  npx driggsby@latest rules tags
231
233
  npx driggsby@latest rules save --params-file answers.json
232
234
  `;
235
+ export const ITEMS_HELP = `Save and remove the items behind your charges.
236
+
237
+ An itemized charge carries what it bought: an order's or a receipt's items,
238
+ each in a category, so Driggsby counts the charge by what was in it. Each
239
+ action runs one Driggsby item tool and prints its result as JSON; the JSON
240
+ goes to stdout and nothing else does. Start with describe: it prints the
241
+ item format and the process. Read items back with
242
+ npx driggsby@latest query query_item_sql --sql <SQL>.
243
+
244
+ Sign in first with npx driggsby@latest login.
245
+
246
+ Usage: npx driggsby@latest items <ACTION> [--params <JSON>] [--yes]
247
+
248
+ Actions:
249
+ describe Print the item format and the process.
250
+ save Save the items of up to 25 charges. Saving a charge again
251
+ replaces its items.
252
+ delete Remove the items of charges by transaction_ref. There is no
253
+ undo; needs --yes.
254
+
255
+ Options:
256
+ --params <JSON> The action's params as a JSON object, like
257
+ '{"transaction_refs":["txn_..."]}'.
258
+ --params-file <PATH> Read the params object from a JSON file instead.
259
+ --yes Confirm a delete.
260
+ -h, --help Print help
261
+
262
+ Each charge in a save is saved or refused on its own (items that don't add
263
+ up to the charge, say): the JSON result reports each one, with the reason
264
+ and the fix, and the command exits 0. Only a failure of the whole call puts
265
+ its message on stderr and exits 1.
266
+
267
+ Examples:
268
+ npx driggsby@latest items describe
269
+ npx driggsby@latest items save --params-file order.json
270
+ npx driggsby@latest items delete --params-file refs.json --yes
271
+ `;
233
272
  export const MCP_HELP = `Set up Driggsby MCP clients.
234
273
 
235
274
  Usage: npx driggsby@latest mcp <COMMAND>
@@ -13,8 +13,8 @@
13
13
  export const STYLES_CSS = `/* Driggsby's console palette, so this page reads as part of the app it
14
14
  opens inside. Change these together if you re-theme (all but
15
15
  --font-sans, which stays as it is); keep --bg-app equal to the
16
- "background" in driggsby.json. The status tones are the console's own,
17
- for gains, losses, warnings, and a live dot. */
16
+ "background" in driggsby.json. The status tones are for gains, losses,
17
+ warnings, and a live dot. */
18
18
  :root {
19
19
  color-scheme: dark;
20
20
  --bg-app: #000000;
@@ -35,17 +35,14 @@ export const STYLES_CSS = `/* Driggsby's console palette, so this page reads as
35
35
  --ease: cubic-bezier(0.2, 0, 0, 1);
36
36
  }
37
37
 
38
- /* Inter, which Driggsby serves at /-/inter.woff2 on this app's own origin
39
- (driggsby dev serves the same file there), so nothing loads from another
40
- site. Inter is the only font a dashboard uses: set all text with
41
- var(--font-sans), keep that stack as it is (the faces after Inter only
42
- stand in if the font is slow or fails to load, or lacks a character),
43
- and name no other font anywhere else. A <canvas> or a chart library's
44
- font setting can't read var(): name "Inter" there, followed only by
45
- sans-serif, and draw a canvas after document.fonts.ready. The page
46
- loads its font from its own address, so it looks the same everywhere
47
- it's shown. Text waits for the font (font-display: block, preloaded)
48
- rather than flash another face first. */
38
+ /* Inter, from this page's own address, so it looks the same everywhere
39
+ it's shown. Inter is the only font a dashboard uses: set all text with
40
+ var(--font-sans), keep that stack as it is, and name no other font
41
+ anywhere else. A <canvas> or a chart library's font setting can't read
42
+ var(): name "Inter" there, followed only by sans-serif, and draw a
43
+ canvas after document.fonts.ready. Text waits for the font
44
+ (font-display: block, preloaded) rather than flash another face
45
+ first. */
49
46
  @font-face {
50
47
  font-family: "Inter";
51
48
  src: url("/-/inter.woff2") format("woff2");
@@ -78,8 +75,7 @@ textarea {
78
75
  }
79
76
 
80
77
  /* A compact column: a few figures and a short list read best close
81
- together. The console's own pages run wider (75rem); widen this as the
82
- page grows. */
78
+ together. Widen it as the page grows. */
83
79
  main {
84
80
  max-width: 800px;
85
81
  margin: 0 auto;
@@ -104,8 +100,7 @@ h2 {
104
100
  letter-spacing: -0.02em;
105
101
  }
106
102
 
107
- /* The console's stat tiles: a card per figure, 16px apart, the row
108
- capped at the console's 920px. */
103
+ /* A card per figure, 16px apart. */
109
104
  .stat-grid {
110
105
  display: grid;
111
106
  grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -111,12 +111,13 @@ export const APP_JS = `// Your app's code. Edit anything — driggsby dev reload
111
111
  // list_recurring_transactions subscriptions, bills, and paychecks
112
112
  // query_cash_sql SQL over cash transactions ({ sql: "..." })
113
113
  // query_investment_sql SQL over investment activity ({ sql: "..." })
114
+ // query_item_sql SQL over the items in itemized charges
115
+ // ({ sql: "..." })
114
116
  // search_cash_transactions bank and card transaction search
115
117
  // search_investment_activity trades, dividends, and transfers
116
118
  //
117
- // Params mirror Driggsby's public MCP tools of the same names, with one
118
- // simplification: those tools require a "reason" string, but a watch can
119
- // leave it out — Driggsby fills one in naming the dashboard.
119
+ // Params mirror Driggsby's public MCP tools of the same names; a watch
120
+ // needs no "reason".
120
121
  //
121
122
  // To see a tool's exact result shape before writing render code, run it
122
123
  // once from the terminal — the JSON it prints is what the callback gets:
@@ -236,9 +237,9 @@ function renderAccounts(result) {
236
237
  const displayName = asText(account.account_display_name);
237
238
  const institution = asText(account.institution_name);
238
239
  const mask = asText(account.account_mask_last4);
239
- // The institution's initial on a round pill, the way Driggsby's own
240
- // account rows draw it. Array.from keeps an emoji-leading name from
241
- // breaking the render (it splits by whole character, not UTF-16 half).
240
+ // The institution's initial on a round pill. Array.from keeps an
241
+ // emoji-leading name from breaking the render (it splits by whole
242
+ // character, not UTF-16 half).
242
243
  const initialSource = (institution || displayName).trim();
243
244
  const initial = (Array.from(initialSource)[0] || "?").toUpperCase();
244
245
  const institutionLine = [institution, mask ? "····" + mask : ""].filter(Boolean).join(" ");
@@ -353,9 +354,8 @@ function settledRenderer(render) {
353
354
 
354
355
  // ---------------------------------------------------------------------------
355
356
  // When a call fails. A watch whose call fails never runs its callback; it
356
- // runs onError instead, with { message, kind }, once for each new failure —
357
- // after Driggsby has already retried a call that failed because it was busy
358
- // or slow. The section says why where its data would be: in place of its
357
+ // runs onError instead, with { message, kind }, once for each new failure.
358
+ // The section says why where its data would be: in place of its
359
359
  // skeleton, so a failure never looks like a page still loading, or under
360
360
  // the data it already shows, which stays. The watch asks again on its own
361
361
  // the next time your data changes, and the next good result repaints the
@@ -0,0 +1,11 @@
1
+ // The items command's actions. Each maps to exactly one Driggsby item tool
2
+ // (the same tools the Driggsby MCP server offers); describe reads those
3
+ // tools' own descriptions instead of calling one.
4
+ export const ITEM_ACTION_TOOLS = {
5
+ save: "save_transaction_items",
6
+ delete: "delete_transaction_items",
7
+ };
8
+ export const ITEM_ACTIONS = ["describe", "save", "delete"];
9
+ export function isItemAction(value) {
10
+ return ITEM_ACTIONS.includes(value);
11
+ }
@@ -0,0 +1,97 @@
1
+ // driggsby items: save and remove the items behind the signed-in person's
2
+ // charges (what an order or a receipt actually bought). Each action is one
3
+ // call to a Driggsby item tool (the same tools the Driggsby MCP server
4
+ // offers); describe prints those tools' own descriptions, so the item format
5
+ // is never copied into this CLI. stdout carries only JSON (the result, or a
6
+ // refusal's own details) so an agent can pipe or save it; every message is a
7
+ // CliError on stderr.
8
+ import { apiBaseUrl } from "../api/base-url.js";
9
+ import { CliError } from "../cli-error.js";
10
+ import { defaultCredentialEnvironment } from "../credentials/store.js";
11
+ import { deployFailure, requireDeploySession } from "../deploy/api-session.js";
12
+ import { callRuleTool, listTools } from "../rules/rules-rpc.js";
13
+ import { terminalSafeJson } from "../terminal-text.js";
14
+ import { ITEM_ACTION_TOOLS, ITEM_ACTIONS } from "./item-actions.js";
15
+ // The item tools take an optional short reason; when the caller gives none,
16
+ // this names the CLI.
17
+ export const ITEMS_REASON = "Itemizing charges from the driggsby CLI.";
18
+ export const ITEMS_NOT_AVAILABLE_MESSAGE = "This sign-in can't save items. Sign in again and approve the request:\n" +
19
+ " npx driggsby@latest login\n" +
20
+ "If items still aren't available after that, they aren't open to your account yet.";
21
+ function defaultItemsIo() {
22
+ return {
23
+ out: (text) => {
24
+ process.stdout.write(text);
25
+ },
26
+ };
27
+ }
28
+ export async function runItems(options, environment = defaultCredentialEnvironment(), io = defaultItemsIo()) {
29
+ const { action, params } = options;
30
+ if (action === "delete" && options.yes !== true) {
31
+ throw new CliError("Removing a charge's items can't be undone, so it needs --yes.", 2);
32
+ }
33
+ const baseUrl = apiBaseUrl(environment.env);
34
+ const retryCommand = retryCommandFor(options);
35
+ try {
36
+ const session = await requireDeploySession(environment);
37
+ if (action === "describe") {
38
+ const tools = await listTools(session, new Set(Object.values(ITEM_ACTION_TOOLS)), ITEMS_NOT_AVAILABLE_MESSAGE);
39
+ io.out(`${terminalSafeJson(describePayload(tools))}\n`);
40
+ return 0;
41
+ }
42
+ const outcome = await callRuleTool(session, ITEM_ACTION_TOOLS[action], withReason(params));
43
+ if (outcome.kind === "ok") {
44
+ io.out(`${terminalSafeJson(outcome.result)}\n`);
45
+ return 0;
46
+ }
47
+ if (outcome.kind === "refused") {
48
+ io.out(`${terminalSafeJson(outcome.details)}\n`);
49
+ }
50
+ throw new CliError(outcome.message, 1);
51
+ }
52
+ catch (error) {
53
+ throw deployFailure(error, baseUrl, retryCommand);
54
+ }
55
+ }
56
+ // Each action's flags, keyed by action so a new action can't be left out.
57
+ const ACTION_FLAGS = {
58
+ save: " --params <JSON>",
59
+ delete: " --params <JSON> --yes",
60
+ };
61
+ // What describe prints: the tools' own descriptions, plus the CLI command
62
+ // that runs each tool the descriptions name.
63
+ export function describePayload(tools) {
64
+ const howToRun = {};
65
+ for (const action of ITEM_ACTIONS) {
66
+ if (action !== "describe") {
67
+ howToRun[ITEM_ACTION_TOOLS[action]] = `npx driggsby@latest items ${action}${ACTION_FLAGS[action]}`;
68
+ }
69
+ }
70
+ // The two read tools the item descriptions send an agent to: finding the
71
+ // charges, and reading their items back.
72
+ howToRun.search_cash_transactions = "npx driggsby@latest query search_cash_transactions --params <JSON>";
73
+ howToRun.query_item_sql = "npx driggsby@latest query query_item_sql --sql <SQL>";
74
+ return {
75
+ how_to_run: howToRun,
76
+ notes: [
77
+ "--params is the tool's arguments object as JSON; --params-file <PATH> reads the same object from a file.",
78
+ "The CLI fills in reason when you leave it out.",
79
+ ],
80
+ tools,
81
+ };
82
+ }
83
+ function withReason(params) {
84
+ const reason = params.reason;
85
+ if (typeof reason === "string" && reason.trim() !== "") {
86
+ return params;
87
+ }
88
+ return { ...params, reason: ITEMS_REASON };
89
+ }
90
+ // The command to run again after a network blip. Params are never rebuilt
91
+ // into a shell line (no quoting is right for every shell), so they are named
92
+ // in a plain sentence under the command instead. A retried save is safe:
93
+ // saving a charge again replaces its items rather than adding to them.
94
+ function retryCommandFor(options) {
95
+ const command = `npx driggsby@latest items ${options.action}${options.action === "delete" ? " --yes" : ""}`;
96
+ return Object.keys(options.params).length === 0 ? command : `${command}\n with the same params as before`;
97
+ }
package/dist/main.js CHANGED
@@ -11,6 +11,7 @@ import { runInit } from "./init/init-command.js";
11
11
  import { runRollback } from "./deploy/rollback-command.js";
12
12
  import { runVersions } from "./deploy/versions-command.js";
13
13
  import { runQuery } from "./query/query-command.js";
14
+ import { runItems } from "./items/items-command.js";
14
15
  import { runRules } from "./rules/rules-command.js";
15
16
  import { runLogin, runLoginWithCode } from "./login/login.js";
16
17
  import { runLogout } from "./login/logout.js";
@@ -51,6 +52,8 @@ async function run(argv) {
51
52
  return await runQuery({ tool: command.tool, params: command.params });
52
53
  case "rules":
53
54
  return await runRules({ action: command.action, params: command.params, yes: command.yes });
55
+ case "items":
56
+ return await runItems({ action: command.action, params: command.params, yes: command.yes });
54
57
  }
55
58
  }
56
59
  try {
@@ -1,4 +1,4 @@
1
- // The rules command's channel to Driggsby: one JSON-RPC POST to /mcp with
1
+ // The rules and items commands' channel to Driggsby: one JSON-RPC POST to /mcp with
2
2
  // the saved sign-in. Unlike the dev preview's broker, a tool refusal keeps
3
3
  // its structured details (a blocking suggestion and its options, the
4
4
  // overlapping rule), because an agent needs them to answer and save again.
@@ -29,13 +29,17 @@ export async function callRuleTool(session, tool, toolArguments) {
29
29
  // this sign-in sees. None listed means the sign-in predates rules or rules
30
30
  // aren't open to the account; either way the next step is the same.
31
31
  export async function listRuleTools(session) {
32
+ return listTools(session, new Set(Object.values(RULE_ACTION_TOOLS)), RULES_NOT_AVAILABLE_MESSAGE);
33
+ }
34
+ // The wanted tools' own descriptions from this sign-in's tools/list (the
35
+ // rules and items commands each describe their own tools this way).
36
+ export async function listTools(session, wanted, notAvailableMessage) {
32
37
  const payload = await postMcp(session, { jsonrpc: "2.0", id: 1, method: "tools/list" });
33
38
  const tools = asRecord(asRecord(payload)?.result)?.tools;
34
39
  if (!Array.isArray(tools)) {
35
40
  throw new CliError(GENERIC_TOOL_TROUBLE, 1);
36
41
  }
37
42
  const listed = tools;
38
- const wanted = new Set(Object.values(RULE_ACTION_TOOLS));
39
43
  const found = listed.flatMap((tool) => {
40
44
  const record = asRecord(tool);
41
45
  if (record === null ||
@@ -54,7 +58,7 @@ export async function listRuleTools(session) {
54
58
  ];
55
59
  });
56
60
  if (found.length === 0) {
57
- throw new CliError(RULES_NOT_AVAILABLE_MESSAGE, 1);
61
+ throw new CliError(notAvailableMessage, 1);
58
62
  }
59
63
  return found;
60
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driggsby",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "description": "Set up and build on Driggsby, the MCP server for personal finance.",
5
5
  "license": "Apache-2.0 AND OFL-1.1",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "node": ">=18"
31
31
  },
32
32
  "dependencies": {
33
- "@driggsby/deploy": "0.7.5",
34
- "@driggsby/sdk": "0.7.5"
33
+ "@driggsby/deploy": "0.8.0",
34
+ "@driggsby/sdk": "0.8.0"
35
35
  }
36
36
  }