@fuzzle/opencode-accountant 0.0.4 → 0.0.5-next.1

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.
Files changed (3) hide show
  1. package/README.md +75 -0
  2. package/dist/index.js +164 -69
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,6 +12,81 @@ An OpenCode accounting agent, specialized in double-entry-bookkepping with hledg
12
12
 
13
13
  <!-- Installation instructions go here -->
14
14
 
15
+ ## Configuration
16
+
17
+ ### Price Fetching Configuration
18
+
19
+ The `update-prices` tool requires a configuration file to specify which currency pairs to fetch. Create a `config/prices.yaml` file in your project directory with the following structure:
20
+
21
+ ```yaml
22
+ currencies:
23
+ BTC:
24
+ source: coinmarketcap
25
+ pair: BTC/CHF
26
+ file: btc-chf.journal
27
+ backfill_date: '2025-12-31'
28
+
29
+ EUR:
30
+ source: ecb
31
+ pair: EUR/CHF
32
+ file: eur-chf.journal
33
+ backfill_date: '2025-12-31'
34
+
35
+ USD:
36
+ source: yahoo
37
+ pair: USDCHF=X
38
+ file: usd-chf.journal
39
+ fmt_base: USD
40
+ backfill_date: '2025-06-01'
41
+ ```
42
+
43
+ #### Configuration Options
44
+
45
+ Each currency entry supports the following fields:
46
+
47
+ | Field | Required | Description |
48
+ | --------------- | -------- | -------------------------------------------------------------------------- |
49
+ | `source` | Yes | The pricehist data source (e.g., `coinmarketcap`, `ecb`, `yahoo`) |
50
+ | `pair` | Yes | The currency pair to fetch (e.g., `BTC/CHF`, `EUR/CHF`) |
51
+ | `file` | Yes | Output filename in `ledger/currencies/` (e.g., `btc-chf.journal`) |
52
+ | `fmt_base` | No | Base currency format override for pricehist (e.g., `USD` for yahoo source) |
53
+ | `backfill_date` | No | Start date for backfill mode. Defaults to January 1st of the current year |
54
+
55
+ #### Directory Structure
56
+
57
+ The plugin expects the following directory structure in your project:
58
+
59
+ ```
60
+ your-project/
61
+ ├── config/
62
+ │ └── prices.yaml # Price fetching configuration
63
+ └── ledger/
64
+ └── currencies/ # Where price journal files are written
65
+ ├── btc-chf.journal
66
+ ├── eur-chf.journal
67
+ └── usd-chf.journal
68
+ ```
69
+
70
+ #### Base Currency
71
+
72
+ The `pair` field determines the currency conversion. While the examples above use CHF as the base currency, you can use any currency supported by your price source:
73
+
74
+ ```yaml
75
+ currencies:
76
+ BTC:
77
+ source: coinmarketcap
78
+ pair: BTC/EUR # Prices in EUR instead of CHF
79
+ file: btc-eur.journal
80
+ ```
81
+
82
+ #### Available Data Sources
83
+
84
+ The `source` field accepts any source supported by [pricehist](https://github.com/chrisberkhout/pricehist). Common sources include:
85
+
86
+ - `coinmarketcap` - Cryptocurrency prices
87
+ - `ecb` - European Central Bank exchange rates
88
+ - `yahoo` - Yahoo Finance (use `fmt_base` for proper formatting)
89
+
15
90
  ## Development
16
91
 
17
92
  - `mise run build` - Build the module
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ var __export = (target, all) => {
11
11
  };
12
12
 
13
13
  // src/index.ts
14
- import { dirname, join as join2 } from "path";
14
+ import { dirname, join as join3 } from "path";
15
15
  import { fileURLToPath } from "url";
16
16
 
17
17
  // src/utils/agentLoader.ts
@@ -15048,22 +15048,99 @@ function tool(input) {
15048
15048
  tool.schema = exports_external;
15049
15049
  // src/tools/update-prices.ts
15050
15050
  var {$ } = globalThis.Bun;
15051
- import * as path from "path";
15051
+ import * as path2 from "path";
15052
+ import * as fs2 from "fs";
15053
+
15054
+ // src/utils/pricesConfig.ts
15052
15055
  import * as fs from "fs";
15053
- var TICKERS = {
15054
- BTC: { source: "coinmarketcap", pair: "BTC/CHF", file: "btc-chf.journal" },
15055
- EUR: { source: "ecb", pair: "EUR/CHF", file: "eur-chf.journal" },
15056
- USD: { source: "yahoo", pair: "USDCHF=X", file: "usd-chf.journal", fmtBase: "USD" }
15057
- };
15056
+ import * as path from "path";
15057
+ var CONFIG_FILE = "config/prices.yaml";
15058
+ var REQUIRED_CURRENCY_FIELDS = ["source", "pair", "file"];
15059
+ function getDefaultBackfillDate() {
15060
+ const year = new Date().getFullYear();
15061
+ return `${year}-01-01`;
15062
+ }
15063
+ function validateCurrencyConfig(name, config2) {
15064
+ if (typeof config2 !== "object" || config2 === null) {
15065
+ throw new Error(`Invalid config for currency '${name}': expected an object`);
15066
+ }
15067
+ const configObj = config2;
15068
+ for (const field of REQUIRED_CURRENCY_FIELDS) {
15069
+ if (typeof configObj[field] !== "string" || configObj[field] === "") {
15070
+ throw new Error(`Invalid config for currency '${name}': missing required field '${field}'`);
15071
+ }
15072
+ }
15073
+ return {
15074
+ source: configObj.source,
15075
+ pair: configObj.pair,
15076
+ file: configObj.file,
15077
+ fmt_base: typeof configObj.fmt_base === "string" ? configObj.fmt_base : undefined,
15078
+ backfill_date: typeof configObj.backfill_date === "string" ? configObj.backfill_date : undefined
15079
+ };
15080
+ }
15081
+ function loadPricesConfig(directory) {
15082
+ const configPath = path.join(directory, CONFIG_FILE);
15083
+ if (!fs.existsSync(configPath)) {
15084
+ throw new Error(`Configuration file not found: ${CONFIG_FILE}. Please refer to the plugin's GitHub repository for setup instructions.`);
15085
+ }
15086
+ let parsed;
15087
+ try {
15088
+ const content = fs.readFileSync(configPath, "utf-8");
15089
+ parsed = jsYaml.load(content);
15090
+ } catch (err) {
15091
+ if (err instanceof jsYaml.YAMLException) {
15092
+ throw new Error(`Failed to parse ${CONFIG_FILE}: ${err.message}`);
15093
+ }
15094
+ throw err;
15095
+ }
15096
+ if (typeof parsed !== "object" || parsed === null) {
15097
+ throw new Error(`Invalid config: ${CONFIG_FILE} must contain a YAML object`);
15098
+ }
15099
+ const parsedObj = parsed;
15100
+ if (!parsedObj.currencies || typeof parsedObj.currencies !== "object") {
15101
+ throw new Error(`Invalid config: 'currencies' section is required`);
15102
+ }
15103
+ const currenciesObj = parsedObj.currencies;
15104
+ if (Object.keys(currenciesObj).length === 0) {
15105
+ throw new Error(`Invalid config: 'currencies' section must contain at least one currency`);
15106
+ }
15107
+ const currencies = {};
15108
+ for (const [name, config2] of Object.entries(currenciesObj)) {
15109
+ currencies[name] = validateCurrencyConfig(name, config2);
15110
+ }
15111
+ return { currencies };
15112
+ }
15113
+
15114
+ // src/tools/update-prices.ts
15115
+ async function defaultPriceFetcher(cmdArgs) {
15116
+ const result = await $`pricehist ${cmdArgs}`.quiet();
15117
+ return result.stdout.toString().trim();
15118
+ }
15058
15119
  function getYesterday() {
15059
15120
  const d = new Date;
15060
15121
  d.setDate(d.getDate() - 1);
15061
15122
  return d.toISOString().split("T")[0];
15062
15123
  }
15124
+ function parsePriceLine(line) {
15125
+ const match = line.match(/^P (\d{4}-\d{2}-\d{2})(?: \d{2}:\d{2}:\d{2})? .+$/);
15126
+ if (!match)
15127
+ return null;
15128
+ return {
15129
+ date: match[1],
15130
+ formattedLine: line
15131
+ };
15132
+ }
15133
+ function filterPriceLinesByDateRange(priceLines, startDate, endDate) {
15134
+ return priceLines.map(parsePriceLine).filter((parsed) => {
15135
+ if (!parsed)
15136
+ return false;
15137
+ return parsed.date >= startDate && parsed.date <= endDate;
15138
+ }).sort((a, b) => a.date.localeCompare(b.date)).map((parsed) => parsed.formattedLine);
15139
+ }
15063
15140
  function updateJournalWithPrices(journalPath, newPriceLines) {
15064
15141
  let existingLines = [];
15065
- if (fs.existsSync(journalPath)) {
15066
- existingLines = fs.readFileSync(journalPath, "utf-8").split(`
15142
+ if (fs2.existsSync(journalPath)) {
15143
+ existingLines = fs2.readFileSync(journalPath, "utf-8").split(`
15067
15144
  `).filter((line) => line.trim() !== "");
15068
15145
  }
15069
15146
  const priceMap = new Map;
@@ -15078,82 +15155,100 @@ function updateJournalWithPrices(journalPath, newPriceLines) {
15078
15155
  priceMap.set(date5, line);
15079
15156
  }
15080
15157
  const sortedLines = Array.from(priceMap.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([, line]) => line);
15081
- fs.writeFileSync(journalPath, sortedLines.join(`
15158
+ fs2.writeFileSync(journalPath, sortedLines.join(`
15082
15159
  `) + `
15083
15160
  `);
15084
15161
  }
15085
- var update_prices_default = tool({
15086
- description: "ACCOUNTANT AGENT ONLY: Fetches end-of-day prices for all tickers (BTC, EUR, USD in CHF) and appends them to the corresponding price journals.",
15087
- args: {
15088
- backfill: tool.schema.boolean().optional().describe("If true, fetch all available history from 2025-12-31")
15089
- },
15090
- async execute(params, context) {
15091
- const { directory, agent } = context;
15092
- const { backfill } = params;
15093
- if (agent !== "accountant") {
15094
- return JSON.stringify({
15095
- error: "This tool is restricted to the accountant agent only.",
15096
- hint: "Use: Task(subagent_type='accountant', prompt='update prices')",
15097
- caller: agent || "main assistant"
15098
- });
15099
- }
15100
- const endDate = getYesterday();
15101
- const startDate = backfill ? "2025-12-31" : endDate;
15102
- const results = [];
15103
- for (const [ticker, config2] of Object.entries(TICKERS)) {
15104
- try {
15105
- const cmdArgs = [
15106
- "fetch",
15107
- "-o",
15108
- "ledger",
15109
- "-s",
15110
- startDate,
15111
- "-e",
15112
- endDate,
15113
- config2.source,
15114
- config2.pair
15115
- ];
15116
- if (config2.fmtBase) {
15117
- cmdArgs.push("--fmt-base", config2.fmtBase);
15118
- }
15119
- const result = await $`pricehist ${cmdArgs}`.quiet();
15120
- const output = result.stdout.toString().trim();
15121
- const priceLines = output.split(`
15162
+ async function updatePricesCore(directory, agent, backfill, priceFetcher = defaultPriceFetcher, configLoader = loadPricesConfig) {
15163
+ if (agent !== "accountant") {
15164
+ return JSON.stringify({
15165
+ error: "This tool is restricted to the accountant agent only.",
15166
+ hint: "Use: Task(subagent_type='accountant', prompt='update prices')",
15167
+ caller: agent || "main assistant"
15168
+ });
15169
+ }
15170
+ let config2;
15171
+ try {
15172
+ config2 = configLoader(directory);
15173
+ } catch (err) {
15174
+ return JSON.stringify({
15175
+ error: err instanceof Error ? err.message : String(err)
15176
+ });
15177
+ }
15178
+ const endDate = getYesterday();
15179
+ const defaultBackfillDate = getDefaultBackfillDate();
15180
+ const results = [];
15181
+ for (const [ticker, currencyConfig] of Object.entries(config2.currencies)) {
15182
+ try {
15183
+ const startDate = backfill ? currencyConfig.backfill_date || defaultBackfillDate : endDate;
15184
+ const cmdArgs = [
15185
+ "fetch",
15186
+ "-o",
15187
+ "ledger",
15188
+ "-s",
15189
+ startDate,
15190
+ "-e",
15191
+ endDate,
15192
+ currencyConfig.source,
15193
+ currencyConfig.pair
15194
+ ];
15195
+ if (currencyConfig.fmt_base) {
15196
+ cmdArgs.push("--fmt-base", currencyConfig.fmt_base);
15197
+ }
15198
+ const output = await priceFetcher(cmdArgs);
15199
+ const rawPriceLines = output.split(`
15122
15200
  `).filter((line) => line.startsWith("P "));
15123
- if (priceLines.length === 0) {
15124
- results.push({
15125
- ticker,
15126
- error: `No price lines in pricehist output: ${output}`
15127
- });
15128
- continue;
15129
- }
15130
- const journalPath = path.join(directory, "ledger", config2.file);
15131
- updateJournalWithPrices(journalPath, priceLines);
15132
- const latestPriceLine = priceLines[priceLines.length - 1];
15201
+ if (rawPriceLines.length === 0) {
15133
15202
  results.push({
15134
15203
  ticker,
15135
- priceLine: latestPriceLine,
15136
- file: config2.file
15204
+ error: `No price lines in pricehist output: ${output}`
15137
15205
  });
15138
- } catch (err) {
15206
+ continue;
15207
+ }
15208
+ const priceLines = filterPriceLinesByDateRange(rawPriceLines, startDate, endDate);
15209
+ if (priceLines.length === 0) {
15139
15210
  results.push({
15140
15211
  ticker,
15141
- error: String(err)
15212
+ error: `No price data found within date range ${startDate} to ${endDate}`
15142
15213
  });
15214
+ continue;
15143
15215
  }
15216
+ const journalPath = path2.join(directory, "ledger", "currencies", currencyConfig.file);
15217
+ updateJournalWithPrices(journalPath, priceLines);
15218
+ const latestPriceLine = priceLines[priceLines.length - 1];
15219
+ results.push({
15220
+ ticker,
15221
+ priceLine: latestPriceLine,
15222
+ file: currencyConfig.file
15223
+ });
15224
+ } catch (err) {
15225
+ results.push({
15226
+ ticker,
15227
+ error: String(err)
15228
+ });
15144
15229
  }
15145
- return JSON.stringify({
15146
- success: results.every((r) => !("error" in r)),
15147
- startDate,
15148
- endDate,
15149
- backfill: !!backfill,
15150
- results
15151
- });
15230
+ }
15231
+ return JSON.stringify({
15232
+ success: results.every((r) => !("error" in r)),
15233
+ endDate,
15234
+ backfill: !!backfill,
15235
+ results
15236
+ });
15237
+ }
15238
+ var update_prices_default = tool({
15239
+ description: "ACCOUNTANT AGENT ONLY: Fetches end-of-day prices for all configured currencies (from config/prices.yaml) and appends them to the corresponding price journals in ledger/currencies/.",
15240
+ args: {
15241
+ backfill: tool.schema.boolean().optional().describe("If true, fetch history from each currency's configured backfill_date (or Jan 1 of current year if not specified)")
15242
+ },
15243
+ async execute(params, context) {
15244
+ const { directory, agent } = context;
15245
+ const { backfill } = params;
15246
+ return updatePricesCore(directory, agent, backfill || false);
15152
15247
  }
15153
15248
  });
15154
15249
  // src/index.ts
15155
15250
  var __dirname2 = dirname(fileURLToPath(import.meta.url));
15156
- var AGENT_FILE = join2(__dirname2, "..", "agent", "accountant.md");
15251
+ var AGENT_FILE = join3(__dirname2, "..", "agent", "accountant.md");
15157
15252
  var AccountantPlugin = async () => {
15158
15253
  const agent = loadAgent(AGENT_FILE);
15159
15254
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.0.4",
3
+ "version": "0.0.5-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",