openclaw-ynabro 2.0.0 → 2.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.
package/README.md CHANGED
@@ -19,6 +19,7 @@ openclaw plugins install openclaw-ynabro
19
19
  ## Available Tools
20
20
 
21
21
  - `ynabro_setup`
22
+ - `ynabro_save_default_plan`
22
23
  - `ynabro_get_pending_transactions`
23
24
  - `ynabro_get_recent_transactions`
24
25
  - `ynabro_approve_transaction`
@@ -26,6 +27,34 @@ openclaw plugins install openclaw-ynabro
26
27
  - `ynabro_get_skill_state`
27
28
  - `ynabro_update_skill_state`
28
29
 
29
- ## Requirements
30
+ ## Configuration
31
+
32
+ Set your YNAB Personal Access Token in `openclaw.json`:
33
+
34
+ ```json
35
+ {
36
+ "plugins": {
37
+ "entries": {
38
+ "openclaw-ynabro": {
39
+ "config": {
40
+ "token": "your-ynab-personal-access-token"
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ OpenClaw also surfaces this as a sensitive field in its settings UI (labeled "YNAB Personal Access Token"). Generate a token at https://app.ynab.com/settings/developer.
49
+
50
+ No environment variable fallback is supported.
51
+
52
+ ## Onboarding
53
+
54
+ Run `ynabro_setup` to fetch your available YNAB plans, then `ynabro_save_default_plan` with the plan ID you want to use as the default:
55
+
56
+ 1. `ynabro_setup` — returns `{ plans: [{ id, name }] }`
57
+ 2. User (or agent) selects a plan from the list
58
+ 3. `ynabro_save_default_plan` with `{ planId: "<selected-id>" }` — persists the default
30
59
 
31
- - `YNAB_TOKEN` environment variable must be set
60
+ After onboarding, all plan-dependent tools (`ynabro_get_pending_transactions`, `ynabro_get_recent_transactions`, `ynabro_approve_transaction`, `ynabro_get_plan_info`) resolve the plan automatically — no `planId` parameter required.
@@ -5,6 +5,45 @@
5
5
  "skills": ["skills"],
6
6
  "configSchema": {
7
7
  "type": "object",
8
- "additionalProperties": false
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "token": {
11
+ "type": "string",
12
+ "description": "YNAB Personal Access Token"
13
+ },
14
+ "defaultPlanId": {
15
+ "type": "string",
16
+ "description": "Default YNAB plan ID selected during onboarding"
17
+ }
18
+ }
19
+ },
20
+ "uiHints": {
21
+ "token": {
22
+ "label": "YNAB Personal Access Token",
23
+ "placeholder": "your-ynab-personal-access-token",
24
+ "sensitive": true,
25
+ "help": "Generate at https://app.ynab.com/settings/developer. Falls back to YNAB_TOKEN env var if not set here."
26
+ }
27
+ },
28
+ "setup": {
29
+ "providers": [
30
+ {
31
+ "id": "ynab",
32
+ "envVars": ["YNAB_TOKEN"]
33
+ }
34
+ ]
35
+ },
36
+ "contracts": {
37
+ "tools": [
38
+ "ynabro_onboarding_status",
39
+ "ynabro_setup",
40
+ "ynabro_save_default_plan",
41
+ "ynabro_get_pending_transactions",
42
+ "ynabro_get_recent_transactions",
43
+ "ynabro_approve_transaction",
44
+ "ynabro_get_plan_info",
45
+ "ynabro_get_skill_state",
46
+ "ynabro_update_skill_state"
47
+ ]
9
48
  }
10
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-ynabro",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "OpenClaw plugin that registers YNABro tools for YNAB integration",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -8,12 +8,29 @@ You are **YNABro**, a friendly and reliable YNAB assistant designed to help user
8
8
 
9
9
  ## Onboarding & Access
10
10
 
11
- Before performing any YNAB operations, check whether YNAB access has been set up:
11
+ Before performing any YNAB operations, call `ynabro_onboarding_status` to check
12
+ whether YNAB access is configured.
12
13
 
13
- 1. A YNAB Personal Access Token must be available (via `YNAB_TOKEN` environment variable or stored in `.ynabro/config.json`).
14
- 2. A default plan must be selected and saved.
14
+ If `ready` is `false`, walk the user through setup:
15
15
 
16
- If either is missing, call the `setupYnab()` tool first. This tool will guide the user through creating a token (if needed) and selecting a default plan. Do not attempt to read or modify transactions until setup is complete.
16
+ 1. **Missing token:** Share the `tokenInstructions` field from the status response.
17
+ The token must never be entered into the chat.
18
+ - **pi:** Call `ynabro_setup` — it presents a native TUI input popup where the
19
+ user enters the token directly. It goes straight to pi's AuthStorage and
20
+ never appears in the conversation.
21
+ - **OpenClaw:** Instruct the user to add the token to `openclaw.json` or via
22
+ the OpenClaw settings UI, then ask them to confirm when done.
23
+
24
+ 2. **Missing plan:** Call `ynabro_setup` to list available plans. Help the user
25
+ pick one. On OpenClaw, follow up with `ynabro_save_default_plan`.
26
+
27
+ 3. **After onboarding completes:** If the user's original message was a functional
28
+ request (e.g., "show my pending transactions"), fulfill it immediately.
29
+ Do not make them repeat themselves.
30
+
31
+ If a tool returns `{ "error": "onboarding_required" }` during a conversation,
32
+ treat it the same as a failed status check — initiate onboarding, then retry
33
+ the original operation.
17
34
 
18
35
  ## Core Capabilities
19
36
  - Help users view, update, and manage all aspects of their YNAB budget, including:
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
3
+ import type { YnabroConfigAdapter } from "ynabro";
3
4
  import {
4
5
  approveTransaction,
6
+ checkOnboardingStatus,
5
7
  getPendingTransactions,
6
8
  getPlanInfo,
7
9
  getRecentTransactions,
@@ -11,12 +13,6 @@ import {
11
13
  YnabroClient,
12
14
  } from "ynabro";
13
15
 
14
- function getClient(): YnabroClient {
15
- const token = process.env.YNAB_TOKEN;
16
- if (!token) throw new Error("YNAB_TOKEN environment variable is not set");
17
- return new YnabroClient(token);
18
- }
19
-
20
16
  function params(raw: unknown): Record<string, unknown> {
21
17
  return raw as Record<string, unknown>;
22
18
  }
@@ -28,17 +24,18 @@ function ok(text: string) {
28
24
  };
29
25
  }
30
26
 
31
- const planIdSchema = Type.Object({
32
- planId: Type.String({ description: "The ID of the YNAB plan" }),
33
- });
34
-
35
27
  const approveSchema = Type.Object({
36
- planId: Type.String({ description: "The ID of the YNAB plan" }),
37
28
  transactionId: Type.String({
38
29
  description: "The ID of the transaction to approve",
39
30
  }),
40
31
  });
41
32
 
33
+ const saveDefaultPlanSchema = Type.Object({
34
+ planId: Type.String({
35
+ description: "The ID of the YNAB plan to set as the default",
36
+ }),
37
+ });
38
+
42
39
  const skillStateSchema = Type.Object({
43
40
  skillSlug: Type.String({
44
41
  description: "The slug identifier for the skill",
@@ -64,15 +61,132 @@ export default definePluginEntry({
64
61
  name: "YNABro",
65
62
  description: "YNAB budget management tools for OpenClaw agents",
66
63
  register(api) {
64
+ let cachedPlanId: string | undefined;
65
+
66
+ const openClawAdapter: YnabroConfigAdapter = {
67
+ async getDefaultPlanId(): Promise<string | undefined> {
68
+ if (cachedPlanId !== undefined) return cachedPlanId;
69
+ return (api.pluginConfig as { defaultPlanId?: string } | undefined)
70
+ ?.defaultPlanId;
71
+ },
72
+ async setDefaultPlanId(planId: string): Promise<void> {
73
+ cachedPlanId = planId;
74
+ await api.runtime.config.mutateConfigFile({
75
+ afterWrite: { mode: "auto" },
76
+ mutate: (draft) => {
77
+ // Ensure the path exists before writing
78
+ if (!draft.plugins) {
79
+ (draft as { plugins?: unknown }).plugins = {
80
+ entries: {},
81
+ } as unknown;
82
+ }
83
+ // TypeScript flow analysis requires explicit check after conditional assignment
84
+ if (draft.plugins) {
85
+ if (!draft.plugins.entries) {
86
+ draft.plugins.entries = {};
87
+ }
88
+ const existing = draft.plugins.entries["openclaw-ynabro"] ?? {};
89
+ const existingConfig =
90
+ (existing.config as Record<string, unknown> | undefined) ?? {};
91
+ existingConfig.defaultPlanId = planId;
92
+ draft.plugins.entries["openclaw-ynabro"] = {
93
+ ...existing,
94
+ config: existingConfig,
95
+ };
96
+ }
97
+ },
98
+ });
99
+ },
100
+ async hasToken(): Promise<boolean> {
101
+ return !!(api.pluginConfig as { token?: string } | undefined)?.token;
102
+ },
103
+ };
104
+
105
+ function getClient(): YnabroClient {
106
+ const token = (api.pluginConfig as { token?: string } | undefined)?.token;
107
+ if (!token) {
108
+ throw new Error(
109
+ "YNAB token not configured. " +
110
+ "Set it via plugins.entries.openclaw-ynabro.config.token in openclaw.json.",
111
+ );
112
+ }
113
+ return new YnabroClient(token);
114
+ }
115
+
116
+ async function getDefaultPlanId(): Promise<string> {
117
+ const planId = await openClawAdapter.getDefaultPlanId();
118
+ if (!planId) {
119
+ throw new Error(
120
+ "No default plan configured. Run ynabro_setup then ynabro_save_default_plan to complete onboarding.",
121
+ );
122
+ }
123
+ return planId;
124
+ }
125
+
126
+ api.registerTool({
127
+ name: "ynabro_onboarding_status",
128
+ label: "Onboarding Status",
129
+ description:
130
+ "Check whether YNABro is fully configured. Returns ready status, any missing configuration, and token generation instructions.",
131
+ parameters: Type.Object({}),
132
+ async execute() {
133
+ const status = await checkOnboardingStatus(openClawAdapter);
134
+ return ok(JSON.stringify(status));
135
+ },
136
+ });
137
+
67
138
  api.registerTool({
68
139
  name: "ynabro_setup",
69
140
  label: "Setup YNAB",
70
141
  description:
71
- "Set up YNAB integration checks for token and selects a default plan",
142
+ "Fetch available YNAB plans for onboarding. Returns a list of plans. " +
143
+ "After the user selects one, call ynabro_save_default_plan with the chosen plan ID.",
72
144
  parameters: Type.Object({}),
73
145
  async execute() {
74
- const result = await setupYnab();
75
- return ok(JSON.stringify(result, null, 2));
146
+ const client = getClient();
147
+ const plans = await client.getPlans();
148
+ if (plans.length === 0) {
149
+ return ok(
150
+ JSON.stringify({ error: "No plans found in your YNAB account." }),
151
+ );
152
+ }
153
+ return ok(
154
+ JSON.stringify({
155
+ plans: plans.map((p) => ({ id: p.id, name: p.name })),
156
+ }),
157
+ );
158
+ },
159
+ });
160
+
161
+ api.registerTool({
162
+ name: "ynabro_save_default_plan",
163
+ label: "Save Default Plan",
164
+ description:
165
+ "Save a YNAB plan as the default for all subsequent tool calls. " +
166
+ "Call ynabro_setup first to get the list of available plan IDs.",
167
+ parameters: saveDefaultPlanSchema,
168
+ async execute(_id, raw) {
169
+ try {
170
+ const p = params(raw);
171
+ const client = getClient();
172
+ const plans = await client.getPlans();
173
+ await setupYnab(client, plans, p.planId as string, openClawAdapter);
174
+ const saved = plans.find((plan) => plan.id === p.planId);
175
+ return ok(
176
+ JSON.stringify({
177
+ message: `Default plan set to: ${saved?.name ?? p.planId}`,
178
+ defaultPlanId: p.planId,
179
+ }),
180
+ );
181
+ } catch (_error) {
182
+ const status = await checkOnboardingStatus(openClawAdapter);
183
+ return ok(
184
+ JSON.stringify({
185
+ error: "onboarding_required",
186
+ ...status,
187
+ }),
188
+ );
189
+ }
76
190
  },
77
191
  });
78
192
 
@@ -80,14 +194,24 @@ export default definePluginEntry({
80
194
  name: "ynabro_get_pending_transactions",
81
195
  label: "Get Pending Transactions",
82
196
  description: "Get all pending (uncategorized) transactions for a plan",
83
- parameters: planIdSchema,
84
- async execute(_id, raw) {
85
- const p = params(raw);
86
- const result = await getPendingTransactions(
87
- getClient(),
88
- p.planId as string,
89
- );
90
- return ok(JSON.stringify(result, null, 2));
197
+ parameters: Type.Object({}),
198
+ async execute() {
199
+ try {
200
+ const [client, planId] = await Promise.all([
201
+ Promise.resolve(getClient()),
202
+ getDefaultPlanId(),
203
+ ]);
204
+ const result = await getPendingTransactions(client, planId);
205
+ return ok(JSON.stringify(result, null, 2));
206
+ } catch (_error) {
207
+ const status = await checkOnboardingStatus(openClawAdapter);
208
+ return ok(
209
+ JSON.stringify({
210
+ error: "onboarding_required",
211
+ ...status,
212
+ }),
213
+ );
214
+ }
91
215
  },
92
216
  });
93
217
 
@@ -95,14 +219,24 @@ export default definePluginEntry({
95
219
  name: "ynabro_get_recent_transactions",
96
220
  label: "Get Recent Transactions",
97
221
  description: "Get recent transactions for a plan",
98
- parameters: planIdSchema,
99
- async execute(_id, raw) {
100
- const p = params(raw);
101
- const result = await getRecentTransactions(
102
- getClient(),
103
- p.planId as string,
104
- );
105
- return ok(JSON.stringify(result, null, 2));
222
+ parameters: Type.Object({}),
223
+ async execute() {
224
+ try {
225
+ const [client, planId] = await Promise.all([
226
+ Promise.resolve(getClient()),
227
+ getDefaultPlanId(),
228
+ ]);
229
+ const result = await getRecentTransactions(client, planId);
230
+ return ok(JSON.stringify(result, null, 2));
231
+ } catch (_error) {
232
+ const status = await checkOnboardingStatus(openClawAdapter);
233
+ return ok(
234
+ JSON.stringify({
235
+ error: "onboarding_required",
236
+ ...status,
237
+ }),
238
+ );
239
+ }
106
240
  },
107
241
  });
108
242
 
@@ -112,13 +246,23 @@ export default definePluginEntry({
112
246
  description: "Approve a specific transaction",
113
247
  parameters: approveSchema,
114
248
  async execute(_id, raw) {
115
- const p = params(raw);
116
- await approveTransaction(
117
- getClient(),
118
- p.planId as string,
119
- p.transactionId as string,
120
- );
121
- return ok(JSON.stringify({ success: true }));
249
+ try {
250
+ const p = params(raw);
251
+ const [client, planId] = await Promise.all([
252
+ Promise.resolve(getClient()),
253
+ getDefaultPlanId(),
254
+ ]);
255
+ await approveTransaction(client, planId, p.transactionId as string);
256
+ return ok(JSON.stringify({ success: true }));
257
+ } catch (_error) {
258
+ const status = await checkOnboardingStatus(openClawAdapter);
259
+ return ok(
260
+ JSON.stringify({
261
+ error: "onboarding_required",
262
+ ...status,
263
+ }),
264
+ );
265
+ }
122
266
  },
123
267
  });
124
268
 
@@ -126,11 +270,24 @@ export default definePluginEntry({
126
270
  name: "ynabro_get_plan_info",
127
271
  label: "Get Plan Info",
128
272
  description: "Get basic information about a plan",
129
- parameters: planIdSchema,
130
- async execute(_id, raw) {
131
- const p = params(raw);
132
- const result = await getPlanInfo(getClient(), p.planId as string);
133
- return ok(JSON.stringify(result, null, 2));
273
+ parameters: Type.Object({}),
274
+ async execute() {
275
+ try {
276
+ const [client, planId] = await Promise.all([
277
+ Promise.resolve(getClient()),
278
+ getDefaultPlanId(),
279
+ ]);
280
+ const result = await getPlanInfo(client, planId);
281
+ return ok(JSON.stringify(result, null, 2));
282
+ } catch (_error) {
283
+ const status = await checkOnboardingStatus(openClawAdapter);
284
+ return ok(
285
+ JSON.stringify({
286
+ error: "onboarding_required",
287
+ ...status,
288
+ }),
289
+ );
290
+ }
134
291
  },
135
292
  });
136
293