pi-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
@@ -28,4 +28,4 @@ pi install npm:pi-ynabro
28
28
 
29
29
  ## Requirements
30
30
 
31
- - `YNAB_TOKEN` environment variable must be set
31
+ Run `ynabro_setup` once to complete interactive onboarding. The tool will prompt for a YNAB Personal Access Token (generate at https://app.ynab.com/settings/developer) and let you select a default plan. Both are stored securely in pi's `AuthStorage` (`~/.pi/agent/auth.json`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ynabro",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Pi extension 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,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { AuthStorage } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
4
+ import type { OnboardingStatus, YnabroConfigAdapter } from "ynabro";
3
5
  import {
4
6
  approveTransaction,
7
+ checkOnboardingStatus,
5
8
  getPendingTransactions,
6
9
  getPlanInfo,
7
10
  getRecentTransactions,
@@ -11,18 +14,45 @@ import {
11
14
  YnabroClient,
12
15
  } from "ynabro";
13
16
 
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
+ const authStorage = AuthStorage.create();
18
+
19
+ const piConfigAdapter: YnabroConfigAdapter = {
20
+ async getDefaultPlanId(): Promise<string | undefined> {
21
+ const credential = authStorage.get("ynab-plan");
22
+ if (credential?.type === "api_key") {
23
+ return credential.key;
24
+ }
25
+ return undefined;
26
+ },
27
+ async setDefaultPlanId(planId: string): Promise<void> {
28
+ authStorage.set("ynab-plan", { type: "api_key", key: planId });
29
+ },
30
+ async hasToken(): Promise<boolean> {
31
+ return !!(await authStorage.getApiKey("ynab"));
32
+ },
33
+ };
34
+
35
+ async function getClient(): Promise<YnabroClient> {
36
+ const token = await authStorage.getApiKey("ynab");
37
+ if (!token) throw new Error("YNAB token not configured");
17
38
  return new YnabroClient(token);
18
39
  }
19
40
 
20
- const planIdSchema = Type.Object({
21
- planId: Type.String({ description: "The ID of the YNAB plan" }),
22
- });
41
+ async function getDefaultPlanId(): Promise<string> {
42
+ const planId = await piConfigAdapter.getDefaultPlanId();
43
+ if (!planId) throw new Error("No default plan configured");
44
+ return planId;
45
+ }
46
+
47
+ async function checkConfigured(): Promise<OnboardingStatus | null> {
48
+ const status = await checkOnboardingStatus(piConfigAdapter);
49
+ if (!status.ready) {
50
+ return status;
51
+ }
52
+ return null;
53
+ }
23
54
 
24
55
  const approveSchema = Type.Object({
25
- planId: Type.String({ description: "The ID of the YNAB plan" }),
26
56
  transactionId: Type.String({
27
57
  description: "The ID of the transaction to approve",
28
58
  }),
@@ -49,13 +79,49 @@ const updateSkillStateSchema = Type.Object({
49
79
  });
50
80
 
51
81
  export default function ynabroExtension(api: ExtensionAPI): void {
82
+ api.registerTool({
83
+ name: "ynabro_onboarding_status",
84
+ label: "Onboarding Status",
85
+ description:
86
+ "Check whether YNABro is fully configured. Returns ready status, any missing configuration, and token generation instructions.",
87
+ parameters: Type.Object({}),
88
+ async execute(_toolCallId, _params) {
89
+ const status = await checkOnboardingStatus(piConfigAdapter);
90
+ return {
91
+ content: [{ type: "text", text: JSON.stringify(status) }],
92
+ details: undefined,
93
+ };
94
+ },
95
+ });
96
+
52
97
  api.registerTool({
53
98
  name: "ynabro_get_pending_transactions",
54
99
  label: "Get Pending Transactions",
55
- description: "Get all pending (uncategorized) transactions for a plan",
56
- parameters: planIdSchema,
57
- async execute(_toolCallId, params) {
58
- const result = await getPendingTransactions(getClient(), params.planId);
100
+ description:
101
+ "Get all pending (uncategorized) transactions for the default plan",
102
+ parameters: Type.Object({}),
103
+ async execute(_toolCallId, _params) {
104
+ const onboardingError = await checkConfigured();
105
+ if (onboardingError) {
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text",
110
+ text: JSON.stringify({
111
+ error: "onboarding_required",
112
+ ...onboardingError,
113
+ }),
114
+ },
115
+ ],
116
+ details: undefined,
117
+ };
118
+ }
119
+
120
+ const [client, planId] = await Promise.all([
121
+ getClient(),
122
+ getDefaultPlanId(),
123
+ ]);
124
+ const result = await getPendingTransactions(client, planId);
59
125
  return {
60
126
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
61
127
  details: undefined,
@@ -66,10 +132,30 @@ export default function ynabroExtension(api: ExtensionAPI): void {
66
132
  api.registerTool({
67
133
  name: "ynabro_get_recent_transactions",
68
134
  label: "Get Recent Transactions",
69
- description: "Get recent transactions for a plan",
70
- parameters: planIdSchema,
71
- async execute(_toolCallId, params) {
72
- const result = await getRecentTransactions(getClient(), params.planId);
135
+ description: "Get recent transactions for the default plan",
136
+ parameters: Type.Object({}),
137
+ async execute(_toolCallId, _params) {
138
+ const onboardingError = await checkConfigured();
139
+ if (onboardingError) {
140
+ return {
141
+ content: [
142
+ {
143
+ type: "text",
144
+ text: JSON.stringify({
145
+ error: "onboarding_required",
146
+ ...onboardingError,
147
+ }),
148
+ },
149
+ ],
150
+ details: undefined,
151
+ };
152
+ }
153
+
154
+ const [client, planId] = await Promise.all([
155
+ getClient(),
156
+ getDefaultPlanId(),
157
+ ]);
158
+ const result = await getRecentTransactions(client, planId);
73
159
  return {
74
160
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
75
161
  details: undefined,
@@ -80,14 +166,30 @@ export default function ynabroExtension(api: ExtensionAPI): void {
80
166
  api.registerTool({
81
167
  name: "ynabro_approve_transaction",
82
168
  label: "Approve Transaction",
83
- description: "Approve a specific transaction",
169
+ description: "Approve a specific transaction in the default plan",
84
170
  parameters: approveSchema,
85
171
  async execute(_toolCallId, params) {
86
- await approveTransaction(
172
+ const onboardingError = await checkConfigured();
173
+ if (onboardingError) {
174
+ return {
175
+ content: [
176
+ {
177
+ type: "text",
178
+ text: JSON.stringify({
179
+ error: "onboarding_required",
180
+ ...onboardingError,
181
+ }),
182
+ },
183
+ ],
184
+ details: undefined,
185
+ };
186
+ }
187
+
188
+ const [client, planId] = await Promise.all([
87
189
  getClient(),
88
- params.planId,
89
- params.transactionId,
90
- );
190
+ getDefaultPlanId(),
191
+ ]);
192
+ await approveTransaction(client, planId, params.transactionId);
91
193
  return {
92
194
  content: [{ type: "text", text: JSON.stringify({ success: true }) }],
93
195
  details: undefined,
@@ -98,10 +200,30 @@ export default function ynabroExtension(api: ExtensionAPI): void {
98
200
  api.registerTool({
99
201
  name: "ynabro_get_plan_info",
100
202
  label: "Get Plan Info",
101
- description: "Get basic information about a plan",
102
- parameters: planIdSchema,
103
- async execute(_toolCallId, params) {
104
- const result = await getPlanInfo(getClient(), params.planId);
203
+ description: "Get basic information about the default plan",
204
+ parameters: Type.Object({}),
205
+ async execute(_toolCallId, _params) {
206
+ const onboardingError = await checkConfigured();
207
+ if (onboardingError) {
208
+ return {
209
+ content: [
210
+ {
211
+ type: "text",
212
+ text: JSON.stringify({
213
+ error: "onboarding_required",
214
+ ...onboardingError,
215
+ }),
216
+ },
217
+ ],
218
+ details: undefined,
219
+ };
220
+ }
221
+
222
+ const [client, planId] = await Promise.all([
223
+ getClient(),
224
+ getDefaultPlanId(),
225
+ ]);
226
+ const result = await getPlanInfo(client, planId);
105
227
  return {
106
228
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
107
229
  details: undefined,
@@ -113,12 +235,83 @@ export default function ynabroExtension(api: ExtensionAPI): void {
113
235
  name: "ynabro_setup",
114
236
  label: "Setup YNAB",
115
237
  description:
116
- "Set up YNAB integration — checks for token and selects a default plan",
238
+ "Set up or reconfigure YNAB integration — stores API token and selects a default plan",
117
239
  parameters: Type.Object({}),
118
- async execute() {
119
- const result = await setupYnab();
240
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
241
+ // Step 1: Token check
242
+ let token = await authStorage.getApiKey("ynab");
243
+ if (!token) {
244
+ const input = await ctx.ui.input(
245
+ "YNAB Personal Access Token",
246
+ "Paste your token from https://app.ynab.com/settings/developer",
247
+ );
248
+ if (!input) {
249
+ return {
250
+ content: [
251
+ {
252
+ type: "text",
253
+ text: JSON.stringify({
254
+ error: "Setup cancelled — no token provided.",
255
+ }),
256
+ },
257
+ ],
258
+ details: undefined,
259
+ };
260
+ }
261
+ authStorage.set("ynab", { type: "api_key", key: input });
262
+ token = input;
263
+ }
264
+
265
+ // Step 2: Fetch plans
266
+ const client = new YnabroClient(token);
267
+ const plans = await client.getPlans();
268
+ if (plans.length === 0) {
269
+ return {
270
+ content: [
271
+ {
272
+ type: "text",
273
+ text: JSON.stringify({
274
+ error: "No plans found in your YNAB account.",
275
+ }),
276
+ },
277
+ ],
278
+ details: undefined,
279
+ };
280
+ }
281
+
282
+ // Step 3: Plan selector
283
+ const planOptions = plans.map((p) => `${p.name} (${p.id})`);
284
+ const selected = await ctx.ui.select(
285
+ "Select your default YNAB plan",
286
+ planOptions,
287
+ );
288
+ if (!selected) {
289
+ return {
290
+ content: [
291
+ {
292
+ type: "text",
293
+ text: JSON.stringify({
294
+ error: "Setup cancelled — no plan selected.",
295
+ }),
296
+ },
297
+ ],
298
+ details: undefined,
299
+ };
300
+ }
301
+
302
+ // Step 4: Store and return
303
+ const selectedPlan = plans[planOptions.indexOf(selected)];
304
+ await setupYnab(client, plans, selectedPlan.id, piConfigAdapter);
120
305
  return {
121
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
306
+ content: [
307
+ {
308
+ type: "text",
309
+ text: JSON.stringify({
310
+ message: `Setup complete! Default plan set to: ${selectedPlan.name}`,
311
+ defaultPlanId: selectedPlan.id,
312
+ }),
313
+ },
314
+ ],
122
315
  details: undefined,
123
316
  };
124
317
  },