Skip to content

Commit 488a71c

Browse files
committed
feat: Universal CLI
1 parent e756d82 commit 488a71c

2 files changed

Lines changed: 93 additions & 55 deletions

File tree

README.md

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ It adapts to your workspace, not the other way around.
255255
To use lx, you need both the CLI tool and the Go library. Here is the straightforward setup for any Gopher.
256256

257257
## 1. Install the CLI
258+
258259
`lx` is deployed via Homebrew for macOS.
259260

260261
```bash
@@ -267,24 +268,79 @@ brew install lx
267268
> Currently, only macOS (Intel/Apple Silicon) is supported.
268269
269270
## 2. Add the Library to your Project
271+
270272
Add the dependency to your project to use `lx.Gen()` in your code.
271273

272274
```bash
273275
go get github.com/chebread/lx
276+
274277
```
275278

276279
## 3. Configuration
277280

278281
Create an `lx-config.yaml` file in your home directory (`~/`) or project root.
282+
`lx` supports two modes: Direct API and Universal Command.
283+
284+
### Option A: Direct API (Google Gemini)
285+
286+
The simplest setup if you have a Google API Key.
279287

280288
```yaml
281289
provider: "gemini"
282290
api_key: "YOUR_API_KEY"
283-
model: "foo_bar"
291+
model: "gemini-2.0-flash"
292+
284293
```
285294
286-
> [!NOTE]
287-
> Currently, only Google Gemini is supported.
295+
### Option B: Universal CLI (Gemini, Claude, Ollama, etc.)
296+
297+
`lx` can wrap any CLI tool installed on your machine.
298+
Use the `command` provider and define the argument template. `lx` will automatically substitute `{{prompt}}` and `{{model}}` at runtime.
299+
300+
#### 1. Google Gemini CLI (Zero Cost / No API Key in Config)
301+
302+
```yaml
303+
provider: "command"
304+
bin_path: "/usr/local/bin/gemini" # Check with `which gemini`
305+
model: "gemini-2.0-flash"
306+
args:
307+
- "-p"
308+
- "{{prompt}}"
309+
- "-m"
310+
- "{{model}}"
311+
- "-o"
312+
- "text"
313+
314+
```
315+
316+
#### 2. Claude Code (Anthropic)
317+
318+
```yaml
319+
provider: "command"
320+
bin_path: "/usr/local/bin/claude"
321+
model: "claude-3-7-sonnet"
322+
args:
323+
- "-p"
324+
- "{{prompt}}"
325+
- "--model"
326+
- "{{model}}"
327+
328+
```
329+
330+
#### 3. Ollama (Local / Offline / Free)
331+
332+
Run Llama 3 or DeepSeek locally without internet.
333+
334+
```yaml
335+
provider: "command"
336+
bin_path: "/usr/local/bin/ollama"
337+
model: "llama3"
338+
args:
339+
- "run"
340+
- "{{model}}"
341+
- "{{prompt}}"
342+
343+
```
288344

289345
---
290346

cmd/main.go

Lines changed: 34 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ import (
3333
var version = "dev"
3434

3535
type Config struct {
36-
Provider string `yaml:"provider"`
37-
ApiKey string `yaml:"api_key"`
38-
Model string `yaml:"model"`
39-
BinPath string `yaml:"bin_path"` // <-- [수정 1] 이 줄을 추가하세요
36+
Provider string `yaml:"provider"`
37+
ApiKey string `yaml:"api_key"`
38+
Model string `yaml:"model"`
39+
BinPath string `yaml:"bin_path"`
40+
Args []string `yaml:"args"`
4041
}
4142

4243
type TargetInfo struct {
@@ -71,6 +72,7 @@ type options struct {
7172

7273
type commandLLM struct {
7374
binPath string
75+
args []string // <-- [수정 2] 템플릿 저장용 필드 추가
7476
}
7577

7678
func main() {
@@ -137,8 +139,6 @@ func main() {
137139
clear(backups)
138140

139141
if err != nil {
140-
// 에러가 났으므로 즉시 소스를 원래대로 돌려놓고 종료합니다.
141-
// (log.Fatalf는 os.Exit을 호출하므로 defer가 실행되지 않을 수 있어 수동 복구합니다)
142142
revertCode(backups)
143143
log.Fatalf("\n[lx] Stop: Execution failed. Fix your Go code first.\nError: %v", err)
144144
}
@@ -208,12 +208,9 @@ func injectSpyCode(root string) (map[string]fileBackup, error) {
208208
return true
209209
}
210210

211-
// 1. 함수의 반환 타입들을 순서대로 추출합니다.
212-
// 예: func foo() (int, error) -> [int, error]
213211
var returnTypes []ast.Expr
214212
if fn.Type.Results != nil {
215213
for _, field := range fn.Type.Results.List {
216-
// 이름이 있는 반환값 (a, b int) 또는 없는 반환값 (int, int) 처리
217214
count := len(field.Names)
218215
if count == 0 {
219216
count = 1
@@ -227,7 +224,6 @@ func injectSpyCode(root string) (map[string]fileBackup, error) {
227224
isVoid := len(returnTypes) == 0
228225

229226
if isVoid {
230-
// [Void 함수 처리] SpyVoid 사용 (기존과 동일)
231227
deferStmt := &ast.DeferStmt{
232228
Call: &ast.CallExpr{
233229
Fun: &ast.SelectorExpr{
@@ -245,38 +241,29 @@ func injectSpyCode(root string) (map[string]fileBackup, error) {
245241
fn.Body.List = append([]ast.Stmt{deferStmt}, fn.Body.List...)
246242
modified = true
247243
} else {
248-
// [반환값이 있는 함수 처리]
249244
ast.Inspect(fn.Body, func(inner ast.Node) bool {
250245
retStmt, ok := inner.(*ast.ReturnStmt)
251246
if !ok {
252247
return true
253248
}
254249

255250
for i, resultExpr := range retStmt.Results {
256-
// 인덱스 안전장치 및 이미 Spy 처리된 것 스킵
257251
if i >= len(returnTypes) || isSpyCall(resultExpr) {
258252
continue
259253
}
260254

261-
// [핵심 수정] 제네릭 타입을 명시적으로 생성합니다.
262-
// 예: lx.Spy(...) -> lx.Spy[float64](...)
263-
264-
// 1. lx.Spy 선택자 생성
265255
spySelector := &ast.SelectorExpr{
266256
X: ast.NewIdent("lx"),
267257
Sel: ast.NewIdent("Spy"),
268258
}
269259

270-
// 2. 제네릭 인스턴스화 표현식 생성 (IndexExpr 사용)
271-
// lx.Spy[Type] 형태를 만듭니다.
272260
spyInstance := &ast.IndexExpr{
273261
X: spySelector,
274-
Index: returnTypes[i], // 미리 추출해둔 타입 사용
262+
Index: returnTypes[i],
275263
}
276264

277-
// 3. 함수 호출 표현식 생성
278265
spyCall := &ast.CallExpr{
279-
Fun: spyInstance, // 명시된 제네릭 함수 사용
266+
Fun: spyInstance,
280267
Args: []ast.Expr{
281268
&ast.BasicLit{
282269
Kind: token.STRING,
@@ -360,7 +347,6 @@ func runAndCapture(opts options, rootDir string) ([]TraceData, error) {
360347
return nil, err
361348
}
362349

363-
// 1. 프로젝트 내의 실행 가능한 모든 진입점(package main이 있는 디렉토리)을 찾습니다.
364350
entryPoints, err := findMainPackages(absRoot)
365351
if err != nil {
366352
return nil, fmt.Errorf("failed to scan for main packages: %w", err)
@@ -378,9 +364,8 @@ func runAndCapture(opts options, rootDir string) ([]TraceData, error) {
378364
var allTraces []TraceData
379365
var executionErrors []string
380366

381-
// 2. 발견된 모든 진입점에 대해 각각 실행합니다.
382367
for _, dir := range entryPoints {
383-
// 상대 경로로 변환하여 로그 가독성 향상
368+
384369
relDir, _ := filepath.Rel(absRoot, dir)
385370
if relDir == "" {
386371
relDir = "."
@@ -389,8 +374,7 @@ func runAndCapture(opts options, rootDir string) ([]TraceData, error) {
389374

390375
traces, err := executeSinglePackage(ctx, goExe, dir, opts)
391376
if err != nil {
392-
// 특정 패키지 실행 실패가 전체 프로세스를 중단시키지 않도록 경고만 하고 넘어갈지 결정해야 합니다.
393-
// 여기서는 에러를 기록하고 계속 진행합니다.
377+
394378
executionErrors = append(executionErrors, fmt.Sprintf("%s: %v", relDir, err))
395379
continue
396380
}
@@ -399,7 +383,7 @@ func runAndCapture(opts options, rootDir string) ([]TraceData, error) {
399383

400384
if len(executionErrors) > 0 {
401385
errMsg := strings.Join(executionErrors, "\n\t- ")
402-
// 경고(Warning)가 아니라 에러(Error)를 반환하여 main에서 잡게 합니다.
386+
403387
return allTraces, fmt.Errorf("execution failed in:\n\t- %s", errMsg)
404388
}
405389

@@ -450,7 +434,7 @@ func executeSinglePackage(ctx context.Context, goExe, dir string, opts options)
450434
var td TraceData
451435
if err := json.Unmarshal([]byte(payload), &td); err == nil {
452436
td.Function = normalizeFuncName(td.Function)
453-
// 파일 경로는 절대 경로로 저장되어야 나중에 매칭하기 쉽습니다.
437+
454438
if !filepath.IsAbs(td.File) {
455439
td.File = filepath.Join(dir, td.File)
456440
}
@@ -473,7 +457,6 @@ func executeSinglePackage(ctx context.Context, goExe, dir string, opts options)
473457
waitErr = scanErr
474458
}
475459

476-
// 컨텍스트 타임아웃 체크
477460
if ctx.Err() == context.DeadlineExceeded {
478461
return traces, fmt.Errorf("timeout")
479462
}
@@ -503,16 +486,15 @@ func findMainPackages(root string) ([]string, error) {
503486

504487
dir := filepath.Dir(path)
505488
if _, ok := seen[dir]; ok {
506-
// 이미 이 디렉토리는 package main임을 확인했으므로 스킵
489+
507490
return nil
508491
}
509492

510-
// 파일 내용을 파싱하여 패키지 이름 확인
511493
fset := token.NewFileSet()
512-
// PackageClauseOnly 옵션으로 빠르게 패키지명만 파싱
494+
513495
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.PackageClauseOnly)
514496
if err != nil {
515-
return nil // 파싱 에러 무시
497+
return nil
516498
}
517499

518500
if f.Name.Name == "main" {
@@ -768,7 +750,6 @@ func processSingleTarget(opts options, llm LLM, cfg *Config, target TargetInfo)
768750
}
769751
}
770752

771-
// 4. 시스템 프롬프트 강화 (규칙 추가)
772753
systemPrompt := fmt.Sprintf(`GO FUNC BODY GEN.
773754
774755
SIG: %s
@@ -862,7 +843,6 @@ func newLLM(cfg *Config) (LLM, error) {
862843
return nil, errors.New("nil config")
863844
}
864845

865-
// Model은 공통 필수
866846
if strings.TrimSpace(cfg.Model) == "" {
867847
return nil, errors.New("empty model")
868848
}
@@ -874,7 +854,6 @@ func newLLM(cfg *Config) (LLM, error) {
874854

875855
switch provider {
876856
case "gemini":
877-
// 기존 구글 API 방식
878857
if strings.TrimSpace(cfg.ApiKey) == "" {
879858
return nil, errors.New("empty api_key")
880859
}
@@ -887,12 +866,15 @@ func newLLM(cfg *Config) (LLM, error) {
887866
}
888867
return &geminiLLM{client: client}, nil
889868

890-
case "command", "gemini-cli":
891-
// [수정 2] 새로운 커맨드 방식 추가
869+
case "command":
892870
if strings.TrimSpace(cfg.BinPath) == "" {
893871
return nil, errors.New("empty bin_path (required for command provider)")
894872
}
895-
return &commandLLM{binPath: cfg.BinPath}, nil
873+
874+
return &commandLLM{
875+
binPath: cfg.BinPath,
876+
args: cfg.Args,
877+
}, nil
896878

897879
default:
898880
return nil, fmt.Errorf("unsupported provider: %s", cfg.Provider)
@@ -908,17 +890,27 @@ func (g *geminiLLM) Generate(ctx context.Context, model string, prompt string) (
908890
}
909891

910892
func (c *commandLLM) Generate(ctx context.Context, model string, prompt string) (string, error) {
911-
args := []string{"-p", prompt, "-m", model, "-o", "text"}
893+
var finalArgs []string
894+
895+
if len(c.args) == 0 {
896+
finalArgs = []string{"-p", prompt, "-m", model, "-o", "text"}
897+
} else {
898+
for _, arg := range c.args {
899+
replaced := strings.ReplaceAll(arg, "{{prompt}}", prompt)
900+
replaced = strings.ReplaceAll(replaced, "{{model}}", model)
901+
finalArgs = append(finalArgs, replaced)
902+
}
903+
}
912904

913-
cmd := exec.CommandContext(ctx, c.binPath, args...)
905+
cmd := exec.CommandContext(ctx, c.binPath, finalArgs...)
914906

915907
var out bytes.Buffer
916908
var stderr bytes.Buffer
917909
cmd.Stdout = &out
918910
cmd.Stderr = &stderr
919911

920912
if err := cmd.Run(); err != nil {
921-
return "", fmt.Errorf("gemini-cli failed: %v\nStderr: %s", err, stderr.String())
913+
return "", fmt.Errorf("command execution failed: %v\nStderr: %s", err, stderr.String())
922914
}
923915

924916
return out.String(), nil
@@ -991,7 +983,6 @@ func loadConfig() (*Config, string, error) {
991983
}
992984

993985
func cleanAICode(code string) string {
994-
// 1. 마크다운(```) 제거
995986
if start := strings.Index(code, "```"); start != -1 {
996987
if firstNL := strings.Index(code[start:], "\n"); firstNL != -1 {
997988
content := code[start+firstNL+1:]
@@ -1001,7 +992,6 @@ func cleanAICode(code string) string {
1001992
}
1002993
}
1003994

1004-
// 2. 함수 시그니처(func ... { ) 제거
1005995
if strings.Contains(code, "func ") && strings.Contains(code, "{") {
1006996
if open := strings.Index(code, "{"); open != -1 {
1007997
if close := strings.LastIndex(code, "}"); close != -1 {
@@ -1010,26 +1000,18 @@ func cleanAICode(code string) string {
10101000
}
10111001
}
10121002

1013-
// [핵심 수정] 3. 덩그러니 남은 외곽 중괄호({ ... }) 제거
1014-
// AI가 코드 블록만 덜렁 줄 때가 있는데, 이걸 벗겨야 이중 중괄호가 안 생깁니다.
10151003
trimmed := strings.TrimSpace(code)
10161004
if strings.HasPrefix(trimmed, "{") && strings.HasSuffix(trimmed, "}") {
1017-
// 앞뒤 중괄호를 제거합니다.
10181005
code = trimmed[1 : len(trimmed)-1]
10191006
}
10201007

1021-
// 4. 줄 단위로 쪼개서 불필요한 공백 및 lx.Gen 호출 제거
10221008
lines := strings.Split(code, "\n")
10231009
var finalLines []string
10241010
for _, line := range lines {
10251011
t := strings.TrimSpace(line)
1026-
// 빈 줄이거나 lx.Gen 호출이면 무시
10271012
if t == "" || strings.Contains(t, "lx.Gen(") {
10281013
continue
10291014
}
1030-
// 원본 라인의 들여쓰기는 유지하는 것이 좋지만,
1031-
// 외곽 중괄호를 벗긴 경우 들여쓰기가 과도할 수 있으므로 TrimSpace를 하거나
1032-
// 여기서는 안전하게 내용만 가져갑니다. gofmt가 어차피 정리해줍니다.
10331015
finalLines = append(finalLines, line)
10341016
}
10351017

0 commit comments

Comments
 (0)