SLOPSHOPPER

krci-triage

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

newguardcommandtoaststatusprocess
★ 2v0.5.1Apache-2.0updated 2026-10-08KubeRocketCI/claude-plugins/plugins/krci-triage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · krci-triage
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /krci-kubelock ⎿ krci-triage: krci-kubelock: off. ⎿ krci-triage: Recommended: turn it on in /config → krci-kubelock. It pins kubectl, helm and every script Claude runs to kind ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

krci-triage

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.

Commands

/krci-triage:setup-testbed — stand up the testbed

Clones (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 workspace

Clones 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 end

Given 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

Skill

  • krci-testbed — locates the workspace and testbed (or points to the setup commands if missing) and reads their 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.

krci-kubelock — recommended: turn it on

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.
  • Bash commands that escape the pin are denied before they run: --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.
  • Contexts a command adds to the pinned file are stripped after it runs.
  • The status line shows ⎈ kind-krci 🔒. Without the context, kube access is blocked (⎈ kind-krci missing · kube access blocked) and the pin is retried every 30 seconds.
  • Edits to the krci-kubelock settings are denied; only you change them, in /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 rowKeyDefaultPurpose
krci-kubelockkubelockfalseTurns the lock on
krci-kubelock contextkubelockContextkind-krciThe only context Claude may use

Stored in ~/.claude/settings.json under pluginConfigs["krci-triage@kuberocketci-plugins"].options.

Limits:

  • Requires Claude Code v2.1.287 or later, macOS or Linux, and kubectl on PATH.
  • A testbed cluster created while the lock is on lands in the pinned file only. Run kind export kubeconfig --name <cluster> in your own terminal to add it to ~/.kube/config.
  • Stops mistakes, not a deliberate bypass. --safe-mode, disableAllHooks, and an organization's allowManagedModsOnly turn it off; the missing status line shows it.

Typical flow

/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.

Installation

claude plugin install krci-triage

License

Apache-2.0

Source 3 files
hooks/register.ts 274 lines
1import 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}
274
hooks/kubelock.ts 225 lines
1export 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}
225
types/index.d.ts 13 lines
1export 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