Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions scripts/clone-mcp-client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { PLUGIN_VERSION } from './plugin-version.mjs'

export const DEFAULT_CLONE_MCP_URL = 'https://api.clone.is/mcp'
export const CLONE_MCP_CLIENT_NAME = 'clone-loop'

export function cloneMcpEndpoint() {
return process.env.CLONE_MCP_URL || DEFAULT_CLONE_MCP_URL
}

export function cloneMcpClientInfo() {
return {
name: CLONE_MCP_CLIENT_NAME,
version: PLUGIN_VERSION,
}
}

export function parseMcpPayload(text) {
const dataFrames = String(text || '')
.split(/\r?\n\r?\n/)
.map((event) =>
event
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice('data:'.length).trim())
.join('\n')
.trim(),
)
.filter(Boolean)

for (const data of dataFrames) {
try {
return JSON.parse(data)
} catch {}
}

return text ? JSON.parse(text) : null
}

export async function cloneMcpRpc(endpoint, token, method, params = {}, sessionId = '') {
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'X-Clone-API-Key': token,
}
if (sessionId) headers['mcp-session-id'] = sessionId

const res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
})

const text = await res.text()
if (!res.ok) {
throw new Error(`Clone MCP ${method} failed with HTTP ${res.status}${text ? `: ${text.slice(0, 500)}` : ''}`)
}
const payload = text ? parseMcpPayload(text) : null
if (payload?.error) {
throw new Error(`Clone MCP ${method} failed: ${payload.error.message || JSON.stringify(payload.error)}`)
}

return {
sessionId: res.headers.get('mcp-session-id') || sessionId,
payload,
}
}
118 changes: 110 additions & 8 deletions scripts/manage-api-key.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,25 @@ import {
resolveCloneToken,
writePluginConfigToken,
} from './clone-auth.mjs'
import { cloneMcpClientInfo, cloneMcpEndpoint, cloneMcpRpc } from './clone-mcp-client.mjs'

const args = process.argv.slice(2)
const command = args[0] || 'status'
const positionalArgs = args.filter((arg) => arg !== '--connect')
const connectAfterStore = args.includes('--connect')
const command = positionalArgs[0] || 'status'
const CLONE_LOOP_SOURCE_DETAIL = 'clone-loop:claude-code'
let handled = false

function usage() {
console.log(`Clone API key manager

USAGE:
/clone:api-key status
/clone:api-key import-env
/clone:api-key import-env --connect
/clone:api-key set <key>
/clone:api-key set <key> --connect
/clone:api-key connect
/clone:api-key clear

TOKEN PRIORITY:
Expand All @@ -33,6 +41,70 @@ function fail(message) {
process.exit(1)
}

function parseToolBody(payload, toolName) {
const content = payload?.result?.content?.[0]
if (payload?.result?.isError) {
throw new Error(`${toolName} returned an error: ${content?.text || 'unknown MCP tool error'}`)
}
if (content?.type !== 'text' || !content.text) {
throw new Error(`${toolName} returned an empty response`)
}

try {
return JSON.parse(content.text)
} catch {
throw new Error(`${toolName} returned non-JSON response: ${content.text}`)
}
}

function dashboardSessionUrl(sessionId) {
const base = String(process.env.CLONE_DASHBOARD_URL || 'https://clone.is').replace(/\/+$/, '')
return `${base}/console?session=${encodeURIComponent(sessionId)}#sources`
}

async function startDashboardSession(token) {
const endpoint = cloneMcpEndpoint()
const init = await cloneMcpRpc(endpoint, token, 'initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: cloneMcpClientInfo(),
})
if (!init.sessionId) throw new Error('MCP initialize did not return an mcp-session-id header.')

const started = await cloneMcpRpc(
endpoint,
token,
'tools/call',
{
name: 'start_session',
arguments: {
source: 'agent',
source_detail: CLONE_LOOP_SOURCE_DETAIL,
},
},
init.sessionId,
)
const body = parseToolBody(started.payload, 'start_session')
const cloneSessionId = body.session_id || body.sessionId
if (!cloneSessionId) throw new Error('start_session did not return a Clone session id.')
return cloneSessionId
}

async function connectToDashboard() {
const resolved = resolveCloneToken()
if (resolved.isDemo) {
fail('Private Clone API key is required. Create a key in Clone Dashboard, then run /clone:api-key import-env --connect or /clone:api-key set <key> --connect.')
}

console.log('Connecting Clone Loop to Clone Dashboard via MCP...')
console.log(`Source: ${resolved.source}`)
console.log(`Token: ${resolved.masked}`)
const sessionId = await startDashboardSession(resolved.token)
console.log('Connected Clone Loop to Clone Dashboard.')
console.log(`Clone session: ${sessionId}`)
console.log(`Dashboard: ${dashboardSessionUrl(sessionId)}`)
}

function printResolvedToken(prefix = '') {
const resolved = resolveCloneToken()
if (prefix) console.log(prefix)
Expand All @@ -57,34 +129,64 @@ if (command === '-h' || command === '--help' || command === 'help') {
}

if (command === 'status') {
if (connectAfterStore) fail('Usage: /clone:api-key status')
if (args.length !== 1 && args.length !== 0) fail('Usage: /clone:api-key status')
printResolvedToken()
process.exit(0)
}

if (command === 'import-env') {
if (args.length !== 1) fail('Usage: /clone:api-key import-env')
handled = true
if (positionalArgs.length !== 1) fail('Usage: /clone:api-key import-env [--connect]')
const token = String(process.env.CLONE_API_TOKEN || '').trim()
if (!token) fail('CLONE_API_TOKEN is not set in this agent process.')

writePluginConfigToken(token)
console.log('Stored Clone API key from CLONE_API_TOKEN.')
console.log(`Token: ${maskToken(token)}`)
console.log(`Plugin config: ${authFilePath()}`)
process.exit(0)
if (connectAfterStore) {
try {
await connectToDashboard()
} catch (err) {
fail(err instanceof Error ? err.message : String(err))
}
} else {
process.exit(0)
}
}

if (command === 'set') {
if (args.length !== 2 || !String(args[1] || '').trim()) {
fail('Usage: /clone:api-key set <key>')
handled = true
if (positionalArgs.length !== 2 || !String(positionalArgs[1] || '').trim()) {
fail('Usage: /clone:api-key set <key> [--connect]')
}

const token = String(args[1]).trim()
const token = String(positionalArgs[1]).trim()
writePluginConfigToken(token)
console.log('Stored Clone API key in plugin config.')
console.log(`Token: ${maskToken(token)}`)
console.log(`Plugin config: ${authFilePath()}`)
console.log('Prefer import-env when possible because direct slash-command arguments may remain in the transcript.')
process.exit(0)
if (connectAfterStore) {
try {
await connectToDashboard()
} catch (err) {
fail(err instanceof Error ? err.message : String(err))
}
} else {
process.exit(0)
}
}

if (command === 'connect') {
handled = true
if (positionalArgs.length !== 1) fail('Usage: /clone:api-key connect')
try {
await connectToDashboard()
} catch (err) {
fail(err instanceof Error ? err.message : String(err))
}
}

if (command === 'clear') {
Expand All @@ -95,4 +197,4 @@ if (command === 'clear') {
process.exit(0)
}

fail(`Unknown /clone:api-key command: ${command}`)
if (!handled) fail(`Unknown /clone:api-key command: ${command}`)
27 changes: 27 additions & 0 deletions scripts/plugin-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const DEFAULT_VERSION = '0.0.0'
const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
const MANIFEST_PATHS = [
join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'),
join(PLUGIN_ROOT, '.codex-plugin', 'plugin.json'),
]

export function readPluginVersion() {
for (const manifestPath of MANIFEST_PATHS) {
if (!existsSync(manifestPath)) continue

try {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
if (typeof manifest.version === 'string' && manifest.version.trim()) {
return manifest.version
}
} catch {}
}

return DEFAULT_VERSION
}

export const PLUGIN_VERSION = readPluginVersion()
Loading