Skip to content
Open
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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Open your agent and run:

```text
/clone:api-key status
/clone:api-key login
/clone:interview "Clarify the feature before coding" --mode deep
/clone:loop "Run tests and fix any failures" --max-iterations 5
```
Expand All @@ -104,8 +105,7 @@ Cancel anytime with `/clone:cancel-loop`.

> [!NOTE]
> Clone ships with a public **demo API key** so you can try it in seconds.
> For private memory and your own prediction quality, set `CLONE_API_TOKEN`
> and run `/clone:api-key import-env`.
> For private memory and your own prediction quality, run `/clone:api-key login`.

To update later: `claude plugin marketplace update clone-loop && claude plugin update clone-labs@clone-loop`.

Expand All @@ -116,7 +116,7 @@ To update later: `claude plugin marketplace update clone-loop && claude plugin u
| `/clone:interview "<topic>" [options]` | Clarify a goal into an executable plan. |
| `/clone:loop "<task>" [options]` | Start a loop. |
| `/clone:cancel-loop` | Cancel the active loop. |
| `/clone:api-key status\|import-env\|set\|clear` | Manage your Clone API key. |
| `/clone:api-key login\|status\|import-env\|set\|clear` | Manage your Clone API key. |
| `/clone:help` | Show command help. |

### Options for `/clone:interview`
Expand Down Expand Up @@ -226,12 +226,14 @@ claude
```

```text
/clone:api-key login
/clone:api-key import-env
/clone:api-key status
```

Prefer `import-env` — `/clone:api-key set <key>` works but the key may
linger in your transcript.
Prefer `login` for browser-based setup. `import-env` is useful when
`CLONE_API_TOKEN` is already set. `/clone:api-key set <key>` works but the
key may linger in your transcript.

> [!WARNING]
> The demo key is public and shared. Don't use it with private memory.
Expand Down
3 changes: 2 additions & 1 deletion commands/api-key.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: "Manage the Clone API key used by Clone Loop"
argument-hint: "status|import-env|set <key>|clear"
argument-hint: "login|status|import-env|set <key>|clear"
allowed-tools: Bash(node *manage-api-key.mjs*)
hide-from-slash-command-tool: "true"
---
Expand All @@ -16,6 +16,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/manage-api-key.mjs" $ARGUMENTS
Supported subcommands:

- `status`: show the effective token source and masked token.
- `login`: open clone.is, sign in, create a private API key, and save it into plugin data.
- `import-env`: save the current `CLONE_API_TOKEN` value into Claude plugin data.
- `set <key>`: save a key directly into Claude plugin data. Prefer `import-env` because direct command arguments may remain in the transcript.
- `clear`: remove the saved plugin config key.
Expand Down
3 changes: 2 additions & 1 deletion commands/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,13 @@ Manage the Clone API key used by Clone Loop.

```bash
/clone:api-key status
/clone:api-key login
/clone:api-key import-env
/clone:api-key clear
```

Token priority is nonblank `CLONE_API_TOKEN`, then plugin config, then demo fallback.
Prefer `import-env` over `set <key>` because direct slash-command arguments can
Prefer `login` for browser-based setup. Use `import-env` over `set <key>` because direct slash-command arguments can
remain in the transcript.

### /clone:cancel-loop
Expand Down
165 changes: 161 additions & 4 deletions scripts/manage-api-key.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#!/usr/bin/env node

import { randomBytes } from 'node:crypto'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import {
authFilePath,
clearPluginConfigToken,
Expand All @@ -12,8 +15,9 @@ import {
import { cloneMcpClientInfo, cloneMcpEndpoint, cloneMcpRpc } from './clone-mcp-client.mjs'

const args = process.argv.slice(2)
const positionalArgs = args.filter((arg) => arg !== '--connect')
const positionalArgs = args.filter((arg) => !['--connect', '--no-open'].includes(arg))
const connectAfterStore = args.includes('--connect')
const noOpen = args.includes('--no-open')
const command = positionalArgs[0] || 'status'
let handled = false

Expand Down Expand Up @@ -44,6 +48,8 @@ function usage() {

USAGE:
/clone:api-key status
/clone:api-key login
/clone:api-key login --no-open
/clone:api-key import-env
/clone:api-key import-env --connect
/clone:api-key set <key>
Expand Down Expand Up @@ -83,6 +89,10 @@ function dashboardSessionUrl(sessionId) {
return `${base}/console?session=${encodeURIComponent(sessionId)}#sources`
}

function dashboardBaseUrl() {
return String(process.env.CLONE_DASHBOARD_URL || 'https://clone.is').replace(/\/+$/, '')
}

async function startDashboardSession(token) {
const endpoint = cloneMcpEndpoint()
const init = await cloneMcpRpc(endpoint, token, 'initialize', {
Expand Down Expand Up @@ -111,10 +121,9 @@ async function startDashboardSession(token) {
return cloneSessionId
}

async function connectToDashboard() {
const resolved = resolveCloneToken()
async function connectResolvedToDashboard(resolved) {
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.')
fail('Private Clone API key is required. Run /clone:api-key login, or create a key in Clone Dashboard and run /clone:api-key import-env --connect or /clone:api-key set <key> --connect.')
}

console.log('Connecting Clone Loop to Clone Dashboard via MCP...')
Expand All @@ -126,6 +135,149 @@ async function connectToDashboard() {
console.log(`Dashboard: ${dashboardSessionUrl(sessionId)}`)
}

async function connectToDashboard() {
await connectResolvedToDashboard(resolveCloneToken())
}

function randomState() {
return randomBytes(24).toString('base64url')
}

function openBrowser(url) {
const platform = process.platform
const command =
platform === 'win32'
? { file: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }
: platform === 'darwin'
? { file: 'open', args: [url] }
: { file: 'xdg-open', args: [url] }
return new Promise((resolve, reject) => {
const child = spawn(command.file, command.args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
})
child.once('error', reject)
child.once('spawn', () => {
child.unref()
resolve()
})
})
}

async function startCallbackServer(expectedState) {
let resolveReady
let rejectReady
const ready = new Promise((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
})
let resolveCode
let rejectCode
const codePromise = new Promise((resolve, reject) => {
resolveCode = resolve
rejectCode = reject
})
const server = createServer((req, res) => {
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1')
if (requestUrl.pathname !== '/callback') {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('Not found')
return
}

const code = requestUrl.searchParams.get('code') || ''
const state = requestUrl.searchParams.get('state') || ''
if (!code || state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('Clone Loop login failed. You can close this tab and retry.')
rejectCode(new Error('Clone Loop login callback state mismatch.'))
return
}

res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end('<!doctype html><title>Clone Loop connected</title><p>Clone Loop connected. You can close this tab.</p>')
resolveCode(code)
})
server.once('error', (err) => {
rejectReady(err)
rejectCode(err)
})
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
rejectCode(new Error('Could not start Clone Loop login callback server.'))
server.close()
return
}
resolveReady({
redirectUri: `http://127.0.0.1:${address.port}/callback`,
codePromise,
close: () => new Promise((resolveClose) => server.close(resolveClose)),
})
})
return ready
}
Comment on lines +168 to +220
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Hanging Promise & Missing Timeout in Callback Server

There are two issues in startCallbackServer:

  1. Hanging Promise on Invalid Address: If address is null or a string, rejectCode is called, but rejectReady is never called. Since loginWithClone awaits startCallbackServer (which returns the ready promise), the command will hang indefinitely because ready is never resolved or rejected.
  2. Missing Timeout: If the user starts the login flow but never completes it in the browser, the callback server will run indefinitely, hanging the CLI process. Adding a standard 5-minute timeout ensures the process exits gracefully if inactive.
async function startCallbackServer(expectedState) {
  let resolveReady
  let rejectReady
  const ready = new Promise((resolve, reject) => {
    resolveReady = resolve
    rejectReady = reject
  })
  let resolveCode
  let rejectCode
  const codePromise = new Promise((resolve, reject) => {
    resolveCode = resolve
    rejectCode = reject
  })

  const timeoutId = setTimeout(() => {
    rejectCode(new Error('Clone Loop login timed out after 5 minutes.'))
  }, 300000)

  const server = createServer((req, res) => {
    const requestUrl = new URL(req.url || '/', 'http://127.0.0.1')
    if (requestUrl.pathname !== '/callback') {
      res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
      res.end('Not found')
      return
    }

    const code = requestUrl.searchParams.get('code') || ''
    const state = requestUrl.searchParams.get('state') || ''
    if (!code || state !== expectedState) {
      res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
      res.end('Clone Loop login failed. You can close this tab and retry.')
      rejectCode(new Error('Clone Loop login callback state mismatch.'))
      return
    }

    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
    res.end('<!doctype html><title>Clone Loop connected</title><p>Clone Loop connected. You can close this tab.</p>')
    clearTimeout(timeoutId)
    resolveCode(code)
  })
  server.once('error', (err) => {
    clearTimeout(timeoutId)
    rejectReady(err)
    rejectCode(err)
  })
  server.listen(0, '127.0.0.1', () => {
    const address = server.address()
    if (!address || typeof address === 'string') {
      clearTimeout(timeoutId)
      const err = new Error('Could not start Clone Loop login callback server.')
      rejectReady(err)
      rejectCode(err)
      server.close()
      return
    }
    resolveReady({
      redirectUri: `http://127.0.0.1:${address.port}/callback`,
      codePromise,
      close: () => {
        clearTimeout(timeoutId)
        return new Promise((resolveClose) => server.close(resolveClose))
      },
    })
  })
  return ready
}


async function exchangeCloneLoopCode({ code, state }) {
const res = await fetch(`${dashboardBaseUrl()}/api/auth/clone-loop/connect/exchange/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(body?.error || `Clone Loop login exchange failed with HTTP ${res.status}`)
}
const token = String(body?.key || '').trim()
if (!token) throw new Error('Clone Loop login exchange did not return an API key.')
return token
}

async function loginWithClone() {
if (connectAfterStore) fail('Usage: /clone:api-key login [--no-open]')
if (positionalArgs.length !== 1) fail('Usage: /clone:api-key login [--no-open]')

const state = randomState()
const callback = await startCallbackServer(state)
const authorizeUrl = new URL('/clone-loop/connect', dashboardBaseUrl())
authorizeUrl.searchParams.set('redirect_uri', callback.redirectUri)
authorizeUrl.searchParams.set('state', state)
authorizeUrl.searchParams.set('agent_id', cloneLoopAgentId())

console.log(`Authorize: ${authorizeUrl.toString()}`)
if (!noOpen) {
try {
await openBrowser(authorizeUrl.toString())
} catch (err) {
console.log(`Open this URL in your browser: ${authorizeUrl.toString()}`)
console.log(`Browser launch failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
console.log('Waiting for Clone browser authorization...')

try {
const code = await callback.codePromise
await callback.close()
const token = await exchangeCloneLoopCode({ code, state })
writePluginConfigToken(token)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Warn when CLONE_API_TOKEN shadows the login token

When the agent process already has a nonblank CLONE_API_TOKEN, this stores the OAuth key in plugin config and then verifies that new key directly, but subsequent Clone Loop commands still resolve the environment token first via resolveCloneToken(). In that environment /clone:api-key login appears to switch the user to the newly authorized private key while later loops continue using the old environment key, so the command should fail/warn or otherwise make the effective-token precedence clear after login.

Useful? React with 👍 / 👎.

console.log('Stored Clone API key from Clone OAuth login.')
console.log(`Token: ${maskToken(token)}`)
console.log(`Plugin config: ${authFilePath()}`)
await connectResolvedToDashboard({
token,
source: 'plugin config',
masked: maskToken(token),
isDemo: false,
})
} catch (err) {
try {
await callback.close()
} catch {}
fail(err instanceof Error ? err.message : String(err))
}
Comment on lines +241 to +278
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Unhandled Promise Rejection on Server Startup or Browser Launch Failure

Currently, startCallbackServer is called outside of the try...catch block. If the callback server fails to start (e.g., due to port binding issues or invalid address info), the promise will reject and throw an unhandled exception, crashing the CLI process with a full stack trace instead of exiting cleanly.

Wrapping the entire initialization and execution flow in a single try...catch block ensures all errors (including server startup and browser launch failures) are caught and handled gracefully via fail().

  let callback
  try {
    const state = randomState()
    callback = await startCallbackServer(state)
    const authorizeUrl = new URL('/clone-loop/connect', dashboardBaseUrl())
    authorizeUrl.searchParams.set('redirect_uri', callback.redirectUri)
    authorizeUrl.searchParams.set('state', state)
    authorizeUrl.searchParams.set('agent_id', cloneLoopAgentId())

    console.log(`Authorize: ${authorizeUrl.toString()}`)
    if (!noOpen) {
      try {
        await openBrowser(authorizeUrl.toString())
      } catch (err) {
        console.log(`Open this URL in your browser: ${authorizeUrl.toString()}`)
        console.log(`Browser launch failed: ${err instanceof Error ? err.message : String(err)}`)
      }
    }
    console.log('Waiting for Clone browser authorization...')

    const code = await callback.codePromise
    await callback.close()
    const token = await exchangeCloneLoopCode({ code, state })
    writePluginConfigToken(token)
    console.log('Stored Clone API key from Clone OAuth login.')
    console.log(`Token: ${maskToken(token)}`)
    console.log(`Plugin config: ${authFilePath()}`)
    await connectResolvedToDashboard({
      token,
      source: 'plugin config',
      masked: maskToken(token),
      isDemo: false,
    })
  } catch (err) {
    if (callback) {
      try {
        await callback.close()
      } catch {}
    }
    fail(err instanceof Error ? err.message : String(err))
  }

}

function printResolvedToken(prefix = '') {
const resolved = resolveCloneToken()
if (prefix) console.log(prefix)
Expand Down Expand Up @@ -156,6 +308,11 @@ if (command === 'status') {
process.exit(0)
}

if (command === 'login') {
handled = true
await loginWithClone()
}

if (command === 'import-env') {
handled = true
if (positionalArgs.length !== 1) fail('Usage: /clone:api-key import-env [--connect]')
Expand Down
4 changes: 3 additions & 1 deletion skills/clone-api-key/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Manage the Clone API key used by Clone Loop.

# Clone API Key

Use this skill when the user asks to check, import, set, or clear the Clone API key.
Use this skill when the user asks to log in, check, import, set, or clear the Clone API key.

Run:

Expand All @@ -16,6 +16,8 @@ node "${PLUGIN_ROOT}/scripts/manage-api-key.mjs" $ARGUMENTS
Supported subcommands:

- `status`
- `login`
- `login --no-open`
- `import-env`
- `import-env --connect`
- `set <key>`
Expand Down
2 changes: 1 addition & 1 deletion skills/clone-help/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Explain the Codex Clone Loop commands:
- `clone-setup`: enable Codex plugin hooks and show API key status.
- `clone-interview "<topic>" --mode deep`: clarify requirements into a local spec before coding, using Clone-predicted answers when confidence is high.
- `clone-loop "<task>" --max-iterations 5`: start a confidence-gated loop.
- `clone-api-key status|import-env|set <key>|clear`: manage the Clone API key.
- `clone-api-key login|status|import-env|set <key>|clear`: manage the Clone API key.
- `clone-cancel-loop`: cancel the active loop.

Mention that Clone Interview v1 is plugin-only for question generation: it inspects repo facts, asks one user-judgment question at a time, uses Clone MCP to predict high-confidence answers, escalates low-confidence questions to the user, and writes `.claude/clone-interview.local.md` or the user's `--output` path.
Expand Down
Loading