@fuzzle/opencode-accountant 0.0.3-next.1 → 0.0.4-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 +143 -72
  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,13 +15048,74 @@ 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);
@@ -15062,8 +15123,8 @@ function getYesterday() {
15062
15123
  }
15063
15124
  function updateJournalWithPrices(journalPath, newPriceLines) {
15064
15125
  let existingLines = [];
15065
- if (fs.existsSync(journalPath)) {
15066
- existingLines = fs.readFileSync(journalPath, "utf-8").split(`
15126
+ if (fs2.existsSync(journalPath)) {
15127
+ existingLines = fs2.readFileSync(journalPath, "utf-8").split(`
15067
15128
  `).filter((line) => line.trim() !== "");
15068
15129
  }
15069
15130
  const priceMap = new Map;
@@ -15078,82 +15139,92 @@ function updateJournalWithPrices(journalPath, newPriceLines) {
15078
15139
  priceMap.set(date5, line);
15079
15140
  }
15080
15141
  const sortedLines = Array.from(priceMap.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([, line]) => line);
15081
- fs.writeFileSync(journalPath, sortedLines.join(`
15142
+ fs2.writeFileSync(journalPath, sortedLines.join(`
15082
15143
  `) + `
15083
15144
  `);
15084
15145
  }
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(`
15146
+ async function updatePricesCore(directory, agent, backfill, priceFetcher = defaultPriceFetcher, configLoader = loadPricesConfig) {
15147
+ if (agent !== "accountant") {
15148
+ return JSON.stringify({
15149
+ error: "This tool is restricted to the accountant agent only.",
15150
+ hint: "Use: Task(subagent_type='accountant', prompt='update prices')",
15151
+ caller: agent || "main assistant"
15152
+ });
15153
+ }
15154
+ let config2;
15155
+ try {
15156
+ config2 = configLoader(directory);
15157
+ } catch (err) {
15158
+ return JSON.stringify({
15159
+ error: err instanceof Error ? err.message : String(err)
15160
+ });
15161
+ }
15162
+ const endDate = getYesterday();
15163
+ const defaultBackfillDate = getDefaultBackfillDate();
15164
+ const results = [];
15165
+ for (const [ticker, currencyConfig] of Object.entries(config2.currencies)) {
15166
+ try {
15167
+ const startDate = backfill ? currencyConfig.backfill_date || defaultBackfillDate : endDate;
15168
+ const cmdArgs = [
15169
+ "fetch",
15170
+ "-o",
15171
+ "ledger",
15172
+ "-s",
15173
+ startDate,
15174
+ "-e",
15175
+ endDate,
15176
+ currencyConfig.source,
15177
+ currencyConfig.pair
15178
+ ];
15179
+ if (currencyConfig.fmt_base) {
15180
+ cmdArgs.push("--fmt-base", currencyConfig.fmt_base);
15181
+ }
15182
+ const output = await priceFetcher(cmdArgs);
15183
+ const priceLines = output.split(`
15122
15184
  `).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];
15133
- results.push({
15134
- ticker,
15135
- priceLine: latestPriceLine,
15136
- file: config2.file
15137
- });
15138
- } catch (err) {
15185
+ if (priceLines.length === 0) {
15139
15186
  results.push({
15140
15187
  ticker,
15141
- error: String(err)
15188
+ error: `No price lines in pricehist output: ${output}`
15142
15189
  });
15190
+ continue;
15143
15191
  }
15192
+ const journalPath = path2.join(directory, "ledger", "currencies", currencyConfig.file);
15193
+ updateJournalWithPrices(journalPath, priceLines);
15194
+ const latestPriceLine = priceLines[priceLines.length - 1];
15195
+ results.push({
15196
+ ticker,
15197
+ priceLine: latestPriceLine,
15198
+ file: currencyConfig.file
15199
+ });
15200
+ } catch (err) {
15201
+ results.push({
15202
+ ticker,
15203
+ error: String(err)
15204
+ });
15144
15205
  }
15145
- return JSON.stringify({
15146
- success: results.every((r) => !("error" in r)),
15147
- startDate,
15148
- endDate,
15149
- backfill: !!backfill,
15150
- results
15151
- });
15206
+ }
15207
+ return JSON.stringify({
15208
+ success: results.every((r) => !("error" in r)),
15209
+ endDate,
15210
+ backfill: !!backfill,
15211
+ results
15212
+ });
15213
+ }
15214
+ var update_prices_default = tool({
15215
+ 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/.",
15216
+ args: {
15217
+ 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)")
15218
+ },
15219
+ async execute(params, context) {
15220
+ const { directory, agent } = context;
15221
+ const { backfill } = params;
15222
+ return updatePricesCore(directory, agent, backfill || false);
15152
15223
  }
15153
15224
  });
15154
15225
  // src/index.ts
15155
15226
  var __dirname2 = dirname(fileURLToPath(import.meta.url));
15156
- var AGENT_FILE = join2(__dirname2, "..", "agent", "accountant.md");
15227
+ var AGENT_FILE = join3(__dirname2, "..", "agent", "accountant.md");
15157
15228
  var AccountantPlugin = async () => {
15158
15229
  const agent = loadAgent(AGENT_FILE);
15159
15230
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.0.3-next.1",
3
+ "version": "0.0.4-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",