@nexstone/rift-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.
@@ -1,15 +1,35 @@
1
1
  /**
2
2
  * Bridge between the TypeScript CLI and the Python engine.
3
- * Spawns Python processes and reads NDJSON output line by line.
3
+ *
4
+ * The CLI ships under three install paths:
5
+ *
6
+ * 1. Source clone — `engine/` directory exists above the CLI binary.
7
+ * We invoke `python -m rift.cli` with PYTHONPATH set to `engine/src`.
8
+ * Used during development.
9
+ *
10
+ * 2. Installed wheel — user did `pip install rift-engine-core`. The
11
+ * wheel ships a `rift-engine` console script as its entry point.
12
+ * We invoke that binary directly. Used for end-user pip/brew installs.
13
+ *
14
+ * 3. Env override — `RIFT_ENGINE_BINARY=/path/to/rift-engine` forces us
15
+ * to use a specific binary. Used by the Homebrew formula to point at
16
+ * the libexec venv's rift-engine even if PATH is weird.
17
+ *
18
+ * Detection runs in priority order: env override → source → installed.
19
+ * Each spawned process inherits the same environment + extraEnv overlay.
4
20
  */
21
+ import { type ChildProcess } from 'node:child_process';
5
22
  export interface EngineMessage {
6
23
  type: 'progress' | 'result' | 'error' | 'status' | 'trade' | 'candle' | 'heartbeat' | 'shutdown' | 'step' | 'step_done' | 'soak';
7
24
  [key: string]: unknown;
8
25
  }
9
26
  export declare function getDataDir(): string;
27
+ /** Public helper — install-mode-aware strategies dir, for commands that
28
+ * scaffold or list strategies. */
29
+ export declare function resolveStrategiesDir(): string;
10
30
  export declare function runEngine(command: string, args: string[], onMessage: (msg: EngineMessage) => void, extraEnv?: Record<string, string>): Promise<void>;
11
31
  /** Get the child process from a runEngine promise (for sending signals) */
12
- export declare function getEngineProcess(promise: Promise<void>): import('node:child_process').ChildProcess | null;
32
+ export declare function getEngineProcess(promise: Promise<void>): ChildProcess | null;
13
33
  /**
14
34
  * Spawn the Python engine as a detached daemon process.
15
35
  * Returns immediately after the process is started.
@@ -1,84 +1,179 @@
1
1
  /**
2
2
  * Bridge between the TypeScript CLI and the Python engine.
3
- * Spawns Python processes and reads NDJSON output line by line.
3
+ *
4
+ * The CLI ships under three install paths:
5
+ *
6
+ * 1. Source clone — `engine/` directory exists above the CLI binary.
7
+ * We invoke `python -m rift.cli` with PYTHONPATH set to `engine/src`.
8
+ * Used during development.
9
+ *
10
+ * 2. Installed wheel — user did `pip install rift-engine-core`. The
11
+ * wheel ships a `rift-engine` console script as its entry point.
12
+ * We invoke that binary directly. Used for end-user pip/brew installs.
13
+ *
14
+ * 3. Env override — `RIFT_ENGINE_BINARY=/path/to/rift-engine` forces us
15
+ * to use a specific binary. Used by the Homebrew formula to point at
16
+ * the libexec venv's rift-engine even if PATH is weird.
17
+ *
18
+ * Detection runs in priority order: env override → source → installed.
19
+ * Each spawned process inherits the same environment + extraEnv overlay.
4
20
  */
5
21
  import { spawn, execSync } from 'node:child_process';
6
22
  import { createInterface } from 'node:readline';
7
23
  import * as path from 'node:path';
8
24
  import * as fs from 'node:fs';
25
+ import * as os from 'node:os';
9
26
  import { fileURLToPath } from 'node:url';
10
27
  const __filename = fileURLToPath(import.meta.url);
11
28
  const __dirname = path.dirname(__filename);
12
- function getEngineDir() {
29
+ /** Walk up looking for `engine/pyproject.toml` + `engine/src/rift/cli.py`.
30
+ * Only present in a source clone, not in any installed layout. */
31
+ function findSourceEngineDir() {
13
32
  let dir = path.resolve(__dirname);
14
33
  for (let i = 0; i < 10; i++) {
15
34
  const engineDir = path.join(dir, 'engine');
16
- // Require both pyproject.toml AND the rift.cli entry point.
17
- // Prevents matching packages/engine (the new rift_engine library)
18
- // before reaching the legacy engine/ host that owns -m rift.cli.
35
+ // Both probes required to avoid matching `packages/engine/` (the
36
+ // rift_engine library subpackage) before reaching the real `engine/`
37
+ // host that owns `rift.cli`.
19
38
  if (fs.existsSync(path.join(engineDir, 'pyproject.toml')) &&
20
39
  fs.existsSync(path.join(engineDir, 'src', 'rift', 'cli.py')))
21
40
  return engineDir;
22
- dir = path.dirname(dir);
41
+ const parent = path.dirname(dir);
42
+ if (parent === dir)
43
+ break;
44
+ dir = parent;
23
45
  }
24
- throw new Error('Cannot find RIFT engine directory');
46
+ return null;
25
47
  }
26
- function findPython() {
27
- // Check engine's uv-managed venv first
28
- const engineVenv = path.join(getEngineDir(), '.venv', 'bin', 'python3');
48
+ /** Locate a binary on PATH via `which`. Returns null if not found. */
49
+ function findOnPath(binary) {
50
+ try {
51
+ const out = execSync(`command -v ${binary}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
52
+ return out || null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function detectEngine() {
59
+ // 1. Env override
60
+ const envBinary = process.env.RIFT_ENGINE_BINARY;
61
+ if (envBinary && fs.existsSync(envBinary)) {
62
+ return { kind: 'installed', binary: envBinary };
63
+ }
64
+ // 2. Source clone
65
+ const sourceDir = findSourceEngineDir();
66
+ if (sourceDir) {
67
+ return { kind: 'source', engineDir: sourceDir };
68
+ }
69
+ // 3. Installed wheel — rift-engine on PATH
70
+ const binary = findOnPath('rift-engine');
71
+ if (binary) {
72
+ return { kind: 'installed', binary };
73
+ }
74
+ throw new Error('Cannot find RIFT engine.\n' +
75
+ '\n' +
76
+ 'Install one of:\n' +
77
+ ' brew install Nexstone/tap/rift # all-in-one, recommended\n' +
78
+ ' pip install rift-engine-core # Python engine only\n' +
79
+ '\n' +
80
+ 'Or set RIFT_ENGINE_BINARY=/path/to/rift-engine to use a specific install.');
81
+ }
82
+ /** Find Python for source mode. Prefers the engine's uv-managed venv. */
83
+ function findPythonForSource(engineDir) {
84
+ const engineVenv = path.join(engineDir, '.venv', 'bin', 'python3');
29
85
  if (fs.existsSync(engineVenv))
30
86
  return engineVenv;
31
- // Check for managed venv in data dir
32
87
  const dataVenv = path.join(getDataDir(), 'venv', 'bin', 'python3');
33
88
  if (fs.existsSync(dataVenv))
34
89
  return dataVenv;
35
- // Fall back to system Python
36
90
  for (const cmd of ['python3.14', 'python3.13', 'python3']) {
37
91
  try {
38
92
  execSync(`${cmd} --version`, { stdio: 'ignore' });
39
93
  return cmd;
40
94
  }
41
95
  catch {
42
- continue;
96
+ // try next
43
97
  }
44
98
  }
45
99
  throw new Error('Python 3.13+ not found. Install Python or run: rift setup');
46
100
  }
47
- function getStrategiesDir() {
48
- let dir = path.resolve(__dirname);
49
- for (let i = 0; i < 10; i++) {
50
- const strategiesDir = path.join(dir, 'strategies');
51
- if (fs.existsSync(strategiesDir))
52
- return strategiesDir;
53
- dir = path.dirname(dir);
101
+ function getStrategiesDir(engine) {
102
+ if (engine.kind === 'source') {
103
+ // Walk up from the CLI's location looking for a `strategies/` sibling
104
+ // to engine/. Falls back to <repo-root>/strategies.
105
+ let dir = path.resolve(__dirname);
106
+ for (let i = 0; i < 10; i++) {
107
+ const stratDir = path.join(dir, 'strategies');
108
+ if (fs.existsSync(stratDir))
109
+ return stratDir;
110
+ const parent = path.dirname(dir);
111
+ if (parent === dir)
112
+ break;
113
+ dir = parent;
114
+ }
115
+ return path.join(engine.engineDir, '..', 'strategies');
116
+ }
117
+ // Installed mode: strategies live under the user's data dir. We create
118
+ // it on demand so `rift new` and `rift algo` see the same path even
119
+ // if the user never explicitly initialized it.
120
+ const stratDir = path.join(getDataDir(), 'strategies');
121
+ if (!fs.existsSync(stratDir)) {
122
+ fs.mkdirSync(stratDir, { recursive: true });
54
123
  }
55
- const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
56
- return path.join(projectRoot, 'strategies');
124
+ return stratDir;
57
125
  }
58
126
  export function getDataDir() {
59
- return path.join(process.env.HOME || '~', '.rift');
127
+ return path.join(process.env.HOME || os.homedir(), '.rift');
60
128
  }
61
- export async function runEngine(command, args, onMessage, extraEnv) {
62
- const python = findPython();
63
- const engineDir = getEngineDir();
64
- const strategiesDir = getStrategiesDir();
65
- const fullArgs = [
66
- '-m', 'rift.cli',
67
- command,
68
- ...args,
69
- ];
70
- // Only add --strategies-dir for commands that accept it
71
- if (['backtest', 'strategies', 'compare', 'walk-forward', 'sweep', 'montecarlo', 'portfolio-backtest', 'research', 'quick-test', 'algo'].includes(command)) {
72
- fullArgs.push('--strategies-dir', strategiesDir);
129
+ /** Public helper install-mode-aware strategies dir, for commands that
130
+ * scaffold or list strategies. */
131
+ export function resolveStrategiesDir() {
132
+ return getStrategiesDir(detectEngine());
133
+ }
134
+ const COMMANDS_WITH_STRATEGIES_DIR = new Set([
135
+ 'backtest', 'strategies', 'compare', 'walk-forward',
136
+ 'sweep', 'montecarlo', 'portfolio-backtest', 'research',
137
+ 'quick-test', 'algo',
138
+ ]);
139
+ function buildSpawnPlan(command, args, extraEnv, engine) {
140
+ const finalArgs = [];
141
+ let cmd;
142
+ let cwd;
143
+ let env;
144
+ if (engine.kind === 'source') {
145
+ const python = findPythonForSource(engine.engineDir);
146
+ cmd = python;
147
+ finalArgs.push('-m', 'rift.cli');
148
+ cwd = engine.engineDir;
149
+ env = {
150
+ ...process.env,
151
+ ...extraEnv,
152
+ PYTHONPATH: path.join(engine.engineDir, 'src'),
153
+ PYTHONUNBUFFERED: '1',
154
+ };
73
155
  }
74
- const proc = spawn(python, fullArgs, {
75
- cwd: engineDir,
76
- env: {
156
+ else {
157
+ cmd = engine.binary;
158
+ cwd = undefined; // inherit caller's cwd
159
+ env = {
77
160
  ...process.env,
78
161
  ...extraEnv,
79
- PYTHONPATH: path.join(engineDir, 'src'),
80
162
  PYTHONUNBUFFERED: '1',
81
- },
163
+ };
164
+ }
165
+ finalArgs.push(command, ...args);
166
+ if (COMMANDS_WITH_STRATEGIES_DIR.has(command)) {
167
+ finalArgs.push('--strategies-dir', getStrategiesDir(engine));
168
+ }
169
+ return { cmd, args: finalArgs, env, cwd };
170
+ }
171
+ export async function runEngine(command, args, onMessage, extraEnv) {
172
+ const engine = detectEngine();
173
+ const plan = buildSpawnPlan(command, args, extraEnv, engine);
174
+ const proc = spawn(plan.cmd, plan.args, {
175
+ cwd: plan.cwd,
176
+ env: plan.env,
82
177
  stdio: ['ignore', 'pipe', 'pipe'],
83
178
  });
84
179
  const promise = new Promise((resolve, reject) => {
@@ -135,7 +230,7 @@ export async function runEngine(command, args, onMessage, extraEnv) {
135
230
  }
136
231
  /** Get the child process from a runEngine promise (for sending signals) */
137
232
  export function getEngineProcess(promise) {
138
- return promise?._proc ?? null;
233
+ return promise._proc ?? null;
139
234
  }
140
235
  /**
141
236
  * Spawn the Python engine as a detached daemon process.
@@ -143,24 +238,13 @@ export function getEngineProcess(promise) {
143
238
  * The daemon writes its own PID file and log file.
144
239
  */
145
240
  export function spawnDaemon(command, args, extraEnv) {
146
- const python = findPython();
147
- const engineDir = getEngineDir();
148
- const fullArgs = [
149
- '-m', 'rift.cli',
150
- command,
151
- ...args,
152
- '--daemon',
153
- ];
241
+ const engine = detectEngine();
242
+ const plan = buildSpawnPlan(command, [...args, '--daemon'], extraEnv, engine);
154
243
  // Open /dev/null for stdio — daemon writes to its own log file
155
244
  const devNull = fs.openSync('/dev/null', 'r+');
156
- const proc = spawn(python, fullArgs, {
157
- cwd: engineDir,
158
- env: {
159
- ...process.env,
160
- ...extraEnv,
161
- PYTHONPATH: path.join(engineDir, 'src'),
162
- PYTHONUNBUFFERED: '1',
163
- },
245
+ const proc = spawn(plan.cmd, plan.args, {
246
+ cwd: plan.cwd,
247
+ env: plan.env,
164
248
  stdio: [devNull, devNull, devNull],
165
249
  detached: true,
166
250
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexstone/rift-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "RIFT — Research / Iteration / Forecast / Trade",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",