Skip to content
Closed
2 changes: 1 addition & 1 deletion autoresearch/eval-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function judge(criteria: JudgeCriteria, output: string): boolean {
}

function runCommand(cmd: string, timeout = 30000): string {
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
const localCmd = cmd.replace(/^opencli /, `node dist/src/main.js `);
try {
return execSync(localCmd, {
cwd: PROJECT_ROOT,
Expand Down
2 changes: 1 addition & 1 deletion autoresearch/eval-save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const PROJECT_ROOT = join(__dirname, '..');
/** Run a command, using local dist/main.js instead of global opencli for consistency */
function runCommand(cmd: string, timeout = 30000): string {
// Use local build so tests always run against the current source
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
const localCmd = cmd.replace(/^opencli /, `node dist/src/main.js `);
try {
return execSync(localCmd, {
cwd: PROJECT_ROOT,
Expand Down
203 changes: 203 additions & 0 deletions skills/opencli-repair/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
---
name: opencli-repair
description: Diagnose and fix broken OpenCLI adapters when websites change. Use when an opencli command fails with SELECTOR, EMPTY_RESULT, API_ERROR, or PAGE_CHANGED errors. Reads structured diagnostic output and uses browser automation to discover what changed and patch the adapter.
allowed-tools: Bash(opencli:*), Read, Edit, Write
---

# OpenCLI Repair — AI-Driven Adapter Self-Repair

When an adapter breaks because a website changed its DOM, API, or auth flow, use this skill to diagnose the failure and patch the adapter.

## Prerequisites

```bash
opencli doctor # Verify extension + daemon connectivity
```

## When to Use This Skill

Use when `opencli <site> <command>` fails with errors like:
- **SELECTOR** — element not found (DOM changed)
- **EMPTY_RESULT** — no data returned (API response changed)
- **API_ERROR** / **NETWORK** — endpoint moved or broke
- **PAGE_CHANGED** — page structure no longer matches
- **COMMAND_EXEC** — runtime error in adapter logic
- **TIMEOUT** — page loads differently, adapter waits for wrong thing

## Step 1: Collect Diagnostic Context

Run the failing command with diagnostic mode enabled:

```bash
OPENCLI_DIAGNOSTIC=1 opencli <site> <command> [args...] 2>diagnostic.json
```

This outputs a `RepairContext` JSON between `___OPENCLI_DIAGNOSTIC___` markers in stderr:

```json
{
"error": {
"code": "SELECTOR",
"message": "Could not find element: .old-selector",
"hint": "The page UI may have changed."
},
"adapter": {
"site": "example",
"command": "example/search",
"sourcePath": "/path/to/clis/example/search.ts",
"source": "// full adapter source code"
},
"page": {
"url": "https://example.com/search",
"snapshot": "// DOM snapshot with [N] indices",
"networkRequests": [],
"consoleErrors": []
},
"timestamp": "2025-01-01T00:00:00.000Z"
}
```

**Parse it:**
```bash
# Extract JSON between markers from stderr output
cat diagnostic.json | sed -n '/___OPENCLI_DIAGNOSTIC___/{n;p;}'
```

## Step 2: Analyze the Failure

Read the diagnostic context and the adapter source. Classify the root cause:

| Error Code | Likely Cause | Repair Strategy |
|-----------|-------------|-----------------|
| SELECTOR | DOM restructured, class/id renamed | Explore current DOM → find new selector |
| EMPTY_RESULT | API response schema changed, or data moved | Check network → find new response path |
| API_ERROR | Endpoint URL changed, new params required | Discover new API via network intercept |
| AUTH_REQUIRED | Login flow changed, cookies expired | Walk login flow with operate |
| TIMEOUT | Page loads differently, spinner/lazy-load | Add/update wait conditions |
| PAGE_CHANGED | Major redesign | May need full adapter rewrite |

**Key questions to answer:**
1. What is the adapter trying to do? (Read the `source` field)
2. What did the page look like when it failed? (Read the `snapshot` field)
3. What network requests happened? (Read `networkRequests`)
4. What's the gap between what the adapter expects and what the page provides?

## Step 3: Explore the Current Website

Use `opencli operate` to inspect the live website. **Never use the broken adapter** — it will just fail again.

### DOM changed (SELECTOR errors)

```bash
# Open the page and inspect current DOM
opencli operate open https://example.com/target-page && opencli operate state

# Look for elements that match the adapter's intent
# Compare the snapshot with what the adapter expects
```

### API changed (API_ERROR, EMPTY_RESULT)

```bash
# Open page with network interceptor, then trigger the action manually
opencli operate open https://example.com/target-page && opencli operate state

# Interact to trigger API calls
opencli operate click <N> && opencli operate network

# Inspect specific API response
opencli operate network --detail <index>
```

### Auth changed (AUTH_REQUIRED)

```bash
# Check current auth state
opencli operate open https://example.com && opencli operate state

# If login page: inspect the login form
opencli operate state # Look for login form fields
```

## Step 4: Patch the Adapter

Read the adapter source file and make targeted fixes:

```bash
# Read the adapter
cat <sourcePath from diagnostic>
```

### Common Fixes

**Selector update:**
```typescript
// Before: page.evaluate('document.querySelector(".old-class")...')
// After: page.evaluate('document.querySelector(".new-class")...')
```

**API endpoint change:**
```typescript
// Before: const resp = await page.evaluate(`fetch('/api/v1/old-endpoint')...`)
// After: const resp = await page.evaluate(`fetch('/api/v2/new-endpoint')...`)
```

**Response schema change:**
```typescript
// Before: const items = data.results
// After: const items = data.data.items // API now nests under "data"
```

**Wait condition update:**
```typescript
// Before: await page.waitForSelector('.loading-spinner', { hidden: true })
// After: await page.waitForSelector('[data-loaded="true"]')
```

### Rules for Patching

1. **Make minimal changes** — fix only what's broken, don't refactor
2. **Keep the same output structure** — `columns` and return format must stay compatible
3. **Prefer API over DOM scraping** — if you discover a JSON API during exploration, switch to it
4. **Use `@jackwener/opencli/*` imports only** — never add third-party package imports
5. **Test after patching** — run the command again to verify

## Step 5: Verify the Fix

```bash
# Run the command normally (without diagnostic mode)
opencli <site> <command> [args...]
```

If it still fails, go back to Step 3 and explore further. If the website has fundamentally changed (major redesign, removed feature), report that the adapter needs a full rewrite.

## When to Give Up

Not all failures are repairable with a quick patch:

- **Site requires CAPTCHA** — can't automate this
- **Feature completely removed** — the data no longer exists
- **Major redesign** — needs full adapter rewrite via `opencli-explorer` skill
- **Rate limited / IP blocked** — not an adapter issue

In these cases, clearly communicate the situation to the user rather than making futile patches.

## Example Repair Session

```
1. User runs: opencli zhihu hot
→ Fails: SELECTOR "Could not find element: .HotList-item"

2. AI runs: OPENCLI_DIAGNOSTIC=1 opencli zhihu hot 2>diag.json
→ Gets RepairContext with DOM snapshot showing page loaded

3. AI reads diagnostic: snapshot shows the page loaded but uses ".HotItem" instead of ".HotList-item"

4. AI explores: opencli operate open https://www.zhihu.com/hot && opencli operate state
→ Confirms new class name ".HotItem" with child ".HotItem-content"

5. AI patches: Edit clis/zhihu/hot.ts — replace ".HotList-item" with ".HotItem"

6. AI verifies: opencli zhihu hot
→ Success: returns hot topics
```
100 changes: 100 additions & 0 deletions src/diagnostic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { buildRepairContext, isDiagnosticEnabled, emitDiagnostic, type RepairContext } from './diagnostic.js';
import { CliError, SelectorError, CommandExecutionError } from './errors.js';
import type { InternalCliCommand } from './registry.js';

function makeCmd(overrides: Partial<InternalCliCommand> = {}): InternalCliCommand {
return {
site: 'test-site',
name: 'test-cmd',
description: 'test',
args: [],
...overrides,
} as InternalCliCommand;
}

describe('isDiagnosticEnabled', () => {
const origEnv = process.env.OPENCLI_DIAGNOSTIC;
afterEach(() => {
if (origEnv === undefined) delete process.env.OPENCLI_DIAGNOSTIC;
else process.env.OPENCLI_DIAGNOSTIC = origEnv;
});

it('returns false when env not set', () => {
delete process.env.OPENCLI_DIAGNOSTIC;
expect(isDiagnosticEnabled()).toBe(false);
});

it('returns true when env is "1"', () => {
process.env.OPENCLI_DIAGNOSTIC = '1';
expect(isDiagnosticEnabled()).toBe(true);
});

it('returns false for other values', () => {
process.env.OPENCLI_DIAGNOSTIC = 'true';
expect(isDiagnosticEnabled()).toBe(false);
});
});

describe('buildRepairContext', () => {
it('captures CliError fields', () => {
const err = new SelectorError('.missing-element', 'Element removed');
const ctx = buildRepairContext(err, makeCmd());

expect(ctx.error.code).toBe('SELECTOR');
expect(ctx.error.message).toContain('.missing-element');
expect(ctx.error.hint).toBe('Element removed');
expect(ctx.error.stack).toBeDefined();
expect(ctx.adapter.site).toBe('test-site');
expect(ctx.adapter.command).toBe('test-site/test-cmd');
expect(ctx.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});

it('handles non-CliError errors', () => {
const err = new TypeError('Cannot read property "x" of undefined');
const ctx = buildRepairContext(err, makeCmd());

expect(ctx.error.code).toBe('UNKNOWN');
expect(ctx.error.message).toContain('Cannot read property');
expect(ctx.error.hint).toBeUndefined();
});

it('includes page state when provided', () => {
const pageState: RepairContext['page'] = {
url: 'https://example.com/page',
snapshot: '<div>...</div>',
networkRequests: [{ url: '/api/data', status: 200 }],
consoleErrors: ['Uncaught TypeError'],
};
const ctx = buildRepairContext(new CommandExecutionError('boom'), makeCmd(), pageState);

expect(ctx.page).toEqual(pageState);
});

it('omits page when not provided', () => {
const ctx = buildRepairContext(new Error('boom'), makeCmd());
expect(ctx.page).toBeUndefined();
});
});

describe('emitDiagnostic', () => {
it('writes delimited JSON to stderr', () => {
const writeSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);

const ctx = buildRepairContext(new CommandExecutionError('test error'), makeCmd());
emitDiagnostic(ctx);

const output = writeSpy.mock.calls.map(c => c[0]).join('');
expect(output).toContain('___OPENCLI_DIAGNOSTIC___');
expect(output).toContain('"code":"COMMAND_EXEC"');
expect(output).toContain('"message":"test error"');

// Verify JSON is parseable between markers
const match = output.match(/___OPENCLI_DIAGNOSTIC___\n(.*)\n___OPENCLI_DIAGNOSTIC___/);
expect(match).toBeTruthy();
const parsed = JSON.parse(match![1]);
expect(parsed.error.code).toBe('COMMAND_EXEC');

writeSpy.mockRestore();
});
});
Loading
Loading