@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.
- package/README.md +75 -0
- package/dist/index.js +164 -69
- 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
|
|
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
|
|
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
|
-
|
|
15054
|
-
|
|
15055
|
-
|
|
15056
|
-
|
|
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 (
|
|
15066
|
-
existingLines =
|
|
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
|
-
|
|
15158
|
+
fs2.writeFileSync(journalPath, sortedLines.join(`
|
|
15082
15159
|
`) + `
|
|
15083
15160
|
`);
|
|
15084
15161
|
}
|
|
15085
|
-
|
|
15086
|
-
|
|
15087
|
-
|
|
15088
|
-
|
|
15089
|
-
|
|
15090
|
-
|
|
15091
|
-
|
|
15092
|
-
|
|
15093
|
-
|
|
15094
|
-
|
|
15095
|
-
|
|
15096
|
-
|
|
15097
|
-
|
|
15098
|
-
|
|
15099
|
-
}
|
|
15100
|
-
|
|
15101
|
-
|
|
15102
|
-
|
|
15103
|
-
|
|
15104
|
-
|
|
15105
|
-
|
|
15106
|
-
|
|
15107
|
-
|
|
15108
|
-
|
|
15109
|
-
|
|
15110
|
-
|
|
15111
|
-
|
|
15112
|
-
|
|
15113
|
-
|
|
15114
|
-
|
|
15115
|
-
|
|
15116
|
-
|
|
15117
|
-
|
|
15118
|
-
|
|
15119
|
-
|
|
15120
|
-
|
|
15121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15136
|
-
file: config2.file
|
|
15204
|
+
error: `No price lines in pricehist output: ${output}`
|
|
15137
15205
|
});
|
|
15138
|
-
|
|
15206
|
+
continue;
|
|
15207
|
+
}
|
|
15208
|
+
const priceLines = filterPriceLinesByDateRange(rawPriceLines, startDate, endDate);
|
|
15209
|
+
if (priceLines.length === 0) {
|
|
15139
15210
|
results.push({
|
|
15140
15211
|
ticker,
|
|
15141
|
-
error:
|
|
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
|
-
|
|
15146
|
-
|
|
15147
|
-
|
|
15148
|
-
|
|
15149
|
-
|
|
15150
|
-
|
|
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 =
|
|
15251
|
+
var AGENT_FILE = join3(__dirname2, "..", "agent", "accountant.md");
|
|
15157
15252
|
var AccountantPlugin = async () => {
|
|
15158
15253
|
const agent = loadAgent(AGENT_FILE);
|
|
15159
15254
|
return {
|