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.
- package/README.md +15 -7
- package/cli.js +246 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# OSuite SDK (v2.9.
|
|
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
|
|
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
|
|
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
|
|
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}`);
|