<!--
CLI Orchestrator Fallback
Part of the Bles Software agent skills repository:
https://www.bles-software.com/claude-skills

Install for Claude Code:
  mkdir -p ~/.claude/skills/cli-orchestrator-fallback
  mv cli-orchestrator-fallback.md ~/.claude/skills/cli-orchestrator-fallback/SKILL.md

Install for Codex:
  mkdir -p ~/.codex/skills/cli-orchestrator-fallback
  mv cli-orchestrator-fallback.md ~/.codex/skills/cli-orchestrator-fallback/SKILL.md

For Gemini CLI and other agents, drop the body of this file into the
project instruction file the agent already reads.
-->

---
name: cli-orchestrator-fallback
description: Pattern for invoking AI CLIs (claude -p, codex exec) from Node.js with primary/fallback orchestration. Use this when integrating local Claude Code or OpenAI Codex CLIs into a production service that needs structured JSON output, schema validation, and graceful degradation if one provider is down.
---

# CLI Orchestrator with Fallback (Claude → Codex)

## When to use this skill

Use this when a Node.js service needs to call an LLM for **structured JSON output**, the user wants to use **CLI-mode tools with their existing accounts** (Claude Max, ChatGPT Pro/Enterprise) instead of API keys, and the service must keep working if one CLI is down.

Examples:
- "Use the Claude Max account on this server to generate plan JSON, fall back to GPT-5 via Codex if Claude is down."
- "Add a CLI-based AI orchestrator to my backend that doesn't require an API key."
- "We need primary/fallback between two LLM providers."

## What you get

A `runWithFallback({ prompt, jsonSchema, requiredFields, preferred, label, timeoutMs })` function that:
1. Tries `claude -p --model opus --effort xhigh` first (Claude Max OAuth, free if subscribed)
2. Falls back to `codex exec -c model="gpt-5.4" -c model_reasoning_effort="xhigh"` (ChatGPT account)
3. Auto-parses fenced JSON, validates required fields, returns `{ provider, elapsedMs, plan }`

## The 6 critical gotchas

These are non-obvious failures discovered during integration. **Bake all of them in** or the orchestrator will silently fail.

### 1. Strip `ANTHROPIC_API_KEY` from env
If `ANTHROPIC_API_KEY` is set (even to a stale or unrelated key), `claude -p` uses it instead of the Claude Max OAuth session in `~/.claude/.credentials.json` and returns "Invalid API key". Always:
```javascript
const env = { ...process.env };
delete env.ANTHROPIC_API_KEY;
delete env.CLAUDECODE;
delete env.CLAUDE_CODE_ENTRYPOINT;
delete env.CLAUDE_CODE_EXECPATH;
delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
```

### 2. Spawn from a fresh tmp cwd to bypass auto-mode classifier
Claude Code auto-discovers `CLAUDE.md` from cwd upward AND has an "auto-mode classifier" that decides if a prompt is a "real dev task". For trivial test prompts (e.g. `"Return JSON: {hello: world}"`), it returns `{"ok": true, "reason": "Not a dev task"}` instead of obeying.

Two fixes:
- Use **realistic prompts** (full system prompt + meaningful user content). Auto-mode passes these through.
- Spawn from a fresh `mkdtempSync()` cwd per call so no `CLAUDE.md` is found.

```javascript
const ephemeralCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'orch-'));
spawnSync('claude', args, { cwd: ephemeralCwd, ... });
fs.rmSync(ephemeralCwd, { recursive: true, force: true });
```

### 3. Append the NVM bin to PATH so PM2 can find `claude` and `codex`
PM2 typically runs Node v20 from `/usr/bin/node`, but `claude` and `codex` are installed under `~/.nvm/versions/node/v22.22.0/bin/`. Always:
```javascript
env.PATH = `${env.PATH}:~/.nvm/versions/node/v22.22.0/bin`;
```

### 4. Codex requires OpenAI strict-mode schemas
`codex exec --output-schema FILE` fails with `'additionalProperties' is required to be supplied and to be false` unless every `type: 'object'` in the schema has `additionalProperties: false` AND every property in `properties` is also in `required`. Auto-normalize schemas before passing to Codex:
```javascript
function normalizeSchemaForOpenAIStrict(s) {
  if (!s || typeof s !== 'object') return s;
  const out = { ...s };
  if (out.type === 'object' && out.properties) {
    for (const [k, v] of Object.entries(out.properties)) {
      out.properties[k] = normalizeSchemaForOpenAIStrict(v);
      if (!out.required?.includes(k)) out.required = [...(out.required || []), k];
    }
    if (out.additionalProperties === undefined) out.additionalProperties = false;
  } else if (out.type === 'array' && out.items) {
    out.items = normalizeSchemaForOpenAIStrict(out.items);
  }
  return out;
}
```

### 5. Use `--ephemeral` on Codex to avoid session artifacts
Without `--ephemeral`, every `codex exec` call leaves a session file in `~/.codex/sessions/`. Always include `--ephemeral` for headless service usage. Also pass `--skip-git-repo-check -s read-only --color never` for clean stdout.

### 6. Defensive JSON parsing (try direct → strip fences → slice braces)
Both CLIs occasionally wrap JSON in markdown fences despite explicit "no fences" instructions. Always parse defensively:
```javascript
function parseJsonResponse(raw) {
  let trimmed = String(raw).trim();
  if (trimmed.startsWith('```')) trimmed = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '');
  try { return JSON.parse(trimmed); } catch (_) {}
  // Last-resort: find first { or [ and matching close
  const open = Math.min(...['{', '['].map(c => trimmed.indexOf(c)).filter(i => i >= 0));
  const close = trimmed.lastIndexOf(trimmed[open] === '{' ? '}' : ']');
  return JSON.parse(trimmed.slice(open, close + 1));
}
```

## The argument shapes

**Claude (primary):**
```bash
claude -p \
  --output-format text \
  --no-session-persistence \
  --model opus \
  --effort xhigh \
  --disable-slash-commands \
  --system-prompt "<your system prompt here>"
# stdin = user prompt
```

**Codex (fallback):**
```bash
codex exec \
  -c 'model="gpt-5.4"' \
  -c 'model_reasoning_effort="xhigh"' \
  --ephemeral \
  --skip-git-repo-check \
  -s read-only \
  --color never \
  --output-schema /tmp/schema.json
# stdin = user prompt (system prompt is folded in here too)
```

## Health check

Always log a startup health check so on-call knows if you're running on a single-CLI fallback:
```javascript
function healthCheck() {
  const env = buildEnv();
  return {
    claude: { ok: spawnSync('claude', ['--version'], { env, encoding: 'utf-8' }).status === 0 },
    codex:  { ok: spawnSync('codex',  ['--version'], { env, encoding: 'utf-8' }).status === 0 },
  };
}
```

## Reference implementation

`/opt/autoposter/src/cliOrchestrator.js` (306 lines) — production reference for the autoposter "Reels" feature.
`/opt/autoposter/scripts/reels-smoke.js` — end-to-end smoke test you can copy.

## Companion pattern: streaming progress markers from a long-running subprocess

For pipelines that take minutes (image gen → video → TTS → lip sync → render), have the subprocess emit single-line markers like:
```
IMAGE: /path/to/image.png
VIDEO: /path/to/video.mp4
TTS: /path/to/audio.mp3
LIPSYNC: /path/to/synced.mp4
SUBTITLE: /path/to/subbed.mp4
FINAL: /path/to/final.mp4
```

Then in Node, line-buffer stdout and regex-match these markers to update job status in your DB:
```javascript
proc.stdout.on('data', (chunk) => {
  buffer += chunk.toString('utf-8');
  let idx;
  while ((idx = buffer.indexOf('\n')) !== -1) {
    const line = buffer.slice(0, idx).trim();
    buffer = buffer.slice(idx + 1);
    for (const marker of PROGRESS_MARKERS) {
      if (marker.regex.test(line)) {
        await updateJob(jobId, { currentStep: marker.label, lastLine: line });
        break;
      }
    }
    if (line.startsWith('FINAL:')) finalPath = line.slice(6).trim();
  }
});
```

This is much simpler than SSE and works through any process boundary.
