From b24f8491986206466e3a0866120660c0bbbe12ec Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Tue, 14 Apr 2026 12:10:51 +0000 Subject: [PATCH 1/2] feat: add RTK hook support for token-optimized CLI output When enableRtk is set in adapter config, the adapter: - Adds an init container to download the RTK binary via configurable image - Mounts RTK binary in the main container via shared emptyDir volume - Runs `rtk install claude-code` with an isolated temp HOME, then moves the generated hooks to the workspace's .claude/settings.local.json (project-level) to avoid polluting the shared PVC's global settings - Disables RTK telemetry (RTK_NO_TELEMETRY=1) for automated environments - Supports rtkVersion for pinning and rtkImage for custom installer images Key improvement over the reverted d074cb2: hooks are written to the project-level settings.local.json instead of the shared ~/.claude/settings.json, preventing RTK hooks from leaking to non-RTK agents on the same PVC. Co-Authored-By: Paperclip --- src/index.ts | 5 ++ src/server/config-schema.ts | 20 ++++++ src/server/job-manifest.test.ts | 110 ++++++++++++++++++++++++++++++++ src/server/job-manifest.ts | 39 ++++++++++- 4 files changed, 173 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 4af773d..0dfe600 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,11 @@ Kubernetes fields: - ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300 - retainJobs (boolean, optional): skip cleanup on completion for debugging +RTK fields (token optimization): +- enableRtk (boolean, optional): enable RTK to reduce token usage by filtering CLI output. Configures Claude Code PreToolUse/PostToolUse hooks automatically via project-level settings. Adds an init container to download the RTK binary. +- rtkVersion (string, optional): RTK version to install; defaults to "latest" +- rtkImage (string, optional): container image for the RTK download init container; defaults to "curlimages/curl:8.12.1" + Operational fields: - timeoutSec (number, optional): run timeout in seconds; 0 means no timeout - graceSec (number, optional): additional grace before adapter gives up after Job deadline diff --git a/src/server/config-schema.ts b/src/server/config-schema.ts index 98265bf..8654f46 100644 --- a/src/server/config-schema.ts +++ b/src/server/config-schema.ts @@ -108,6 +108,26 @@ export function getConfigSchema(): AdapterConfigSchema { label: "Memory Limit", hint: "Memory limit for Job pods (e.g. 128Mi, 512Mi, 1Gi).", }, + // RTK (token optimization) + { + type: "toggle", + key: "enableRtk", + label: "Enable RTK", + hint: "Install and enable RTK (rtk-ai/rtk) to reduce token usage by filtering CLI output through PreToolUse/PostToolUse hooks. Adds an init container to download the RTK binary.", + default: false, + }, + { + type: "text", + key: "rtkVersion", + label: "RTK Version", + hint: "RTK version to install (e.g. '0.5.0'). Defaults to 'latest'.", + }, + { + type: "text", + key: "rtkImage", + label: "RTK Installer Image", + hint: "Container image for the RTK download init container. Defaults to curlimages/curl:8.12.1.", + }, // Scheduling { type: "textarea", diff --git a/src/server/job-manifest.test.ts b/src/server/job-manifest.test.ts index adc18a6..c199ba2 100644 --- a/src/server/job-manifest.test.ts +++ b/src/server/job-manifest.test.ts @@ -497,6 +497,116 @@ describe("buildJobManifest", () => { }); }); + describe("RTK integration", () => { + it("does not add RTK init container by default", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const inits = job.spec?.template?.spec?.initContainers ?? []; + expect(inits).toHaveLength(1); + expect(inits[0]?.name).toBe("write-prompt"); + }); + + it("adds install-rtk init container when enableRtk is true", () => { + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const inits = job.spec?.template?.spec?.initContainers ?? []; + expect(inits).toHaveLength(2); + expect(inits[1]?.name).toBe("install-rtk"); + expect(inits[1]?.image).toBe("curlimages/curl:8.12.1"); + }); + + it("uses custom rtkImage for init container", () => { + ctx.config = { enableRtk: true, rtkImage: "my-registry/rtk-installer:v1" }; + const { job } = buildJobManifest({ ctx, selfPod }); + const inits = job.spec?.template?.spec?.initContainers ?? []; + const rtkInit = inits.find((c) => c.name === "install-rtk"); + expect(rtkInit?.image).toBe("my-registry/rtk-installer:v1"); + }); + + it("adds rtk-bin emptyDir volume when enableRtk is true", () => { + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const rtkVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "rtk-bin"); + expect(rtkVol?.emptyDir).toEqual({}); + }); + + it("mounts rtk-bin in main container when enableRtk is true", () => { + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const rtkMount = job.spec?.template?.spec?.containers[0]?.volumeMounts?.find( + (vm) => vm.name === "rtk-bin", + ); + expect(rtkMount?.mountPath).toBe("/tmp/rtk-bin"); + }); + + it("prepends rtk setup to main command with project-level settings isolation", () => { + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const command = job.spec?.template?.spec?.containers[0]?.command; + expect(command?.[2]).toContain('export PATH="/tmp/rtk-bin:$PATH"'); + expect(command?.[2]).toContain("rtk install claude-code"); + expect(command?.[2]).toContain("settings.local.json"); + expect(command?.[2]).toContain("export HOME=/paperclip"); + expect(command?.[2]).toContain("cat /tmp/prompt/prompt.txt | claude"); + }); + + it("does not prepend rtk setup when enableRtk is false", () => { + ctx.config = { enableRtk: false }; + const { job } = buildJobManifest({ ctx, selfPod }); + const command = job.spec?.template?.spec?.containers[0]?.command; + expect(command?.[2]).not.toContain("rtk"); + expect(command?.[2]).toMatch(/^cat \/tmp\/prompt\/prompt\.txt/); + }); + + it("does not add rtk-bin volume when enableRtk is false", () => { + ctx.config = { enableRtk: false }; + const { job } = buildJobManifest({ ctx, selfPod }); + expect(job.spec?.template?.spec?.volumes?.find((v) => v.name === "rtk-bin")).toBeUndefined(); + }); + + it("sets RTK_NO_TELEMETRY env var when enableRtk is true", () => { + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const rtkTelemetry = job.spec?.template?.spec?.containers[0]?.env?.find( + (e) => e.name === "RTK_NO_TELEMETRY", + ); + expect(rtkTelemetry?.value).toBe("1"); + }); + + it("does not set RTK_NO_TELEMETRY when enableRtk is false", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const rtkTelemetry = job.spec?.template?.spec?.containers[0]?.env?.find( + (e) => e.name === "RTK_NO_TELEMETRY", + ); + expect(rtkTelemetry).toBeUndefined(); + }); + + it("uses custom rtkVersion in install command", () => { + ctx.config = { enableRtk: true, rtkVersion: "0.5.0" }; + const { job } = buildJobManifest({ ctx, selfPod }); + const inits = job.spec?.template?.spec?.initContainers ?? []; + const rtkInit = inits.find((c) => c.name === "install-rtk"); + expect(rtkInit?.command?.[2]).toContain("RTK_VERSION=0.5.0"); + }); + + it("mounts rtk-bin in install-rtk init container", () => { + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const inits = job.spec?.template?.spec?.initContainers ?? []; + const rtkInit = inits.find((c) => c.name === "install-rtk"); + expect(rtkInit?.volumeMounts).toContainEqual({ name: "rtk-bin", mountPath: "/tmp/rtk-bin" }); + }); + + it("writes hooks to workspace .claude/settings.local.json not global settings", () => { + ctx.context = { paperclipWorkspace: { cwd: "/paperclip/workspaces/agent-abc" } }; + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const command = job.spec?.template?.spec?.containers[0]?.command; + expect(command?.[2]).toContain("/paperclip/workspaces/agent-abc/.claude"); + expect(command?.[2]).toContain("settings.local.json"); + expect(command?.[2]).not.toMatch(/HOME="\/paperclip".*rtk install/); + }); + }); + describe("return value", () => { it("returns job, jobName, namespace, prompt, claudeArgs, promptMetrics", () => { const result = buildJobManifest({ ctx, selfPod }); diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts index 0243cfb..ef07db6 100644 --- a/src/server/job-manifest.ts +++ b/src/server/job-manifest.ts @@ -148,6 +148,10 @@ function buildEnvVars( // HOME must be /paperclip to match PVC mount and enable session resume merged.HOME = "/paperclip"; + if (asBoolean(config.enableRtk, false)) { + merged.RTK_NO_TELEMETRY = "1"; + } + // Convert to V1EnvVar array const envVars: k8s.V1EnvVar[] = Object.entries(merged).map(([name, value]) => ({ name, @@ -171,6 +175,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { // K8s Job pods are always unattended — no one to approve permission prompts const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true); const extraArgs = asStringArray(config.extraArgs); + const enableRtk = asBoolean(config.enableRtk, false); + const rtkVersion = asString(config.rtkVersion, "latest"); + const rtkImage = asString(config.rtkImage, "curlimages/curl:8.12.1"); const timeoutSec = asNumber(config.timeoutSec, 0); const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300); const resources = parseObject(config.resources); @@ -282,6 +289,11 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { }, ]; + if (enableRtk) { + volumes.push({ name: "rtk-bin", emptyDir: {} }); + volumeMounts.push({ name: "rtk-bin", mountPath: "/tmp/rtk-bin" }); + } + // Mount shared PVC for /paperclip (session state, workspaces, data) if (selfPod.pvcClaimName) { volumes.push({ @@ -326,7 +338,10 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { // Build the claude command string for the main container const claudeArgsEscaped = claudeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" "); - const mainCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`; + const claudeCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`; + const mainCommand = enableRtk + ? `export PATH="/tmp/rtk-bin:$PATH" && _RTK_HOME=$(mktemp -d) && HOME="$_RTK_HOME" rtk install claude-code 2>/dev/null && mkdir -p '${workingDir}/.claude' && mv "$_RTK_HOME/.claude/settings.json" '${workingDir}/.claude/settings.local.json' 2>/dev/null; rm -rf "$_RTK_HOME"; export HOME=/paperclip && ${claudeCommand}` + : claudeCommand; const job: k8s.V1Job = { apiVersion: "batch/v1", @@ -368,6 +383,28 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { limits: { cpu: "100m", memory: "64Mi" }, }, }, + ...(enableRtk + ? [ + { + name: "install-rtk", + image: rtkImage, + imagePullPolicy: "IfNotPresent" as const, + command: [ + "sh", + "-c", + rtkVersion === "latest" + ? "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | RTK_INSTALL_DIR=/tmp/rtk-bin sh" + : `curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | RTK_INSTALL_DIR=/tmp/rtk-bin RTK_VERSION=${rtkVersion} sh`, + ], + volumeMounts: [{ name: "rtk-bin", mountPath: "/tmp/rtk-bin" }], + securityContext, + resources: { + requests: { cpu: "10m", memory: "32Mi" }, + limits: { cpu: "200m", memory: "128Mi" }, + }, + }, + ] + : []), ], containers: [ { -- 2.52.0 From 77e4a136442fba9f1cc2b0ef6990c84508333692 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 15 Apr 2026 21:42:20 +0000 Subject: [PATCH 2/2] feat: declare adapter plugin capabilities on ServerAdapterModule Adds supportsInstructionsBundle, instructionsPathKey, and requiresMaterializedRuntimeSkills flags so the UI renders the bundle editor for claude_k8s agents. Bumps adapter-utils peer dep to the canary that includes the capability type fields. Co-Authored-By: Paperclip --- package-lock.json | 49 +++++++++++++++++++++++---------------------- package.json | 4 ++-- src/server/index.ts | 3 +++ 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6766df8..af735b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@farhoodliquor/paperclip-adapter-claude-k8s", - "version": "0.1.9", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@farhoodliquor/paperclip-adapter-claude-k8s", - "version": "0.1.9", + "version": "0.1.12", "license": "MIT", "dependencies": { "@kubernetes/client-node": "^1.0.0", "picocolors": "^1.1.1" }, "devDependencies": { - "@paperclipai/adapter-utils": "^0.3.0", + "@paperclipai/adapter-utils": "^2026.415.0-canary.7", "@types/node": "^24.6.0", "@vitest/coverage-v8": "^4.1.4", "typescript": "^5.7.3", @@ -194,9 +194,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -223,10 +223,11 @@ } }, "node_modules/@paperclipai/adapter-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-0.3.1.tgz", - "integrity": "sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==", - "dev": true + "version": "2026.415.0-canary.7", + "resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.415.0-canary.7.tgz", + "integrity": "sha512-VNzIZmu1lrK6QM8Ad9WkOihZItfkj21NHKQf+artDcbwFT2hHbDAD9hdW2W9NMVxYdFvvnws3w76FI/BUbCMbQ==", + "dev": true, + "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.15", @@ -803,9 +804,9 @@ } }, "node_modules/bare-fs": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", - "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -1767,13 +1768,13 @@ } }, "node_modules/openid-client": { - "version": "6.8.2", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", - "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.3.tgz", + "integrity": "sha512-AoY/NaN9esS3+xvHInFSK0g3skSfeE0uqQAKRj4rB6/GsBIvzwTUaYo9+HcqpKIaP0dP85p5W07hayKgS4GAeA==", "license": "MIT", "dependencies": { - "jose": "^6.1.3", - "oauth4webapi": "^3.8.4" + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" }, "funding": { "url": "https://github.com/sponsors/panva" @@ -1806,9 +1807,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -1960,9 +1961,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 39e5668..fe7acf4 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,10 @@ "picocolors": "^1.1.1" }, "peerDependencies": { - "@paperclipai/adapter-utils": ">=0.3.0" + "@paperclipai/adapter-utils": ">=2026.415.0-canary.7" }, "devDependencies": { - "@paperclipai/adapter-utils": "^0.3.0", + "@paperclipai/adapter-utils": "2026.415.0-canary.7", "@types/node": "^24.6.0", "@vitest/coverage-v8": "^4.1.4", "typescript": "^5.7.3", diff --git a/src/server/index.ts b/src/server/index.ts index 81de51d..02b23f5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -30,6 +30,9 @@ export function createServerAdapter(): ServerAdapterModule { listSkills: listK8sSkills, syncSkills: syncK8sSkills, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc, getConfigSchema, }; -- 2.52.0