diff --git a/lib/peer/ClaudeCodeDriver.mjs b/lib/peer/ClaudeCodeDriver.mjs new file mode 100644 index 0000000..30e2f4b --- /dev/null +++ b/lib/peer/ClaudeCodeDriver.mjs @@ -0,0 +1,284 @@ +/** + * ClaudeCodeDriver — drives the local `claude` CLI for OrgX peer dispatch. + * + * The Claude Code CLI supports non-interactive one-shot mode via `-p` with a + * prompt argument, streaming JSON events on stdout when `--output-format + * stream-json` is set. The driver: + * + * 1. detect() — runs `claude --version` to confirm the CLI is on PATH + * and that auth is configured (the CLI errors if no + * subscription is active). + * 2. dispatch() — spawns `claude -p --output-format stream-json + * --plugin-dir ` in the task's repo_path, + * reads the NDJSON stream on stdout, yields PeerToServer + * messages as events land. Rule-based deviation checks + * run against each file-edit event. + * 3. cancel() — kills the spawned process (SIGTERM then SIGKILL after 3s). + * 4. probe() — cheap liveness check that runs `claude --version` with a + * short timeout. + * + * This driver is a *peer implementation detail*. The plugin peer runtime + * (see lib/peer/peer.mjs) wires it into @useorgx/orgx-gateway-sdk's PeerClient. + */ + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const CANCEL_GRACE_MS = 3_000; + +export class ClaudeCodeDriver { + id = 'claude_code'; + + constructor(opts = {}) { + this.opts = opts; + this.running = new Map(); // run_id → ChildProcess + } + + async detect() { + try { + const out = await runOnce('claude', ['--version'], { timeoutMs: 5_000 }); + return { + installed: true, + authenticated: !/not authenticated|subscription/i.test(out.stderr), + version: out.stdout.trim() || undefined, + subscription_active: true, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (/ENOENT|not found/i.test(message)) { + return { installed: false, authenticated: false, error: message }; + } + return { installed: true, authenticated: false, error: message }; + } + } + + async probe() { + try { + await runOnce('claude', ['--version'], { timeoutMs: 2_500 }); + return { subscription_active: true, session_alive: true, queue_depth: this.running.size }; + } catch { + return { subscription_active: false, session_alive: false, queue_depth: this.running.size }; + } + } + + async *dispatch(task, context) { + const prompt = renderPrompt(task); + const cwd = task.repo_path || process.cwd(); + const args = [ + '-p', + prompt, + '--output-format', + 'stream-json', + '--plugin-dir', + this.opts.pluginDir ?? PLUGIN_ROOT, + ]; + + const startedAt = new Date().toISOString(); + const child = spawn('claude', args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ORGX_RUN_ID: context.run_id }, + }); + this.running.set(context.run_id, child); + + yield { kind: 'task.started', run_id: context.run_id, started_at: startedAt }; + + const rules = (await (this.opts.skillRules?.() ?? Promise.resolve([]))).filter(Boolean); + const seen = new Set(); + let firstResponseAt = null; + let tokensTotal = 0; + + try { + for await (const line of readNdjson(child.stdout)) { + const event = safeParse(line); + if (!event || typeof event !== 'object') continue; + + if (!firstResponseAt && (event.kind === 'tool_call' || event.kind === 'chat' || event.kind === 'file_edit')) { + firstResponseAt = new Date().toISOString(); + } + + if (event.kind === 'tokens_used') { + tokensTotal += Number(event.delta ?? 0); + continue; + } + + if (event.kind === 'file_edit' || event.kind === 'tool_call') { + const summary = + event.kind === 'file_edit' + ? `edit ${event.path} — ${event.summary ?? 'change'}` + : `call ${event.tool ?? 'tool'} — ${event.summary ?? ''}`; + yield { + kind: 'task.step', + run_id: context.run_id, + step: { + kind: event.kind, + summary, + evidence_ref: event.diff_ref ?? event.ref ?? null, + }, + }; + + for (const rule of rules) { + if (rule.match?.on !== event.kind) continue; + const text = + event.kind === 'file_edit' + ? `${event.path ?? ''} ${event.summary ?? ''}` + : `${event.tool ?? ''} ${event.summary ?? ''}`; + try { + if (!new RegExp(rule.match.pattern).test(text)) continue; + } catch { + continue; + } + const dedupe = `${rule.skill_id}:${rule.dedupe_fingerprint}:${context.run_id}`; + if (seen.has(dedupe)) continue; + seen.add(dedupe); + yield { + kind: 'task.deviation', + run_id: context.run_id, + skill_id: rule.skill_id, + evidence_kind: rule.evidence_kind, + evidence_ref: event.diff_ref ?? event.ref ?? event.path ?? event.tool ?? '', + dedupe_key: dedupe, + severity: 'warn', + }; + } + } + + if (event.kind === 'assistant_completed') { + tokensTotal = tokensTotal || Number(event.tokens_used ?? 0); + yield { + kind: 'task.completed', + run_id: context.run_id, + outcome_kind: 'shipped', + started_at: startedAt, + first_response_at: firstResponseAt ?? startedAt, + completed_at: new Date().toISOString(), + tokens_used: tokensTotal, + provider: 'anthropic', + source_sub_type: 'subscription', + source_driver: 'claude_code', + cost_estimate_cents: 0, + }; + return; + } + + if (event.kind === 'error') { + yield { + kind: 'task.failed', + run_id: context.run_id, + reason: event.message ?? 'claude errored', + recoverable: event.recoverable === true, + }; + return; + } + } + + // stdout closed without explicit completed → check exit code. + const exitCode = await waitExit(child); + if (exitCode === 0) { + yield { + kind: 'task.completed', + run_id: context.run_id, + outcome_kind: 'shipped', + started_at: startedAt, + first_response_at: firstResponseAt ?? startedAt, + completed_at: new Date().toISOString(), + tokens_used: tokensTotal, + provider: 'anthropic', + source_sub_type: 'subscription', + source_driver: 'claude_code', + cost_estimate_cents: 0, + }; + } else { + yield { + kind: 'task.failed', + run_id: context.run_id, + reason: `claude exited ${exitCode}`, + recoverable: exitCode === null, + }; + } + } finally { + this.running.delete(context.run_id); + } + } + + async cancel(runId) { + const child = this.running.get(runId); + if (!child) return; + child.kill('SIGTERM'); + setTimeout(() => { + if (!child.killed) child.kill('SIGKILL'); + }, CANCEL_GRACE_MS).unref?.(); + this.running.delete(runId); + } +} + +// ───────── Helpers ───────────────────────────────────────────────────────── + +function runOnce(cmd, args, opts = {}) { + const timeoutMs = opts.timeoutMs ?? 10_000; + return new Promise((resolveFn, rejectFn) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + const t = setTimeout(() => { + child.kill('SIGKILL'); + rejectFn(new Error(`${cmd} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + child.stdout.on('data', (d) => { + stdout += d.toString('utf8'); + }); + child.stderr.on('data', (d) => { + stderr += d.toString('utf8'); + }); + child.on('error', (err) => { + clearTimeout(t); + rejectFn(err); + }); + child.on('close', (code) => { + clearTimeout(t); + if (code === 0) resolveFn({ stdout, stderr }); + else rejectFn(new Error(`${cmd} exited ${code}: ${stderr.slice(0, 300)}`)); + }); + }); +} + +async function* readNdjson(stream) { + let buffer = ''; + for await (const chunk of stream) { + buffer += chunk.toString('utf8'); + let nl; + while ((nl = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (line) yield line; + } + } + if (buffer.trim()) yield buffer.trim(); +} + +function safeParse(s) { + try { + return JSON.parse(s); + } catch { + return null; + } +} + +function waitExit(child) { + return new Promise((resolveFn) => { + if (child.exitCode !== null) resolveFn(child.exitCode); + else child.once('close', (code) => resolveFn(code)); + }); +} + +function renderPrompt(task) { + const parts = [task.title]; + if (task.description) parts.push('\n\n', task.description); + if (task.skill_ids?.length) { + parts.push('\n\nSkills to honor:\n'); + for (const id of task.skill_ids) parts.push(` - ${id}\n`); + } + return parts.join(''); +} diff --git a/lib/peer/README.md b/lib/peer/README.md new file mode 100644 index 0000000..649a542 --- /dev/null +++ b/lib/peer/README.md @@ -0,0 +1,72 @@ +# OrgX Peer Sidecar for Claude Code + +The plugin under `@useorgx/claude-code-plugin` has always been a **Claude Code CLI plugin** (hooks + skills + commands + agents) you load with `claude --plugin-dir .`. + +This folder adds a second shape: a **peer sidecar** that connects to OrgX server over WebSocket and dispatches to your local `claude` CLI on demand. That sidecar implements [Gateway Protocol v1](https://github.com/useorgx/orgx-gateway-sdk/blob/main/PROTOCOL.md) via the shared `@useorgx/orgx-gateway-sdk` package. + +## Mental model + +- The CLI plugin is **loaded by you** when you run Claude Code interactively. +- The peer sidecar is **loaded by the machine** (e.g. autostart on login) and listens for OrgX dispatches. When a task arrives, it runs `claude -p --plugin-dir ` under the hood — which re-uses the same skills + hooks your interactive sessions use. + +Both shapes share a codebase and the same skill catalog. + +## Run it + +```bash +# from the plugin root +ORGX_API_KEY=oxk_your_token_here \ +ORGX_WORKSPACE_ID= \ +node lib/peer/cli.mjs +``` + +Or programmatically: + +```js +import { startPeer } from '@useorgx/claude-code-plugin/peer'; + +const peer = await startPeer({ + apiKey: process.env.ORGX_API_KEY, + workspaceId: process.env.ORGX_WORKSPACE_ID, +}); + +// later +await peer.stop(); +``` + +## Required oxk_ scopes + +- `gateway:drive` — accept task.dispatch / emit task.step + task.completed +- `plugin:heartbeat` — post `POST /api/v1/licenses/heartbeat` weekly + +## Protocol + +- On boot: calls `GET /api/v1/plan-skills` to pull skill rules the peer checks against file-edit / tool-call events. Matches emit `task.deviation` to the server. +- `claude` is invoked with `--output-format stream-json` so stdout is NDJSON events that the Driver translates into wire-protocol messages. +- Token usage is accumulated from `tokens_used` events emitted by Claude Code when present; `cost_estimate_cents` is set to 0 because subscription-backed dispatches don't carry a price — the server fills `saved_estimate_cents` later via the receipt aggregator. + +## Peer lifecycle + +``` +startPeer() + ├─ load plugin.manifest.json (unsigned in dev → 'degraded' in permissive mode) + ├─ new PeerClient({ baseUrl: wss://useorgx.com, apiKey, workspaceId, + │ pluginId: '@useorgx/claude-code-plugin', + │ drivers: [new ClaudeCodeDriver(…)] }) + ├─ client.connect() // WebSocket + protocol handshake + ├─ postHeartbeat() // initial license heartbeat + └─ setInterval(heartbeat, 7d) // keep status active +``` + +`client.disconnect()` on stop clears the heartbeat timer. + +## Files + +- `ClaudeCodeDriver.mjs` — Driver implementing Gateway Protocol v1. Spawns `claude`, reads NDJSON stdout, emits task.step / task.deviation / task.completed / task.failed. +- `peer.mjs` — `startPeer()` wires Driver into PeerClient + manages heartbeat. +- `cli.mjs` — shell entrypoint so `node lib/peer/cli.mjs` just works. +- `peer.test.mjs` — Node `node --test` unit coverage for the Driver (spawns a fake `claude` via a test fixture). + +## Status + +Alpha — lands alongside the other Sovereign Execution plugin peers (orgx-codex-plugin, orgx-opencode-plugin). See initiative [`993cabeb`](https://useorgx.com/live/993cabeb-8162-4f35-9b4d-3832df9d5f83). diff --git a/lib/peer/cli.mjs b/lib/peer/cli.mjs new file mode 100644 index 0000000..0f31d23 --- /dev/null +++ b/lib/peer/cli.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * CLI entrypoint: `orgx-claude-code-peer` — starts the plugin's peer + * sidecar so OrgX server can dispatch tasks to the user's local Claude + * Code session. + */ + +import { startPeer } from './peer.mjs'; + +async function main() { + const apiKey = process.env.ORGX_API_KEY; + const workspaceId = process.env.ORGX_WORKSPACE_ID; + const baseUrl = process.env.ORGX_BASE_URL ?? 'https://useorgx.com'; + if (!apiKey || !workspaceId) { + console.error('Missing ORGX_API_KEY and/or ORGX_WORKSPACE_ID. Export both and retry.'); + process.exit(2); + } + + const peer = await startPeer({ apiKey, workspaceId, baseUrl }); + console.log( + '[orgx-claude-code-plugin] peer running — ctrl-c to stop. Dispatches arrive when OrgX sends them.' + ); + + const shutdown = async () => { + await peer.stop(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((err) => { + console.error('[orgx-claude-code-plugin] fatal', err); + process.exit(1); +}); diff --git a/lib/peer/peer.mjs b/lib/peer/peer.mjs new file mode 100644 index 0000000..2baa9af --- /dev/null +++ b/lib/peer/peer.mjs @@ -0,0 +1,138 @@ +/** + * Peer runtime — boots the ClaudeCodeDriver inside PeerClient from + * @useorgx/orgx-gateway-sdk and manages the weekly license heartbeat. + * + * The claude-code-plugin retains its original identity (a Claude Code + * CLI plugin loaded via `--plugin-dir`). This module is the peer sidecar + * that runs alongside the user's Claude Code install, connecting to + * OrgX server and driving Claude Code for dispatched tasks. + * + * Usage: + * + * ORGX_API_KEY=oxk_... ORGX_WORKSPACE_ID= node lib/peer/cli.mjs + * + * or programmatically: + * import { startPeer } from './peer/peer.mjs'; + * const peer = await startPeer({ apiKey, workspaceId }); + * // later: await peer.stop(); + */ + +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { PeerClient } from '@useorgx/orgx-gateway-sdk'; + +import { ClaudeCodeDriver } from './ClaudeCodeDriver.mjs'; + +const HEARTBEAT_MS = 7 * 24 * 60 * 60 * 1000; +const HERE = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = resolve(HERE, '..', '..'); + +export async function startPeer(opts) { + const baseUrl = opts.baseUrl ?? 'https://useorgx.com'; + const manifest = await loadManifest(); + + const driver = opts.driver ?? new ClaudeCodeDriver({ + pluginDir: opts.pluginDir ?? PLUGIN_ROOT, + skillRules: () => fetchSkillRules(baseUrl, opts), + }); + + const client = new PeerClient({ + baseUrl: httpsToWss(baseUrl), + apiKey: opts.apiKey, + workspaceId: opts.workspaceId, + pluginId: '@useorgx/claude-code-plugin', + drivers: [driver], + onOpen: () => console.log('[orgx-claude-code-plugin] connected'), + onClose: (code, reason) => console.warn('[orgx-claude-code-plugin] closed', { code, reason }), + onError: (err) => console.error('[orgx-claude-code-plugin] error', err), + }); + client.connect(); + + let heartbeatTimer = null; + if (!opts.skipHeartbeat) { + await postHeartbeat(baseUrl, opts, manifest).catch((err) => + console.warn('[orgx-claude-code-plugin] initial heartbeat failed', err) + ); + heartbeatTimer = setInterval(() => { + postHeartbeat(baseUrl, opts, manifest).catch((err) => + console.warn('[orgx-claude-code-plugin] weekly heartbeat failed', err) + ); + }, HEARTBEAT_MS); + heartbeatTimer.unref?.(); + } + + return { + stop: async () => { + if (heartbeatTimer) clearInterval(heartbeatTimer); + client.disconnect(); + }, + }; +} + +async function loadManifest() { + const path = resolve(PLUGIN_ROOT, 'plugin.manifest.json'); + try { + const raw = await readFile(path, 'utf8'); + return JSON.parse(raw); + } catch { + return { + plugin_name: '@useorgx/claude-code-plugin', + version: '0.0.0-dev', + manifest_fingerprint: 'dev-placeholder', + signature: '', + }; + } +} + +async function postHeartbeat(baseUrl, opts, manifest) { + const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1/licenses/heartbeat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${opts.apiKey}`, + }, + body: JSON.stringify({ + workspace_id: opts.workspaceId, + plugin_name: manifest.plugin_name, + version: manifest.version, + manifest_fingerprint: manifest.manifest_fingerprint, + signature: manifest.signature, + }), + }); + if (!res.ok) { + throw new Error(`heartbeat ${res.status}`); + } +} + +async function fetchSkillRules(baseUrl, opts) { + try { + const res = await fetch( + `${baseUrl.replace(/\/$/, '')}/api/v1/plan-skills?workspace_id=${encodeURIComponent(opts.workspaceId)}`, + { headers: { Authorization: `Bearer ${opts.apiKey}` } } + ); + if (!res.ok) return []; + const body = await res.json(); + const rules = []; + for (const skill of body.skills ?? []) { + for (const rule of skill.rules ?? []) { + rules.push({ + skill_id: skill.id, + match: { pattern: rule.pattern, on: rule.on }, + dedupe_fingerprint: rule.dedupe_fingerprint, + evidence_kind: rule.evidence_kind, + }); + } + } + return rules; + } catch { + return []; + } +} + +function httpsToWss(url) { + if (url.startsWith('https://')) return 'wss://' + url.slice('https://'.length); + if (url.startsWith('http://')) return 'ws://' + url.slice('http://'.length); + return url; +} diff --git a/lib/peer/peer.test.mjs b/lib/peer/peer.test.mjs new file mode 100644 index 0000000..2e89390 --- /dev/null +++ b/lib/peer/peer.test.mjs @@ -0,0 +1,137 @@ +/** + * ClaudeCodeDriver tests. We swap PATH to prepend a temp directory + * containing a fake `claude` shim, so spawn('claude', …) hits our + * shim. The shim emits a scripted NDJSON stream on stdout based on + * the $CLAUDE_FIXTURE env var. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, chmod, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { ClaudeCodeDriver } from './ClaudeCodeDriver.mjs'; + +let workdir; +let originalPath; + +before(async () => { + workdir = await mkdtemp(join(tmpdir(), 'ccp-peer-test-')); + const fixtures = { + SUCCESS_TRACE: [ + JSON.stringify({ kind: 'tool_call', tool: 'read_file', summary: 'tests/billing.py' }), + JSON.stringify({ + kind: 'file_edit', + path: 'tests/billing.py', + summary: 'replaced class-based with @pytest.mark.parametrize', + }), + JSON.stringify({ kind: 'tokens_used', delta: 3400 }), + JSON.stringify({ kind: 'assistant_completed', tokens_used: 3400 }), + ].join('\n'), + ERROR_TRACE: [ + JSON.stringify({ kind: 'error', message: 'session interrupted', recoverable: true }), + ].join('\n'), + VERSION_ONLY: '', + }; + + const shim = `#!/usr/bin/env node +const fixture = process.env.CLAUDE_FIXTURE; +const traces = ${JSON.stringify(fixtures)}; +if (process.argv.includes('--version')) { + process.stdout.write('claude 1.2.3\\n'); + process.exit(0); +} +const trace = traces[fixture] || ''; +if (!trace) { process.exit(0); } +process.stdout.write(trace + '\\n'); +process.exit(0); +`; + + const shimPath = join(workdir, 'claude'); + await writeFile(shimPath, shim); + await chmod(shimPath, 0o755); + + originalPath = process.env.PATH; + process.env.PATH = `${workdir}:${originalPath}`; +}); + +after(async () => { + process.env.PATH = originalPath; + await rm(workdir, { recursive: true, force: true }); +}); + +async function collect(generator) { + const out = []; + for await (const msg of generator) out.push(msg); + return out; +} + +describe('ClaudeCodeDriver', () => { + it('detect reports installed + authenticated when --version works', async () => { + process.env.CLAUDE_FIXTURE = 'VERSION_ONLY'; + const d = new ClaudeCodeDriver(); + const s = await d.detect(); + assert.equal(s.installed, true); + assert.equal(s.authenticated, true); + assert.match(s.version, /claude 1\.2\.3/); + }); + + it('dispatch yields task.started → task.step → task.completed on a success trace', async () => { + process.env.CLAUDE_FIXTURE = 'SUCCESS_TRACE'; + const d = new ClaudeCodeDriver({ skillRules: async () => [] }); + const msgs = await collect( + d.dispatch( + { title: 'refactor tests', driver: 'claude_code' }, + { run_id: 'r1', idempotency_key: 'k1' } + ) + ); + const kinds = msgs.map((m) => m.kind); + assert.ok(kinds.includes('task.started'), 'expected task.started'); + assert.equal(kinds.filter((k) => k === 'task.step').length, 2); + assert.equal(kinds[kinds.length - 1], 'task.completed'); + const completed = msgs.at(-1); + assert.equal(completed.provider, 'anthropic'); + assert.equal(completed.source_sub_type, 'subscription'); + assert.equal(completed.source_driver, 'claude_code'); + assert.equal(completed.tokens_used, 3400); + }); + + it('dispatch emits task.deviation when a skill rule matches a file_edit', async () => { + process.env.CLAUDE_FIXTURE = 'SUCCESS_TRACE'; + const d = new ClaudeCodeDriver({ + skillRules: async () => [ + { + skill_id: 'parametrize-tests', + match: { pattern: 'parametrize', on: 'file_edit' }, + dedupe_fingerprint: 'parametrize-tests-v1', + evidence_kind: 'test_style_shift', + }, + ], + }); + const msgs = await collect( + d.dispatch( + { title: 'refactor tests', driver: 'claude_code' }, + { run_id: 'r1', idempotency_key: 'k1' } + ) + ); + const deviations = msgs.filter((m) => m.kind === 'task.deviation'); + assert.equal(deviations.length, 1); + assert.equal(deviations[0].skill_id, 'parametrize-tests'); + assert.equal(deviations[0].evidence_kind, 'test_style_shift'); + }); + + it('dispatch emits task.failed on an error event', async () => { + process.env.CLAUDE_FIXTURE = 'ERROR_TRACE'; + const d = new ClaudeCodeDriver({ skillRules: async () => [] }); + const msgs = await collect( + d.dispatch( + { title: 'anything', driver: 'claude_code' }, + { run_id: 'r1', idempotency_key: 'k1' } + ) + ); + const failed = msgs.find((m) => m.kind === 'task.failed'); + assert.ok(failed, 'expected task.failed'); + assert.equal(failed.recoverable, true); + }); +}); diff --git a/package.json b/package.json index 7556428..54ec888 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,29 @@ { "name": "@useorgx/claude-code-plugin", "version": "0.1.1", - "description": "OrgX Claude Code plugin with MCP + runtime telemetry hooks", + "description": "OrgX Claude Code plugin with MCP + runtime telemetry hooks + Sovereign Execution peer sidecar", "type": "module", "private": true, + "bin": { + "orgx-claude-code-peer": "lib/peer/cli.mjs" + }, + "exports": { + ".": "./lib/peer/peer.mjs", + "./peer": "./lib/peer/peer.mjs" + }, "scripts": { "typecheck": "tsc --noEmit", - "test": "node --test tests/*.test.mjs", + "test": "node --test tests/*.test.mjs lib/peer/peer.test.mjs", + "test:peer": "node --test lib/peer/peer.test.mjs", "verify": "node scripts/verify-plugin.mjs", "check": "npm run typecheck && npm run test && npm run verify", "pack": "mkdir -p artifacts && npm pack --pack-destination artifacts", "release:dry-run": "node scripts/release-dry-run.mjs", - "dev:claude": "claude --plugin-dir ." + "dev:claude": "claude --plugin-dir .", + "peer:start": "node lib/peer/cli.mjs" + }, + "dependencies": { + "@useorgx/orgx-gateway-sdk": "github:useorgx/orgx-gateway-sdk" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/plugin.manifest.json b/plugin.manifest.json new file mode 100644 index 0000000..0b1eccf --- /dev/null +++ b/plugin.manifest.json @@ -0,0 +1,9 @@ +{ + "plugin_name": "@useorgx/claude-code-plugin", + "version": "0.1.1", + "manifest_fingerprint": "", + "signature": "", + "capabilities": ["gateway:drive", "plugin:heartbeat"], + "driver_ids": ["claude_code"], + "notes": "fingerprint + signature are populated by an npm prepublish step; dev builds ship unsigned and receive 'degraded' status from the OrgX server's license heartbeat in permissive mode." +}