orquesta-cli 0.1.21 → 0.1.23

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/dist/cli.js CHANGED
@@ -30,6 +30,7 @@ program
30
30
  .option('--debug', 'Enable debug logging')
31
31
  .option('--llm-log', 'Enable LLM logging')
32
32
  .option('--eval', 'Evaluation mode: read JSON from stdin, output NDJSON events')
33
+ .option('--login', 'Sign in to Orquesta via browser (no token paste required)')
33
34
  .option('--token <token>', 'Connect to Orquesta dashboard with CLI token')
34
35
  .option('--project <projectId>', 'Select project ID when connecting with token')
35
36
  .option('--switch-project [projectId]', 'Switch to a different project')
@@ -67,6 +68,14 @@ program
67
68
  const success = await switchProject(projectId);
68
69
  process.exit(success ? 0 : 1);
69
70
  }
71
+ if (options.login) {
72
+ const { browserLogin } = await import('./setup/browser-login.js');
73
+ const result = await browserLogin();
74
+ if (!result.success) {
75
+ console.error(chalk.red(result.error || 'Login failed'));
76
+ process.exit(1);
77
+ }
78
+ }
70
79
  if (options.token) {
71
80
  const result = await connectWithToken(options.token, options.project);
72
81
  if (!result.connected) {
@@ -5,6 +5,13 @@ import { NetworkError, APIError, TimeoutError, ConnectionError, } from '../../er
5
5
  import { LLMError, TokenLimitError, RateLimitError, ContextLengthError, } from '../../errors/llm.js';
6
6
  import { logger, isLLMLogEnabled } from '../../utils/logger.js';
7
7
  import { usageTracker } from '../usage-tracker.js';
8
+ import { getForcedTier } from '../routing-state.js';
9
+ function buildPerRequestHeaders() {
10
+ const tier = getForcedTier();
11
+ if (!tier)
12
+ return undefined;
13
+ return { 'X-Batuta-Force-Tier': tier };
14
+ }
8
15
  export class LLMClient {
9
16
  axiosInstance;
10
17
  baseUrl;
@@ -111,6 +118,7 @@ export class LLMClient {
111
118
  this.currentAbortController = new AbortController();
112
119
  const response = await this.axiosInstance.post(url, requestBody, {
113
120
  signal: this.currentAbortController.signal,
121
+ headers: buildPerRequestHeaders(),
114
122
  });
115
123
  this.currentAbortController = null;
116
124
  const elapsed = logger.endTimer('llm-api-call');
@@ -270,6 +278,7 @@ export class LLMClient {
270
278
  const response = await this.axiosInstance.post(url, requestBody, {
271
279
  responseType: 'stream',
272
280
  signal: this.currentAbortController.signal,
281
+ headers: buildPerRequestHeaders(),
273
282
  });
274
283
  logger.debug('Streaming response started', { status: response.status });
275
284
  const stream = response.data;
@@ -0,0 +1,4 @@
1
+ export type ForcedTier = 'fast' | 'balanced' | 'premium' | null;
2
+ export declare function getForcedTier(): ForcedTier;
3
+ export declare function setForcedTier(tier: ForcedTier): void;
4
+ //# sourceMappingURL=routing-state.d.ts.map
@@ -0,0 +1,8 @@
1
+ let forcedTier = null;
2
+ export function getForcedTier() {
3
+ return forcedTier;
4
+ }
5
+ export function setForcedTier(tier) {
6
+ forcedTier = tier;
7
+ }
8
+ //# sourceMappingURL=routing-state.js.map
@@ -3,6 +3,7 @@ import { usageTracker } from './usage-tracker.js';
3
3
  import { logger } from '../utils/logger.js';
4
4
  import { fullSync } from '../orquesta/config-sync.js';
5
5
  import { configManager } from './config/config-manager.js';
6
+ import { getForcedTier, setForcedTier } from './routing-state.js';
6
7
  export async function executeSlashCommand(command, context) {
7
8
  const trimmedCommand = command.trim();
8
9
  logger.enter('executeSlashCommand', { command: trimmedCommand });
@@ -131,6 +132,42 @@ export async function executeSlashCommand(command, context) {
131
132
  },
132
133
  };
133
134
  }
135
+ if (trimmedCommand === '/route' || trimmedCommand.startsWith('/route ')) {
136
+ const arg = trimmedCommand.slice('/route'.length).trim().toLowerCase();
137
+ const currentModel = configManager.getCurrentModel();
138
+ const currentModelId = currentModel?.id ?? null;
139
+ const onBatutaAuto = currentModelId === 'batuta-auto';
140
+ let body;
141
+ if (!arg) {
142
+ const pinned = getForcedTier();
143
+ body = pinned
144
+ ? `Batuta Auto tier pinned to: ${pinned}.\n\nUse /route auto to let the classifier decide again.`
145
+ : `Batuta Auto: classifier-driven (default).\n\nUsage: /route [auto|fast|balanced|premium]\n fast → Haiku 4.5 (cheapest, simple Q&A)\n balanced → Sonnet 4.6 (code, day-to-day)\n premium → Opus 4.7 (reasoning, long context)\n auto → let the classifier decide per prompt`;
146
+ }
147
+ else if (arg === 'auto' || arg === 'reset' || arg === 'clear') {
148
+ setForcedTier(null);
149
+ body = `Batuta Auto: pin cleared. Classifier will choose tier per prompt.`;
150
+ }
151
+ else if (arg === 'fast' || arg === 'balanced' || arg === 'premium') {
152
+ setForcedTier(arg);
153
+ body = onBatutaAuto
154
+ ? `Batuta Auto: pinned to ${arg}. Every prompt this session routes to ${arg === 'fast' ? 'Haiku 4.5' : arg === 'balanced' ? 'Sonnet 4.6' : 'Opus 4.7'}.`
155
+ : `Pin set to ${arg}, but the active model is "${currentModelId ?? 'none'}", not "batuta-auto" — the pin only takes effect when you switch to batuta-auto (use /model).`;
156
+ }
157
+ else {
158
+ body = `Unknown tier: "${arg}". Use one of: auto, fast, balanced, premium.`;
159
+ }
160
+ const updatedMessages = [
161
+ ...context.messages,
162
+ { role: 'assistant', content: body },
163
+ ];
164
+ context.setMessages(updatedMessages);
165
+ return {
166
+ handled: true,
167
+ shouldContinue: false,
168
+ updatedContext: { messages: updatedMessages },
169
+ };
170
+ }
134
171
  if (trimmedCommand === '/usage') {
135
172
  const usageMessage = usageTracker.formatUsageDisplay();
136
173
  const updatedMessages = [
@@ -276,6 +313,7 @@ Available commands:
276
313
  /tool - Enable/disable optional tools (Browser, Background)
277
314
  /load - Load a saved session
278
315
  /usage - Show token usage statistics
316
+ /route - Pin Batuta Auto tier (fast/balanced/premium/auto)
279
317
  /sync - Bidirectional sync with Orquesta dashboard (pull & push LLM configs)
280
318
 
281
319
  CLI Options (restart required):
@@ -0,0 +1,5 @@
1
+ export declare function browserLogin(): Promise<{
2
+ success: boolean;
3
+ error?: string;
4
+ }>;
5
+ //# sourceMappingURL=browser-login.d.ts.map
@@ -0,0 +1,90 @@
1
+ import { spawn } from 'child_process';
2
+ import { randomUUID } from 'crypto';
3
+ import chalk from 'chalk';
4
+ import { configManager } from '../core/config/config-manager.js';
5
+ import { syncOrquestaConfigs } from '../orquesta/config-sync.js';
6
+ import { logger } from '../utils/logger.js';
7
+ const ORQUESTA_API = process.env['ORQUESTA_API_URL'] || 'https://getorquesta.com';
8
+ const WS_URL = process.env['ORQUESTA_WS_URL']?.replace(/^wss?:\/\//, 'https://') || 'https://ws.orquesta.live';
9
+ const POLL_INTERVAL_MS = 2000;
10
+ const TIMEOUT_MS = 5 * 60 * 1000;
11
+ function openBrowser(url) {
12
+ const opener = process.platform === 'darwin' ? 'open' :
13
+ process.platform === 'win32' ? 'start' :
14
+ 'xdg-open';
15
+ try {
16
+ const child = spawn(opener, [url], { detached: true, stdio: 'ignore', shell: process.platform === 'win32' });
17
+ child.unref();
18
+ return true;
19
+ }
20
+ catch (err) {
21
+ logger.debug('Failed to open browser', { err });
22
+ return false;
23
+ }
24
+ }
25
+ async function pollOnce(sessionId) {
26
+ try {
27
+ const res = await fetch(`${WS_URL}/auth/result/${sessionId}`);
28
+ if (res.status !== 200)
29
+ return null;
30
+ const data = await res.json();
31
+ if (!data.token || !data.organizationId)
32
+ return null;
33
+ return {
34
+ token: data.token,
35
+ organizationId: data.organizationId,
36
+ organizationName: data.organizationName || 'Unknown',
37
+ };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export async function browserLogin() {
44
+ const sessionId = randomUUID();
45
+ const url = `${ORQUESTA_API}/cli/auth?session=${sessionId}`;
46
+ console.log();
47
+ console.log(chalk.cyan('Logging in to Orquesta…'));
48
+ console.log();
49
+ const opened = openBrowser(url);
50
+ if (opened) {
51
+ console.log(chalk.dim('Opened your browser. If nothing happened, copy this URL:'));
52
+ }
53
+ else {
54
+ console.log(chalk.yellow('Could not open a browser automatically. Open this URL on any device:'));
55
+ }
56
+ console.log(' ' + chalk.cyan(url));
57
+ console.log();
58
+ console.log(chalk.dim('Waiting for authorization…'));
59
+ const deadline = Date.now() + TIMEOUT_MS;
60
+ await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
61
+ while (Date.now() < deadline) {
62
+ const hit = await pollOnce(sessionId);
63
+ if (hit) {
64
+ await configManager.setOrquestaConfig({
65
+ token: hit.token,
66
+ organizationId: hit.organizationId,
67
+ organizationName: hit.organizationName,
68
+ projectId: undefined,
69
+ projectName: undefined,
70
+ autoSync: true,
71
+ connectedAt: new Date().toISOString(),
72
+ });
73
+ console.log();
74
+ console.log(chalk.green(`✓ Logged in as ${hit.organizationName}`));
75
+ try {
76
+ const sync = await syncOrquestaConfigs();
77
+ if (sync.success) {
78
+ console.log(chalk.dim(` Synced ${sync.added + sync.updated} endpoint(s) from the dashboard.`));
79
+ }
80
+ }
81
+ catch (err) {
82
+ logger.debug('Initial sync after login failed', { err });
83
+ }
84
+ return { success: true };
85
+ }
86
+ await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
87
+ }
88
+ return { success: false, error: 'Timed out waiting for authorization (5 min).' };
89
+ }
90
+ //# sourceMappingURL=browser-login.js.map
@@ -9,6 +9,10 @@ export declare const TodoPanel: React.FC<TodoPanelProps>;
9
9
  export declare const TodoStatusBar: React.FC<{
10
10
  todos: TodoItem[];
11
11
  projectName?: string;
12
+ batutaUsage?: {
13
+ tokensUsed: number;
14
+ tokenLimit: number;
15
+ } | null;
12
16
  }>;
13
17
  export default TodoPanel;
14
18
  //# sourceMappingURL=TodoPanel.d.ts.map
@@ -100,12 +100,31 @@ export const TodoPanel = React.memo(({ todos, currentTodoId, isProcessing = fals
100
100
  }
101
101
  return true;
102
102
  });
103
- export const TodoStatusBar = React.memo(({ todos, projectName }) => {
103
+ export const TodoStatusBar = React.memo(({ todos, projectName, batutaUsage }) => {
104
104
  useEffect(() => {
105
105
  logger.debug('TodoStatusBar rendered', { todoCount: todos.length });
106
106
  }, [todos.length]);
107
+ const batutaIndicator = batutaUsage && batutaUsage.tokenLimit > 0 ? (() => {
108
+ const pct = (batutaUsage.tokensUsed / batutaUsage.tokenLimit) * 100;
109
+ const color = pct > 90 ? 'red' : pct > 70 ? 'yellow' : 'greenBright';
110
+ const fmt = (n) => n < 1000 ? `${n}` : n < 1_000_000 ? `${(n / 1000).toFixed(1)}k` : `${(n / 1_000_000).toFixed(2)}M`;
111
+ return (React.createElement(React.Fragment, null,
112
+ React.createElement(Text, { color: "gray" }, "Batuta "),
113
+ React.createElement(Text, { color: color },
114
+ fmt(batutaUsage.tokensUsed),
115
+ "/",
116
+ fmt(batutaUsage.tokenLimit))));
117
+ })() : null;
107
118
  if (todos.length === 0) {
108
- return null;
119
+ if (!batutaIndicator)
120
+ return null;
121
+ return (React.createElement(Box, null,
122
+ projectName && (React.createElement(React.Fragment, null,
123
+ React.createElement(Text, { color: "magenta" },
124
+ "\uD83D\uDCC1 ",
125
+ projectName),
126
+ React.createElement(Text, { color: "white" }, " \u2502 "))),
127
+ batutaIndicator));
109
128
  }
110
129
  const completedCount = todos.filter(t => t.status === 'completed').length;
111
130
  const inProgressCount = todos.filter(t => t.status === 'in_progress').length;
@@ -134,7 +153,10 @@ export const TodoStatusBar = React.memo(({ todos, projectName }) => {
134
153
  currentTodo.title.length > 30 ? '...' : ''))),
135
154
  inProgressCount === 0 && completedCount === todos.length && (React.createElement(React.Fragment, null,
136
155
  React.createElement(Text, { color: "white" }, " \u2502 "),
137
- React.createElement(Text, { color: "greenBright" }, "Done")))));
156
+ React.createElement(Text, { color: "greenBright" }, "Done"))),
157
+ batutaIndicator && (React.createElement(React.Fragment, null,
158
+ React.createElement(Text, { color: "white" }, " \u2502 "),
159
+ batutaIndicator))));
138
160
  });
139
161
  export default TodoPanel;
140
162
  //# sourceMappingURL=TodoPanel.js.map
@@ -8,6 +8,7 @@ import { getShellConfig, isNativeWindows } from '../../utils/platform-utils.js';
8
8
  import { CustomTextInput } from './CustomTextInput.js';
9
9
  import { LLMClient, createLLMClient } from '../../core/llm/llm-client.js';
10
10
  import { TodoPanel, TodoStatusBar } from '../TodoPanel.js';
11
+ import { useBatutaUsage } from '../hooks/useBatutaUsage.js';
11
12
  import { sessionManager } from '../../core/session/session-manager.js';
12
13
  import { initializeDocsDirectory, setDocsSearchProgressCallback } from '../../agents/docs-search/index.js';
13
14
  import { DocsSearchProgress } from './DocsSearchProgress.js';
@@ -96,6 +97,7 @@ export const PlanExecuteApp = ({ llmClient: initialLlmClient, modelInfo }) => {
96
97
  const [subActivities, setSubActivities] = useState([]);
97
98
  const [sessionTokens, setSessionTokens] = useState(0);
98
99
  const [sessionElapsed, setSessionElapsed] = useState(0);
100
+ const batutaUsage = useBatutaUsage();
99
101
  const [currentToolName, setCurrentToolName] = useState(null);
100
102
  const [showSessionBrowser, setShowSessionBrowser] = useState(false);
101
103
  const [showSettings, setShowSettings] = useState(false);
@@ -1339,9 +1341,9 @@ export const PlanExecuteApp = ({ llmClient: initialLlmClient, modelInfo }) => {
1339
1341
  React.createElement(Text, { color: "cyan" }, currentModelInfo.model),
1340
1342
  React.createElement(Text, { color: "gray" }, " \u2502 "),
1341
1343
  React.createElement(Text, { color: "gray" }, shortenPath(process.cwd())),
1342
- planExecutionState.todos.length > 0 && (React.createElement(React.Fragment, null,
1344
+ (planExecutionState.todos.length > 0 || batutaUsage) && (React.createElement(React.Fragment, null,
1343
1345
  React.createElement(Text, { color: "gray" }, " \u2502 "),
1344
- React.createElement(TodoStatusBar, { todos: planExecutionState.todos, projectName: configManager.getOrquestaConfig()?.projectName })))),
1346
+ React.createElement(TodoStatusBar, { todos: planExecutionState.todos, projectName: configManager.getOrquestaConfig()?.projectName, batutaUsage: batutaUsage })))),
1345
1347
  React.createElement(Text, { color: "gray", dimColor: true }, "Tab: mode \u2502 /help")))),
1346
1348
  showLogFiles && (React.createElement(Box, { marginTop: 0 },
1347
1349
  React.createElement(LogBrowser, { onClose: () => setShowLogFiles(false) })))));
@@ -19,6 +19,11 @@ export interface StatusBarProps {
19
19
  healthStatus?: 'healthy' | 'unhealthy' | 'checking' | 'unknown';
20
20
  currentActivity?: string;
21
21
  sessionElapsedSeconds?: number;
22
+ batutaUsage?: {
23
+ tokensUsed: number;
24
+ tokenLimit: number;
25
+ tier: 'llm' | 'llm_team' | null;
26
+ } | null;
22
27
  }
23
28
  export declare const StatusBar: React.FC<StatusBarProps>;
24
29
  export default StatusBar;
@@ -62,7 +62,7 @@ function formatElapsedTime(seconds) {
62
62
  const secs = seconds % 60;
63
63
  return `${mins}m ${secs}s`;
64
64
  }
65
- export const StatusBar = ({ model, endpoint: _endpoint, projectName, organizationName: _organizationName, workingDirectory, status = 'idle', message: _message, messageCount = 0, sessionTokens = 0, contextUsage, contextRemainingPercent, todoCount, todoCompleted, healthStatus, currentActivity, sessionElapsedSeconds, }) => {
65
+ export const StatusBar = ({ model, endpoint: _endpoint, projectName, organizationName: _organizationName, workingDirectory, status = 'idle', message: _message, messageCount = 0, sessionTokens = 0, contextUsage, contextRemainingPercent, todoCount, todoCompleted, healthStatus, currentActivity, sessionElapsedSeconds, batutaUsage, }) => {
66
66
  void _endpoint;
67
67
  void _message;
68
68
  useEffect(() => {
@@ -149,6 +149,16 @@ export const StatusBar = ({ model, endpoint: _endpoint, projectName, organizatio
149
149
  React.createElement(Text, { color: "cyan" },
150
150
  "\u26A1 ",
151
151
  formatTokens(sessionTokens)))),
152
+ batutaUsage && batutaUsage.tokenLimit > 0 && (() => {
153
+ const pct = (batutaUsage.tokensUsed / batutaUsage.tokenLimit) * 100;
154
+ const color = pct > 90 ? 'red' : pct > 70 ? 'yellow' : 'green';
155
+ return (React.createElement(Box, { marginRight: 2 },
156
+ React.createElement(Text, { color: "gray" }, "Batuta "),
157
+ React.createElement(Text, { color: color },
158
+ formatTokens(batutaUsage.tokensUsed),
159
+ "/",
160
+ formatTokens(batutaUsage.tokenLimit))));
161
+ })(),
152
162
  contextUsage && contextUsage.current > 0 && (React.createElement(Box, { marginRight: 2 },
153
163
  React.createElement(Text, { color: "gray" }, "CTX "),
154
164
  React.createElement(ContextMiniBar, { current: contextUsage.current, max: contextUsage.max }))),
@@ -0,0 +1,8 @@
1
+ export interface BatutaUsage {
2
+ tokensUsed: number;
3
+ tokenLimit: number;
4
+ tier: 'llm' | 'llm_team' | null;
5
+ enabled: boolean;
6
+ }
7
+ export declare function useBatutaUsage(): BatutaUsage | null;
8
+ //# sourceMappingURL=useBatutaUsage.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { configManager } from '../../core/config/config-manager.js';
3
+ const ORQUESTA_API = process.env['ORQUESTA_API_URL'] || 'https://getorquesta.com';
4
+ const POLL_INTERVAL_MS = 60_000;
5
+ export function useBatutaUsage() {
6
+ const [usage, setUsage] = useState(null);
7
+ useEffect(() => {
8
+ let cancelled = false;
9
+ async function tick() {
10
+ const endpoint = configManager.getCurrentEndpoint();
11
+ const orquestaConfig = configManager.getOrquestaConfig();
12
+ const onBatuta = endpoint?.provider === 'batuta' || endpoint?.id === 'batuta-proxy';
13
+ if (!onBatuta || !orquestaConfig?.token) {
14
+ if (!cancelled)
15
+ setUsage(null);
16
+ return;
17
+ }
18
+ try {
19
+ const res = await fetch(`${ORQUESTA_API}/api/billing`, {
20
+ headers: { Authorization: `Bearer ${orquestaConfig.token}` },
21
+ });
22
+ if (!res.ok || cancelled)
23
+ return;
24
+ const data = await res.json();
25
+ if (data.batutaLlm && !cancelled) {
26
+ setUsage({
27
+ tokensUsed: data.batutaLlm.tokensUsed ?? 0,
28
+ tokenLimit: data.batutaLlm.tokenLimit ?? 0,
29
+ tier: data.batutaLlm.tier ?? null,
30
+ enabled: data.batutaLlm.enabled ?? false,
31
+ });
32
+ }
33
+ }
34
+ catch {
35
+ }
36
+ }
37
+ tick();
38
+ const interval = setInterval(tick, POLL_INTERVAL_MS);
39
+ return () => {
40
+ cancelled = true;
41
+ clearInterval(interval);
42
+ };
43
+ }, []);
44
+ return usage;
45
+ }
46
+ //# sourceMappingURL=useBatutaUsage.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquesta-cli",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Orquesta CLI - AI-powered coding assistant with team collaboration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",