dexterm-cli 0.1.1 → 0.1.3

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 (155) hide show
  1. package/dist/packages/agents/src/bus.js +57 -6
  2. package/dist/packages/agents/src/executor.js +25 -0
  3. package/dist/packages/agents/src/follow.js +105 -0
  4. package/dist/packages/agents/src/gate.js +3 -0
  5. package/dist/packages/agents/src/identity.js +41 -2
  6. package/dist/packages/agents/src/index.js +5 -0
  7. package/dist/packages/agents/src/spectator-server.js +153 -0
  8. package/dist/packages/agents/src/spectator.js +212 -0
  9. package/dist/packages/agents/src/tiers.js +5 -0
  10. package/dist/packages/agents/src/tools.js +3 -0
  11. package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
  12. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
  13. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
  14. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
  15. package/dist/packages/cli/src/agent-cmd.js +107 -10
  16. package/dist/packages/cli/src/agent-spectator.js +37 -0
  17. package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
  18. package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
  19. package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
  20. package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
  21. package/dist/packages/cli/src/alerts/creds.js +83 -0
  22. package/dist/packages/cli/src/alerts/format.js +66 -0
  23. package/dist/packages/cli/src/alerts/index.js +10 -0
  24. package/dist/packages/cli/src/alerts/notifier.js +53 -0
  25. package/dist/packages/cli/src/alerts/route.js +19 -0
  26. package/dist/packages/cli/src/alerts/types.js +1 -0
  27. package/dist/packages/cli/src/alerts-cmd.js +212 -0
  28. package/dist/packages/cli/src/app-help-gen.js +365 -0
  29. package/dist/packages/cli/src/backtest-cmd.js +62 -3
  30. package/dist/packages/cli/src/coin-target.js +2 -0
  31. package/dist/packages/cli/src/doctor.js +21 -6
  32. package/dist/packages/cli/src/export.js +235 -0
  33. package/dist/packages/cli/src/follow-cmd.js +107 -1
  34. package/dist/packages/cli/src/help-cmd.js +48 -0
  35. package/dist/packages/cli/src/live/index.js +66 -3
  36. package/dist/packages/cli/src/live/screens/token-live.js +24 -0
  37. package/dist/packages/cli/src/live/signals.js +109 -0
  38. package/dist/packages/cli/src/live/trade.js +24 -1
  39. package/dist/packages/cli/src/main.js +596 -49
  40. package/dist/packages/cli/src/mcp-cmd.js +321 -0
  41. package/dist/packages/cli/src/pnl-app.js +221 -0
  42. package/dist/packages/cli/src/pnl-card.js +615 -0
  43. package/dist/packages/cli/src/record-cmd.js +455 -0
  44. package/dist/packages/cli/src/rules.js +69 -2
  45. package/dist/packages/cli/src/screens/follows.js +91 -2
  46. package/dist/packages/cli/src/screens/overlay.js +3 -0
  47. package/dist/packages/cli/src/screens/positions.js +1 -1
  48. package/dist/packages/cli/src/screens/rules.js +1 -1
  49. package/dist/packages/cli/src/skill-eval-build.js +109 -0
  50. package/dist/packages/cli/src/spectate-cmd.js +104 -0
  51. package/dist/packages/cli/src/state.js +1 -1
  52. package/dist/packages/cli/src/update.js +294 -0
  53. package/dist/packages/cli/src/version.js +3 -0
  54. package/dist/packages/cli/src/voice-cmd.js +194 -0
  55. package/dist/packages/cli/src/voice-hud.js +1 -1
  56. package/dist/packages/cli/src/watch-cmd.js +190 -0
  57. package/dist/packages/custom/src/rules.js +3 -2
  58. package/dist/packages/custom/src/schema.js +24 -1
  59. package/dist/packages/data/src/analyst-actions.js +130 -3
  60. package/dist/packages/data/src/coin-target.js +82 -0
  61. package/dist/packages/data/src/fast-lane.js +16 -2
  62. package/dist/packages/data/src/index.js +4 -0
  63. package/dist/packages/data/src/intel/helius-history.js +12 -1
  64. package/dist/packages/data/src/intel/live-signals.js +185 -0
  65. package/dist/packages/data/src/larp/link-baseline.js +196 -0
  66. package/dist/packages/data/src/sources/dexscreener.js +14 -0
  67. package/dist/packages/data/src/sources/pumpportal.js +6 -1
  68. package/dist/packages/data/src/trade/copy/index.js +1 -1
  69. package/dist/packages/data/src/trade/copy/stats.js +36 -2
  70. package/dist/packages/data/src/trade/copy/tracker.js +3 -2
  71. package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
  72. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
  73. package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
  74. package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
  75. package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
  76. package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
  77. package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
  78. package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
  79. package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
  80. package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
  81. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
  82. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
  83. package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
  84. package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
  85. package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
  86. package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
  87. package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
  88. package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
  89. package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
  90. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
  91. package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
  92. package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
  93. package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
  94. package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
  95. package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
  96. package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
  97. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
  98. package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
  99. package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
  100. package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
  101. package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
  102. package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
  103. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
  104. package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
  105. package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
  106. package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
  107. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
  108. package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
  109. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
  110. package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
  111. package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
  112. package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
  113. package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
  114. package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
  115. package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
  116. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
  117. package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
  118. package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
  119. package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
  120. package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
  121. package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
  122. package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
  123. package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
  124. package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
  125. package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
  126. package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
  127. package/dist/packages/mcp/src/app-help.js +135 -0
  128. package/dist/packages/mcp/src/index.js +3 -0
  129. package/dist/packages/mcp/src/server.js +179 -5
  130. package/dist/packages/mcp/src/spectator-sink.js +72 -0
  131. package/dist/packages/packs/src/index.js +3 -0
  132. package/dist/packages/packs/src/skill-backtest.js +300 -0
  133. package/dist/packages/packs/src/skill-care.js +206 -0
  134. package/dist/packages/popup/src/pages/spectator.css +45 -0
  135. package/dist/packages/popup/src/pages/spectator.html +25 -0
  136. package/dist/packages/popup/src/pages/spectator.js +74 -0
  137. package/dist/packages/popup/src/selftest.js +19 -0
  138. package/dist/packages/popup/src/server.js +7 -1
  139. package/dist/packages/tui/src/index.js +3 -0
  140. package/dist/packages/tui/src/keymap.js +12 -0
  141. package/dist/packages/tui/src/raster-font.js +195 -0
  142. package/dist/packages/tui/src/raster.js +213 -0
  143. package/dist/packages/voice/src/controller.js +57 -4
  144. package/dist/packages/voice/src/doctor.js +96 -11
  145. package/dist/packages/voice/src/download.js +184 -0
  146. package/dist/packages/voice/src/engines.js +55 -8
  147. package/dist/packages/voice/src/index.js +9 -3
  148. package/dist/packages/voice/src/speakable.js +111 -0
  149. package/dist/packages/voice/src/stt/whisper.js +14 -4
  150. package/dist/packages/voice/src/tts/espeak.js +131 -0
  151. package/dist/packages/voice/src/tts/piper-setup.js +229 -0
  152. package/dist/packages/voice/src/tts/piper.js +449 -0
  153. package/dist/packages/voice/src/tts/say.js +72 -35
  154. package/dist/packages/voice/src/tts/voices.js +209 -0
  155. package/package.json +5 -9
@@ -0,0 +1,321 @@
1
+ // `dexterm mcp …` — the read-only MCP server plus one-command setup for any agent (task-1405).
2
+ //
3
+ // dexterm mcp run the read-only MCP server on stdio (what the analyst uses and
4
+ // what `claude mcp add dexterm -- dexterm mcp` wires up)
5
+ // dexterm mcp install <client> write/print the MCP config for a client (claude-code, claude-desktop,
6
+ // cursor, codex, generic). Shows the exact change, asks first,
7
+ // is idempotent, and backs the file up before editing.
8
+ // dexterm mcp doctor spawn the server over stdio, do the MCP handshake, and list the tools,
9
+ // prompts and resources the agent sees — proof the connection works.
10
+ //
11
+ // install writes nothing without consent: it prints the target file and the exact snippet, then (unless
12
+ // --yes) asks on a TTY; a non-TTY run without --yes prints the plan and stops. --print never writes. The
13
+ // config dir is resolved from the environment; --config FILE overrides it (tests point this at a temp dir),
14
+ // so this command NEVER has to touch the real ~/.claude, Cursor or Claude Desktop config to be exercised.
15
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawn } from 'node:child_process';
20
+ import readline from 'node:readline';
21
+ function flag(rest, name) { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; }
22
+ function has(rest, name) { return rest.includes(name); }
23
+ export function defaultCommand(env, useNode) {
24
+ if (!useNode)
25
+ return { command: 'dexterm', args: ['mcp'] };
26
+ // Fallback for when dexterm is not on PATH: run this repo's server bin with the current node.
27
+ return { command: process.execPath, args: [mcpBinPath(), 'mcp'] };
28
+ }
29
+ /** Where this repo's standalone MCP server bin lives (used by the --node command and by `doctor`). */
30
+ export function mcpBinPath() {
31
+ const here = dirname(fileURLToPath(import.meta.url)); // packages/cli/src
32
+ return resolve(here, '..', '..', 'mcp', 'bin', 'dexterm-mcp.mjs');
33
+ }
34
+ function home(env) { return env.HOME || env.USERPROFILE || homedir(); }
35
+ function appSupport(env, platform, app) {
36
+ if (platform === 'darwin')
37
+ return join(home(env), 'Library', 'Application Support', app);
38
+ if (platform === 'win32')
39
+ return join(env.APPDATA || join(home(env), 'AppData', 'Roaming'), app);
40
+ return join(env.XDG_CONFIG_HOME || join(home(env), '.config'), app);
41
+ }
42
+ export const CLIENTS = [
43
+ {
44
+ id: 'claude-code', label: 'Claude Code', format: 'json', keyPath: 'mcpServers',
45
+ path: (env) => join(env.DEXTERM_CWD || process.cwd(), '.mcp.json'),
46
+ note: 'project-scoped .mcp.json in the current directory; or run `claude mcp add dexterm -- dexterm mcp`.',
47
+ },
48
+ {
49
+ id: 'claude-desktop', label: 'Claude Desktop', format: 'json', keyPath: 'mcpServers',
50
+ path: (env, platform) => join(appSupport(env, platform, 'Claude'), 'claude_desktop_config.json'),
51
+ note: 'restart Claude Desktop after editing so it re-reads the config.',
52
+ },
53
+ {
54
+ id: 'cursor', label: 'Cursor', format: 'json', keyPath: 'mcpServers',
55
+ path: (env) => join(home(env), '.cursor', 'mcp.json'),
56
+ note: 'global Cursor MCP config (~/.cursor/mcp.json); a project .cursor/mcp.json works too.',
57
+ },
58
+ {
59
+ id: 'codex', label: 'Codex CLI', format: 'toml', keyPath: 'mcp_servers',
60
+ path: (env) => join(env.CODEX_HOME || join(home(env), '.codex'), 'config.toml'),
61
+ note: 'Codex reads MCP servers from the [mcp_servers.*] tables in ~/.codex/config.toml.',
62
+ },
63
+ {
64
+ id: 'generic', label: 'Generic (print the snippet)', format: 'print', keyPath: 'mcpServers',
65
+ path: () => null,
66
+ note: 'the standard stdio mcpServers entry most agents take; paste it where your agent reads MCP servers, or pass --config FILE to write a JSON file.',
67
+ },
68
+ ];
69
+ export function clientById(id) { return CLIENTS.find((c) => c.id === id); }
70
+ /** The JSON object a json-format client expects under its server map, for `dexterm`. */
71
+ export function serverEntryJson(cmd) {
72
+ return { command: cmd.command, args: cmd.args };
73
+ }
74
+ /** The TOML block a codex-style client expects. */
75
+ export function serverEntryToml(cmd) {
76
+ const args = cmd.args.map((a) => JSON.stringify(a)).join(', ');
77
+ return `[mcp_servers.dexterm]\ncommand = ${JSON.stringify(cmd.command)}\nargs = [${args}]\n`;
78
+ }
79
+ /** Plan a JSON merge: returns the new file text and whether anything changes. Preserves every other key. */
80
+ export function planJson(existingText, keyPath, entry) {
81
+ let obj = {};
82
+ if (existingText && existingText.trim()) {
83
+ try {
84
+ const parsed = JSON.parse(existingText);
85
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
86
+ obj = parsed;
87
+ }
88
+ catch {
89
+ throw new Error('the existing config is not valid JSON — fix or move it, then re-run');
90
+ }
91
+ }
92
+ const map = (obj[keyPath] && typeof obj[keyPath] === 'object' && !Array.isArray(obj[keyPath])) ? obj[keyPath] : {};
93
+ const prev = map.dexterm;
94
+ const was = prev === undefined ? 'absent' : JSON.stringify(prev) === JSON.stringify(entry) ? 'same' : 'different';
95
+ const next = { ...obj, [keyPath]: { ...map, dexterm: entry } };
96
+ return { text: JSON.stringify(next, null, 2) + '\n', changed: was !== 'same', was };
97
+ }
98
+ /** Plan a TOML merge: append the dexterm block when there is no [mcp_servers.dexterm] yet; never clobber. */
99
+ export function planToml(existingText, block) {
100
+ const cur = existingText ?? '';
101
+ if (cur.includes(block.trim()))
102
+ return { text: cur, changed: false, was: 'same', conflict: false };
103
+ if (/\[mcp_servers\.dexterm\]/.test(cur))
104
+ return { text: cur, changed: false, was: 'different', conflict: true };
105
+ const sep = cur.length && !cur.endsWith('\n\n') ? (cur.endsWith('\n') ? '\n' : '\n\n') : '';
106
+ return { text: cur + sep + block, changed: true, was: 'absent', conflict: false };
107
+ }
108
+ function readLineYesNo(io, prompt) {
109
+ const stdin = io.stdin;
110
+ if (!stdin || !stdin.isTTY)
111
+ return Promise.resolve(false);
112
+ return new Promise((res) => {
113
+ const rl = readline.createInterface({ input: stdin, output: io.stdout });
114
+ rl.question(prompt, (ans) => { rl.close(); res(/^y(es)?$/i.test(ans.trim())); });
115
+ });
116
+ }
117
+ function backupPath(file) {
118
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
119
+ return `${file}.bak-${stamp}`;
120
+ }
121
+ async function install(rest, io) {
122
+ const out = (s) => io.stdout.write(s + '\n');
123
+ const errOut = (s) => io.stderr.write(s + '\n');
124
+ const id = rest.find((a) => !a.startsWith('-'));
125
+ const spec = id ? clientById(id) : undefined;
126
+ if (!spec) {
127
+ errOut(`usage: dexterm mcp install <${CLIENTS.map((c) => c.id).join('|')}> [--config FILE] [--print] [--node] [--yes]`);
128
+ if (id)
129
+ errOut(`unknown client "${id}". known: ${CLIENTS.map((c) => c.id).join(', ')}`);
130
+ return 2;
131
+ }
132
+ const printOnly = has(rest, '--print');
133
+ const useNode = has(rest, '--node');
134
+ const yes = has(rest, '--yes') || has(rest, '-y');
135
+ const cmd = defaultCommand(io.env, useNode);
136
+ const override = flag(rest, '--config');
137
+ const target = override ? resolve(override) : spec.path(io.env, process.platform);
138
+ const forcePrint = spec.format === 'print' && !override;
139
+ // The snippet we would add, always shown.
140
+ const snippet = spec.format === 'toml' ? serverEntryToml(cmd) : JSON.stringify({ [spec.keyPath]: { dexterm: serverEntryJson(cmd) } }, null, 2);
141
+ out(`dexterm MCP server for ${spec.label}:`);
142
+ out('');
143
+ out(indent(snippet));
144
+ out('');
145
+ if (spec.note)
146
+ out(`note: ${spec.note}`);
147
+ if (printOnly || forcePrint) {
148
+ if (forcePrint && !printOnly)
149
+ out('(this client has no single well-known config file — paste the snippet above, or pass --config FILE to write it.)');
150
+ return 0;
151
+ }
152
+ if (!target) {
153
+ out('pass --config FILE to write this to a specific file.');
154
+ return 0;
155
+ }
156
+ const existed = existsSync(target);
157
+ const existingText = existed ? readFileSync(target, 'utf8') : null;
158
+ let plan;
159
+ try {
160
+ plan = spec.format === 'toml'
161
+ ? planToml(existingText, serverEntryToml(cmd))
162
+ : planJson(existingText, spec.keyPath, serverEntryJson(cmd));
163
+ }
164
+ catch (e) {
165
+ errOut(`dexterm mcp install: ${e.message} (${target})`);
166
+ return 1;
167
+ }
168
+ out('');
169
+ out(`target: ${target}${existed ? '' : ' (new file)'}`);
170
+ if (plan.conflict) {
171
+ out('a [mcp_servers.dexterm] section already exists and differs; not modifying it. Review it by hand, or edit it to match the snippet above.');
172
+ return 0;
173
+ }
174
+ if (!plan.changed) {
175
+ out('already configured — nothing to change (idempotent).');
176
+ return 0;
177
+ }
178
+ out(plan.was === 'different' ? 'this UPDATES the existing dexterm entry.' : existed ? 'this ADDS a dexterm entry (your other servers are preserved).' : 'this CREATES the config file with a dexterm entry.');
179
+ if (!yes) {
180
+ const ok = await readLineYesNo(io, 'write this change? [y/N] ');
181
+ if (!ok) {
182
+ out(io.stdin?.isTTY ? 'cancelled; nothing was written.' : 'not a TTY and --yes was not given; nothing written. Re-run with --yes to write, or --print to just print.');
183
+ return io.stdin?.isTTY ? 0 : 0;
184
+ }
185
+ }
186
+ try {
187
+ mkdirSync(dirname(target), { recursive: true });
188
+ if (existed) {
189
+ const bak = backupPath(target);
190
+ copyFileSync(target, bak);
191
+ out(`backed up: ${bak}`);
192
+ }
193
+ writeFileSync(target, plan.text, { mode: 0o600 });
194
+ out(`wrote ${target}.`);
195
+ out(`verify it: dexterm mcp doctor`);
196
+ return 0;
197
+ }
198
+ catch (e) {
199
+ errOut(`dexterm mcp install: could not write ${target}: ${e.message}`);
200
+ return 1;
201
+ }
202
+ }
203
+ function indent(s) { return s.split('\n').map((l) => ' ' + l).join('\n'); }
204
+ async function doctor(rest, io) {
205
+ const out = (s) => io.stdout.write(s + '\n');
206
+ const errOut = (s) => io.stderr.write(s + '\n');
207
+ // Prefer the command install writes (`dexterm mcp`); fall back to this repo's node bin when dexterm is
208
+ // not on PATH (e.g. a dev checkout). Either way this is a real stdio child, not an in-process shortcut.
209
+ const useNode = has(rest, '--node');
210
+ const cmd = useNode ? defaultCommand(io.env, true) : { command: 'dexterm', args: ['mcp'] };
211
+ out(`dexterm mcp doctor — spawning: ${cmd.command} ${cmd.args.join(' ')}`);
212
+ const result = await probe(cmd, io.env).catch((e) => ({ error: e.message }));
213
+ if ('error' in result && !useNode && /ENOENT|not found|spawn/i.test(result.error)) {
214
+ // dexterm not on PATH: retry with the bundled node bin so doctor still verifies the server.
215
+ out(`(dexterm not on PATH; retrying with this checkout's server)`);
216
+ const fallback = defaultCommand(io.env, true);
217
+ out(`dexterm mcp doctor — spawning: ${fallback.command} ${fallback.args.join(' ')}`);
218
+ const r2 = await probe(fallback, io.env).catch((e) => ({ error: e.message }));
219
+ return report(r2, out, errOut);
220
+ }
221
+ return report(result, out, errOut);
222
+ }
223
+ function report(r, out, errOut) {
224
+ if ('error' in r) {
225
+ errOut(`✗ could not reach the MCP server: ${r.error}`);
226
+ return 1;
227
+ }
228
+ const info = r.serverInfo;
229
+ out(`✓ connected to ${info?.name ?? 'dexterm'} ${info?.version ?? ''} over stdio (MCP initialize ok)`);
230
+ out(`tools (${r.tools.length}): ${r.tools.join(', ')}`);
231
+ out(`prompts/skills (${r.prompts.length}): ${r.prompts.join(', ') || '(none)'}`);
232
+ out(`resources: ${r.resources}`);
233
+ out('the connection works; add it to your agent with `dexterm mcp install <client>`.');
234
+ return 0;
235
+ }
236
+ function probe(cmd, env) {
237
+ return new Promise((res, rej) => {
238
+ const child = spawn(cmd.command, cmd.args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...env, DEXTERM_OFFLINE: '1', NO_COLOR: '1' } });
239
+ const timer = setTimeout(() => { try {
240
+ child.kill();
241
+ }
242
+ catch { /* ignore */ } rej(new Error('timed out waiting for the server (10s)')); }, 10_000);
243
+ let stderr = '';
244
+ child.on('error', (e) => { clearTimeout(timer); rej(e); });
245
+ child.stderr.on('data', (d) => { stderr += String(d); });
246
+ const rl = readline.createInterface({ input: child.stdout });
247
+ const pending = new Map();
248
+ let seq = 0;
249
+ const send = (method, params) => {
250
+ const id = ++seq;
251
+ const msg = { jsonrpc: '2.0', id, method, ...(params ? { params } : {}) };
252
+ return new Promise((r) => { pending.set(id, r); child.stdin.write(JSON.stringify(msg) + '\n'); });
253
+ };
254
+ rl.on('line', (line) => {
255
+ if (!line.trim())
256
+ return;
257
+ let msg;
258
+ try {
259
+ msg = JSON.parse(line);
260
+ }
261
+ catch {
262
+ return;
263
+ }
264
+ if (typeof msg.id === 'number' && pending.has(msg.id)) {
265
+ const r = pending.get(msg.id);
266
+ pending.delete(msg.id);
267
+ r(msg.result ?? {});
268
+ }
269
+ });
270
+ (async () => {
271
+ try {
272
+ const init = await send('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'dexterm-doctor', version: '1' } });
273
+ const toolsRes = await send('tools/list');
274
+ const promptsRes = await send('prompts/list');
275
+ const resRes = await send('resources/list');
276
+ clearTimeout(timer);
277
+ try {
278
+ child.stdin.end();
279
+ }
280
+ catch { /* ignore */ }
281
+ try {
282
+ child.kill();
283
+ }
284
+ catch { /* ignore */ }
285
+ const tools = Array.isArray(toolsRes.tools) ? toolsRes.tools.map((t) => String(t.name)) : [];
286
+ const prompts = Array.isArray(promptsRes.prompts) ? promptsRes.prompts.map((p) => String(p.name)) : [];
287
+ const resources = Array.isArray(resRes.resources) ? resRes.resources.length : 0;
288
+ res({ serverInfo: init.serverInfo ?? {}, tools, prompts, resources });
289
+ }
290
+ catch (e) {
291
+ clearTimeout(timer);
292
+ rej(new Error(`${e.message}${stderr ? ` · server stderr: ${stderr.slice(0, 200)}` : ''}`));
293
+ }
294
+ })();
295
+ });
296
+ }
297
+ const USAGE = `dexterm mcp [<command>]
298
+ (no command) run the read-only MCP server on stdio (claude mcp add dexterm -- dexterm mcp)
299
+ install <client> write/print the MCP config for a client and verify what changes
300
+ clients: ${CLIENTS.map((c) => c.id).join(', ')}
301
+ --config FILE write to a specific file (instead of the client's default)
302
+ --print only print the snippet, never write
303
+ --node emit a "node <path> mcp" command (when dexterm is not on PATH)
304
+ --yes write without the confirmation prompt
305
+ doctor spawn the server, do the MCP handshake, and list the tools/prompts/resources`;
306
+ /** `dexterm mcp …`. With no subcommand (or an arg the server consumes, e.g. --replay) it runs the server. */
307
+ export async function runMcp(rest, io) {
308
+ const sub = rest[0];
309
+ if (sub === 'install')
310
+ return install(rest.slice(1), io);
311
+ if (sub === 'doctor')
312
+ return doctor(rest.slice(1), io);
313
+ if (sub === 'help' || sub === '--help' || sub === '-h') {
314
+ io.stdout.write(USAGE + '\n');
315
+ return 0;
316
+ }
317
+ // Anything else (no args, or server flags like --replay) → the stdio server, unchanged.
318
+ const { runStdioServer } = await import('../../mcp/src/index.js');
319
+ await runStdioServer({ argv: rest });
320
+ return 0;
321
+ }
@@ -0,0 +1,221 @@
1
+ // dexterm — PNL CARD glue for the app (task-1407). `H` on the token, positions and followed-wallets screens opens a
2
+ // preview of the coin's PnL card built from dexterm's own records:
3
+ // token → that coin: LIVE when the signer's ledger has fills for it, else PAPER
4
+ // positions → every coin with fills (newest activity first; ← → cycle), LIVE or PAPER as above
5
+ // follows → the selected followed wallet's coins (TRACKED), newest first
6
+ // In the preview: x share to X · t copy as text · s save only · a show/hide wallet · z exact size/band ·
7
+ // f 16:9 ⇄ 1:1 · ← → other coin · esc close. Share = save + clipboard image + X compose intent in the user's own
8
+ // browser (pnl-card.ts shareCard); dexterm posts nothing. Nothing here trades, signs or edits a number.
9
+ import { mix } from '../../tui/src/index.js';
10
+ import { computeCard, renderCard, previewCanvas, cardText, paperCardInput, liveCardInput, trackedCardInput, CardError, } from "./pnl-card.js";
11
+ const MARK_VIA = 'dexscreener';
12
+ function coinMeta(state, mint) {
13
+ const tv = state.live?.token?.view;
14
+ if (tv && tv.mint === mint)
15
+ return { symbol: tv.symbol, name: tv.name, image: tv.image };
16
+ const row = state.screener.rows.find((r) => r.mint === mint);
17
+ return { symbol: row?.symbol ?? null, name: row?.name ?? null, image: row?.image ?? null };
18
+ }
19
+ function imageOf(state, url) {
20
+ if (!url || !state.images)
21
+ return null;
22
+ const st = state.images.get(url, 2);
23
+ return st.state === 'ready' ? st.img : null;
24
+ }
25
+ /** The newest price mark for a mint: the positions mark pass, else the open token's market panel ÷ SOL/USD. */
26
+ export function markFor(state, mint) {
27
+ const pos = state.live?.positions;
28
+ const p = pos?.markOf(mint) ?? null, at = pos?.markAt(mint) ?? null;
29
+ if (p !== null && at !== null)
30
+ return { priceSol: p, at, via: MARK_VIA };
31
+ const tv = state.live?.token?.view;
32
+ const solUsd = pos?.solUsd() ?? null;
33
+ const m = tv?.panels.market;
34
+ if (tv && tv.mint === mint && tv.price && solUsd && m?.fetchedAt)
35
+ return { priceSol: tv.price / solUsd, at: m.fetchedAt, via: `${m.via ?? 'market'} ÷ SOL/USD` };
36
+ return null;
37
+ }
38
+ /** Coins with paper or live fills, newest activity first. */
39
+ function tradedCoins(state) {
40
+ const live = state.live;
41
+ if (!live)
42
+ return [];
43
+ const last = new Map();
44
+ const see = (mint, t, symbol) => { const e = last.get(mint); if (!e || t > e.t)
45
+ last.set(mint, { t, symbol }); };
46
+ for (const r of live.wallet.trades(1000))
47
+ see(r.mint, r.t, r.symbol);
48
+ for (const r of live.livePositions?.records(1000) ?? [])
49
+ see(r.mint, r.t, r.symbol);
50
+ return [...last.entries()].sort((a, b) => b[1].t - a[1].t).map(([mint, v]) => ({ mint, symbol: v.symbol }));
51
+ }
52
+ /** Open the preview for the current screen. Returns a reason when there is nothing to card. */
53
+ export function openPnl(state) {
54
+ if (!state.live)
55
+ return 'PnL cards are made from recorded fills: run dexterm live (not --fixtures / --replay)';
56
+ const base = { idx: 0, stats: null, error: null, busy: false, notice: null, opts: { aspect: 'wide', showWallet: false, exactSize: false } };
57
+ if (state.screen === 'follows') {
58
+ const follows = state.copy?.follows() ?? [];
59
+ const f = follows[Math.max(0, Math.min(follows.length - 1, state.copyUi?.selected ?? 0))];
60
+ if (!f)
61
+ return 'follow a wallet first (i), then H cards its coins';
62
+ const seen = new Set();
63
+ const choices = [];
64
+ for (const tr of [...(state.copy?.trades(f.addr) ?? [])].sort((a, b) => b.t - a.t)) {
65
+ if (seen.has(tr.mint) || tr.side === 'transfer')
66
+ continue;
67
+ seen.add(tr.mint);
68
+ choices.push({ mint: tr.mint, symbol: coinMeta(state, tr.mint).symbol ?? tr.mint.slice(0, 4) });
69
+ }
70
+ if (!choices.length)
71
+ return 'no decoded swaps for this wallet yet (r polls now)';
72
+ state.pnl = { origin: 'follows', choices, wallet: f.addr, ...base };
73
+ }
74
+ else if (state.screen === 'token') {
75
+ const mint = state.live.token?.view?.mint ?? state.token?.mint ?? null;
76
+ if (!mint)
77
+ return 'open a coin first';
78
+ const has = (state.live.livePositions?.records(1, mint).length ?? 0) > 0 || state.live.wallet.trades(1000).some((r) => r.mint === mint);
79
+ if (!has)
80
+ return 'no paper or live fills for this coin yet: PnL cards come from your own recorded trades';
81
+ state.pnl = { origin: 'token', choices: [{ mint, symbol: coinMeta(state, mint).symbol ?? mint.slice(0, 4) }], wallet: null, ...base };
82
+ }
83
+ else if (state.screen === 'positions') {
84
+ const choices = tradedCoins(state);
85
+ if (!choices.length)
86
+ return 'no paper or live fills yet: B / S on a token to trade, then H here';
87
+ state.pnl = { origin: 'positions', choices, wallet: null, ...base };
88
+ }
89
+ else
90
+ return 'PnL cards open from the token, positions (P) or followed wallets (U) screens';
91
+ state.overlay = 'pnl';
92
+ return null;
93
+ }
94
+ /** The card input for the current choice, from the right ledger. Async only for a tracked coin that needs decimals / a price. */
95
+ export async function pnlInput(state, ui) {
96
+ const live = state.live;
97
+ if (!live)
98
+ throw new CardError('no live context');
99
+ const ch = ui.choices[ui.idx];
100
+ const meta = coinMeta(state, ch.mint);
101
+ const image = imageOf(state, meta.image);
102
+ if (ui.origin === 'follows' && ui.wallet && state.copy) {
103
+ const copy = state.copy;
104
+ const st = copy.status();
105
+ const stats = copy.stats(ui.wallet);
106
+ let mark = markFor(state, ch.mint);
107
+ let decimals = null;
108
+ // decimals + a price for a public mint: the same keyless reads the mirror checks make (no wallet, no user data)
109
+ const m = await copy.mirrorMarket(ch.mint, { symbol: meta.symbol });
110
+ decimals = m.decimals;
111
+ const solUsd = live.positions.solUsd();
112
+ if (!mark && m.priceUsd && m.priceAt && solUsd)
113
+ mark = { priceSol: m.priceUsd / solUsd, at: m.priceAt, via: `${MARK_VIA} ÷ SOL/USD` };
114
+ return trackedCardInput(copy.trades(ui.wallet), {
115
+ wallet: ui.wallet, mint: ch.mint, symbol: meta.symbol ?? m.symbol ?? ch.symbol, name: meta.name, decimals, mark,
116
+ historyAt: st.lastPollAt, complete: stats.sample.complete, image,
117
+ });
118
+ }
119
+ const liveRows = live.livePositions?.fills(ch.mint) ?? [];
120
+ if (liveRows.length) {
121
+ return liveCardInput(liveRows, { mint: ch.mint, symbol: meta.symbol ?? undefined, name: meta.name, wallet: live.linkedWallet?.publicKey ?? null, mark: markFor(state, ch.mint), image });
122
+ }
123
+ return paperCardInput(live.wallet.trades(1000), { mint: ch.mint, symbol: meta.symbol ?? undefined, name: meta.name, mark: markFor(state, ch.mint), image });
124
+ }
125
+ /** (Re)build the card for the current choice; on refusal the preview shows the reason instead of a card. */
126
+ export async function loadPnl(state, now = Date.now()) {
127
+ const ui = state.pnl;
128
+ if (!ui)
129
+ return;
130
+ try {
131
+ ui.stats = computeCard(await pnlInput(state, ui), now);
132
+ ui.error = null;
133
+ }
134
+ catch (e) {
135
+ ui.stats = null;
136
+ ui.error = e instanceof Error ? e.message : String(e);
137
+ }
138
+ }
139
+ /** Keys while the preview is open. Pure: the loop performs reload / share. */
140
+ export function pnlKey(ui, key) {
141
+ if (key.name === 'esc' || (key.name === 'char' && key.ch === 'q'))
142
+ return { kind: 'close' };
143
+ if (ui.busy)
144
+ return { kind: 'none' };
145
+ const ch = key.name === 'char' && !key.ctrl ? key.ch : undefined;
146
+ if (key.name === 'right' || ch === ']' || ch === 'l') {
147
+ if (ui.choices.length > 1) {
148
+ ui.idx = (ui.idx + 1) % ui.choices.length;
149
+ return { kind: 'reload' };
150
+ }
151
+ return { kind: 'none' };
152
+ }
153
+ if (key.name === 'left' || ch === '[' || ch === 'h') {
154
+ if (ui.choices.length > 1) {
155
+ ui.idx = (ui.idx - 1 + ui.choices.length) % ui.choices.length;
156
+ return { kind: 'reload' };
157
+ }
158
+ return { kind: 'none' };
159
+ }
160
+ if (ch === 'a') {
161
+ ui.opts.showWallet = !ui.opts.showWallet;
162
+ ui.notice = ui.opts.showWallet ? 'wallet + tx signatures SHOWN on the card' : 'wallet hidden';
163
+ return { kind: 'none' };
164
+ }
165
+ if (ch === 'z') {
166
+ ui.opts.exactSize = !ui.opts.exactSize;
167
+ ui.notice = ui.opts.exactSize ? 'exact SOL size SHOWN on the card' : 'size shown as a band';
168
+ return { kind: 'none' };
169
+ }
170
+ if (ch === 'f') {
171
+ ui.opts.aspect = ui.opts.aspect === 'wide' ? 'square' : 'wide';
172
+ return { kind: 'none' };
173
+ }
174
+ if (!ui.stats)
175
+ return { kind: 'none' };
176
+ if (ch === 'x')
177
+ return { kind: 'share', action: 'x' };
178
+ if (ch === 't')
179
+ return { kind: 'share', action: 'text' };
180
+ if (ch === 's')
181
+ return { kind: 'share', action: 'save' };
182
+ return { kind: 'none' };
183
+ }
184
+ const HINTS = 'x share to X · t copy text · s save · a wallet · z size · f 16:9/1:1 · ←→ coin · esc';
185
+ /** The preview overlay: the card itself at cell size when the terminal fits it, else its text; then the keys. */
186
+ export function renderPnlOverlay(c, state) {
187
+ const ui = state.pnl;
188
+ if (!ui)
189
+ return;
190
+ const t = c.theme;
191
+ c.noRain(0, 0, c.w, c.h);
192
+ c.fill(0, 0, c.w, c.h, ' ', { bg: mix(t.bg, '#000000', 0.5) });
193
+ const ch = ui.choices[ui.idx];
194
+ const head = `PnL CARD · $${ch?.symbol ?? '?'}${ui.choices.length > 1 ? ` (${ui.idx + 1}/${ui.choices.length})` : ''} · ${ui.opts.aspect === 'wide' ? '16:9' : '1:1'} · wallet ${ui.opts.showWallet ? 'SHOWN' : 'hidden'} · size ${ui.opts.exactSize ? 'EXACT' : 'band'}`;
195
+ c.text(1, 0, head, { fg: t.text, bg: t.headBg, b: true }, c.w - 2);
196
+ c.fill(0, c.h - 1, c.w, 1, ' ', { bg: t.headBg });
197
+ c.text(1, c.h - 1, ui.busy ? 'working… (esc closes)' : (ui.notice ?? HINTS), { fg: ui.notice ? t.warn : t.muted, bg: t.headBg }, c.w - 2);
198
+ const area = { x: 0, y: 1, w: c.w, h: c.h - 2 };
199
+ if (!ui.stats) {
200
+ c.text(2, 3, ui.error ? `no card: ${ui.error}` : 'building the card…', { fg: ui.error ? t.warn : t.muted, bg: c.bg[c.idx(2, 3)] }, c.w - 4);
201
+ return;
202
+ }
203
+ const r = renderCard(ui.stats, ui.opts);
204
+ const pv = previewCanvas(r);
205
+ if (pv.w <= area.w && pv.h <= area.h) {
206
+ const ox = area.x + Math.floor((area.w - pv.w) / 2), oy = area.y + Math.floor((area.h - pv.h) / 2);
207
+ for (let y = 0; y < pv.h; y++)
208
+ for (let x = 0; x < pv.w; x++) {
209
+ const i = pv.idx(x, y), j = c.idx(ox + x, oy + y);
210
+ c.ch[j] = pv.ch[i];
211
+ c.fg[j] = pv.fg[i];
212
+ c.bg[j] = pv.bg[i];
213
+ c.at[j] = pv.at[i];
214
+ }
215
+ return;
216
+ }
217
+ // too small for the card grid: the same facts as text (the PNG is still full size)
218
+ const lines = cardText(ui.stats, ui.opts).trimEnd().split('\n');
219
+ lines.forEach((l, k) => { if (2 + k < c.h - 1)
220
+ c.text(2, 2 + k, l, { fg: k === 0 ? t.warn : t.text, bg: c.bg[c.idx(2, 2 + k)] }, c.w - 4); });
221
+ }