@goose-plugins/crypto 1.0.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 +71 -0
- package/client.js +161 -0
- package/index.js +331 -0
- package/mission.example.json +12 -0
- package/package.json +49 -0
- package/store.js +96 -0
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @goose-plugins/crypto
|
|
2
|
+
|
|
3
|
+
Crypto price tracking for [Goose](https://github.com/rorystandley/goose) — spot prices, market stats, a persisted watchlist, and a command-centre tile.
|
|
4
|
+
|
|
5
|
+
Uses the [CoinGecko](https://www.coingecko.com/en/api) API. No API key is required for light personal use; set `COINGECKO_API_KEY` if you hit rate limits.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
From your Goose checkout, with this package in the sibling `goose-plugins` checkout:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install --ignore-scripts ../goose-plugins/crypto
|
|
13
|
+
npm run link-plugins
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or add to Goose `package.json`:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
"@goose-plugins/crypto": "file:../goose-plugins/crypto"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Optional env vars:
|
|
23
|
+
|
|
24
|
+
```dotenv
|
|
25
|
+
# Demo or Pro key — raises CoinGecko rate limits
|
|
26
|
+
COINGECKO_API_KEY=
|
|
27
|
+
# Set to 1 when using a Pro key (pro-api.coingecko.com)
|
|
28
|
+
COINGECKO_PRO=0
|
|
29
|
+
# Request timeout in ms (default 15000)
|
|
30
|
+
COINGECKO_TIMEOUT_MS=15000
|
|
31
|
+
# Override watchlist file path (default ./data/crypto-watchlist.json)
|
|
32
|
+
CRYPTO_WATCHLIST_PATH=./data/crypto-watchlist.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Restart Goose and `goose-scheduler`. Configuration is lazy: importing the plugin needs no network and no key.
|
|
36
|
+
|
|
37
|
+
## Command centre
|
|
38
|
+
|
|
39
|
+
- **Plugins:** tools appear via Goose’s plugin metadata endpoint after restart.
|
|
40
|
+
- **Chat:** ask for prices, market stats, or watchlist changes. Tool activity shows in the Chat stream.
|
|
41
|
+
- **Tiles:** exports a `watchlist` tile. Once Goose supports plugin tiles, it shows live watchlist prices on the Overview.
|
|
42
|
+
- **Missions:** merge `mission.example.json` into `data/missions.json` for a morning price digest.
|
|
43
|
+
|
|
44
|
+
## Tools
|
|
45
|
+
|
|
46
|
+
| Tool | Purpose | Risk |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `crypto_status` | Config + CoinGecko connectivity check | safe |
|
|
49
|
+
| `crypto_get_price` | Spot price + 24h change for symbols/ids | safe |
|
|
50
|
+
| `crypto_get_market` | Price, volume, market cap, rank | safe |
|
|
51
|
+
| `crypto_list_watchlist` | Show persisted watchlist | safe |
|
|
52
|
+
| `crypto_add_watchlist` | Track an asset | moderate |
|
|
53
|
+
| `crypto_remove_watchlist` | Stop tracking an asset | moderate |
|
|
54
|
+
|
|
55
|
+
Symbols like `BTC` / `ETH` are mapped to CoinGecko ids. You can also pass ids directly (`bitcoin`, `ethereum`).
|
|
56
|
+
|
|
57
|
+
## Examples
|
|
58
|
+
|
|
59
|
+
- “What’s BTC and ETH trading at?”
|
|
60
|
+
- “Give me market stats for SOL.”
|
|
61
|
+
- “Add DOGE to my crypto watchlist.”
|
|
62
|
+
- “What’s on my crypto watchlist?”
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npm ci
|
|
68
|
+
npm test
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Tests mock HTTP; nothing hits CoinGecko live. Publish with the repo’s `crypto-v<semver>` tag workflow when ready.
|
package/client.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy CoinGecko client for the crypto plugin.
|
|
3
|
+
*
|
|
4
|
+
* The public API works without a key at modest volume. Set COINGECKO_API_KEY
|
|
5
|
+
* (demo or pro) to raise rate limits. Configuration is read on first use so
|
|
6
|
+
* Goose can import the plugin during discovery without contacting CoinGecko.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PUBLIC_BASE = 'https://api.coingecko.com/api/v3';
|
|
10
|
+
const PRO_BASE = 'https://pro-api.coingecko.com/api/v3';
|
|
11
|
+
|
|
12
|
+
export class CryptoError extends Error {}
|
|
13
|
+
|
|
14
|
+
/** Common ticker → CoinGecko id map for fast resolution without a search round-trip. */
|
|
15
|
+
export const SYMBOL_TO_ID = {
|
|
16
|
+
btc: 'bitcoin',
|
|
17
|
+
xbt: 'bitcoin',
|
|
18
|
+
eth: 'ethereum',
|
|
19
|
+
sol: 'solana',
|
|
20
|
+
ada: 'cardano',
|
|
21
|
+
xrp: 'ripple',
|
|
22
|
+
doge: 'dogecoin',
|
|
23
|
+
dot: 'polkadot',
|
|
24
|
+
avax: 'avalanche-2',
|
|
25
|
+
link: 'chainlink',
|
|
26
|
+
matic: 'matic-network',
|
|
27
|
+
pol: 'polygon-ecosystem-token',
|
|
28
|
+
atom: 'cosmos',
|
|
29
|
+
near: 'near',
|
|
30
|
+
apt: 'aptos',
|
|
31
|
+
arb: 'arbitrum',
|
|
32
|
+
op: 'optimism',
|
|
33
|
+
sui: 'sui',
|
|
34
|
+
ton: 'the-open-network',
|
|
35
|
+
trx: 'tron',
|
|
36
|
+
bnb: 'binancecoin',
|
|
37
|
+
ltc: 'litecoin',
|
|
38
|
+
bch: 'bitcoin-cash',
|
|
39
|
+
uni: 'uniswap',
|
|
40
|
+
aave: 'aave',
|
|
41
|
+
mkr: 'maker',
|
|
42
|
+
pepe: 'pepe',
|
|
43
|
+
shib: 'shiba-inu',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function connectionInfo(env = process.env) {
|
|
47
|
+
const apiKey = env.COINGECKO_API_KEY?.trim() || '';
|
|
48
|
+
const pro = env.COINGECKO_PRO === 'true' || env.COINGECKO_PRO === '1';
|
|
49
|
+
return {
|
|
50
|
+
configured: true,
|
|
51
|
+
apiKeyConfigured: Boolean(apiKey),
|
|
52
|
+
pro,
|
|
53
|
+
baseUrl: pro ? PRO_BASE : PUBLIC_BASE,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function timeoutMs(env) {
|
|
58
|
+
const raw = Number(env.COINGECKO_TIMEOUT_MS || 15000);
|
|
59
|
+
if (!Number.isInteger(raw) || raw < 100 || raw > 120000) {
|
|
60
|
+
throw new CryptoError('COINGECKO_TIMEOUT_MS must be an integer between 100 and 120000.');
|
|
61
|
+
}
|
|
62
|
+
return raw;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{ env?: NodeJS.ProcessEnv, fetchFn?: typeof fetch }} [options]
|
|
67
|
+
*/
|
|
68
|
+
export function createClient({ env = process.env, fetchFn = globalThis.fetch } = {}) {
|
|
69
|
+
const info = connectionInfo(env);
|
|
70
|
+
const apiKey = env.COINGECKO_API_KEY?.trim() || '';
|
|
71
|
+
const timeout = timeoutMs(env);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
info,
|
|
75
|
+
async request(path, query = {}) {
|
|
76
|
+
if (!/^\/[a-z0-9/_-]*$/i.test(path)) throw new CryptoError('Invalid CoinGecko API path.');
|
|
77
|
+
const url = new URL(`${info.baseUrl}${path}`);
|
|
78
|
+
for (const [key, value] of Object.entries(query)) {
|
|
79
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
80
|
+
url.searchParams.set(key, String(value));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const headers = { Accept: 'application/json' };
|
|
84
|
+
if (apiKey) {
|
|
85
|
+
headers[info.pro ? 'x-cg-pro-api-key' : 'x-cg-demo-api-key'] = apiKey;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetchFn(url, {
|
|
92
|
+
method: 'GET',
|
|
93
|
+
redirect: 'error',
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
headers,
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const hint = {
|
|
99
|
+
401: 'API key is invalid.',
|
|
100
|
+
403: 'API key is not allowed for this endpoint.',
|
|
101
|
+
429: 'Rate limited by CoinGecko — wait and retry, or set COINGECKO_API_KEY.',
|
|
102
|
+
404: 'Asset not found.',
|
|
103
|
+
}[response.status] || 'Request failed; check CoinGecko status and API version.';
|
|
104
|
+
throw new CryptoError(`CoinGecko HTTP ${response.status}: ${hint}`);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return await response.json();
|
|
108
|
+
} catch {
|
|
109
|
+
throw new CryptoError('CoinGecko returned invalid JSON.');
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof CryptoError) throw error;
|
|
113
|
+
if (controller.signal.aborted) throw new CryptoError('CoinGecko request timed out.');
|
|
114
|
+
throw new CryptoError('Cannot reach CoinGecko. Check DNS, TLS, and connectivity.');
|
|
115
|
+
} finally {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let _client = null;
|
|
123
|
+
|
|
124
|
+
export function getClient(options) {
|
|
125
|
+
if (options) return createClient(options);
|
|
126
|
+
if (!_client) _client = createClient();
|
|
127
|
+
return _client;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Reset singleton — used by tests. */
|
|
131
|
+
export function resetClient() {
|
|
132
|
+
_client = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve a user-supplied symbol or CoinGecko id to a CoinGecko id.
|
|
137
|
+
* Prefers the local symbol map, then treats the input as an id if it looks like one.
|
|
138
|
+
*/
|
|
139
|
+
export function resolveId(input) {
|
|
140
|
+
if (typeof input !== 'string') return null;
|
|
141
|
+
const raw = input.trim();
|
|
142
|
+
if (!raw) return null;
|
|
143
|
+
const lower = raw.toLowerCase();
|
|
144
|
+
if (SYMBOL_TO_ID[lower]) return SYMBOL_TO_ID[lower];
|
|
145
|
+
// CoinGecko ids are lowercase kebab-case; allow them through.
|
|
146
|
+
if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(lower)) return lower;
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resolveIds(inputs) {
|
|
151
|
+
const list = Array.isArray(inputs) ? inputs : String(inputs || '').split(/[\s,]+/);
|
|
152
|
+
const ids = [];
|
|
153
|
+
const unknown = [];
|
|
154
|
+
for (const item of list) {
|
|
155
|
+
if (!item) continue;
|
|
156
|
+
const id = resolveId(item);
|
|
157
|
+
if (id) ids.push(id);
|
|
158
|
+
else unknown.push(String(item));
|
|
159
|
+
}
|
|
160
|
+
return { ids: [...new Set(ids)], unknown };
|
|
161
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crypto plugin for Goose.
|
|
3
|
+
*
|
|
4
|
+
* Tools:
|
|
5
|
+
* crypto_status — provider / config check
|
|
6
|
+
* crypto_get_price — spot price for one or more assets
|
|
7
|
+
* crypto_get_market — market stats (24h change, volume, market cap)
|
|
8
|
+
* crypto_list_watchlist — show persisted watchlist
|
|
9
|
+
* crypto_add_watchlist — add an asset to the watchlist
|
|
10
|
+
* crypto_remove_watchlist — remove an asset from the watchlist
|
|
11
|
+
*
|
|
12
|
+
* Tile:
|
|
13
|
+
* watchlist — command-centre table of watchlist prices (via Goose plugin tiles)
|
|
14
|
+
*
|
|
15
|
+
* Provider: CoinGecko public API (optional COINGECKO_API_KEY for higher limits).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { CryptoError, getClient, resolveId, resolveIds, connectionInfo } from './client.js';
|
|
19
|
+
import { addAsset, getWatchlist, removeAsset, setWatchlist } from './store.js';
|
|
20
|
+
|
|
21
|
+
function fail(error) {
|
|
22
|
+
const message = error instanceof CryptoError ? error.message : 'Unexpected crypto plugin failure.';
|
|
23
|
+
return `Error: ${message}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ok(data) {
|
|
27
|
+
return JSON.stringify(data);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseSymbols(symbols) {
|
|
31
|
+
if (Array.isArray(symbols)) return symbols;
|
|
32
|
+
if (typeof symbols === 'string') return symbols.split(/[\s,]+/).filter(Boolean);
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function fetchSimplePrices(ids, vsCurrency = 'usd') {
|
|
37
|
+
const client = getClient();
|
|
38
|
+
const data = await client.request('/simple/price', {
|
|
39
|
+
ids: ids.join(','),
|
|
40
|
+
vs_currencies: vsCurrency,
|
|
41
|
+
include_24hr_change: 'true',
|
|
42
|
+
include_last_updated_at: 'true',
|
|
43
|
+
});
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchMarkets(ids, vsCurrency = 'usd') {
|
|
48
|
+
const client = getClient();
|
|
49
|
+
const data = await client.request('/coins/markets', {
|
|
50
|
+
vs_currency: vsCurrency,
|
|
51
|
+
ids: ids.join(','),
|
|
52
|
+
price_change_percentage: '24h',
|
|
53
|
+
});
|
|
54
|
+
if (!Array.isArray(data)) throw new CryptoError('Unexpected markets response from CoinGecko.');
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatPrice(value, currency) {
|
|
59
|
+
if (value == null || Number.isNaN(Number(value))) return null;
|
|
60
|
+
const n = Number(value);
|
|
61
|
+
const digits = n >= 1000 ? 2 : n >= 1 ? 4 : 6;
|
|
62
|
+
return { amount: n, currency: currency.toUpperCase(), display: `${n.toLocaleString('en-US', {
|
|
63
|
+
style: 'currency',
|
|
64
|
+
currency: currency.toUpperCase(),
|
|
65
|
+
maximumFractionDigits: digits,
|
|
66
|
+
})}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatChange(value) {
|
|
70
|
+
if (value == null || Number.isNaN(Number(value))) return null;
|
|
71
|
+
const n = Number(value);
|
|
72
|
+
return { percent: n, display: `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`, tone: n > 0 ? 'up' : n < 0 ? 'down' : 'neutral' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const crypto_status = {
|
|
76
|
+
name: 'crypto_status',
|
|
77
|
+
description: 'Check crypto plugin configuration and CoinGecko connectivity without exposing API keys.',
|
|
78
|
+
riskLevel: 'safe',
|
|
79
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
80
|
+
execute: async () => {
|
|
81
|
+
try {
|
|
82
|
+
const info = connectionInfo();
|
|
83
|
+
const client = getClient();
|
|
84
|
+
const ping = await client.request('/ping');
|
|
85
|
+
return ok({
|
|
86
|
+
...info,
|
|
87
|
+
connected: Boolean(ping?.gecko_says),
|
|
88
|
+
provider: 'coingecko',
|
|
89
|
+
watchlistCount: getWatchlist().length,
|
|
90
|
+
nextStep: info.apiKeyConfigured
|
|
91
|
+
? null
|
|
92
|
+
: 'Optional: set COINGECKO_API_KEY for higher CoinGecko rate limits.',
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return fail(error);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const crypto_get_price = {
|
|
101
|
+
name: 'crypto_get_price',
|
|
102
|
+
description: 'Get the current spot price for one or more crypto assets. Accepts tickers (BTC, ETH) or CoinGecko ids (bitcoin, ethereum). Returns JSON with price, 24h change, and last update time.',
|
|
103
|
+
riskLevel: 'safe',
|
|
104
|
+
parameters: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
symbols: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: 'Comma-separated tickers or CoinGecko ids, e.g. "BTC,ETH,solana".',
|
|
110
|
+
},
|
|
111
|
+
vsCurrency: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
description: 'Quote currency. Defaults to usd.',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
required: ['symbols'],
|
|
117
|
+
},
|
|
118
|
+
execute: async ({ symbols, vsCurrency = 'usd' } = {}) => {
|
|
119
|
+
try {
|
|
120
|
+
const { ids, unknown } = resolveIds(parseSymbols(symbols));
|
|
121
|
+
if (!ids.length) throw new CryptoError(`Could not resolve any assets from: ${symbols}`);
|
|
122
|
+
const currency = String(vsCurrency || 'usd').toLowerCase();
|
|
123
|
+
const data = await fetchSimplePrices(ids, currency);
|
|
124
|
+
const prices = ids.map(id => {
|
|
125
|
+
const row = data[id];
|
|
126
|
+
if (!row) return { id, error: 'No price returned' };
|
|
127
|
+
return {
|
|
128
|
+
id,
|
|
129
|
+
price: formatPrice(row[currency], currency),
|
|
130
|
+
change24h: formatChange(row[`${currency}_24h_change`]),
|
|
131
|
+
lastUpdatedAt: row.last_updated_at
|
|
132
|
+
? new Date(row.last_updated_at * 1000).toISOString()
|
|
133
|
+
: null,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
return ok({ vsCurrency: currency, prices, unknown });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return fail(error);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const crypto_get_market = {
|
|
144
|
+
name: 'crypto_get_market',
|
|
145
|
+
description: 'Get market stats for crypto assets: price, 24h change, 24h volume, market cap, and rank. Accepts tickers or CoinGecko ids.',
|
|
146
|
+
riskLevel: 'safe',
|
|
147
|
+
parameters: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
symbols: {
|
|
151
|
+
type: 'string',
|
|
152
|
+
description: 'Comma-separated tickers or CoinGecko ids.',
|
|
153
|
+
},
|
|
154
|
+
vsCurrency: {
|
|
155
|
+
type: 'string',
|
|
156
|
+
description: 'Quote currency. Defaults to usd.',
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
required: ['symbols'],
|
|
160
|
+
},
|
|
161
|
+
execute: async ({ symbols, vsCurrency = 'usd' } = {}) => {
|
|
162
|
+
try {
|
|
163
|
+
const { ids, unknown } = resolveIds(parseSymbols(symbols));
|
|
164
|
+
if (!ids.length) throw new CryptoError(`Could not resolve any assets from: ${symbols}`);
|
|
165
|
+
const currency = String(vsCurrency || 'usd').toLowerCase();
|
|
166
|
+
const markets = await fetchMarkets(ids, currency);
|
|
167
|
+
const byId = Object.fromEntries(markets.map(m => [m.id, m]));
|
|
168
|
+
const rows = ids.map(id => {
|
|
169
|
+
const m = byId[id];
|
|
170
|
+
if (!m) return { id, error: 'No market data returned' };
|
|
171
|
+
return {
|
|
172
|
+
id: m.id,
|
|
173
|
+
symbol: m.symbol?.toUpperCase(),
|
|
174
|
+
name: m.name,
|
|
175
|
+
rank: m.market_cap_rank,
|
|
176
|
+
price: formatPrice(m.current_price, currency),
|
|
177
|
+
change24h: formatChange(m.price_change_percentage_24h),
|
|
178
|
+
volume24h: m.total_volume,
|
|
179
|
+
marketCap: m.market_cap,
|
|
180
|
+
lastUpdatedAt: m.last_updated || null,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
return ok({ vsCurrency: currency, markets: rows, unknown });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return fail(error);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const crypto_list_watchlist = {
|
|
191
|
+
name: 'crypto_list_watchlist',
|
|
192
|
+
description: 'List assets on the persisted crypto watchlist (used by the command-centre crypto tile and price digests).',
|
|
193
|
+
riskLevel: 'safe',
|
|
194
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
195
|
+
execute: async () => ok({ assets: getWatchlist() }),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const crypto_add_watchlist = {
|
|
199
|
+
name: 'crypto_add_watchlist',
|
|
200
|
+
description: 'Add a crypto asset to the watchlist by ticker or CoinGecko id. Use when the user asks to track a coin.',
|
|
201
|
+
riskLevel: 'moderate',
|
|
202
|
+
parameters: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
symbol: {
|
|
206
|
+
type: 'string',
|
|
207
|
+
description: 'Ticker or CoinGecko id, e.g. "BTC" or "bitcoin".',
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
required: ['symbol'],
|
|
211
|
+
},
|
|
212
|
+
execute: async ({ symbol } = {}) => {
|
|
213
|
+
try {
|
|
214
|
+
const id = resolveId(symbol);
|
|
215
|
+
if (!id) throw new CryptoError(`Could not resolve asset: ${symbol}`);
|
|
216
|
+
// Enrich from markets when possible; fall back to id-only.
|
|
217
|
+
let meta = { id, symbol: String(symbol).toUpperCase(), name: id };
|
|
218
|
+
try {
|
|
219
|
+
const [market] = await fetchMarkets([id], 'usd');
|
|
220
|
+
if (market) {
|
|
221
|
+
meta = { id: market.id, symbol: market.symbol?.toUpperCase() || meta.symbol, name: market.name || meta.name };
|
|
222
|
+
}
|
|
223
|
+
} catch { /* keep meta fallback */ }
|
|
224
|
+
const assets = addAsset(meta);
|
|
225
|
+
return ok({ added: meta, assets });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return fail(error);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const crypto_remove_watchlist = {
|
|
233
|
+
name: 'crypto_remove_watchlist',
|
|
234
|
+
description: 'Remove a crypto asset from the watchlist by ticker or CoinGecko id.',
|
|
235
|
+
riskLevel: 'moderate',
|
|
236
|
+
parameters: {
|
|
237
|
+
type: 'object',
|
|
238
|
+
properties: {
|
|
239
|
+
symbol: {
|
|
240
|
+
type: 'string',
|
|
241
|
+
description: 'Ticker or CoinGecko id to remove.',
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
required: ['symbol'],
|
|
245
|
+
},
|
|
246
|
+
execute: async ({ symbol } = {}) => {
|
|
247
|
+
try {
|
|
248
|
+
const id = resolveId(symbol);
|
|
249
|
+
if (!id) throw new CryptoError(`Could not resolve asset: ${symbol}`);
|
|
250
|
+
return ok(removeAsset(id));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return fail(error);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export const tools = [
|
|
258
|
+
crypto_status,
|
|
259
|
+
crypto_get_price,
|
|
260
|
+
crypto_get_market,
|
|
261
|
+
crypto_list_watchlist,
|
|
262
|
+
crypto_add_watchlist,
|
|
263
|
+
crypto_remove_watchlist,
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Command-centre tile — Goose discovers `tiles` and renders them in the web UI.
|
|
268
|
+
* `load()` must return a serialisable payload; keep it fast and side-effect free
|
|
269
|
+
* aside from read-only HTTP + local watchlist reads.
|
|
270
|
+
*/
|
|
271
|
+
export const tiles = [
|
|
272
|
+
{
|
|
273
|
+
id: 'watchlist',
|
|
274
|
+
title: 'Crypto',
|
|
275
|
+
description: 'Watchlist spot prices via CoinGecko',
|
|
276
|
+
refreshSeconds: 60,
|
|
277
|
+
async load() {
|
|
278
|
+
const assets = getWatchlist();
|
|
279
|
+
if (!assets.length) {
|
|
280
|
+
return {
|
|
281
|
+
kind: 'table',
|
|
282
|
+
columns: [
|
|
283
|
+
{ key: 'asset', label: 'Asset' },
|
|
284
|
+
{ key: 'price', label: 'Price' },
|
|
285
|
+
{ key: 'change', label: '24h' },
|
|
286
|
+
],
|
|
287
|
+
rows: [],
|
|
288
|
+
emptyMessage: 'Watchlist is empty. Ask Goose to add BTC or ETH.',
|
|
289
|
+
updatedAt: new Date().toISOString(),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const ids = assets.map(a => a.id);
|
|
294
|
+
const data = await fetchSimplePrices(ids, 'usd');
|
|
295
|
+
const rows = assets.map(asset => {
|
|
296
|
+
const row = data[asset.id];
|
|
297
|
+
const price = formatPrice(row?.usd, 'usd');
|
|
298
|
+
const change = formatChange(row?.usd_24h_change);
|
|
299
|
+
return {
|
|
300
|
+
tone: change?.tone || 'neutral',
|
|
301
|
+
cells: {
|
|
302
|
+
asset: `${asset.symbol} · ${asset.name}`,
|
|
303
|
+
price: price?.display || '—',
|
|
304
|
+
change: change?.display || '—',
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
return {
|
|
309
|
+
kind: 'table',
|
|
310
|
+
columns: [
|
|
311
|
+
{ key: 'asset', label: 'Asset' },
|
|
312
|
+
{ key: 'price', label: 'Price', align: 'right' },
|
|
313
|
+
{ key: 'change', label: '24h', align: 'right' },
|
|
314
|
+
],
|
|
315
|
+
rows,
|
|
316
|
+
footer: 'Prices from CoinGecko · ask Goose to change the watchlist',
|
|
317
|
+
updatedAt: new Date().toISOString(),
|
|
318
|
+
};
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return {
|
|
321
|
+
kind: 'error',
|
|
322
|
+
message: error instanceof CryptoError ? error.message : 'Failed to load crypto prices.',
|
|
323
|
+
updatedAt: new Date().toISOString(),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
// Re-export for tests / advanced callers
|
|
331
|
+
export { setWatchlist, getWatchlist };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crypto-morning-digest",
|
|
3
|
+
"cron": "0 8 * * *",
|
|
4
|
+
"timezone": "Europe/London",
|
|
5
|
+
"enabled": false,
|
|
6
|
+
"contextId": "mission-crypto-morning-digest",
|
|
7
|
+
"freshContext": true,
|
|
8
|
+
"maxIterations": 4,
|
|
9
|
+
"task": "Call crypto_list_watchlist, then crypto_get_market with those symbols. Summarise prices and 24h moves in a short briefing. Do not invent numbers — use only tool results. If a tool returns Error:, say so clearly.",
|
|
10
|
+
"notifySlack": false,
|
|
11
|
+
"speak": false
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@goose-plugins/crypto",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Crypto price tracking for Goose — watchlist, spot prices, and a command-centre tile",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./store.js": "./store.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"client.js",
|
|
13
|
+
"store.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"mission.example.json"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"registry": "https://registry.npmjs.org/"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"goose",
|
|
30
|
+
"goose-plugins",
|
|
31
|
+
"crypto",
|
|
32
|
+
"coingecko",
|
|
33
|
+
"prices"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"author": "Rory Standley",
|
|
37
|
+
"homepage": "https://github.com/rorystandley/goose-plugins/tree/main/crypto",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/rorystandley/goose-plugins.git",
|
|
41
|
+
"directory": "crypto"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/rorystandley/goose-plugins/issues"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"vitest": "^4.0.18"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/store.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crypto watchlist store.
|
|
3
|
+
*
|
|
4
|
+
* Persists tracked CoinGecko ids to data/crypto-watchlist.json.
|
|
5
|
+
* Follows the architecture plugin pattern — module-level load, silent
|
|
6
|
+
* write failures, returns copies to prevent external mutation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_WATCHLIST = [
|
|
13
|
+
{ id: 'bitcoin', symbol: 'BTC', name: 'Bitcoin' },
|
|
14
|
+
{ id: 'ethereum', symbol: 'ETH', name: 'Ethereum' },
|
|
15
|
+
{ id: 'solana', symbol: 'SOL', name: 'Solana' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function storePath() {
|
|
19
|
+
return process.env.CRYPTO_WATCHLIST_PATH
|
|
20
|
+
|| path.join(process.cwd(), 'data', 'crypto-watchlist.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function empty() {
|
|
24
|
+
return { assets: DEFAULT_WATCHLIST.map(a => ({ ...a })) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function load() {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(fs.readFileSync(storePath(), 'utf8'));
|
|
30
|
+
if (!Array.isArray(parsed?.assets)) return empty();
|
|
31
|
+
return {
|
|
32
|
+
assets: parsed.assets
|
|
33
|
+
.filter(a => a && typeof a.id === 'string')
|
|
34
|
+
.map(a => ({
|
|
35
|
+
id: a.id,
|
|
36
|
+
symbol: typeof a.symbol === 'string' ? a.symbol : a.id.toUpperCase(),
|
|
37
|
+
name: typeof a.name === 'string' ? a.name : a.id,
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
} catch {
|
|
41
|
+
return empty();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function save(data) {
|
|
46
|
+
try {
|
|
47
|
+
const file = storePath();
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
|
50
|
+
} catch { /* silent */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let store = load();
|
|
54
|
+
|
|
55
|
+
export function getWatchlist() {
|
|
56
|
+
return store.assets.map(a => ({ ...a }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function setWatchlist(assets) {
|
|
60
|
+
store = {
|
|
61
|
+
assets: assets.map(a => ({
|
|
62
|
+
id: a.id,
|
|
63
|
+
symbol: a.symbol || a.id.toUpperCase(),
|
|
64
|
+
name: a.name || a.id,
|
|
65
|
+
})),
|
|
66
|
+
};
|
|
67
|
+
save(store);
|
|
68
|
+
return getWatchlist();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function addAsset(asset) {
|
|
72
|
+
if (store.assets.some(a => a.id === asset.id)) {
|
|
73
|
+
store.assets = store.assets.map(a => (a.id === asset.id ? { ...a, ...asset } : a));
|
|
74
|
+
} else {
|
|
75
|
+
store.assets.push({
|
|
76
|
+
id: asset.id,
|
|
77
|
+
symbol: asset.symbol || asset.id.toUpperCase(),
|
|
78
|
+
name: asset.name || asset.id,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
save(store);
|
|
82
|
+
return getWatchlist();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function removeAsset(id) {
|
|
86
|
+
const before = store.assets.length;
|
|
87
|
+
store.assets = store.assets.filter(a => a.id !== id);
|
|
88
|
+
save(store);
|
|
89
|
+
return { removed: before !== store.assets.length, assets: getWatchlist() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Test helper — reload from disk / reset in-memory state. */
|
|
93
|
+
export function reloadStore() {
|
|
94
|
+
store = load();
|
|
95
|
+
return getWatchlist();
|
|
96
|
+
}
|