osuite 2.9.3 → 2.9.5

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 (3) hide show
  1. package/README.md +15 -7
  2. package/cli.js +246 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # OSuite SDK (v2.9.2)
1
+ # OSuite SDK (v2.9.5)
2
2
 
3
3
  **Governed action SDK for AI agents.**
4
4
 
@@ -11,6 +11,13 @@ The OSuite SDK helps teams route approvals, record governed decisions, and produ
11
11
  npm install osuite
12
12
  ```
13
13
 
14
+ ### One-command runtime connect
15
+ ```bash
16
+ curl -fsSL https://studio.osuite.ai/install.sh | bash
17
+ ```
18
+
19
+ The installer detects Codex, Claude Code, SDK, or MCP-style projects, opens a browser handoff, and writes the approved runtime environment into the project (`.codex/.env`, `.claude/.env`, or `.osuite/.env`). API keys remain available as a fallback, but the normal path is terminal start -> Studio approval -> terminal auto-finish.
20
+
14
21
  ### Python
15
22
  ```bash
16
23
  pip install requests
@@ -451,11 +458,11 @@ OSuite uses standard HTTP status codes and custom error classes:
451
458
  Install the OSuite CLI from the project root where your agent works. For Codex and Claude Code, the installer adds project-scoped hook files (`.codex` or `.claude`) while the CLI remains the control surface for doctor, explain, review, and approval:
452
459
 
453
460
  ```bash
454
- curl -fsSL https://studio.osuite.ai/install.sh | bash -s -- codex
455
- # or
456
- curl -fsSL https://studio.osuite.ai/install.sh | bash -s -- claude
461
+ curl -fsSL https://studio.osuite.ai/install.sh | bash
457
462
  ```
458
463
 
464
+ The default installer auto-detects common project lanes and starts a browser handoff. If auto-detection is wrong, force a lane with `OSUITE_INSTALL_TARGET=codex`, `OSUITE_INSTALL_TARGET=claude`, `OSUITE_INSTALL_TARGET=sdk`, or `OSUITE_INSTALL_TARGET=mcp`.
465
+
459
466
  If you prefer npm directly:
460
467
 
461
468
  ```bash
@@ -464,6 +471,7 @@ npm install -g osuite
464
471
 
465
472
  ```bash
466
473
  osuite # branded welcome and command map
474
+ osuite connect auto # detect the current project and start browser handoff
467
475
  osuite doctor # check env, Studio health, runtime, and signature posture
468
476
  osuite status # show the current Studio connection
469
477
  osuite init codex # install .codex hook files into the current project
@@ -475,7 +483,7 @@ osuite approve <actionId> # approve a specific action
475
483
  osuite deny <actionId> # deny a specific action
476
484
  ```
477
485
 
478
- For local CLI agents, copy `.codex/.env.example` or `.claude/.env.example` to `.env`, fill `OSUITE_API_KEY`, and run the agent from the same project root. The CLI is intentionally more than an API wrapper:
486
+ For local CLI agents, `osuite connect auto` prints a browser handoff URL and installs the project hook lane when it can detect Codex or Claude Code. If browser handoff is unavailable, copy `.codex/.env.example` or `.claude/.env.example` to `.env`, fill `OSUITE_API_KEY`, and run the agent from the same project root. The CLI is intentionally more than an API wrapper:
479
487
  - `doctor` tells a developer what is missing before they connect an agent.
480
488
  - `explain` gives a local CAVA preview before a command reaches OSuite.
481
489
  - `approvals` provides a readable approval inbox instead of raw JSON.
@@ -488,10 +496,10 @@ When an agent calls `waitForApproval()`, it prints the action ID and replay link
488
496
  Govern Claude Code tool calls without any SDK instrumentation. The preferred path is the project-root installer:
489
497
 
490
498
  ```bash
491
- curl -fsSL https://studio.osuite.ai/install.sh | bash -s -- claude
499
+ curl -fsSL https://studio.osuite.ai/install.sh | bash
492
500
  ```
493
501
 
494
- It creates `.claude/hooks/*`, `.claude/settings.json`, `.claude/settings.local.json`, and `.claude/.env.example`. Copy the env example to `.claude/.env`, fill `OSUITE_API_KEY`, then run Claude Code from that project root.
502
+ It detects `.claude`, creates `.claude/hooks/*`, `.claude/settings.json`, `.claude/settings.local.json`, and `.claude/.env.example`, then prints the browser handoff. Copy the env example to `.claude/.env` and fill `OSUITE_API_KEY` only when the browser flow is not available.
495
503
 
496
504
  ---
497
505
 
package/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import crypto from 'node:crypto';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { OSuite, buildReviewSurface } from './osuite.js';
7
8
 
@@ -126,6 +127,159 @@ function normalizedBaseUrl(value) {
126
127
  return String(value || DEFAULT_BASE_URL).replace(/\/$/, '');
127
128
  }
128
129
 
130
+ function sanitizeRuntimeTarget(value, fallback = 'auto') {
131
+ const normalized = String(value || fallback)
132
+ .trim()
133
+ .toLowerCase()
134
+ .replace(/[^a-z0-9_-]/g, '');
135
+ if (!normalized) return fallback;
136
+ if (normalized === 'claude-code' || normalized === 'claudecode') return 'claude';
137
+ if (normalized === 'generic') return 'auto';
138
+ return normalized;
139
+ }
140
+
141
+ function detectRuntimeTarget(preferred = 'auto') {
142
+ const target = sanitizeRuntimeTarget(preferred);
143
+ if (target !== 'auto') return target;
144
+
145
+ const cwd = process.cwd();
146
+ if (fs.existsSync(path.join(cwd, '.codex'))) return 'codex';
147
+ if (fs.existsSync(path.join(cwd, '.claude'))) return 'claude';
148
+ if (fs.existsSync(path.join(cwd, 'mcp.json')) || fs.existsSync(path.join(cwd, '.mcp.json'))) return 'mcp';
149
+ if (fs.existsSync(path.join(cwd, 'package.json'))) return 'sdk';
150
+ return 'sdk';
151
+ }
152
+
153
+ function generateConnectCode() {
154
+ return `OS-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
155
+ }
156
+
157
+ function buildConnectHandoffUrl({ baseUrl, runtime, code }) {
158
+ const url = new URL('/connect', normalizedBaseUrl(baseUrl));
159
+ url.searchParams.set('runtime', runtime);
160
+ url.searchParams.set('source', 'cli');
161
+ url.searchParams.set('code', code);
162
+ return url.toString();
163
+ }
164
+
165
+ function defaultAgentIdForRuntime(runtime) {
166
+ if (runtime === 'codex') return 'codex-runtime';
167
+ if (runtime === 'claude') return 'claude-code-runtime';
168
+ if (runtime === 'mcp') return 'mcp-runtime';
169
+ return 'sdk-runtime';
170
+ }
171
+
172
+ function envPathForRuntime(runtime) {
173
+ if (runtime === 'codex') return '.codex/.env';
174
+ if (runtime === 'claude') return '.claude/.env';
175
+ return '.osuite/.env';
176
+ }
177
+
178
+ function orderedEnvEntries(env = {}) {
179
+ const preferred = [
180
+ 'OSUITE_BASE_URL',
181
+ 'OSUITE_API_KEY',
182
+ 'OSUITE_AGENT_ID',
183
+ 'OSUITE_RUNTIME_ADAPTER_ID',
184
+ 'OSUITE_SIGNATURE_MODE',
185
+ ];
186
+ const seen = new Set();
187
+ const entries = [];
188
+ preferred.forEach((key) => {
189
+ if (env[key] !== undefined && env[key] !== null && env[key] !== '') {
190
+ seen.add(key);
191
+ entries.push([key, env[key]]);
192
+ }
193
+ });
194
+ Object.keys(env)
195
+ .sort()
196
+ .forEach((key) => {
197
+ if (!seen.has(key) && env[key] !== undefined && env[key] !== null && env[key] !== '') {
198
+ entries.push([key, env[key]]);
199
+ }
200
+ });
201
+ return entries;
202
+ }
203
+
204
+ function formatEnvBody(env = {}) {
205
+ return `${orderedEnvEntries(env)
206
+ .map(([key, value]) => `${key}=${String(value).replace(/\n/g, '')}`)
207
+ .join('\n')}\n`;
208
+ }
209
+
210
+ function parsePositiveInt(value, fallback) {
211
+ const parsed = Number.parseInt(value, 10);
212
+ if (!Number.isFinite(parsed) || parsed < 0) return fallback;
213
+ return parsed;
214
+ }
215
+
216
+ function sleep(ms) {
217
+ return new Promise((resolve) => setTimeout(resolve, ms));
218
+ }
219
+
220
+ async function postJson(url, body) {
221
+ const response = await fetch(url, {
222
+ method: 'POST',
223
+ headers: { 'content-type': 'application/json' },
224
+ body: JSON.stringify(body || {}),
225
+ });
226
+ const text = await response.text();
227
+ let payload = {};
228
+ if (text) {
229
+ try {
230
+ payload = JSON.parse(text);
231
+ } catch {
232
+ payload = { raw: text };
233
+ }
234
+ }
235
+ return { response, payload };
236
+ }
237
+
238
+ async function startDeviceConnect({ baseUrl, runtime, agentId }) {
239
+ const { response, payload } = await postJson(`${normalizedBaseUrl(baseUrl)}/api/onboarding/device-connect/start`, {
240
+ runtime,
241
+ agent_id: agentId,
242
+ project_root: process.cwd(),
243
+ });
244
+ if (!response.ok) {
245
+ throw new Error(payload.error || `Unable to start browser handoff: HTTP ${response.status}`);
246
+ }
247
+ if (!payload.device_code || !payload.poll_token || !payload.user_code) {
248
+ throw new Error('Studio did not return a complete device-connect payload.');
249
+ }
250
+ return payload;
251
+ }
252
+
253
+ async function pollDeviceConnect({ baseUrl, deviceCode, pollToken, timeoutMs, intervalMs }) {
254
+ const deadline = Date.now() + timeoutMs;
255
+ while (Date.now() <= deadline) {
256
+ const { response, payload } = await postJson(`${normalizedBaseUrl(baseUrl)}/api/onboarding/device-connect/poll`, {
257
+ device_code: deviceCode,
258
+ poll_token: pollToken,
259
+ });
260
+
261
+ if (response.status === 200 && payload.status === 'approved' && payload.credential?.env) {
262
+ return payload.credential;
263
+ }
264
+ if (response.status === 202 || payload.status === 'pending') {
265
+ const serverInterval = Number(payload.interval_seconds || 0) * 1000;
266
+ await sleep(serverInterval > 0 ? Math.max(intervalMs, serverInterval) : intervalMs);
267
+ continue;
268
+ }
269
+ if (response.status === 410 || payload.status === 'expired') {
270
+ throw new Error('Browser handoff expired. Run osuite connect again.');
271
+ }
272
+ if (response.status === 409 || payload.status === 'consumed') {
273
+ throw new Error(payload.error || 'Browser handoff was already consumed.');
274
+ }
275
+ if (response.status === 404) {
276
+ throw new Error('Browser handoff was not found. Run osuite connect again.');
277
+ }
278
+ throw new Error(payload.error || `Unable to finish browser handoff: HTTP ${response.status}`);
279
+ }
280
+ return null;
281
+ }
282
+
129
283
  function projectFilePath(relativePath) {
130
284
  const clean = String(relativePath || '').replace(/^\/+/, '');
131
285
  const root = path.resolve(process.cwd());
@@ -217,6 +371,7 @@ function printHelp() {
217
371
  write(' osuite review <actionId> Render the decision review card');
218
372
  write(' osuite approve <actionId> --reason "Looks safe"');
219
373
  write(' osuite deny <actionId> --reason "Outside change window"');
374
+ write(' osuite connect [auto|codex|claude|sdk|mcp] Detect runtime and start browser handoff');
220
375
  write(' osuite init [sdk|codex|claude|mcp] Print setup steps for a runtime lane');
221
376
  write('');
222
377
  write(c('Environment', ANSI.bold));
@@ -513,6 +668,96 @@ async function init(target = 'generic', args = {}) {
513
668
  }
514
669
  }
515
670
 
671
+ async function connect(target = 'auto', args = {}) {
672
+ const config = readConfig();
673
+ const runtime = detectRuntimeTarget(target);
674
+ const baseUrl = normalizedBaseUrl(args['base-url'] || config.baseUrl || DEFAULT_BASE_URL);
675
+ const configuredAgentId = envValue('OSUITE_AGENT_ID', 'DASHCLAW_AGENT_ID');
676
+ const agentId = String(args['agent-id'] || configuredAgentId || defaultAgentIdForRuntime(runtime));
677
+ const dryRunCode = String(args.code || generateConnectCode()).trim() || generateConnectCode();
678
+ let connectPayload = null;
679
+ let code = dryRunCode;
680
+ let handoffUrl = buildConnectHandoffUrl({ baseUrl, runtime, code });
681
+ const hasApiKey = Boolean(config.apiKey);
682
+
683
+ if (!args['dry-run']) {
684
+ connectPayload = await startDeviceConnect({ baseUrl, runtime, agentId });
685
+ code = connectPayload.user_code;
686
+ handoffUrl = connectPayload.verification_uri_complete || buildConnectHandoffUrl({ baseUrl, runtime, code });
687
+ }
688
+
689
+ write(c('OSuite Connect', `${ANSI.bold}${ANSI.cyan}`));
690
+ write(c('Project-root setup for governed agent actions.', ANSI.dim));
691
+ write('');
692
+ write(`Detected runtime ${runtime}`);
693
+ write(`Project root ${process.cwd()}`);
694
+ write(`Browser handoff ${handoffUrl}`);
695
+ write(`One-time code ${code}`);
696
+ write('');
697
+ write(c('What happens next', ANSI.bold));
698
+ write(' 1. Open the browser handoff URL and approve this terminal connection in Studio.');
699
+ write(' 2. OSuite prepares the runtime identity, project hook lane, and signed env for this project.');
700
+ write(' 3. Return here; the CLI will finish automatically and then you can run osuite doctor.');
701
+ write('');
702
+
703
+ if (PROJECT_BOOTSTRAPS[runtime]) {
704
+ if (args['dry-run']) {
705
+ write(`Dry run: would install project hook files for ${runtime}.`);
706
+ } else {
707
+ await installRuntimeProjectBootstrap(runtime, args);
708
+ }
709
+ } else {
710
+ write(c('Runtime setup', ANSI.bold));
711
+ write(' This runtime uses the embedded SDK/MCP lane. Install the SDK in the app code path that dispatches actions.');
712
+ if (runtime === 'sdk') write(' npm install osuite');
713
+ if (runtime === 'mcp') write(' Wrap consequential tool calls with OSuite preflight, wait-for-approval, and complete-action steps.');
714
+ }
715
+
716
+ if (!args['dry-run'] && connectPayload) {
717
+ const timeoutMs = parsePositiveInt(args['poll-timeout-ms'], 180000);
718
+ const intervalMs = parsePositiveInt(
719
+ args['poll-interval-ms'],
720
+ Math.max(1000, Number(connectPayload.interval_seconds || 2) * 1000),
721
+ );
722
+
723
+ write('');
724
+ write(c('Waiting for browser approval', ANSI.bold));
725
+ write(` Code ${code} expires in about ${Math.round(Number(connectPayload.expires_in || 600) / 60)} minutes.`);
726
+
727
+ const credential = await pollDeviceConnect({
728
+ baseUrl,
729
+ deviceCode: connectPayload.device_code,
730
+ pollToken: connectPayload.poll_token,
731
+ timeoutMs,
732
+ intervalMs,
733
+ });
734
+
735
+ if (credential?.env) {
736
+ const envPath = envPathForRuntime(runtime);
737
+ const result = writeProjectFile(envPath, formatEnvBody(credential.env), { force: true });
738
+ write('');
739
+ write(c('Connected to OSuite', ANSI.green));
740
+ write(` ${result.status === 'written' ? 'wrote' : 'kept'} ${result.relativePath}`);
741
+ write(' Run osuite doctor, then start the agent from this project root.');
742
+ } else {
743
+ write('');
744
+ write(c('Browser approval not completed before the timeout.', ANSI.yellow));
745
+ write(' Keep the browser handoff open or run osuite connect again.');
746
+ }
747
+ }
748
+
749
+ write('');
750
+ write(c('API key fallback', ANSI.bold));
751
+ if (hasApiKey) {
752
+ write(' OSUITE_API_KEY is already present in this shell or project env. Browser handoff can be skipped for this project.');
753
+ } else if (PROJECT_BOOTSTRAPS[runtime]) {
754
+ const envPath = PROJECT_BOOTSTRAPS[runtime].envPath.replace(/\.example$/, '');
755
+ write(` If browser handoff is unavailable, copy ${PROJECT_BOOTSTRAPS[runtime].envPath} to ${envPath} and paste a workspace API key.`);
756
+ } else {
757
+ write(' If browser handoff is unavailable, export OSUITE_BASE_URL and OSUITE_API_KEY before running the agent.');
758
+ }
759
+ }
760
+
516
761
  async function main() {
517
762
  const { command, args } = parseArgs(process.argv.slice(2));
518
763
 
@@ -525,6 +770,7 @@ async function main() {
525
770
  if (command === 'review') return review(args._[0]);
526
771
  if (command === 'approve') return decide(args._[0], 'allow', args);
527
772
  if (command === 'deny') return decide(args._[0], 'deny', args);
773
+ if (command === 'connect') return connect(args._[0] || 'auto', args);
528
774
  if (command === 'init') return init(args._[0] || 'generic', args);
529
775
 
530
776
  write(`Unknown command: ${command}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osuite",
3
- "version": "2.9.3",
3
+ "version": "2.9.5",
4
4
  "description": "OSuite governed action SDK for AI agents. Approve, replay, prove, and verify runtime decisions.",
5
5
  "type": "module",
6
6
  "publishConfig": {