KubeRocketCI testbed and workspace toolkit - provision the source workspace and a try-kuberocketci testbed, then diagnose, reproduce, fix, and verify code…

KubeRocketCI testbed and workspace toolkit. Provision the two prerequisites — a multi-repo source workspace and a local try-kuberocketci testbed — then diagnose, reproduce, fix, and verify code changes across operators, the portal, and charts, including end-to-end Jira-tracked issue triage. Each command is independently callable.
/krci-triage:setup-testbed — stand up the testbedClones (or refreshes) KubeRocketCI/try-kuberocketci and provisions a local kind cluster running the full platform, discovering its make targets from make help / its docs rather than hardcoding them.
/krci-triage:setup-testbed
/krci-triage:setup-testbed ~/dev
/krci-triage:bootstrap-workspace — provision the source workspaceClones KubeRocketCI/krci-workspace (the single source of truth for the KRCI component set) and assembles the platform repositories under sources/. The repository list lives in krci-workspace/repos.yaml, not here.
/krci-triage:bootstrap-workspace
/krci-triage:bootstrap-workspace ~/dev
/krci-triage:krci-fix-the-issue — fix an issue end to endGiven a Jira key (and, optionally, the workspace and testbed paths — otherwise discovered), runs the phased workflow: fetch the ticket → find the root cause across the workspace → reproduce on the testbed → fix at the right layer → verify on the cluster → optional review → branch + conventional commit → optional QA comment back to Jira.
/krci-triage:krci-fix-the-issue EPMDEDP-1234
/krci-triage:krci-fix-the-issue EPMDEDP-1234 ~/dev/krci-workspace ~/dev/try-kuberocketci
CLAUDE.md files for cluster capabilities, then covers the transferable techniques and gotchas: operator rebuild loop (build → kind load → roll out), reproducing through the Kubernetes API, headless Portal verification with Playwright (not the MCP), shell/safety notes, and posting results to Jira without mangling code blocks.A mod that locks Claude to one kube context: the testbed's. It locks the context, not the cluster — inside that context Claude can still change anything. Off by default — turn it on: /config → krci-kubelock → on. The change applies at once and holds for every session.
While on:
KUBECONFIG points at ~/.kube/krci-kubelock/<context>.yaml, your kubeconfig minified to the locked context (default kind-krci). Every kubectl, helm, make target, and script Claude runs sees only that context. Your ~/.kube/config and your own terminals are untouched.--context / --kube-context naming another context, kubectl config use-context, kubectx, kind create cluster or kind export kubeconfig for another cluster, --kubeconfig, KUBECONFIG= overrides, cloud credential fetchers (aws eks update-kubeconfig, gcloud … get-credentials, az aks get-credentials), and reading your real kubeconfig. Claude gets the reason.~/.kube/ is off limits to Bash and the file tools — every kubeconfig there, and listing the folder itself — except the lock's own ~/.kube/krci-kubelock/ and kubectl's cache and http-cache. Nothing in ~/.kube/ is moved or deleted.⎈ kind-krci 🔒. Without the context, kube access is blocked (⎈ kind-krci missing · kube access blocked) and the pin is retried every 30 seconds./config./krci-kubelock shows the state and rebuilds the pin.While off, the first kube command in a session raises a toast recommending krci-kubelock.
/config row | Key | Default | Purpose |
|---|---|---|---|
| krci-kubelock | kubelock | false | Turns the lock on |
| krci-kubelock context | kubelockContext | kind-krci | The only context Claude may use |
Stored in ~/.claude/settings.json under pluginConfigs["krci-triage@kuberocketci-plugins"].options.
Limits:
kubectl on PATH.kind export kubeconfig --name <cluster> in your own terminal to add it to ~/.kube/config.--safe-mode, disableAllHooks, and an organization's allowManagedModsOnly turn it off; the missing status line shows it./krci-triage:setup-testbed # once: stand up the cluster (long-running)
/krci-triage:bootstrap-workspace # once: clone the component repos
/krci-triage:krci-fix-the-issue EPMDEDP-1234 # repeat: per Jira ticket
To validate any code change on the testbed directly, ask Claude to verify it on the cluster (e.g. "verify my change on the cluster") — the krci-testbed skill triggers on its own, locates the workspace/testbed, and applies the same rebuild/reproduce/verify techniques.
claude plugin install krci-triage
Apache-2.0
hooks/register.ts 274 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 EMPTY_KUBECONFIG,
5 LOCK_DIR_NAME,
6 checkCommand,
7 checkPath,
8 isKubeCommand,
9 pinnedFileName,
10 touchesKubelockSetting,
11 withoutLockFiles,
12 type Scope,
13} from './kubelock'
14
15const ORIGIN = { plugin: 'krci-triage', key: 'kubelockOrigin' } as const
16const COMMAND = 'krci-kubelock'
17const DEFAULT_CONTEXT = 'kind-krci'
18const MISSING_RETRY_MS = 30_000
19const ENABLE_HINT = 'turn it on in /config → krci-kubelock'
20
21type Pin = {
22 scope: Scope
23 dir: string
24 file: string
25 /** KUBECONFIG value the pin is built from. */
26 sources: string
27 session: string
28 /** Content last written to `file`; a difference means a command changed it. */
29 written: string
30 isMissing: boolean
31 checkedAt: number
32 error?: string
33}
34
35type Lock = { context: string; ready?: Promise<Pin> }
36
37export const register: Register = (on, options) => {
38 const isOn = options.kubelock === true
39 const context =
40 typeof options.kubelockContext === 'string' && options.kubelockContext.trim() !== ''
41 ? options.kubelockContext.trim()
42 : DEFAULT_CONTEXT
43 const lock: Lock = { context }
44 let isNudged = false
45
46 on('session.start', async ($, e, next) => {
47 await $.command.register({
48 name: COMMAND,
49 description: isOn ? 'Show the krci-kubelock state and re-pin the kubeconfig' : 'Show the krci-kubelock state',
50 })
51 if (!isOn) {
52 await restoreKubeconfig($)
53 $.ui.status(undefined)
54 return next(e)
55 }
56 const pin = await ensure($, lock)
57 if (pin.isMissing) {
58 $.ui.toast(`krci-kubelock: context ${context} not found; kube access is blocked until it exists`, {
59 timeoutMs: 8000,
60 })
61 }
62 return next(e)
63 })
64
65 on('command.run', { command: COMMAND }, async $ => {
66 if (!isOn) {
67 return {
68 text: `krci-kubelock: off.\nRecommended: ${ENABLE_HINT}. It pins kubectl, helm and every script Claude runs to ${context}.`,
69 }
70 }
71 const pin = await ensure($, lock)
72 await refresh($, pin)
73 return { text: describe(pin) }
74 })
75
76 on('tool.call', async ($, e, next) => {
77 if (!isOn) {
78 if (e.tool === 'Bash' && !isNudged && isKubeCommand(e.command)) {
79 isNudged = true
80 $.ui.toast(`krci-kubelock is off. Recommended: ${ENABLE_HINT}`, { timeoutMs: 8000 })
81 }
82 return next(e)
83 }
84
85 if (e.tool === 'Bash') {
86 const pin = await ensure($, lock)
87 const reason = checkCommand(e.command, pin.scope)
88 if (reason !== undefined) {
89 return { deny: denial(reason) }
90 }
91 if (pin.isMissing && (await $.clock.now()) - pin.checkedAt > MISSING_RETRY_MS) {
92 await refresh($, pin)
93 }
94 const ran = await next(e)
95 await reconcile($, pin)
96 return ran
97 }
98
99 const input = e as unknown as Record<string, unknown>
100 const path = [input.file_path, input.notebook_path, input.path].find(
101 (value): value is string => typeof value === 'string',
102 )
103 if (path === undefined) {
104 return next(e)
105 }
106 const pin = await ensure($, lock)
107 const reason =
108 checkPath(path, pin.scope) ??
109 (touchesKubelockSetting(path, JSON.stringify(input))
110 ? 'the krci-kubelock settings belong to the user; change them in /config'
111 : undefined)
112 return reason === undefined ? next(e) : { deny: denial(reason) }
113 }).catch(async ($, e, next) =>
114 next.called
115 ? { deny: denial(`the post-run check failed (${next.error.message}); run /${COMMAND}`) }
116 : { deny: denial(`the check failed (${next.error.message}); run /${COMMAND}`) },
117 )
118}
119
120/** Pins KUBECONFIG once per module load; later calls share the first result. */
121function ensure($: EngineInterface, lock: Lock): Promise<Pin> {
122 lock.ready ??= createPin($, lock.context)
123 return lock.ready
124}
125
126async function createPin($: EngineInterface, context: string): Promise<Pin> {
127 const home = (await $.env.get('HOME')) ?? ''
128 const dir = `${home}/.kube/${LOCK_DIR_NAME}`
129 const file = `${dir}/${pinnedFileName(context)}`
130 const pin: Pin = {
131 scope: { context, home, kubeconfigs: [`${home}/.kube/config`] },
132 dir,
133 file,
134 sources: `${home}/.kube/config`,
135 session: 'session',
136 written: '',
137 isMissing: true,
138 checkedAt: 0,
139 }
140
141 try {
142 const { value } = await $.state.get(ORIGIN)
143 const recorded =
144 value?.isPinned === true ? value.kubeconfig : ((await $.env.get('KUBECONFIG')) ?? null)
145 const kubeconfig = recorded === null ? null : withoutLockFiles(recorded, dir)
146 if (value?.isPinned !== true || kubeconfig !== recorded) {
147 await $.state.set(ORIGIN, { isPinned: true, kubeconfig })
148 }
149 pin.sources = await existingSources($, kubeconfig, pin.sources)
150 pin.scope = {
151 context,
152 home,
153 kubeconfigs: [...new Set([...pin.sources.split(':').filter(Boolean), `${home}/.kube/config`])],
154 }
155 pin.session = await $.session.id()
156 await refresh($, pin)
157 } catch (error) {
158 pin.isMissing = true
159 pin.error = error instanceof Error ? error.message : String(error)
160 showStatus($, pin)
161 } finally {
162 await $.env.set('KUBECONFIG', file)
163 }
164 return pin
165}
166
167/** The entries of a KUBECONFIG value that exist on disk; the fallback when none do. */
168async function existingSources($: EngineInterface, kubeconfig: string | null, fallback: string): Promise<string> {
169 const kept: string[] = []
170 for (const entry of kubeconfig?.split(':') ?? []) {
171 if (entry !== '' && (await $.fs.exists(entry))) {
172 kept.push(entry)
173 }
174 }
175 return kept.length === 0 ? fallback : kept.join(':')
176}
177
178/** Rebuilds the pinned file from the user's own kubeconfig. */
179async function refresh($: EngineInterface, pin: Pin): Promise<void> {
180 const content = await minify($, pin.sources, pin.scope.context)
181 await write($, pin, content ?? EMPTY_KUBECONFIG)
182 pin.isMissing = content === undefined
183 pin.error = undefined
184 pin.checkedAt = await $.clock.now()
185 showStatus($, pin)
186}
187
188/** Strips anything a command added to the pinned file beyond the allowed context. */
189async function reconcile($: EngineInterface, pin: Pin): Promise<void> {
190 try {
191 const current = (await $.fs.exists(pin.file)) ? await $.fs.read(pin.file) : undefined
192 if (current === pin.written) {
193 return
194 }
195 const content = current === undefined ? undefined : await minify($, pin.file, pin.scope.context)
196 if (content === undefined) {
197 await refresh($, pin)
198 return
199 }
200 await write($, pin, content)
201 pin.isMissing = false
202 pin.error = undefined
203 showStatus($, pin)
204 } catch (error) {
205 pin.isMissing = true
206 pin.error = error instanceof Error ? error.message : String(error)
207 showStatus($, pin)
208 }
209}
210
211async function minify($: EngineInterface, kubeconfig: string, context: string): Promise<string | undefined> {
212 try {
213 const run = await $.process.run(
214 ['kubectl', 'config', 'view', '--minify', '--flatten', '--context', context],
215 { env: { KUBECONFIG: kubeconfig }, timeoutMs: 10_000 },
216 )
217 return run.exitCode === 0 && run.stdout.trim() !== '' ? run.stdout : undefined
218 } catch {
219 return undefined
220 }
221}
222
223/** Replaces the pinned file in one rename, owner-only. */
224async function write($: EngineInterface, pin: Pin, content: string): Promise<void> {
225 const temp = `${pin.file}.${pin.session}.tmp`
226 const run = await $.process.run(
227 ['sh', '-c', 'umask 077 && mkdir -p "$1" && cat > "$2" && mv -f "$2" "$3"', 'sh', pin.dir, temp, pin.file],
228 { stdin: content, timeoutMs: 10_000 },
229 )
230 if (run.exitCode !== 0) {
231 throw new Error(`cannot write ${pin.file}: ${run.stderr.trim()}`)
232 }
233 pin.written = content
234}
235
236async function restoreKubeconfig($: EngineInterface): Promise<void> {
237 const { value } = await $.state.get(ORIGIN)
238 if (value?.isPinned !== true) {
239 return
240 }
241 await $.env.set('KUBECONFIG', value.kubeconfig ?? undefined)
242 await $.state.set(ORIGIN, { isPinned: false, kubeconfig: value.kubeconfig })
243}
244
245function showStatus($: EngineInterface, pin: Pin): void {
246 const context = pin.scope.context
247 $.ui.status(
248 pin.error !== undefined
249 ? `⎈ ${context} · krci-kubelock error · kube access blocked`
250 : pin.isMissing
251 ? `⎈ ${context} missing · kube access blocked`
252 : `⎈ ${context} 🔒`,
253 )
254}
255
256function describe(pin: Pin): string {
257 const state =
258 pin.error !== undefined
259 ? `error (${pin.error}), kube access blocked`
260 : pin.isMissing
261 ? 'not found, kube access blocked until it exists'
262 : 'found'
263 return [
264 'krci-kubelock: on',
265 `Context: ${pin.scope.context} (${state})`,
266 `KUBECONFIG: ${pin.file}`,
267 `Built from: ${pin.sources}`,
268 ].join('\n')
269}
270
271function denial(reason: string): string {
272 return `krci-kubelock: ${reason}.`
273}
274hooks/kubelock.ts 225 lines1export type Scope = {
2 /** The only kube context Claude may target. */
3 context: string
4 /** The user's home directory, absolute. */
5 home: string
6 /** The kubeconfig files the pin is built from, absolute. */
7 kubeconfigs: readonly string[]
8}
9
10/** The lock's own folder under ~/.kube. */
11export const LOCK_DIR_NAME = 'krci-kubelock'
12
13export const EMPTY_KUBECONFIG = `apiVersion: v1
14kind: Config
15clusters: []
16contexts: []
17users: []
18current-context: ""
19preferences: {}
20`
21
22const SEGMENT = /\|\||&&|[;&|\n]/
23const WORD_END = String.raw`(?=$|[\s;&|)\x60'"])`
24const KUBE_TOOL = new RegExp(
25 String.raw`(?:^|[\s/(\x60'"])(?:kubectl|kubecolor|helm|helmfile|k9s|kubectx|kubens|kubie|tkn|krci|flux|stern|argocd|skaffold)` +
26 WORD_END,
27)
28const K_ALIAS = /^\s*k\s/
29const KIND_WRITES_CONTEXT = /(?:^|[\s/(`])kind\s+(?:create\s+cluster|export\s+kubeconfig)\b/
30const KIND_ANY = /(?:^|[\s/(`])kind\s+(?:create|delete|export|get|load)\b/
31
32const NESTED_SHELL = /(?:^|[\s/(`])(?:ba|z|k|da)?sh\s+(?:-\w+\s+)*-\w*c\s+(['"])([\s\S]*?)\1/g
33const SHELL_KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', 'time', 'nohup', 'exec', 'command', 'builtin'])
34const ENV_WRITERS = new Set(['export', 'declare', 'typeset', 'local', 'readonly', 'env', 'sudo', 'unset'])
35const ASSIGNMENT = /^[A-Za-z_]\w*\+?=/
36const KUBECONFIG_WORD = /^(?:--unset=|-u)?KUBECONFIG(?:\+?=.*)?$/
37const KUBECONFIG_FLAG = /(?:^|\s)--kubeconfig(?:[=\s]|$)/
38const CREDENTIAL_FETCHERS = [
39 /\baws\s+eks\s+update-kubeconfig\b/,
40 /\bgcloud\s+container\s+clusters\s+get-credentials\b/,
41 /\baz\s+aks\s+get-credentials\b/,
42 /\bdoctl\s+kubernetes\s+cluster\s+kubeconfig\s+save\b/,
43]
44const HELM_CONTEXT_ENV = /(?<![\w$])HELM_KUBECONTEXT\s*=\s*(\S+)/
45const SETTING_KEY = /\bkubelock/
46const SETTINGS_FILE = /(?:^|\/)settings(?:\.local)?\.json$/
47const KUBE_DIR_OPEN = new Set([LOCK_DIR_NAME, 'cache', 'http-cache'])
48const KUBE_DIR_CHILD = /^\/([^\s/'"`;|&)]+)/
49
50/** A KUBECONFIG value without entries inside the lock's own folder; null when none remain. */
51export function withoutLockFiles(kubeconfig: string, lockDir: string): string | null {
52 const kept = kubeconfig.split(':').filter(entry => entry !== '' && entry !== lockDir && !entry.startsWith(`${lockDir}/`))
53 return kept.length === 0 ? null : kept.join(':')
54}
55
56/** The pinned kubeconfig's file name for a context. */
57export function pinnedFileName(context: string): string {
58 return `${context.replace(/[^\w.-]/g, '_')}.yaml`
59}
60
61/** Whether a shell command runs a Kubernetes client. */
62export function isKubeCommand(command: string): boolean {
63 return command.split(SEGMENT).some(isKubeSegment)
64}
65
66function isKubeSegment(segment: string): boolean {
67 return KUBE_TOOL.test(segment) || K_ALIAS.test(segment) || KIND_ANY.test(segment)
68}
69
70/** Whether a segment assigns, exports or unsets KUBECONFIG, as opposed to merely naming it. */
71function writesKubeconfig(segment: string): boolean {
72 const words = segment.trim().replace(/^[({`$]+/, '').split(/\s+/)
73 let index = 0
74 while (index < words.length && SHELL_KEYWORDS.has(words[index] ?? '')) {
75 index += 1
76 }
77 while (index < words.length && ASSIGNMENT.test(words[index] ?? '')) {
78 if (/^KUBECONFIG\+?=/.test(words[index] ?? '')) {
79 return true
80 }
81 index += 1
82 }
83 const program = (words[index] ?? '').split('/').pop() ?? ''
84 return ENV_WRITERS.has(program) && words.slice(index + 1).some(word => KUBECONFIG_WORD.test(word))
85}
86
87/** Why a Bash command breaks the pin, or undefined when it may run. */
88export function checkCommand(command: string, scope: Scope): string | undefined {
89 if (SETTING_KEY.test(command) && /settings(?:\.local)?\.json|pluginConfigs/.test(command)) {
90 return 'the krci-kubelock settings belong to the user; change them in /config'
91 }
92 for (const nested of command.matchAll(NESTED_SHELL)) {
93 const reason = checkCommand(nested[2] ?? '', scope)
94 if (reason !== undefined) {
95 return reason
96 }
97 }
98 const segments = command.split(SEGMENT)
99 if (segments.some(writesKubeconfig)) {
100 return `KUBECONFIG is pinned to ${scope.context}; overriding or unsetting it is blocked`
101 }
102 if (segments.some(segment => isKubeSegment(segment) && KUBECONFIG_FLAG.test(segment))) {
103 return `--kubeconfig is blocked; kubectl and helm already use the pinned ${scope.context} kubeconfig`
104 }
105 const kubeDir = kubeDirMention(command, scope.home)
106 if (kubeDir !== undefined) {
107 return `${kubeDir} is off limits: ~/.kube holds kubeconfigs for contexts other than ${scope.context}`
108 }
109 const spelled = kubeconfigSpellings(scope).find(path => command.includes(path))
110 if (spelled !== undefined) {
111 return `${spelled} is off limits: it holds contexts other than ${scope.context}`
112 }
113 if (CREDENTIAL_FETCHERS.some(pattern => pattern.test(command))) {
114 return `fetching cluster credentials is blocked; only ${scope.context} is allowed`
115 }
116 const helmContext = HELM_CONTEXT_ENV.exec(command)?.[1]
117 const targets = [...(helmContext === undefined ? [] : [helmContext]), ...segments.flatMap(segmentTargets)].map(
118 unquote,
119 )
120 const foreign = targets.find(target => target !== scope.context)
121 if (foreign !== undefined) {
122 return `only context ${scope.context} is allowed; this command targets ${foreign}`
123 }
124 return undefined
125}
126
127/** Why a file tool may not touch a path, or undefined when it may. */
128export function checkPath(path: string, scope: Scope): string | undefined {
129 const absolute = path === '~' || path.startsWith('~/') ? scope.home + path.slice(1) : path
130 const normal = absolute.startsWith('/') ? normalizePath(absolute) : absolute
131 const kubeDir = `${scope.home}/.kube`
132 if (normal === kubeDir || normal.startsWith(`${kubeDir}/`)) {
133 const child = normal.slice(kubeDir.length + 1).split('/')[0] ?? ''
134 if (!KUBE_DIR_OPEN.has(child)) {
135 return `${path} is off limits: ~/.kube holds kubeconfigs for contexts other than ${scope.context}`
136 }
137 }
138 return realKubeconfigs(scope).includes(normal)
139 ? `${path} is off limits: it holds contexts other than ${scope.context}`
140 : undefined
141}
142
143/** The first spelling of a ~/.kube path in a command outside the lock's folder and kubectl's caches. */
144function kubeDirMention(command: string, home: string): string | undefined {
145 const prefixes = ['~', '$HOME', '${HOME}', ...(home === '' ? [] : [home])].map(root => `${root}/.kube`)
146 for (const prefix of prefixes) {
147 for (let at = command.indexOf(prefix); at !== -1; at = command.indexOf(prefix, at + prefix.length)) {
148 const rest = command.slice(at + prefix.length)
149 if (/^[\w.-]/.test(rest)) {
150 continue
151 }
152 const child = KUBE_DIR_CHILD.exec(rest)?.[1]
153 const next = child === undefined ? '' : rest.slice(child.length + 1)
154 if (child === undefined || !KUBE_DIR_OPEN.has(child) || /^\/\.\.(?:\/|$)/.test(next)) {
155 return child === undefined ? prefix : `${prefix}/${child}`
156 }
157 }
158 }
159 return undefined
160}
161
162/** Whether a file edit changes a krci-kubelock setting in a settings file. */
163export function touchesKubelockSetting(path: string, text: string): boolean {
164 return SETTINGS_FILE.test(path) && SETTING_KEY.test(text)
165}
166
167function segmentTargets(segment: string): string[] {
168 const targets: string[] = []
169 for (const match of segment.matchAll(/(?:^|\s)--kube-context(?:=|\s+)(\S+)/g)) {
170 targets.push(match[1] ?? '')
171 }
172 if (KUBE_TOOL.test(segment) || K_ALIAS.test(segment)) {
173 for (const match of segment.matchAll(/(?:^|\s)--context(?:=|\s+)(\S+)/g)) {
174 targets.push(match[1] ?? '')
175 }
176 const used = /\bconfig\s+use(?:-context)?\s+(\S+)/.exec(segment)?.[1]
177 if (used !== undefined) {
178 targets.push(used)
179 }
180 }
181 const switched = /(?:^|[\s/(`])kubectx\s+(\S+)/.exec(segment)?.[1]
182 if (switched !== undefined && (switched === '-' || !(switched.startsWith('-') || switched.includes('=')))) {
183 targets.push(switched)
184 }
185 const kubie = /(?:^|[\s/(`])kubie\s+(?:ctx|exec)\s+(\S+)/.exec(segment)?.[1]
186 if (kubie !== undefined) {
187 targets.push(kubie)
188 }
189 if (KIND_WRITES_CONTEXT.test(segment)) {
190 const name = /(?:^|\s)--name(?:=|\s+)(\S+)/.exec(segment)?.[1]
191 targets.push(`kind-${name === undefined ? 'kind' : unquote(name)}`)
192 }
193 return targets
194}
195
196function realKubeconfigs(scope: Scope): string[] {
197 return [...new Set([...scope.kubeconfigs, `${scope.home}/.kube/config`])]
198}
199
200function kubeconfigSpellings(scope: Scope): string[] {
201 return realKubeconfigs(scope).flatMap(file => {
202 if (scope.home === '' || !file.startsWith(`${scope.home}/`)) {
203 return [file]
204 }
205 const rest = file.slice(scope.home.length)
206 return [file, `~${rest}`, `$HOME${rest}`, `\${HOME}${rest}`]
207 })
208}
209
210function normalizePath(path: string): string {
211 const parts: string[] = []
212 for (const part of path.split('/')) {
213 if (part === '..') {
214 parts.pop()
215 } else if (part !== '' && part !== '.') {
216 parts.push(part)
217 }
218 }
219 return `/${parts.join('/')}`
220}
221
222function unquote(value: string): string {
223 return value.replace(/^['"]|['"]$/g, '')
224}
225types/index.d.ts 13 lines1export type KubelockOrigin = {
2 /** True while KUBECONFIG points at the pinned file. */
3 isPinned: boolean
4 /** KUBECONFIG as the session started with it; null when unset. */
5 kubeconfig: string | null
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'krci-triage': { kubelockOrigin: KubelockOrigin }
11 }
12}
13