horizon-code 0.1.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/assets/python/highlights.scm +137 -0
- package/assets/python/tree-sitter-python.wasm +0 -0
- package/bin/horizon.js +2 -0
- package/package.json +40 -0
- package/src/ai/client.ts +369 -0
- package/src/ai/system-prompt.ts +86 -0
- package/src/app.ts +1454 -0
- package/src/chat/messages.ts +48 -0
- package/src/chat/renderer.ts +243 -0
- package/src/chat/types.ts +18 -0
- package/src/components/code-panel.ts +329 -0
- package/src/components/footer.ts +72 -0
- package/src/components/hooks-panel.ts +224 -0
- package/src/components/input-bar.ts +193 -0
- package/src/components/mode-bar.ts +245 -0
- package/src/components/session-panel.ts +294 -0
- package/src/components/settings-panel.ts +372 -0
- package/src/components/splash.ts +156 -0
- package/src/components/strategy-panel.ts +489 -0
- package/src/components/tab-bar.ts +112 -0
- package/src/components/tutorial-panel.ts +680 -0
- package/src/components/widgets/progress-bar.ts +38 -0
- package/src/components/widgets/sparkline.ts +57 -0
- package/src/hooks/executor.ts +109 -0
- package/src/index.ts +22 -0
- package/src/keys/handler.ts +198 -0
- package/src/platform/auth.ts +36 -0
- package/src/platform/client.ts +159 -0
- package/src/platform/config.ts +121 -0
- package/src/platform/session-sync.ts +158 -0
- package/src/platform/supabase.ts +376 -0
- package/src/platform/sync.ts +149 -0
- package/src/platform/tiers.ts +103 -0
- package/src/platform/tools.ts +163 -0
- package/src/platform/types.ts +86 -0
- package/src/platform/usage.ts +224 -0
- package/src/research/apis.ts +367 -0
- package/src/research/tools.ts +205 -0
- package/src/research/widgets.ts +523 -0
- package/src/state/store.ts +256 -0
- package/src/state/types.ts +109 -0
- package/src/strategy/ascii-chart.ts +74 -0
- package/src/strategy/code-stream.ts +146 -0
- package/src/strategy/dashboard.ts +140 -0
- package/src/strategy/persistence.ts +82 -0
- package/src/strategy/prompts.ts +626 -0
- package/src/strategy/sandbox.ts +137 -0
- package/src/strategy/tools.ts +764 -0
- package/src/strategy/validator.ts +216 -0
- package/src/strategy/widgets.ts +270 -0
- package/src/syntax/setup.ts +54 -0
- package/src/theme/colors.ts +107 -0
- package/src/theme/icons.ts +27 -0
- package/src/util/hyperlink.ts +21 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { TextRenderable, type CliRenderer } from "@opentui/core";
|
|
2
|
+
import { COLORS } from "../../theme/colors.ts";
|
|
3
|
+
import { ICONS } from "../../theme/icons.ts";
|
|
4
|
+
|
|
5
|
+
const BLOCKS = ICONS.spark;
|
|
6
|
+
|
|
7
|
+
export class Sparkline {
|
|
8
|
+
private text: TextRenderable;
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
renderer: CliRenderer,
|
|
12
|
+
id: string,
|
|
13
|
+
private width: number = 16,
|
|
14
|
+
) {
|
|
15
|
+
this.text = new TextRenderable(renderer, {
|
|
16
|
+
id,
|
|
17
|
+
content: "",
|
|
18
|
+
fg: COLORS.textMuted,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get renderable(): TextRenderable {
|
|
23
|
+
return this.text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setData(values: number[], color?: string): void {
|
|
27
|
+
if (values.length === 0) {
|
|
28
|
+
this.text.content = "";
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const min = Math.min(...values);
|
|
33
|
+
const max = Math.max(...values);
|
|
34
|
+
const range = max - min || 1;
|
|
35
|
+
|
|
36
|
+
const sampled: number[] = [];
|
|
37
|
+
for (let i = 0; i < this.width; i++) {
|
|
38
|
+
const idx = Math.floor((i / this.width) * values.length);
|
|
39
|
+
sampled.push(values[idx] ?? min);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const chars = sampled.map((v) => {
|
|
43
|
+
const normalized = (v - min) / range;
|
|
44
|
+
const blockIdx = Math.min(Math.floor(normalized * BLOCKS.length), BLOCKS.length - 1);
|
|
45
|
+
return BLOCKS[blockIdx];
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.text.content = chars.join("");
|
|
49
|
+
if (color) {
|
|
50
|
+
this.text.fg = color;
|
|
51
|
+
} else {
|
|
52
|
+
const last = values[values.length - 1]!;
|
|
53
|
+
const first = values[0]!;
|
|
54
|
+
this.text.fg = last >= first ? COLORS.success : COLORS.error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Hooks — user-defined bash commands at strategy lifecycle points
|
|
2
|
+
// Each hook requires user approval before executing
|
|
3
|
+
|
|
4
|
+
import { loadConfig, saveConfig } from "../platform/config.ts";
|
|
5
|
+
|
|
6
|
+
export type HookEvent =
|
|
7
|
+
| "before_generate" | "after_generate"
|
|
8
|
+
| "before_validate" | "after_validate"
|
|
9
|
+
| "before_backtest" | "after_backtest"
|
|
10
|
+
| "before_run" | "after_run"
|
|
11
|
+
| "before_deploy" | "after_deploy"
|
|
12
|
+
| "before_stop" | "after_stop";
|
|
13
|
+
|
|
14
|
+
export const HOOK_EVENTS: HookEvent[] = [
|
|
15
|
+
"before_generate", "after_generate",
|
|
16
|
+
"before_validate", "after_validate",
|
|
17
|
+
"before_backtest", "after_backtest",
|
|
18
|
+
"before_run", "after_run",
|
|
19
|
+
"before_deploy", "after_deploy",
|
|
20
|
+
"before_stop", "after_stop",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export interface HookConfig {
|
|
24
|
+
event: HookEvent;
|
|
25
|
+
command: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HookResult {
|
|
31
|
+
event: HookEvent;
|
|
32
|
+
command: string;
|
|
33
|
+
approved: boolean;
|
|
34
|
+
exitCode?: number;
|
|
35
|
+
stdout?: string;
|
|
36
|
+
stderr?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Tool name → before/after hook events
|
|
40
|
+
export const TOOL_BEFORE_HOOK: Record<string, HookEvent> = {
|
|
41
|
+
edit_strategy: "before_generate",
|
|
42
|
+
validate_strategy: "before_validate",
|
|
43
|
+
backtest_strategy: "before_backtest",
|
|
44
|
+
run_strategy: "before_run",
|
|
45
|
+
deploy_strategy: "before_deploy",
|
|
46
|
+
stop_strategy: "before_stop",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const TOOL_AFTER_HOOK: Record<string, HookEvent> = {
|
|
50
|
+
edit_strategy: "after_generate",
|
|
51
|
+
validate_strategy: "after_validate",
|
|
52
|
+
backtest_strategy: "after_backtest",
|
|
53
|
+
run_strategy: "after_run",
|
|
54
|
+
deploy_strategy: "after_deploy",
|
|
55
|
+
stop_strategy: "after_stop",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function loadHooks(): HookConfig[] {
|
|
59
|
+
return (loadConfig().hooks as HookConfig[] | undefined) ?? [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function saveHooks(hooks: HookConfig[]): void {
|
|
63
|
+
const config = loadConfig();
|
|
64
|
+
config.hooks = hooks as any;
|
|
65
|
+
saveConfig(config);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getHooksForEvent(event: HookEvent): HookConfig[] {
|
|
69
|
+
return loadHooks().filter((h) => h.event === event && h.enabled);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function executeHook(hook: HookConfig): Promise<HookResult> {
|
|
73
|
+
try {
|
|
74
|
+
// Restricted env — no secrets (hooks are user-defined shell commands)
|
|
75
|
+
const hookEnv: Record<string, string> = {};
|
|
76
|
+
for (const k of ["PATH", "HOME", "LANG", "LC_ALL", "TERM", "SHELL", "USER"]) {
|
|
77
|
+
if (process.env[k]) hookEnv[k] = process.env[k]!;
|
|
78
|
+
}
|
|
79
|
+
const proc = Bun.spawn(["bash", "-c", hook.command], {
|
|
80
|
+
stdout: "pipe",
|
|
81
|
+
stderr: "pipe",
|
|
82
|
+
env: hookEnv,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const stdout = await Promise.race([
|
|
86
|
+
new Response(proc.stdout).text(),
|
|
87
|
+
new Promise<string>((_, reject) => setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, 30000)),
|
|
88
|
+
]);
|
|
89
|
+
const stderr = await new Response(proc.stderr).text();
|
|
90
|
+
await proc.exited;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
event: hook.event,
|
|
94
|
+
command: hook.command,
|
|
95
|
+
approved: true,
|
|
96
|
+
exitCode: proc.exitCode ?? 1,
|
|
97
|
+
stdout: stdout.slice(0, 1000),
|
|
98
|
+
stderr: stderr.slice(0, 500),
|
|
99
|
+
};
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return {
|
|
102
|
+
event: hook.event,
|
|
103
|
+
command: hook.command,
|
|
104
|
+
approved: true,
|
|
105
|
+
exitCode: 1,
|
|
106
|
+
stderr: err instanceof Error ? err.message : "Hook execution failed",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createCliRenderer } from "@opentui/core";
|
|
2
|
+
import { App } from "./app.ts";
|
|
3
|
+
import { initMCP, closeMCP } from "./ai/client.ts";
|
|
4
|
+
import { saveActiveSession, stopAutoSave } from "./platform/session-sync.ts";
|
|
5
|
+
|
|
6
|
+
// Connect to Horizon MCP server (non-blocking)
|
|
7
|
+
initMCP().catch(() => {});
|
|
8
|
+
|
|
9
|
+
const renderer = await createCliRenderer({
|
|
10
|
+
exitOnCtrlC: false,
|
|
11
|
+
targetFps: 10,
|
|
12
|
+
useAlternateScreen: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// App handles auth restore, session loading, and sync startup internally
|
|
16
|
+
new App(renderer);
|
|
17
|
+
|
|
18
|
+
process.on("beforeExit", () => {
|
|
19
|
+
saveActiveSession().catch(() => {});
|
|
20
|
+
stopAutoSave();
|
|
21
|
+
closeMCP();
|
|
22
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { CliRenderer, KeyEvent } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
export class KeyHandler {
|
|
4
|
+
private quitCallback: (() => void) | null = null;
|
|
5
|
+
private clearCallback: (() => void) | null = null;
|
|
6
|
+
private sessionsCallback: (() => void) | null = null;
|
|
7
|
+
private deploymentsCallback: (() => void) | null = null;
|
|
8
|
+
private scrollCallback: ((delta: number) => void) | null = null;
|
|
9
|
+
private cancelCallback: (() => void) | null = null;
|
|
10
|
+
private anyKeyCallback: (() => void) | null = null;
|
|
11
|
+
private modeCycleCallback: (() => void) | null = null;
|
|
12
|
+
private privacyCallback: (() => void) | null = null;
|
|
13
|
+
private metricsCallback: (() => void) | null = null;
|
|
14
|
+
private codePanelCallback: (() => void) | null = null;
|
|
15
|
+
private codeTabCallback: ((tab: number) => void) | null = null;
|
|
16
|
+
private tutorialNavCallback: ((dir: "left" | "right") => void) | null = null;
|
|
17
|
+
private settingsNavCallback: ((delta: number) => void) | null = null;
|
|
18
|
+
private tabNextCallback: (() => void) | null = null;
|
|
19
|
+
private tabPrevCallback: (() => void) | null = null;
|
|
20
|
+
private tabCloseCallback: (() => void) | null = null;
|
|
21
|
+
private tabNewCallback: (() => void) | null = null;
|
|
22
|
+
private acNavCallback: ((dir: "up" | "down" | "accept" | "dismiss") => void) | null = null;
|
|
23
|
+
acActive = false; // set by app when autocomplete is visible
|
|
24
|
+
|
|
25
|
+
// Panel navigation (shared by session & strategy panels)
|
|
26
|
+
private panelNavCallback: ((delta: number) => void) | null = null;
|
|
27
|
+
private panelSelectCallback: (() => void) | null = null;
|
|
28
|
+
private panelNewCallback: (() => void) | null = null;
|
|
29
|
+
private panelCloseCallback: (() => void) | null = null;
|
|
30
|
+
private panelActionCallback: ((key: string) => void) | null = null;
|
|
31
|
+
|
|
32
|
+
panelActive: "sessions" | "deployments" | null = null;
|
|
33
|
+
panelRenaming = false; // when true, let keys flow to the inline rename input
|
|
34
|
+
codePanelVisible = false; // set by app when code panel is visible
|
|
35
|
+
|
|
36
|
+
constructor(private renderer: CliRenderer) {
|
|
37
|
+
this.renderer.keyInput.on("keypress", (key: KeyEvent) => this.handle(key));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
onQuit(cb: () => void): void { this.quitCallback = cb; }
|
|
41
|
+
onClear(cb: () => void): void { this.clearCallback = cb; }
|
|
42
|
+
onSessions(cb: () => void): void { this.sessionsCallback = cb; }
|
|
43
|
+
onDeployments(cb: () => void): void { this.deploymentsCallback = cb; }
|
|
44
|
+
onScroll(cb: (delta: number) => void): void { this.scrollCallback = cb; }
|
|
45
|
+
onCancel(cb: () => void): void { this.cancelCallback = cb; }
|
|
46
|
+
onAnyKey(cb: () => void): void { this.anyKeyCallback = cb; }
|
|
47
|
+
onModeCycle(cb: () => void): void { this.modeCycleCallback = cb; }
|
|
48
|
+
onPrivacy(cb: () => void): void { this.privacyCallback = cb; }
|
|
49
|
+
onMetrics(cb: () => void): void { this.metricsCallback = cb; }
|
|
50
|
+
onCodePanel(cb: () => void): void { this.codePanelCallback = cb; }
|
|
51
|
+
onCodeTab(cb: (tab: number) => void): void { this.codeTabCallback = cb; }
|
|
52
|
+
onTutorialNav(cb: (dir: "left" | "right") => void): void { this.tutorialNavCallback = cb; }
|
|
53
|
+
onSettingsNav(cb: (delta: number) => void): void { this.settingsNavCallback = cb; }
|
|
54
|
+
onTabNext(cb: () => void): void { this.tabNextCallback = cb; }
|
|
55
|
+
onTabPrev(cb: () => void): void { this.tabPrevCallback = cb; }
|
|
56
|
+
onTabClose(cb: () => void): void { this.tabCloseCallback = cb; }
|
|
57
|
+
onTabNew(cb: () => void): void { this.tabNewCallback = cb; }
|
|
58
|
+
onAcNav(cb: (dir: "up" | "down" | "accept" | "dismiss") => void): void { this.acNavCallback = cb; }
|
|
59
|
+
|
|
60
|
+
onPanelNav(cb: (delta: number) => void): void { this.panelNavCallback = cb; }
|
|
61
|
+
onPanelSelect(cb: () => void): void { this.panelSelectCallback = cb; }
|
|
62
|
+
onPanelNew(cb: () => void): void { this.panelNewCallback = cb; }
|
|
63
|
+
onPanelClose(cb: () => void): void { this.panelCloseCallback = cb; }
|
|
64
|
+
onPanelAction(cb: (key: string) => void): void { this.panelActionCallback = cb; }
|
|
65
|
+
|
|
66
|
+
private handle(key: KeyEvent): void {
|
|
67
|
+
this.anyKeyCallback?.();
|
|
68
|
+
|
|
69
|
+
// Ctrl+C — quit or cancel
|
|
70
|
+
if (key.ctrl && key.name === "c") {
|
|
71
|
+
if (this.panelActive) { this.panelCloseCallback?.(); return; }
|
|
72
|
+
this.cancelCallback?.();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Ctrl+E — sessions
|
|
77
|
+
if (key.ctrl && key.name === "e") {
|
|
78
|
+
this.sessionsCallback?.();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Ctrl+P — privacy toggle
|
|
83
|
+
if (key.ctrl && key.name === "p") {
|
|
84
|
+
this.privacyCallback?.();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Ctrl+T — metrics toggle
|
|
89
|
+
if (key.ctrl && key.name === "t") {
|
|
90
|
+
this.metricsCallback?.();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Tab navigation — Ctrl+J next, Ctrl+K prev
|
|
95
|
+
if (key.ctrl && key.name === "j") { this.tabNextCallback?.(); return; }
|
|
96
|
+
if (key.ctrl && key.name === "k") { this.tabPrevCallback?.(); return; }
|
|
97
|
+
// Ctrl+W — close tab
|
|
98
|
+
if (key.ctrl && key.name === "w") { this.tabCloseCallback?.(); return; }
|
|
99
|
+
// Ctrl+N — new tab
|
|
100
|
+
if (key.ctrl && key.name === "n") { this.tabNewCallback?.(); return; }
|
|
101
|
+
|
|
102
|
+
// Ctrl+G — code panel toggle
|
|
103
|
+
if (key.ctrl && key.name === "g") {
|
|
104
|
+
this.codePanelCallback?.();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Ctrl+1/2/3 — code panel tab switching (some terminals support this)
|
|
109
|
+
if (key.ctrl && (key.name === "1" || key.name === "2" || key.name === "3")) {
|
|
110
|
+
this.codeTabCallback?.(parseInt(key.name));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Tab key — cycle code panel tabs when code panel is visible
|
|
115
|
+
if (key.name === "tab" && !this.acActive && !this.panelActive && this.codePanelVisible) {
|
|
116
|
+
this.codeTabCallback?.(0); // 0 = cycle to next tab
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Ctrl+D — deployments
|
|
121
|
+
if (key.ctrl && key.name === "d") {
|
|
122
|
+
this.deploymentsCallback?.();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Ctrl+L — next tab (was clear, now tab navigation)
|
|
127
|
+
if (key.ctrl && key.name === "l") {
|
|
128
|
+
if (this.panelActive === "sessions") { this.panelNewCallback?.(); return; }
|
|
129
|
+
this.tabNextCallback?.();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Ctrl+H — prev tab
|
|
134
|
+
if (key.ctrl && key.name === "h") {
|
|
135
|
+
this.tabPrevCallback?.();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Renaming mode — Escape cancels rename, everything else goes to the rename input
|
|
140
|
+
if (this.panelActive && this.panelRenaming) {
|
|
141
|
+
if (key.name === "escape") {
|
|
142
|
+
this.panelRenaming = false;
|
|
143
|
+
// Cancel rename — panelAction handler will sync state
|
|
144
|
+
this.panelActionCallback?.("rename-cancel");
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Panel mode — route nav keys
|
|
150
|
+
if (this.panelActive) {
|
|
151
|
+
switch (key.name) {
|
|
152
|
+
case "j": case "down":
|
|
153
|
+
this.panelNavCallback?.(1); return;
|
|
154
|
+
case "k": case "up":
|
|
155
|
+
this.panelNavCallback?.(-1); return;
|
|
156
|
+
case "return":
|
|
157
|
+
this.panelSelectCallback?.(); return;
|
|
158
|
+
case "escape":
|
|
159
|
+
this.panelCloseCallback?.(); return;
|
|
160
|
+
case "n":
|
|
161
|
+
if (this.panelActive === "sessions") this.panelNewCallback?.();
|
|
162
|
+
return;
|
|
163
|
+
case "d": case "p": case "r": case "o":
|
|
164
|
+
if (this.panelActive === "sessions") this.panelActionCallback?.(key.name!);
|
|
165
|
+
if (this.panelActive === "deployments") this.panelActionCallback?.(key.name!);
|
|
166
|
+
return;
|
|
167
|
+
case "k":
|
|
168
|
+
if (this.panelActive === "deployments") this.panelActionCallback?.(key.name!);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Autocomplete navigation (when dropdown is visible)
|
|
175
|
+
if (this.acActive) {
|
|
176
|
+
if (key.name === "up") { this.acNavCallback?.("up"); return; }
|
|
177
|
+
if (key.name === "down") { this.acNavCallback?.("down"); return; }
|
|
178
|
+
if (key.name === "tab") { this.acNavCallback?.("accept"); return; }
|
|
179
|
+
if (key.name === "escape") { this.acNavCallback?.("dismiss"); return; }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Ctrl+R — cycle mode
|
|
183
|
+
if (key.ctrl && key.name === "r") {
|
|
184
|
+
this.modeCycleCallback?.();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Arrow keys for tutorial/settings panel navigation
|
|
189
|
+
if (key.name === "left") { this.tutorialNavCallback?.("left"); return; }
|
|
190
|
+
if (key.name === "right") { this.tutorialNavCallback?.("right"); return; }
|
|
191
|
+
if (key.name === "up") { this.settingsNavCallback?.(-1); return; }
|
|
192
|
+
if (key.name === "down") { this.settingsNavCallback?.(1); return; }
|
|
193
|
+
|
|
194
|
+
if (key.name === "pageup") { this.scrollCallback?.(-10); return; }
|
|
195
|
+
if (key.name === "pagedown") { this.scrollCallback?.(10); return; }
|
|
196
|
+
if (key.name === "escape") { this.cancelCallback?.(); return; }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { saveConfig, loadConfig, getApiKey } from "./config.ts";
|
|
2
|
+
import { platform } from "./client.ts";
|
|
3
|
+
|
|
4
|
+
export type AuthStatus = "authenticated" | "no_key" | "invalid_key";
|
|
5
|
+
|
|
6
|
+
export async function checkAuth(): Promise<AuthStatus> {
|
|
7
|
+
const key = getApiKey();
|
|
8
|
+
if (!key) return "no_key";
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
// Validate the key by making a lightweight request
|
|
12
|
+
await platform.listStrategies();
|
|
13
|
+
return "authenticated";
|
|
14
|
+
} catch (err: any) {
|
|
15
|
+
if (err?.status === 401 || err?.status === 403) return "invalid_key";
|
|
16
|
+
// Network error or server down — assume key is fine
|
|
17
|
+
return "authenticated";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function login(apiKey: string): void {
|
|
22
|
+
const config = loadConfig();
|
|
23
|
+
config.api_key = apiKey;
|
|
24
|
+
saveConfig(config);
|
|
25
|
+
platform.setApiKey(apiKey);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function logout(): void {
|
|
29
|
+
const config = loadConfig();
|
|
30
|
+
delete config.api_key;
|
|
31
|
+
saveConfig(config);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isAuthenticated(): boolean {
|
|
35
|
+
return getApiKey() !== null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { getApiKey, getPlatformUrl } from "./config.ts";
|
|
2
|
+
import type {
|
|
3
|
+
PlatformStrategy,
|
|
4
|
+
PlatformDeployment,
|
|
5
|
+
PlatformMetrics,
|
|
6
|
+
PlatformLog,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
|
|
9
|
+
class PlatformError extends Error {
|
|
10
|
+
constructor(public status: number, message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "PlatformError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class PlatformClient {
|
|
17
|
+
private baseUrl: string;
|
|
18
|
+
private apiKey: string | null;
|
|
19
|
+
|
|
20
|
+
constructor() {
|
|
21
|
+
this.baseUrl = getPlatformUrl();
|
|
22
|
+
this.apiKey = getApiKey();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get authenticated(): boolean {
|
|
26
|
+
return this.apiKey !== null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setApiKey(key: string): void {
|
|
30
|
+
this.apiKey = key;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
34
|
+
// Re-read API key on every request (it may have been set after construction via /login)
|
|
35
|
+
const apiKey = this.apiKey || getApiKey();
|
|
36
|
+
if (!apiKey) throw new PlatformError(401, "Not authenticated. Set HORIZON_API_KEY or run horizon auth login.");
|
|
37
|
+
|
|
38
|
+
const url = `${this.baseUrl}${path}`;
|
|
39
|
+
const res = await fetch(url, {
|
|
40
|
+
...options,
|
|
41
|
+
signal: AbortSignal.timeout(15000),
|
|
42
|
+
headers: {
|
|
43
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
...options.headers,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const body = await res.text().catch(() => "");
|
|
51
|
+
throw new PlatformError(res.status, `${res.status} ${res.statusText}: ${body}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return res.json() as Promise<T>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Strategies ──
|
|
58
|
+
|
|
59
|
+
async listStrategies(): Promise<PlatformStrategy[]> {
|
|
60
|
+
const res = await this.request<{ data: PlatformStrategy[] } | PlatformStrategy[]>("/api/v1/strategies");
|
|
61
|
+
return Array.isArray(res) ? res : (res as any).data ?? [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getStrategy(id: string): Promise<PlatformStrategy> {
|
|
65
|
+
return this.request<PlatformStrategy>(`/api/v1/strategies/${id}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async createStrategy(opts: {
|
|
69
|
+
name: string;
|
|
70
|
+
code: string;
|
|
71
|
+
params?: Record<string, unknown>;
|
|
72
|
+
risk_config?: Record<string, unknown>;
|
|
73
|
+
}): Promise<PlatformStrategy> {
|
|
74
|
+
return this.request<PlatformStrategy>("/api/v1/strategies", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
body: JSON.stringify(opts),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Deployments ──
|
|
81
|
+
|
|
82
|
+
async listDeployments(strategyId: string): Promise<PlatformDeployment[]> {
|
|
83
|
+
const res = await this.request<{ data: PlatformDeployment[] } | PlatformDeployment[]>(`/api/v1/strategies/${strategyId}/deployments`);
|
|
84
|
+
return Array.isArray(res) ? res : (res as any).data ?? [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async deploy(strategyId: string, opts: {
|
|
88
|
+
credentialId: string;
|
|
89
|
+
dryRun: boolean;
|
|
90
|
+
targetMarkets?: Array<{ slug: string; tokenId: string }>;
|
|
91
|
+
deploymentMode?: "manual" | "scanner";
|
|
92
|
+
}): Promise<{ deployment: PlatformDeployment }> {
|
|
93
|
+
return this.request(`/api/v1/strategies/${strategyId}/deploy`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
credential_id: opts.credentialId,
|
|
97
|
+
mode: opts.dryRun ? "paper" : "live",
|
|
98
|
+
markets: opts.targetMarkets ?? [],
|
|
99
|
+
deployment_mode: opts.deploymentMode ?? "manual",
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async stop(strategyId: string): Promise<{ success: boolean }> {
|
|
105
|
+
return this.request(`/api/v1/strategies/${strategyId}/stop`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Metrics ──
|
|
111
|
+
|
|
112
|
+
async getMetrics(strategyId: string, limit = 50): Promise<{
|
|
113
|
+
latest: PlatformMetrics | null;
|
|
114
|
+
history: PlatformMetrics[];
|
|
115
|
+
}> {
|
|
116
|
+
const res = await this.request<{ data: { latest: PlatformMetrics | null; history: PlatformMetrics[] } }>(
|
|
117
|
+
`/api/v1/strategies/${strategyId}/metrics?limit=${limit}`
|
|
118
|
+
);
|
|
119
|
+
return res.data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Logs ──
|
|
123
|
+
|
|
124
|
+
async getLogs(strategyId: string, limit = 100): Promise<PlatformLog[]> {
|
|
125
|
+
const res = await this.request<{ data: PlatformLog[] }>(
|
|
126
|
+
`/api/v1/strategies/${strategyId}/logs?limit=${limit}`
|
|
127
|
+
);
|
|
128
|
+
return res.data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Credentials ──
|
|
132
|
+
|
|
133
|
+
async listCredentials(): Promise<Array<{ id: string; label: string; exchange: string; wallet_address: string | null; created_at: string }>> {
|
|
134
|
+
const res = await this.request<{ data: any[] }>("/api/v1/credentials");
|
|
135
|
+
return res.data ?? [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async createCredential(opts: { label: string; exchange: string; private_key: string; wallet_address?: string }): Promise<{ id: string; label: string; exchange: string }> {
|
|
139
|
+
const res = await this.request<{ data: any }>("/api/v1/credentials", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
body: JSON.stringify(opts),
|
|
142
|
+
});
|
|
143
|
+
return res.data;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Validate ──
|
|
147
|
+
|
|
148
|
+
async validateCode(code: string, strategyId?: string): Promise<{
|
|
149
|
+
valid: boolean;
|
|
150
|
+
errors: string[];
|
|
151
|
+
}> {
|
|
152
|
+
return this.request("/api/strategies/validate", {
|
|
153
|
+
method: "POST",
|
|
154
|
+
body: JSON.stringify({ code, strategyId }),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const platform = new PlatformClient();
|