mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
## Thinking Path > - Paperclip orchestrates AI agents across several adapter implementations. > - ACPX is a local adapter path that can proxy Claude and Codex-style execution. > - Its configuration needed stronger schema defaults, provider-aware model handling, and better UI support. > - Plugin authors also need clear docs for managed resources. > - This pull request improves ACPX adapter configuration and documents plugin-managed resources. > - The benefit is a more predictable adapter setup path without changing unrelated control-plane behavior. ## What Changed - Improved ACPX config schema, execution config handling, UI build config, and route coverage. - Added ACPX model filtering support and tests. - Updated the agent config form and storybook coverage for ACPX model/provider behavior. - Expanded plugin authoring documentation for managed resources. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/acpx-local-execute.test.ts server/src/__tests__/adapter-routes.test.ts ui/src/lib/acpx-model-filter.test.ts` ## Risks - Low-to-medium risk: adapter configuration behavior changes can affect ACPX users, but the change is isolated to ACPX/plugin-doc surfaces and covered by targeted adapter tests. ## Model Used - OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with shell/git/GitHub CLI tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
147 lines
5.4 KiB
TypeScript
147 lines
5.4 KiB
TypeScript
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
|
import {
|
|
DEFAULT_ACPX_LOCAL_AGENT,
|
|
DEFAULT_ACPX_LOCAL_MODE,
|
|
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
|
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
|
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
|
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
|
|
} from "../index.js";
|
|
|
|
function parseCommaArgs(value: string): string[] {
|
|
return value
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseEnvVars(text: string): Record<string, string> {
|
|
const env: Record<string, string> = {};
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
const eq = trimmed.indexOf("=");
|
|
if (eq <= 0) continue;
|
|
const key = trimmed.slice(0, eq).trim();
|
|
const value = trimmed.slice(eq + 1);
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
env[key] = value;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
|
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
|
const env: Record<string, unknown> = {};
|
|
for (const [key, raw] of Object.entries(bindings)) {
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
if (typeof raw === "string") {
|
|
env[key] = { type: "plain", value: raw };
|
|
continue;
|
|
}
|
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
|
const rec = raw as Record<string, unknown>;
|
|
if (rec.type === "plain" && typeof rec.value === "string") {
|
|
env[key] = { type: "plain", value: rec.value };
|
|
continue;
|
|
}
|
|
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
|
env[key] = {
|
|
type: "secret_ref",
|
|
secretId: rec.secretId,
|
|
...(typeof rec.version === "number" || rec.version === "latest"
|
|
? { version: rec.version }
|
|
: {}),
|
|
};
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function parseJsonObject(text: string): Record<string, unknown> | null {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
return parsed as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readNumber(value: unknown, fallback: number): number {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
|
const schemaValues = v.adapterSchemaValues ?? {};
|
|
const agent = String(schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT);
|
|
const ac: Record<string, unknown> = {
|
|
agent,
|
|
mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE,
|
|
permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
|
nonInteractivePermissions:
|
|
schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
|
timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC),
|
|
warmHandleIdleMs: readNumber(schemaValues.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS),
|
|
};
|
|
|
|
for (const key of [
|
|
"agentCommand",
|
|
"cwd",
|
|
"stateDir",
|
|
"instructionsFilePath",
|
|
"promptTemplate",
|
|
"bootstrapPromptTemplate",
|
|
]) {
|
|
const value = schemaValues[key];
|
|
if (typeof value === "string" && value.trim()) ac[key] = value.trim();
|
|
}
|
|
|
|
if (!ac.cwd && v.cwd) ac.cwd = v.cwd;
|
|
if (!ac.instructionsFilePath && v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
|
if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
|
if (!ac.bootstrapPromptTemplate && v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
|
if (v.model?.trim()) ac.model = v.model.trim();
|
|
if (v.thinkingEffort) {
|
|
ac[agent === "codex" ? "modelReasoningEffort" : "effort"] = v.thinkingEffort;
|
|
}
|
|
if (schemaValues.fastMode === true) ac.fastMode = true;
|
|
|
|
const env = parseEnvBindings(v.envBindings);
|
|
const legacy = parseEnvVars(v.envVars);
|
|
for (const [key, value] of Object.entries(legacy)) {
|
|
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
|
env[key] = { type: "plain", value };
|
|
}
|
|
}
|
|
if (typeof schemaValues.env === "string") {
|
|
const schemaEnv = parseJsonObject(schemaValues.env);
|
|
if (schemaEnv) Object.assign(env, schemaEnv);
|
|
} else if (typeof schemaValues.env === "object" && schemaValues.env !== null && !Array.isArray(schemaValues.env)) {
|
|
Object.assign(env, schemaValues.env as Record<string, unknown>);
|
|
}
|
|
if (Object.keys(env).length > 0) ac.env = env;
|
|
|
|
if (v.workspaceStrategyType === "git_worktree") {
|
|
ac.workspaceStrategy = {
|
|
type: "git_worktree",
|
|
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
|
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
|
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
|
};
|
|
}
|
|
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
|
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
|
ac.workspaceRuntime = runtimeServices;
|
|
}
|
|
if (v.command) ac.command = v.command;
|
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
|
return ac;
|
|
}
|