File size: 10,627 Bytes
9584c6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
/**
 * Coding Agent Extension for OpenClaw
 *
 * Integrates Claude Code via ACP (Agent Client Protocol) using acpx as the
 * backend. All Claude Code invocations go through ACP for lifecycle management.
 *
 * Tools:
 *   - claude_code: Spawn Claude Code via ACP to autonomously complete coding tasks
 *   - hf_space_status: Check Space health/stage
 *   - hf_restart_space: Restart a Space
 */

import { execSync } from "node:child_process";
import { existsSync } from "node:fs";

// ── Types ────────────────────────────────────────────────────────────────────

interface PluginApi {
  pluginConfig: Record<string, unknown>;
  logger: { info: (...a: unknown[]) => void; warn: (...a: unknown[]) => void; error: (...a: unknown[]) => void };
  registerTool?: (def: ToolDef) => void;
}

interface ToolDef {
  name: string;
  description: string;
  label?: string;
  parameters: Record<string, unknown>;
  execute: (toolCallId: string, params: Record<string, unknown>) => Promise<ToolResult>;
}

interface ToolResult {
  content: Array<{ type: "text"; text: string }>;
}

// ── Helpers ──────────────────────────────────────────────────────────────────

const WORK_DIR = "/tmp/claude-workspace";
const CLAUDE_TIMEOUT = 300_000; // 5 minutes

function text(t: string): ToolResult {
  return { content: [{ type: "text", text: t }] };
}

function asStr(v: unknown, fallback = ""): string {
  return typeof v === "string" ? v : fallback;
}

// ── Git helpers ──────────────────────────────────────────────────────────────

function ensureRepo(targetSpace: string, hfToken: string): void {
  const repoUrl = `https://user:${hfToken}@huggingface.co/spaces/${targetSpace}`;

  if (existsSync(`${WORK_DIR}/.git`)) {
    // Reset to latest remote state (clean slate for each task)
    try {
      execSync("git fetch origin && git reset --hard origin/main", {
        cwd: WORK_DIR,
        timeout: 30_000,
        stdio: "pipe",
      });
      return;
    } catch {
      // If fetch/reset fails, re-clone
      execSync(`rm -rf ${WORK_DIR}`, { stdio: "pipe" });
    }
  }

  // Fresh clone
  if (existsSync(WORK_DIR)) {
    execSync(`rm -rf ${WORK_DIR}`, { stdio: "pipe" });
  }
  execSync(`git clone --depth 20 ${repoUrl} ${WORK_DIR}`, {
    timeout: 60_000,
    stdio: "pipe",
  });
  execSync('git config user.name "Claude Code"', { cwd: WORK_DIR, stdio: "pipe" });
  execSync('git config user.email "claude-code@huggingclaw"', { cwd: WORK_DIR, stdio: "pipe" });
}

function pushChanges(summary: string): string {
  const status = execSync("git status --porcelain", {
    cwd: WORK_DIR,
    encoding: "utf-8",
  }).trim();

  if (!status) return "No files changed.";

  execSync("git add -A", { cwd: WORK_DIR, stdio: "pipe" });
  // Use a safe commit message
  const msg = summary.slice(0, 72).replace(/"/g, '\\"');
  execSync(`git commit -m "Claude Code: ${msg}"`, { cwd: WORK_DIR, stdio: "pipe" });
  execSync("git push", { cwd: WORK_DIR, timeout: 60_000, stdio: "pipe" });

  return `Pushed changes:\n${status}`;
}

// ── Plugin ───────────────────────────────────────────────────────────────────

const plugin = {
  id: "coding-agent",
  name: "Coding Agent",
  description: "Claude Code via ACP for autonomous coding on HF Spaces",

  register(api: PluginApi) {
    const cfg = (api.pluginConfig as Record<string, unknown>) || {};
    const targetSpace = asStr(cfg.targetSpace) || process.env.CODING_AGENT_TARGET_SPACE || "";
    const hfToken = asStr(cfg.hfToken) || process.env.HF_TOKEN || "";
    const zaiApiKey = asStr(cfg.zaiApiKey) || process.env.ZAI_API_KEY || process.env.ZHIPU_API_KEY || "";

    api.logger.info(`coding-agent: targetSpace=${targetSpace}, zaiKey=${zaiApiKey ? "set" : "missing"}`);

    if (!api.registerTool) {
      api.logger.warn("coding-agent: registerTool unavailable β€” no tools registered");
      return;
    }

    // ── Tool: claude_code ───────────────────────────────────────────────────
    api.registerTool({
      name: "claude_code",
      label: "Run Claude Code",
      description:
        "Run Claude Code via ACP (acpx) to autonomously complete a coding task on the target HF Space. " +
        "Claude Code clones the Space repo, analyzes code, makes changes, and pushes them back. " +
        "Use for: debugging, fixing errors, adding features, refactoring.",
      parameters: {
        type: "object",
        required: ["task"],
        properties: {
          task: {
            type: "string",
            description: "Detailed coding task description. Be specific about what to fix/change and why.",
          },
          auto_push: {
            type: "boolean",
            description: "Automatically push changes after Claude Code finishes (default: true)",
          },
        },
      },
      async execute(_id, params) {
        const task = asStr(params.task);
        const autoPush = params.auto_push !== false;

        if (!targetSpace) return text("Error: no targetSpace configured");
        if (!hfToken) return text("Error: no HF token configured");
        if (!zaiApiKey) return text("Error: no ZAI_API_KEY or ZHIPU_API_KEY configured for Claude Code backend");

        try {
          // 1. Clone / reset to latest
          api.logger.info(`coding-agent: Syncing repo ${targetSpace}...`);
          ensureRepo(targetSpace, hfToken);

          // 2. Run Claude Code via ACP (acpx)
          api.logger.info(`coding-agent: Running Claude Code via ACP: ${task.slice(0, 100)}...`);
          const claudeEnv: Record<string, string> = {
            ...(process.env as Record<string, string>),
            ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
            ANTHROPIC_AUTH_TOKEN: zaiApiKey,
            ANTHROPIC_DEFAULT_OPUS_MODEL: "GLM-4.7",
            ANTHROPIC_DEFAULT_SONNET_MODEL: "GLM-4.7",
            ANTHROPIC_DEFAULT_HAIKU_MODEL: "GLM-4.5-Air",
            CI: "true",
          };

          const output = execSync(
            `acpx claude ${JSON.stringify(task)}`,
            {
              cwd: WORK_DIR,
              env: claudeEnv,
              timeout: CLAUDE_TIMEOUT,
              encoding: "utf-8",
              maxBuffer: 10 * 1024 * 1024, // 10MB
            },
          );

          // 3. Push changes if requested
          let pushResult = "Auto-push disabled.";
          if (autoPush) {
            try {
              pushResult = pushChanges(task);
            } catch (e: unknown) {
              pushResult = `Push failed: ${e instanceof Error ? e.message : e}`;
            }
          }

          return text(
            `=== Claude Code Output ===\n${output}\n\n=== Changes ===\n${pushResult}`,
          );
        } catch (e: unknown) {
          const msg = e instanceof Error ? e.message : String(e);
          return text(`Claude Code failed:\n${msg.slice(0, 3000)}`);
        }
      },
    });

    // ── Tool: hf_space_status ───────────────────────────────────────────────
    api.registerTool({
      name: "hf_space_status",
      label: "Check Space Health",
      description:
        "Check the current status of the target HuggingFace Space. " +
        "Returns: stage (BUILDING, APP_STARTING, RUNNING, RUNTIME_ERROR, BUILD_ERROR, NO_APP_FILE).",
      parameters: {
        type: "object",
        properties: {},
      },
      async execute() {
        if (!targetSpace) return text("Error: no target space configured");
        try {
          const resp = await fetch(`https://huggingface.co/api/spaces/${targetSpace}`, {
            headers: { Authorization: `Bearer ${hfToken}` },
          });
          if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
          const data = (await resp.json()) as Record<string, unknown>;
          const runtime = (data.runtime as Record<string, unknown>) || {};
          const stage = runtime.stage || "unknown";
          const hardware = runtime.hardware || "unknown";

          // Try hitting the Space URL
          let apiStatus = "not checked";
          try {
            const spaceUrl = `https://${targetSpace.replace("/", "-").toLowerCase()}.hf.space`;
            const probe = await fetch(spaceUrl, { signal: AbortSignal.timeout(8000) });
            apiStatus = probe.ok ? `reachable (${probe.status})` : `error (${probe.status})`;
          } catch {
            apiStatus = "unreachable";
          }

          return text(
            `Space: ${targetSpace}\nStage: ${stage}\nHardware: ${hardware}\nAPI: ${apiStatus}`,
          );
        } catch (e: unknown) {
          return text(`Error checking space: ${e instanceof Error ? e.message : e}`);
        }
      },
    });

    // ── Tool: hf_restart_space ──────────────────────────────────────────────
    api.registerTool({
      name: "hf_restart_space",
      label: "Restart Space",
      description: "Restart the target HuggingFace Space. Use when the Space is stuck or after deploying fixes.",
      parameters: {
        type: "object",
        properties: {},
      },
      async execute() {
        if (!targetSpace) return text("Error: no target space configured");
        try {
          const resp = await fetch(`https://huggingface.co/api/spaces/${targetSpace}/restart`, {
            method: "POST",
            headers: { Authorization: `Bearer ${hfToken}` },
          });
          if (!resp.ok) {
            const body = await resp.text().catch(() => "");
            throw new Error(`${resp.status}: ${body}`);
          }
          return text(`Space ${targetSpace} is restarting`);
        } catch (e: unknown) {
          return text(`Error restarting space: ${e instanceof Error ? e.message : e}`);
        }
      },
    });

    api.logger.info("coding-agent: Registered 3 tools (claude_code, hf_space_status, hf_restart_space)");
  },
};

export default plugin;