driggsby 0.3.3 → 0.4.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
@@ -100,6 +100,18 @@ with Node.js 18 or newer.
100
100
  See the [GitHub repo](https://github.com/driggsby/driggsby) for
101
101
  tools, examples, and full documentation.
102
102
 
103
+ ## Transaction rules
104
+
105
+ `npx driggsby@latest rules` reads and changes your transaction rules — your
106
+ standing corrections to how Driggsby reads your transactions: a category, a
107
+ counterparty's name, or tags on a set of transactions. `rules describe`
108
+ prints the rule format and the process. `rules preview` shows exactly what a
109
+ new or changed rule would do and returns the confirm token `rules save`
110
+ needs to apply it; renaming, pausing, or resuming a rule needs its
111
+ `rule_ref` and no preview. `rules delete` needs `--yes`. Every action prints JSON. If you
112
+ signed in with an earlier version of the CLI, run
113
+ `npx driggsby@latest login` once more to use rules.
114
+
103
115
  ## License
104
116
 
105
117
  Apache-2.0
@@ -0,0 +1,182 @@
1
+ // Argv parsing for `driggsby rules <ACTION> [--params <JSON> | --params-file
2
+ // <PATH>] [--yes]`. --params is the rule tool's arguments object, verbatim;
3
+ // --params-file reads the same object from a file, for rules too big or
4
+ // too quote-heavy for a shell line. Usage errors exit 2; echoed argv is
5
+ // sanitized by the shared builders. Help, the action, and the flag rules
6
+ // are all checked before any params are parsed or any file is read.
7
+ import { closeSync, constants, fstatSync, openSync, readSync, statSync } from "node:fs";
8
+ import { helpCommand, unexpectedArgument, unexpectedFlagValue } from "./args-shared.js";
9
+ import { didYouMean } from "./clap-suggestions.js";
10
+ import { CliError } from "./cli-error.js";
11
+ import { RULES_HELP } from "./help.js";
12
+ import { isRuleAction, RULE_ACTIONS } from "./rules/rule-actions.js";
13
+ import { quotedForTerminal } from "./terminal-text.js";
14
+ const RULES_USAGE = "Usage: npx driggsby@latest rules <ACTION> [--params <JSON>] [--yes]";
15
+ // A saved rule with learned patterns and its answers is a few KB; this
16
+ // bounds a mistaken path to something like a log file.
17
+ export const MAX_PARAMS_FILE_BYTES = 1_048_576;
18
+ const MAX_PARAMS_FILE_MB = String(MAX_PARAMS_FILE_BYTES / 1_048_576);
19
+ export function parseRules(argv) {
20
+ let actionToken = null;
21
+ let source = null;
22
+ let yes = false;
23
+ let optionsEnded = false;
24
+ for (let index = 0; index < argv.length; index += 1) {
25
+ const token = argv[index] ?? "";
26
+ if (!optionsEnded && token === "--") {
27
+ optionsEnded = true;
28
+ continue;
29
+ }
30
+ if (!optionsEnded && (token === "-h" || token === "--help")) {
31
+ return helpCommand(RULES_HELP);
32
+ }
33
+ if (!optionsEnded && token === "--yes") {
34
+ yes = true;
35
+ continue;
36
+ }
37
+ if (!optionsEnded && token.startsWith("--yes=")) {
38
+ throw unexpectedFlagValue("--yes", token.slice("--yes=".length), RULES_USAGE);
39
+ }
40
+ const flag = !optionsEnded ? paramsFlag(token) : null;
41
+ if (flag !== null) {
42
+ if (source !== null) {
43
+ throw usageError("error: give the params once, with either --params or --params-file");
44
+ }
45
+ const attached = token.length > flag.length;
46
+ const value = attached ? token.slice(flag.length + 1) : argv[index + 1];
47
+ if (value === undefined || value === "" || (!attached && value.length > 1 && value.startsWith("-"))) {
48
+ throw usageError(`error: a value is required for '${flag}' but none was supplied`);
49
+ }
50
+ source = flag === "--params" ? { flag, json: value } : { flag, path: value };
51
+ index += attached ? 0 : 1;
52
+ continue;
53
+ }
54
+ if (actionToken === null && (optionsEnded || !token.startsWith("-"))) {
55
+ actionToken = token;
56
+ continue;
57
+ }
58
+ throw unexpectedArgument(token, RULES_USAGE);
59
+ }
60
+ if (actionToken === null) {
61
+ throw usageError("error: the following required arguments were not provided:\n <ACTION>");
62
+ }
63
+ if (!isRuleAction(actionToken)) {
64
+ throw unknownAction(actionToken);
65
+ }
66
+ const action = actionToken;
67
+ if (action === "describe" && source !== null) {
68
+ throw usageError("error: rules describe takes no params");
69
+ }
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.");
73
+ }
74
+ if (action !== "delete" && yes) {
75
+ throw usageError("error: '--yes' only applies to rules delete");
76
+ }
77
+ return { kind: "rules", action, params: source === null ? {} : resolveParams(source), yes };
78
+ }
79
+ // "--params", "--params=…", "--params-file", "--params-file=…" → the flag.
80
+ function paramsFlag(token) {
81
+ for (const flag of ["--params-file", "--params"]) {
82
+ if (token === flag || token.startsWith(`${flag}=`)) {
83
+ return flag;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ function resolveParams(source) {
89
+ 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.");
91
+ }
92
+ return parseParamsObject(readParamsFile(source.path), "--params-file", "");
93
+ }
94
+ function parseParamsObject(rawValue, flag, hint) {
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(rawValue);
98
+ }
99
+ catch {
100
+ parsed = null;
101
+ }
102
+ 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}`);
104
+ }
105
+ return parsed;
106
+ }
107
+ // The path is the caller's own; neither it nor the contents are ever echoed
108
+ // back. The read is bounded by bytes actually read, not by the size stat
109
+ // reports (pseudo-files report 0, and a file can grow between stat and
110
+ // read). Special files are refused before they are opened, since opening a
111
+ // FIFO blocks; on POSIX the open is also non-blocking, so a FIFO swapped in
112
+ // after the check is refused by the fstat instead of hanging.
113
+ function readParamsFile(path) {
114
+ let bytes;
115
+ try {
116
+ if (!statSync(path).isFile()) {
117
+ throw new ParamsFileProblem("the path isn't a regular file");
118
+ }
119
+ bytes = readAtMost(path, MAX_PARAMS_FILE_BYTES + 1);
120
+ }
121
+ catch (error) {
122
+ throw usageError(`error: couldn't read the --params-file: ${paramsFileProblem(error)}`);
123
+ }
124
+ 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`);
126
+ }
127
+ return decodeParamsFile(bytes);
128
+ }
129
+ class ParamsFileProblem extends Error {
130
+ }
131
+ function readAtMost(path, limit) {
132
+ const descriptor = openSync(path, process.platform === "win32" ? "r" : constants.O_RDONLY | constants.O_NONBLOCK);
133
+ try {
134
+ if (!fstatSync(descriptor).isFile()) {
135
+ throw new ParamsFileProblem("the path isn't a regular file");
136
+ }
137
+ const buffer = Buffer.alloc(limit);
138
+ let length = 0;
139
+ while (length < limit) {
140
+ const read = readSync(descriptor, buffer, length, limit - length, null);
141
+ if (read === 0) {
142
+ break;
143
+ }
144
+ length += read;
145
+ }
146
+ return buffer.subarray(0, length);
147
+ }
148
+ finally {
149
+ closeSync(descriptor);
150
+ }
151
+ }
152
+ function paramsFileProblem(error) {
153
+ if (error instanceof ParamsFileProblem) {
154
+ return error.message;
155
+ }
156
+ const code = error instanceof Error && "code" in error ? error.code : undefined;
157
+ if (code === "ENOENT") {
158
+ return "there's no file at that path";
159
+ }
160
+ if (code === "EACCES" || code === "EPERM") {
161
+ return "this account isn't allowed to read it";
162
+ }
163
+ return `it must be a readable JSON file under ${MAX_PARAMS_FILE_MB} MB`;
164
+ }
165
+ // Editors and shells on Windows often save "UTF-8" with a byte-order mark,
166
+ // and Windows PowerShell 5.1's > writes UTF-16LE; both read as the JSON
167
+ // they hold.
168
+ function decodeParamsFile(bytes) {
169
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) {
170
+ return bytes.subarray(2).toString("utf16le");
171
+ }
172
+ const text = bytes.toString("utf8");
173
+ return text.startsWith("\uFEFF") ? text.slice(1) : text;
174
+ }
175
+ function unknownAction(token) {
176
+ const similar = didYouMean(token, RULE_ACTIONS);
177
+ 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}`);
179
+ }
180
+ function usageError(firstLines) {
181
+ return new CliError(`${firstLines}\n\n${RULES_USAGE}\n\nFor more information, try '--help'.`, 2);
182
+ }
@@ -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 "./rules/rule-actions.js";
7
8
  import { sanitizeForTerminal } from "./terminal-text.js";
8
9
  export function helpCommand(text) {
9
10
  return { kind: "print-help", text, stream: "stdout", exitCode: 0 };
package/dist/args.js CHANGED
@@ -4,13 +4,15 @@
4
4
  // you mean" tips (via the ported Jaro similarity in clap-suggestions.ts),
5
5
  // "--" end-of-options, and short-flag cluster dispatch. Newer commands
6
6
  // (login, logout) keep the same behavior and exit codes without chasing
7
- // clap's byte-level quirks.
7
+ // clap's byte-level quirks. One deliberate difference: a misplaced flag's
8
+ // cross-level tip names only the subcommand actually typed.
8
9
  import { parseDeploy, parseRollback, parseVersions } from "./args-deploy.js";
9
10
  import { parseDev } from "./args-dev.js";
10
11
  import { parseDelete } from "./args-delete.js";
11
12
  import { parseInit } from "./args-init.js";
12
13
  import { parseMcpSetup } from "./args-mcp-setup.js";
13
14
  import { parseQuery } from "./args-query.js";
15
+ import { parseRules } from "./args-rules.js";
14
16
  import { removeDashesTip, similarArgumentTip, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
15
17
  import { didYouMean } from "./clap-suggestions.js";
16
18
  import { CliError } from "./cli-error.js";
@@ -22,7 +24,7 @@ const MCP_USAGE = "Usage: npx driggsby@latest mcp <COMMAND>";
22
24
  const ROOT_LEVEL = {
23
25
  helpText: ROOT_HELP,
24
26
  usage: ROOT_USAGE,
25
- subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions", "delete", "query"],
27
+ subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions", "delete", "query", "rules"],
26
28
  longFlags: ["help", "version"],
27
29
  subcommandFlags: [
28
30
  { name: "mcp", longFlags: ["help"] },
@@ -35,6 +37,7 @@ const ROOT_LEVEL = {
35
37
  { name: "versions", longFlags: ["help"] },
36
38
  { name: "delete", longFlags: ["yes", "help"] },
37
39
  { name: "query", longFlags: ["sql", "params", "help"] },
40
+ { name: "rules", longFlags: ["params", "params-file", "yes", "help"] },
38
41
  ],
39
42
  usagePrefix: "npx driggsby@latest",
40
43
  hasVersion: true,
@@ -104,6 +107,8 @@ function dispatchSubcommand(name, rest) {
104
107
  return parseVersions(rest);
105
108
  case "query":
106
109
  return parseQuery(rest);
110
+ case "rules":
111
+ return parseRules(rest);
107
112
  case "setup":
108
113
  return parseMcpSetup(rest);
109
114
  default:
@@ -167,18 +172,23 @@ function unknownLongFlagAtLevel(level, token, remainingArgs) {
167
172
  }
168
173
  // clap's cross-level fallback: when a LATER argv token names one of this
169
174
  // level's subcommands and that subcommand has a similar flag, point the
170
- // user at the flag's real home ("tip: 'setup --print' exists").
171
- for (const subcommand of level.subcommandFlags) {
172
- if (!remainingArgs.includes(subcommand.name)) {
173
- continue;
174
- }
175
- const subcommandSimilar = didYouMean(flagName, subcommand.longFlags);
176
- if (subcommandSimilar !== undefined) {
177
- return unexpectedArgument(shown, level.usage, ` tip: '${subcommand.name} --${subcommandSimilar}' exists`);
178
- }
175
+ // user at the flag's real home ("tip: 'setup --print' exists"). The home
176
+ // is only ever the first subcommand actually typed (a flag's value never
177
+ // counts), so `--yes rules delete` is never pointed at the app-deleting
178
+ // `delete --yes`.
179
+ const typed = firstTypedSubcommand(level, remainingArgs);
180
+ const subcommand = level.subcommandFlags.find((entry) => entry.name === typed);
181
+ const subcommandSimilar = subcommand === undefined ? undefined : didYouMean(flagName, subcommand.longFlags);
182
+ if (subcommand !== undefined && subcommandSimilar !== undefined) {
183
+ return unexpectedArgument(shown, level.usage, ` tip: '${subcommand.name} --${subcommandSimilar}' exists`);
179
184
  }
180
185
  return unexpectedArgument(shown, level.usage);
181
186
  }
187
+ // Flags anywhere in the tree whose next token is their value, not a word.
188
+ const VALUE_FLAGS = new Set(["--params", "--params-file", "--sql", "--to", "-s", "--scope"]);
189
+ function firstTypedSubcommand(level, args) {
190
+ return args.find((argument, index) => level.subcommands.includes(argument) && !VALUE_FLAGS.has(args[index - 1] ?? ""));
191
+ }
182
192
  function unrecognizedSubcommand(level, subcommand) {
183
193
  const similar = didYouMean(subcommand, level.subcommands);
184
194
  const tip = similar === undefined ? "" : ` tip: a similar subcommand exists: '${similar}'\n\n`;
package/dist/help.js CHANGED
@@ -21,6 +21,7 @@ Commands:
21
21
  versions List your deployed app's versions.
22
22
  delete Permanently remove a deployed app.
23
23
  query Run one Driggsby data tool and print its result as JSON.
24
+ rules Create, change, and delete your transaction rules.
24
25
 
25
26
  Options:
26
27
  -h, --help Print help
@@ -36,6 +37,7 @@ Examples:
36
37
  npx driggsby@latest dev
37
38
  npx driggsby@latest deploy
38
39
  npx driggsby@latest query get_overview
40
+ npx driggsby@latest rules describe
39
41
  `;
40
42
  export const INIT_HELP = `Create a new Driggsby app in a new folder.
41
43
 
@@ -58,9 +60,9 @@ Options:
58
60
  export const LOGIN_HELP = `Sign in to Driggsby and save an app token on this machine.
59
61
 
60
62
  This opens a Driggsby approval page in your browser. Approving it issues an
61
- app token that lets this machine read your Driggsby data and deploy Driggsby
62
- apps. The token is saved locally for other driggsby commands and is never
63
- printed.
63
+ 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.
64
66
 
65
67
  Usage: npx driggsby@latest login
66
68
 
@@ -182,6 +184,45 @@ Examples:
182
184
  npx driggsby@latest query list_recurring_transactions
183
185
  npx driggsby@latest query query_cash_sql --sql "SELECT 1"
184
186
  `;
187
+ export const RULES_HELP = `Create, change, and delete your transaction rules.
188
+
189
+ A transaction rule is your standing correction to how Driggsby reads your
190
+ transactions: a category, a counterparty's name, or tags on a set of
191
+ transactions. Each action runs one Driggsby rule tool and prints its result
192
+ as JSON; the JSON goes to stdout and nothing else does. Start with describe:
193
+ it prints the rule format, the process, and worked examples.
194
+
195
+ Sign in first with npx driggsby@latest login.
196
+
197
+ Usage: npx driggsby@latest rules <ACTION> [--params <JSON>] [--yes]
198
+
199
+ Actions:
200
+ describe Print the rule format, the process, and examples.
201
+ list List your rules. With {"rule_ref":"..."}, one rule in full.
202
+ tags List your transaction tags.
203
+ preview Show exactly what a rule would change. Saves nothing; returns
204
+ the confirm_token that save needs.
205
+ save Create or change a rule with a preview's confirm_token, or
206
+ rename, pause, or resume one by rule_ref.
207
+ delete Delete a rule by rule_ref. There is no undo; needs --yes.
208
+
209
+ Options:
210
+ --params <JSON> The action's params as a JSON object, like
211
+ '{"rule_ref":"rule_..."}'.
212
+ --params-file <PATH> Read the params object from a JSON file instead.
213
+ --yes Confirm a delete.
214
+ -h, --help Print help
215
+
216
+ If Driggsby refuses a change with details to act on (a question to answer,
217
+ an overlapping rule), stdout carries those details as JSON. Any failure puts
218
+ its message on stderr and exits 1.
219
+
220
+ Examples:
221
+ npx driggsby@latest rules describe
222
+ npx driggsby@latest rules list
223
+ npx driggsby@latest rules tags
224
+ npx driggsby@latest rules save --params-file answers.json
225
+ `;
185
226
  export const MCP_HELP = `Set up Driggsby MCP clients.
186
227
 
187
228
  Usage: npx driggsby@latest mcp <COMMAND>
@@ -5,7 +5,8 @@
5
5
  // an approval.
6
6
  import { CliError } from "../cli-error.js";
7
7
  import { sanitizeForTerminal } from "../terminal-text.js";
8
- const CLAIM_SCOPE = "driggsby.app_deploy";
8
+ // The CLI's own sign-in: read, deploy, and the transaction-rule tools.
9
+ const CLAIM_SCOPE = "driggsby.cli";
9
10
  const CLAIM_APP_NAME = "Driggsby CLI";
10
11
  const REQUEST_TIMEOUT_MS = 15_000;
11
12
  // The server names its own claim lifetime; this cap keeps a buggy or hostile
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 { runRules } from "./rules/rules-command.js";
14
15
  import { runLogin } from "./login/login.js";
15
16
  import { runLogout } from "./login/logout.js";
16
17
  import { runMcpSetup } from "./mcp-setup/setup.js";
@@ -48,6 +49,8 @@ async function run(argv) {
48
49
  return await runVersions({});
49
50
  case "query":
50
51
  return await runQuery({ tool: command.tool, params: command.params });
52
+ case "rules":
53
+ return await runRules({ action: command.action, params: command.params, yes: command.yes });
51
54
  }
52
55
  }
53
56
  try {
@@ -10,7 +10,7 @@ import { McpBroker } from "../dev/mcp-broker.js";
10
10
  import { SQL_TOOLS } from "../args-query.js";
11
11
  import { APP_TOOL_ALLOWLIST } from "../dev/tool-allowlist.js";
12
12
  import { deployFailure, requireDeploySession, SIGN_IN_AGAIN_MESSAGE } from "../deploy/api-session.js";
13
- import { quotedForTerminal, wrapProse } from "../terminal-text.js";
13
+ import { quotedForTerminal, terminalSafeJson, wrapProse } from "../terminal-text.js";
14
14
  // The Driggsby endpoint requires a short reason on most read tools; when
15
15
  // the caller gives none, this names the CLI rather than the dev preview.
16
16
  const QUERY_REASON = "Checking this tool's result shape from the driggsby CLI.";
@@ -52,7 +52,7 @@ export async function runQuery(options, environment = defaultCredentialEnvironme
52
52
  : wrapProse(outcome.error.message);
53
53
  throw new CliError(message, 1);
54
54
  }
55
- io.out(`${JSON.stringify(outcome.result, null, 2)}\n`);
55
+ io.out(`${terminalSafeJson(outcome.result)}\n`);
56
56
  return 0;
57
57
  }
58
58
  catch (error) {
@@ -0,0 +1,14 @@
1
+ // The rules command's actions. Each maps to exactly one Driggsby
2
+ // transaction-rule tool (the same tools the Driggsby MCP server offers);
3
+ // describe reads those tools' own descriptions instead of calling one.
4
+ export const RULE_ACTION_TOOLS = {
5
+ list: "list_transaction_rules",
6
+ tags: "list_transaction_tags",
7
+ preview: "preview_transaction_rule",
8
+ save: "save_transaction_rule",
9
+ delete: "delete_transaction_rule",
10
+ };
11
+ export const RULE_ACTIONS = ["describe", "list", "tags", "preview", "save", "delete"];
12
+ export function isRuleAction(value) {
13
+ return RULE_ACTIONS.includes(value);
14
+ }
@@ -0,0 +1,96 @@
1
+ // driggsby rules: read and change the signed-in person's transaction
2
+ // rules. Each action is one call to a Driggsby transaction-rule tool (the
3
+ // same tools the Driggsby MCP server offers); describe prints those tools'
4
+ // own descriptions, so the rule format is never copied into this CLI.
5
+ // stdout carries only JSON (the result, or a refusal's own details) so an
6
+ // agent can pipe or save it; every message is a CliError on stderr.
7
+ import { apiBaseUrl } from "../api/base-url.js";
8
+ import { CliError } from "../cli-error.js";
9
+ import { defaultCredentialEnvironment } from "../credentials/store.js";
10
+ import { deployFailure, requireDeploySession } from "../deploy/api-session.js";
11
+ import { terminalSafeJson } from "../terminal-text.js";
12
+ import { RULE_ACTION_TOOLS, RULE_ACTIONS } from "./rule-actions.js";
13
+ import { callRuleTool, listRuleTools } from "./rules-rpc.js";
14
+ // Most rule tools require a short reason; when the caller gives none, this
15
+ // names the CLI.
16
+ export const RULES_REASON = "Managing transaction rules from the driggsby CLI.";
17
+ function defaultRulesIo() {
18
+ return {
19
+ out: (text) => {
20
+ process.stdout.write(text);
21
+ },
22
+ };
23
+ }
24
+ export async function runRules(options, environment = defaultCredentialEnvironment(), io = defaultRulesIo()) {
25
+ const { action, params } = options;
26
+ if (action === "delete" && options.yes !== true) {
27
+ throw new CliError("Deleting a rule can't be undone, so it needs --yes.", 2);
28
+ }
29
+ const baseUrl = apiBaseUrl(environment.env);
30
+ const retryCommand = retryCommandFor(options);
31
+ try {
32
+ const session = await requireDeploySession(environment);
33
+ if (action === "describe") {
34
+ const tools = await listRuleTools(session);
35
+ io.out(`${terminalSafeJson(describePayload(tools))}\n`);
36
+ return 0;
37
+ }
38
+ const outcome = await callRuleTool(session, RULE_ACTION_TOOLS[action], withReason(params));
39
+ if (outcome.kind === "ok") {
40
+ io.out(`${terminalSafeJson(outcome.result)}\n`);
41
+ return 0;
42
+ }
43
+ if (outcome.kind === "refused") {
44
+ io.out(`${terminalSafeJson(outcome.details)}\n`);
45
+ }
46
+ throw new CliError(outcome.message, 1);
47
+ }
48
+ catch (error) {
49
+ throw deployFailure(error, baseUrl, retryCommand);
50
+ }
51
+ }
52
+ // Each action's flags, keyed by action so a new action can't be left out.
53
+ const ACTION_FLAGS = {
54
+ list: " [--params <JSON>]",
55
+ tags: "",
56
+ preview: " --params <JSON>",
57
+ save: " --params <JSON>",
58
+ delete: " --params <JSON> --yes",
59
+ };
60
+ // What describe prints: the tools' own descriptions, plus the CLI command
61
+ // that runs each tool the descriptions name.
62
+ export function describePayload(tools) {
63
+ const howToRun = {};
64
+ for (const action of RULE_ACTIONS) {
65
+ if (action !== "describe") {
66
+ howToRun[RULE_ACTION_TOOLS[action]] = `npx driggsby@latest rules ${action}${ACTION_FLAGS[action]}`;
67
+ }
68
+ }
69
+ // The two read tools the rule descriptions send an agent to first.
70
+ howToRun.search_cash_transactions = "npx driggsby@latest query search_cash_transactions --params <JSON>";
71
+ howToRun.query_cash_sql = "npx driggsby@latest query query_cash_sql --sql <SQL>";
72
+ return {
73
+ how_to_run: howToRun,
74
+ notes: [
75
+ "--params is the tool's arguments object as JSON; --params-file <PATH> reads the same object from a file.",
76
+ "The CLI fills in reason when you leave it out.",
77
+ ],
78
+ tools,
79
+ };
80
+ }
81
+ function withReason(params) {
82
+ const reason = params.reason;
83
+ if (typeof reason === "string" && reason.trim() !== "") {
84
+ return params;
85
+ }
86
+ return { ...params, reason: RULES_REASON };
87
+ }
88
+ // The command to run again after a network blip. Params are never rebuilt
89
+ // into a shell line (no quoting is right for every shell), so they are named
90
+ // in a plain sentence under the command instead. A retried save is safe: a
91
+ // confirm token redeems once, so a save that already landed is refused, not
92
+ // applied twice.
93
+ function retryCommandFor(options) {
94
+ const command = `npx driggsby@latest rules ${options.action}${options.action === "delete" ? " --yes" : ""}`;
95
+ return Object.keys(options.params).length === 0 ? command : `${command}\n with the same params as before`;
96
+ }
@@ -0,0 +1,132 @@
1
+ // The rules command's channel to Driggsby: one JSON-RPC POST to /mcp with
2
+ // the saved sign-in. Unlike the dev preview's broker, a tool refusal keeps
3
+ // its structured details (a blocking suggestion and its options, the
4
+ // overlapping rule), because an agent needs them to answer and save again.
5
+ import { CliError } from "../cli-error.js";
6
+ import { SIGN_IN_AGAIN_MESSAGE } from "../deploy/api-session.js";
7
+ import { GENERIC_TOOL_TROUBLE } from "../dev/dev-servers.js";
8
+ import { capForTerminal, sanitizeForTerminal } from "../terminal-text.js";
9
+ import { RULE_ACTION_TOOLS } from "./rule-actions.js";
10
+ // A preview replays the person's posted history; give it longer than a
11
+ // dashboard read gets.
12
+ export const RULES_CALL_TIMEOUT_MS = 60_000;
13
+ // Server messages are hostile text: sanitized and bounded before they reach
14
+ // the terminal. Generous, because a rule refusal explains itself.
15
+ const MAX_SERVER_MESSAGE_CHARS = 1_000;
16
+ export const RULES_NOT_AVAILABLE_MESSAGE = "This sign-in can't use transaction rules. Sign in again and approve the request:\n" +
17
+ " npx driggsby@latest login\n" +
18
+ "If rules still aren't available after that, they aren't open to your account yet.";
19
+ export async function callRuleTool(session, tool, toolArguments) {
20
+ const payload = await postMcp(session, {
21
+ jsonrpc: "2.0",
22
+ id: 1,
23
+ method: "tools/call",
24
+ params: { name: tool, arguments: toolArguments },
25
+ });
26
+ return interpretToolCall(payload);
27
+ }
28
+ // The rule tools' own descriptions and input schemas, from the tools/list
29
+ // this sign-in sees. None listed means the sign-in predates rules or rules
30
+ // aren't open to the account; either way the next step is the same.
31
+ export async function listRuleTools(session) {
32
+ const payload = await postMcp(session, { jsonrpc: "2.0", id: 1, method: "tools/list" });
33
+ const tools = asRecord(asRecord(payload)?.result)?.tools;
34
+ if (!Array.isArray(tools)) {
35
+ throw new CliError(GENERIC_TOOL_TROUBLE, 1);
36
+ }
37
+ const listed = tools;
38
+ const wanted = new Set(Object.values(RULE_ACTION_TOOLS));
39
+ const found = listed.flatMap((tool) => {
40
+ const record = asRecord(tool);
41
+ if (record === null ||
42
+ typeof record.name !== "string" ||
43
+ !wanted.has(record.name) ||
44
+ typeof record.description !== "string") {
45
+ return [];
46
+ }
47
+ return [
48
+ {
49
+ name: record.name,
50
+ title: typeof record.title === "string" ? record.title : null,
51
+ description: record.description,
52
+ inputSchema: record.inputSchema ?? null,
53
+ },
54
+ ];
55
+ });
56
+ if (found.length === 0) {
57
+ throw new CliError(RULES_NOT_AVAILABLE_MESSAGE, 1);
58
+ }
59
+ return found;
60
+ }
61
+ // Three shapes: a protocol error ({ error: { message } }), a tool refusal
62
+ // (result.isError, details in structuredContent), and success.
63
+ export function interpretToolCall(payload) {
64
+ const envelope = asRecord(payload);
65
+ if (envelope === null) {
66
+ return { kind: "error", message: GENERIC_TOOL_TROUBLE };
67
+ }
68
+ const error = asRecord(envelope.error);
69
+ if (error !== null) {
70
+ return { kind: "error", message: serverMessage(error.message) };
71
+ }
72
+ const result = asRecord(envelope.result);
73
+ if (result === null) {
74
+ return { kind: "error", message: GENERIC_TOOL_TROUBLE };
75
+ }
76
+ const structured = result.structuredContent ?? null;
77
+ if (result.isError === true) {
78
+ const message = asRecord(structured)?.error ?? firstText(result.content);
79
+ return { kind: "refused", message: serverMessage(message), details: structured };
80
+ }
81
+ return { kind: "ok", result: structured };
82
+ }
83
+ // A refusal's message lives in structuredContent.error; the MCP text
84
+ // content is the fallback, the same one the dev preview's broker reads.
85
+ function firstText(content) {
86
+ return Array.isArray(content) ? asRecord(content[0])?.text : undefined;
87
+ }
88
+ async function postMcp(session, body) {
89
+ // Bare headers on purpose: the Driggsby endpoint refuses anything that
90
+ // looks like a browser call (an Origin or Sec-Fetch-Site header), and
91
+ // Node's fetch sends neither on its own.
92
+ const response = await fetch(new URL("/mcp", session.baseUrl), {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.token}` },
95
+ body: JSON.stringify(body),
96
+ // The bearer token must never travel to a redirect target.
97
+ redirect: "error",
98
+ signal: AbortSignal.timeout(RULES_CALL_TIMEOUT_MS),
99
+ });
100
+ if (response.status === 401) {
101
+ throw new CliError(SIGN_IN_AGAIN_MESSAGE, 1);
102
+ }
103
+ if (!response.ok) {
104
+ throw new CliError(GENERIC_TOOL_TROUBLE, 1);
105
+ }
106
+ try {
107
+ const parsed = await response.json();
108
+ return parsed;
109
+ }
110
+ catch {
111
+ throw new CliError(GENERIC_TOOL_TROUBLE, 1);
112
+ }
113
+ }
114
+ // capForTerminal strips control and bidi bytes, then bounds the length.
115
+ // Deliberately unwrapped: a server message can end in a command
116
+ // (npx driggsby@latest login), and a hard wrap could split it; the terminal
117
+ // soft-wraps a long line and copy-paste stays intact.
118
+ function serverMessage(value) {
119
+ if (typeof value !== "string") {
120
+ return GENERIC_TOOL_TROUBLE;
121
+ }
122
+ const clean = sanitizeForTerminal(value).trim();
123
+ if (clean === "") {
124
+ return GENERIC_TOOL_TROUBLE;
125
+ }
126
+ return clean.length > MAX_SERVER_MESSAGE_CHARS ? `${capForTerminal(clean, MAX_SERVER_MESSAGE_CHARS - 1)}…` : clean;
127
+ }
128
+ function asRecord(value) {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value)
130
+ ? value
131
+ : null;
132
+ }
@@ -32,3 +32,14 @@ export function wrapNames(names, indent) {
32
32
  lines.push(line);
33
33
  return lines.join("\n");
34
34
  }
35
+ // JSON.stringify escapes C0 controls but passes C1 controls, bidi
36
+ // overrides, and invisible code points through raw; server data is hostile
37
+ // (a counterparty's name comes from a bank), so escape those too, the same
38
+ // class sanitizeForTerminal strips from prose. They can only sit inside JSON
39
+ // strings, so the output stays valid JSON that parses to the same value.
40
+ // Each UTF-16 unit is escaped on its own, so an astral code point (a tag
41
+ // character) becomes a valid surrogate-pair escape.
42
+ const TERMINAL_UNSAFE_IN_JSON = /[\u007f-\u009f\u2028\u2029\ufff9-\ufffb]|\p{Default_Ignorable_Code_Point}/gu;
43
+ export function terminalSafeJson(value) {
44
+ return JSON.stringify(value ?? null, null, 2).replace(TERMINAL_UNSAFE_IN_JSON, (match) => Array.from({ length: match.length }, (_, index) => `\\u${match.charCodeAt(index).toString(16).padStart(4, "0")}`).join(""));
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driggsby",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Set up and build on Driggsby, the MCP server for personal finance.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "node": ">=18"
29
29
  },
30
30
  "dependencies": {
31
- "@driggsby/deploy": "0.3.3",
32
- "@driggsby/sdk": "0.3.3"
31
+ "@driggsby/deploy": "0.4.0",
32
+ "@driggsby/sdk": "0.4.0"
33
33
  }
34
34
  }