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 +1 -1
- package/package.json +1 -1
- package/skills/ynabro/prompts/ynabro.md +21 -4
- package/src/index.ts +221 -28
package/README.md
CHANGED
|
@@ -28,4 +28,4 @@ pi install npm:pi-ynabro
|
|
|
28
28
|
|
|
29
29
|
## Requirements
|
|
30
30
|
|
|
31
|
-
|
|
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
|
@@ -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,
|
|
11
|
+
Before performing any YNAB operations, call `ynabro_onboarding_status` to check
|
|
12
|
+
whether YNAB access is configured.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
2. A default plan must be selected and saved.
|
|
14
|
+
If `ready` is `false`, walk the user through setup:
|
|
15
15
|
|
|
16
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
21
|
-
planId
|
|
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:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
70
|
-
parameters:
|
|
71
|
-
async execute(_toolCallId,
|
|
72
|
-
const
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
102
|
-
parameters:
|
|
103
|
-
async execute(_toolCallId,
|
|
104
|
-
const
|
|
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 —
|
|
238
|
+
"Set up or reconfigure YNAB integration — stores API token and selects a default plan",
|
|
117
239
|
parameters: Type.Object({}),
|
|
118
|
-
async execute() {
|
|
119
|
-
|
|
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: [
|
|
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
|
},
|