자체적으로 탭 완성(tab completions)을 작성하는 쉘
요약
Hash는 AI 에이전트를 활용하여 쉘의 탭 완성(tab completions) 기능을 자동으로 생성하는 도구를 소개합니다. 사용자는 복잡한 쉘 스크립트 작성 없이도 명령어의 도움말을 분석하여 맞춤형 완성 플러그인을 즉시 생성하고 적용할 수 있습니다.
핵심 포인트
- AI 에이전트가 명령어를 분석하여 탭 완성 플러그인 초안을 자동 작성
- 사용자의 피드백을 통한 반복적인 수정 및 검증 프로세스 지원
- 쉘 재시작 없이 플러그인을 즉시 적용하는 핫 리로드 기능 제공
- 복잡한 bash/zsh 설정 없이도 쉽고 빠르게 CLI 도구 확장 가능
대부분의 쉘에서 다음을 입력하고 어떤 일이 일어나는지 확인해 보세요:
❯ docker rm <TAB>
Dockerfile docker-compose.yml docs/
파일명들... docker rm은 파일명을 인자로 받지 않습니다. 이 명령은 현재 이 머신에 존재하는 컨테이너의 이름이나 ID를 인자로 받으며, 머신은 정확히 어떤 것들이 있는지 알고 있습니다. 다만 쉘이 어떻게 물어봐야 할지 모를 뿐입니다...
대부분의 쉘에서 이를 직접 수정할 수 있습니다: COMP_WORDS와 COMPREPLY를 조절하는 bash 함수, _arguments DSL을 사용하는 zsh의 #compdef 파일, 또는 가장 쉬운 방법인 한 줄짜리 fish complete 명령어가 있습니다. bash-completion은 이러한 수정 사항이 수만 줄에 달합니다. 그 정도의 비용이라면, 저는 화요일 오후 4시에 내부 CLI를 위한 완성(completion) 기능을 직접 작성하지 않을 것입니다.
Hash의 경우, 저는 이것이 쉽고 해킹 가능(hackable)하게 유지되기를 원했습니다. 쉘 스크립팅 없이, 그리고 저나 도구의 제작자가 대신 해주기를 기다리지 않고도, 여러분이 직접 1분 안에 쉘에게 새로운 도구를 가르칠 수 있어야 합니다. 그것이 옳은 방향이라고 생각했습니다.
30초, 명령어 하나
그 1분이 여기 있습니다. gh를 대상으로 completions generate를 실제로 실행한 결과이며, 요약된 내용입니다. 전체 출력은 10개의 규칙과 함께 반환되었습니다:
❯ completions generate gh
Inspecting `gh --help`...
Asking the agent to draft a completion plugin for gh...
...
하단의 프롬프트가 바로 핵심 기능입니다:
[a]ccept: 초안이 충분히 괜찮다면 수락합니다. 대개는 괜찮습니다.[r]evise also cover gists: 여러분의 지침과 현재 명세(spec)를 에이전트에게 다시 보냅니다. 원하는 만큼 여러 번 반복할 수 있습니다.- 검증(validation)에 실패한 초안은
accept를 숨기고 오류 메시지를 다음 지침과 함께 보냅니다. 보통 한 번의 반복으로 해결됩니다. - 알 수 없는 필드는 하드 에러(hard errors)로 처리됩니다. 따라서 기계가 작성한
cache_tt는 모델이 수정할 수 있도록 즉시 오류를 발생시키며, 겉보기에는 올바르지만 동작은 잘못된 플러그인을 배포하는 상황을 방지합니다.
Accept를 누르면 파일이 ~/.config/hash/completions/에 저장되고, 플러그인 테이블이 핫 리로드(hot reload)되며, 동일한 쉘 내에서 즉시 완성 기능이 적용됩니다. 재시작은 필요 없습니다.
❯ completions list
Completion plugins:
docker docker (built-in, 13 rules)
...
이 순간부터, gh pr checkout <TAB>은 PR 번호와 그 옆에 제목을 함께 완성해주며, gh run watch <TAB>은 사용자의 워크플로 실행 상태를 인식합니다.
파일을 수동으로 편집하고 completions reload를 실행하세요. 다시 생성하거나 삭제할 수도 있습니다. 수락하는 순간부터 이 파일은 당신의 것입니다. 이것이 제가 드리는 선물입니다. 에이전트(agent)는 당신이 빈 페이지를 넘어설 수 있게 도와준 뒤 떠납니다.
이것이 기능의 전부입니다. 이 포스트의 나머지 내용은 방금 막 백그라운드에서 일어난 일들에 대한 설명입니다.
누가 쉘에게 어떤 컨테이너가 존재하는지 알려줄 수 있는가?
Hash는 이미 처음 세 가지를 갖추고 있었습니다: 이를 탑재한 CLI를 위한 도구 네이티브 완성(tool-native completion, kubectl, gh), 의도적으로 선별된 짧은 목록을 위한 수동 작성 핸들러(hand-written handlers), 그리고 ?? 에이전트입니다. 플러그인은 네 번째 행을 채웁니다.
처음 세 가지는 모두 누군가를 기다립니다. 도구 네이티브 완성(tool-native completion)은 존재할 경우 매우 훌륭하지만, 도구의 제작자가 이를 탑재해야만 합니다. 내장 핸들러(Built-in handlers)가 존재하는 이유는 제가 그것들을 탑재했기 때문이며, 이는 저의 동기 부여와 대역폭(bandwidth)을 넘어 확장될 수 없습니다.
누락된 계층은 실시간으로 쿼리 가능한 상태(queryable state)를 가지고 있지만 완성 프로토콜(completion protocol)이 없는 매우 방대한 범주의 도구들을 위한 것입니다. 무언가는 docker ps를 실행해야 합니다. 유일한 질문은 누가 그것을 선언하느냐, 그리고 어떤 언어로 선언하느냐입니다.
형식은 의도적으로 언어가 아니다
에이전트가 작성한 파일은 세 가지 질문에 답합니다: 어디에 적용되는가, 어떤 명령어가 후보를 생성하는가, 그리고 어떤 열(column)에 값이 포함되어 있는가. 이는 설계 질문을 반영한 결과입니다. 질문은 결코 "플러그인이 무엇을 할 수 있는가?"가 아니라, "docker rm 케이스를 커버하면서 플러그인이 할 수 있는 일을 얼마나 최소화할 수 있는가?"였습니다. 그 결과는 에이전트를 완전히 건너뛰고 수동으로 작성할 수 있을 만큼 충분히 작습니다:
# ~/.config/hash/completions/kubectl.toml
[plugin]
name = "kubectl"
...
이것이 전부입니다. exec 배열은 쉘 해석(shell interpretation) 없이 argv로 직접 전달되며, 각 출력 라인은 하나의 후보(candidate)가 되고, Hash는 컬럼을 분할(split)하고 매핑(map)합니다.
docker 플러그인의 docker ps 소스를 예로 들었습니다. 위의 kubectl 스펙도 동일한 방식으로 매핑됩니다. 파서(parser)는 단 하나의 함수로 구성됩니다: 구분자(delimiter)를 기준으로 라인을 분할하고, 하나의 컬럼을 값(value)으로, 선택적으로 다른 컬럼을 설명(description)으로 가져오며, 빈 값과 중복을 제거합니다. 버그가 발생할 수 있는 표면적(surface area)이 매우 좁습니다.
이 형식의 흥미로운 점은 무엇을 거부하느냐에 있습니다. 조건문(conditionals), 변수(variables), 현재 단어(current word)에 대한 접근, 소스 출력물을 후처리(post-process)하는 방법이 전혀 없습니다. 출력을 재구성(reshaping)해야 할 경우, 재구성은 소스 명령 자체 내부에서 이루어집니다. terraform workspace list는 현재 워크스페이스에 별표(*)를 표시하므로, 워크스페이스 플러그인은 그 단계에서 별표를 제거합니다:
[rules.source]
exec = ["sh", "-c", "terraform workspace list | tr -d '* '"]
파이프라인은 점차 하나의 언어로 성장해가는 설정 언어(config language)가 아니라, 플러그인 자체의 명령 안에 존재합니다.
두 가지 제약 사항이 시스템을 견고하게 유지하는 대부분의 역할을 합니다.
일치하는 규칙이 인자(argument)를 소유합니다. 규칙이 일치하지만 소스에서 아무런 응답이 없다면, 그것은
잘못된 확장 형식(extension format)을 갖게 되는 가장 빠른 방법은, "진짜" 구현은 여러분의 언어로 배포하고 확장 인터페이스(extension surface)는 그 옆에 덧붙여진 덜 중요한 것으로 취급하는 것입니다. 내부 경로는 표현력이 풍부하게 유지되는 반면, 공개 경로는 경직되며, 정작 여러분은 그것을 사용하지 않기 때문에 그 차이를 전혀 느끼지 못하게 됩니다.
그래서 Hash가 배포하는 유일한 제3자 완성 플러그인(third-party completion plugin)은 Docker를 위한 것이며, 그것은 TOML 파일입니다. TOML 형태의 API가 아닙니다. 바이너리 내의 문자열 상수로서의 실제 TOML입니다:
const builtinDockerSpec = `
[plugin]
name = "docker"
...
저 mustParsePluginSpec이 핵심입니다. 이것은 여러분의 파일이 거치는 것과 동일한 ParsePluginSpec을 실행하며, 여기에는 엄격한 미정의 필드 체크(unknown-field check)가 포함되어 있고, 실패 시 *패닉(panic)*을 일으킵니다. 내장 스펙(built-in spec)에 오타가 있다면 쉘은 시작을 거부할 것이며, 이것이 바로 제가 원하는 방식입니다. 만약 제가 형식을 약화시키거나 파서(parser)가 허용하지 않는 키를 추가한다면, 실제로 한 번도 사용하지 않을 확장 인터페이스를 배포하는 대신 즉시 그 사실을 알게 됩니다.
이 방식은 실질적인 이득을 가져다줍니다. 그 13개의 규칙을 작성하면서 형식이 표현할 수 없는 사례들에 계속 부딪혔고, 사용할 수 있는 내부 탈출구(internal escape hatch)가 없었기에, 그 규칙들 하나하나가 여러분의 플러그인도 사용하게 될 형식의 기능(format feature)이 되어야만 했습니다.
그리고 이것은 진정으로 단순한 플러그인이기 때문에, 아무런 권한도 없습니다. 여러분만의 docker.toml을 작성하면 그것이 제 것을 통째로 대체합니다. disabled = true가 포함된 것을 배포하면 제 것은 사라지고 그 자리에는 아무것도 남지 않습니다. 내장된 것은 가장 문자 그대로의 의미에서 참조 구현(reference implementation)입니다. 즉, 여러분은 그 전부를 읽을 수 있습니다.
에이전트가 정답을 맞히는 이유
completions generate는 원래 계획에 없었습니다. 그것은 제 최고의 아이디어들이 항상 떠오르는 장소인 샤워실에서 나중에 나타난 것이었습니다. 이것이 작동하는 이유는 형식이 작기 때문입니다. 로직이 없는 TOML(TOML-with-no-logic)은 감사가 가능합니다. 플러그인을 5초 만에 읽고 여러분의 머신에서 정확히 무엇이 실행될지 알 수 있습니다. 동일한 특성 덕분에 언어 모델(language model)이 이를 안정적으로 초안을 작성할 수 있습니다. 미묘하게 틀릴 수 있는 프로그램 인터페이스(program surface)가 매우 적기 때문입니다: 고정된 키 세트, 고정된 형태, 그리고 검증기(validator)가 존재합니다.
기계적으로는, 쉘이 도구 자체의 --help를 캡처하고, 에이전트에게 스펙 참조(spec reference)와 해당 도움말 텍스트를 전달한 뒤 TOML을 돌려받습니다. gh 실행의 나머지 모든 부분은 추론되었습니다. 모든 소스는 --json과 더불어 정확히 두 개의 탭으로 구분된 열(tab-separated columns)을 출력하는 --jq 템플릿을 요청하는데, 이는 프롬프트가 인간용 표를 스크래핑하는 것보다 기계 판독 가능한(machine-readable) 출력을 선호하며, 모델이 해당 규칙을 gh의 --jq 지원 기능에 매핑했기 때문입니다. max_args = 1은 모든 규칙에 적용되었는데, 이는 gh pr checkout 4821 extra-arg와 같이 첫 번째 위치 인자(positional argument) 이후로는 더 이상 PR 참조가 아니게 되기 때문입니다.
어려운 부분은 지연 시간(latency)이었습니다
형식을 만드는 데는 오후 한나절이 걸렸습니다. 나머지 시간은 하나의 불일치 문제를 해결하는 데 쓰였습니다: 완성 소스(completion source)는 프로세스이고, TAB은 키 입력(keystroke)입니다.
완성 라우터(completion router)는 무언가를 보여주기 위해 약 150 ms의 시간을 가집니다. 콜드(cold) 상태이거나, 바쁘거나, 원격에 있는 docker ps는 이보다 더 오래 걸릴 수 있으며, 두 가지 명백한 해결책 모두 좋지 않습니다: 이를 기다리며 차단(block)하면 TAB 키가 고장 난 것처럼 느껴지고, 마감 시한에 포기하면 소스가 결코 끝나지 않아 플러그인이 아예 작동하지 않게 됩니다.
해결책은 소스를 키 입력의 일부로 취급하는 것을 중단하는 것입니다:
소스는 분리된 컨텍스트(detached context)에서 실행되며 자체 타임아웃에 의해서만 제한되므로, UI 마감 시한이 캐시를 채우기 전에 소스를 종료할 수 없습니다. 요청을 보낸 완성 프로세스는 40 ms 후에 포기하지만, 소스는 포기하지 않습니다.
위에서 아래로 살펴보겠습니다. 각 구성 요소는 단순한(naive) 버전이 특정 방식으로 실패했기 때문에 존재합니다:
40 ms를 기다린 다음, 길을 비켜주세요. 콜드 소스는 공유된 150 ms 예산 중 40 ms를 할당받습니다. 그 시간이 지나면 Hash는 희미하게 fetching completions...를 표시하며 분리된 프로세스가 계속 실행되도록 둡니다.
양보하는 것은 실패하는 것이 아닙니다. 기다리는 것을 포기하면 별도의 Pending 결과를 반환합니다: 이 인자는 내 것이며, 데이터는 전송 중(in flight)이고, 아무도 이에 대해 책임지지 않는다는 의미입니다. 이것이 없다면, 느린 docker ps는 파일 이름들로 저하(degrade)되어 나타나게 됩니다.
메뉴가 스스로 채워집니다. 소스(source)가 도착하면, 콜백(callback)이 완성(completion)을 다시 실행하고 메뉴를 엽니다. 두 번의 TAB를 누를 필요가 없으며, 이 디테일이 당신이 사용하는 도구와, 존재한다는 사실조차 잊게 만드는 도구 사이의 차이를 만듭니다.
오래된 데이터가 빈 데이터보다 낫습니다. cache_ttl이 지나면, 백그라운드에서 새로고침이 실행되는 동안 최대 1분 동안 이전 출력이 계속 제공됩니다. 따라서 cache_ttl은 데이터가 얼마나 _신선한지(fresh)_를 제어하는 것이지, TAB의 반응성(responsiveness)을 제어하는 것이 아닙니다.
실패는 기억되어야 합니다. 이것은 유익한 버그였습니다. Docker가 중단되었을 때, 실패 콜백(failure callback)이 에디터를 깨웠고, 에디터는 재시도했으며, 이는 실패하는 소스를 영원히 다시 실행하게 만들었습니다. 네거티브 캐시(negative cache)는 실패를 5초 동안 기억하여 이 루프를 끊어줍니다.
캐시 키에는 작업 디렉터리(working directory)가 포함됩니다. gh pr list는 모든 리포지토리(repository)에서 서로 다른 답변을 내놓습니다. cd 이후에 잘못된 프로젝트의 답변이 나오는 것은 아예 답변이 없는 것보다 더 나쁩니다.
이 설계가 스스로 충돌하는 유일한 지점은 신선도(freshness)입니다. docker stop web-server를 실행한 다음 docker rm <TAB>을 입력하면, 제 역할을 수행 중인 캐시는 당신의 명령 이전의 상태를 보여줍니다. cache_ttl = "0s"가 바로 이를 위해 존재합니다. 캐싱은 기본적으로 옳지만, 당신이 직접 상태를 변경(mutating)하는 지점에서는 정확히 틀리게 됩니다.
이것이 하지 않는 것
한계점은 중요합니다. 오직 해피 패스(happy path)로만 설명되는 기능은 마케팅에 불과합니다.
생성된 스펙(spec)의 품질은 해당 도구의 --help만큼만 좋습니다. 도움말이 부실한 도구는 빈약한 플러그인을 생성하며, 에이전트(agent)는 볼 수 없는 서브커맨드(subcommand)를 지어내지 않도록 지시받습니다. 따라서 실패 모드는 명령어를 지어내는 것이 아니라, 당신이 기대했던 것보다 적은 범위를 커버하는 플러그인이 되는 방식입니다. 소스(source)는 읽기 전용(read-only)이어야 하며, 비대화형(non-interactive)이고, 경계가 지정되어야 합니다. Hash는 완성(completion) 중에 소스를 자체 프로세스 그룹(process group) 내에서 /dev/null로부터 stdin을 받아 실행할 수 있습니다. 실패한 소스는 침묵을 유지하며 다음 티어(tier)로 넘어갑니다. 따라서 고장 난 플러그인은 마치 플러그인이 없는 것처럼 보입니다.
결론
이 기능은 하나의 TOML 파일과 하나의 서브프로세스(subprocess)입니다. 핵심 아이디어는 확장의 비용이 공백을 견디는 비용보다 낮을 때에만 확장성(extensibility)이 의미를 갖는다는 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기