1
0
Fork 0
forked from lthn/LEM
LEM/docs/plans/2026-02-22-cli-migration-plan.md
Snider c8fc0b515b docs: add CLI migration implementation plan
11-task plan for migrating LEM from manual switch/flag.FlagSet
to core/go pkg/cli registry pattern with grouped commands.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-22 18:25:28 +00:00

26 KiB

LEM CLI Migration Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace LEM's manual 28-case switch os.Args[1] with the Core framework's cli.Main() + cli.WithCommands() pattern, grouping commands into 6 categories.

Architecture: main.go calls cli.Main(cli.WithCommands("lem", lemcmd.AddLEMCommands)). The cmd/lemcmd/ package creates 6 command groups (score, gen, data, export, mon, infra) with cobra commands that pass through to existing lem.Run*() functions. Business logic stays in pkg/lem/ untouched.

Tech Stack: forge.lthn.ai/core/go/pkg/cli (wraps cobra, charmbracelet TUI, Core DI lifecycle)

Design doc: docs/plans/2026-02-22-cli-migration-design.md


Task 1: Add core/go to go.mod

core/go is in the replace block but not in the require block. The compiler needs it to import pkg/cli.

Files:

  • Modify: go.mod

Step 1: Add core/go to require block

Add this line to the first require block in go.mod, before go-i18n:

forge.lthn.ai/core/go v0.0.0-00010101000000-000000000000

Step 2: Run go mod tidy

Run: cd /Users/snider/Code/LEM && go mod tidy

Step 3: Verify build

Run: cd /Users/snider/Code/LEM && go build ./... Expected: Clean build

Step 4: Commit

cd /Users/snider/Code/LEM
git add go.mod go.sum
git commit -m "$(cat <<'EOF'
chore: add core/go to go.mod require block

Prerequisite for CLI migration to core/go pkg/cli framework.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 2: Move runScore, runProbe, runCompare to pkg/lem

Three commands currently live in main.go instead of pkg/lem/. Move them so all 28 commands are accessible from pkg/lem/ before wiring up the new CLI.

Files:

  • Create: pkg/lem/score_cmd.go
  • Modify: main.go (remove the three functions)

Step 1: Create pkg/lem/score_cmd.go

Create /Users/snider/Code/LEM/pkg/lem/score_cmd.go with the three functions moved from main.go. Rename them to exported names and adjust imports:

package lem

import (
	"flag"
	"fmt"
	"log"
	"os"
	"time"
)

// RunScore scores existing response files using LLM judges.
func RunScore(args []string) {
	fs := flag.NewFlagSet("score", flag.ExitOnError)

	input := fs.String("input", "", "Input JSONL response file (required)")
	suites := fs.String("suites", "all", "Comma-separated suites or 'all'")
	judgeModel := fs.String("judge-model", "mlx-community/gemma-3-27b-it-qat-4bit", "Judge model name")
	judgeURL := fs.String("judge-url", "http://10.69.69.108:8090", "Judge API URL")
	concurrency := fs.Int("concurrency", 4, "Max concurrent judge calls")
	output := fs.String("output", "scores.json", "Output score file path")
	resume := fs.Bool("resume", false, "Resume from existing output, skipping scored IDs")

	if err := fs.Parse(args); err != nil {
		log.Fatalf("parse flags: %v", err)
	}

	if *input == "" {
		fmt.Fprintln(os.Stderr, "error: --input is required")
		fs.Usage()
		os.Exit(1)
	}

	responses, err := ReadResponses(*input)
	if err != nil {
		log.Fatalf("read responses: %v", err)
	}
	log.Printf("loaded %d responses from %s", len(responses), *input)

	if *resume {
		if _, statErr := os.Stat(*output); statErr == nil {
			existing, readErr := ReadScorerOutput(*output)
			if readErr != nil {
				log.Fatalf("read existing scores for resume: %v", readErr)
			}

			scored := make(map[string]bool)
			for _, scores := range existing.PerPrompt {
				for _, ps := range scores {
					scored[ps.ID] = true
				}
			}

			var filtered []Response
			for _, r := range responses {
				if !scored[r.ID] {
					filtered = append(filtered, r)
				}
			}
			log.Printf("resume: skipping %d already-scored, %d remaining",
				len(responses)-len(filtered), len(filtered))
			responses = filtered

			if len(responses) == 0 {
				log.Println("all responses already scored, nothing to do")
				return
			}
		}
	}

	client := NewClient(*judgeURL, *judgeModel)
	client.MaxTokens = 512
	judge := NewJudge(client)
	engine := NewEngine(judge, *concurrency, *suites)

	log.Printf("scoring with %s", engine)

	perPrompt := engine.ScoreAll(responses)

	if *resume {
		if _, statErr := os.Stat(*output); statErr == nil {
			existing, _ := ReadScorerOutput(*output)
			for model, scores := range existing.PerPrompt {
				perPrompt[model] = append(scores, perPrompt[model]...)
			}
		}
	}

	averages := ComputeAverages(perPrompt)

	scorerOutput := &ScorerOutput{
		Metadata: Metadata{
			JudgeModel:    *judgeModel,
			JudgeURL:      *judgeURL,
			ScoredAt:      time.Now().UTC(),
			ScorerVersion: "1.0.0",
			Suites:        engine.SuiteNames(),
		},
		ModelAverages: averages,
		PerPrompt:     perPrompt,
	}

	if err := WriteScores(*output, scorerOutput); err != nil {
		log.Fatalf("write scores: %v", err)
	}

	log.Printf("wrote scores to %s", *output)
}

// RunProbe generates responses and scores them.
func RunProbe(args []string) {
	fs := flag.NewFlagSet("probe", flag.ExitOnError)

	model := fs.String("model", "", "Target model name (required)")
	targetURL := fs.String("target-url", "", "Target model API URL (defaults to judge-url)")
	probesFile := fs.String("probes", "", "Custom probes JSONL file (uses built-in content probes if not specified)")
	suites := fs.String("suites", "all", "Comma-separated suites or 'all'")
	judgeModel := fs.String("judge-model", "mlx-community/gemma-3-27b-it-qat-4bit", "Judge model name")
	judgeURL := fs.String("judge-url", "http://10.69.69.108:8090", "Judge API URL")
	concurrency := fs.Int("concurrency", 4, "Max concurrent judge calls")
	output := fs.String("output", "scores.json", "Output score file path")

	if err := fs.Parse(args); err != nil {
		log.Fatalf("parse flags: %v", err)
	}

	if *model == "" {
		fmt.Fprintln(os.Stderr, "error: --model is required")
		fs.Usage()
		os.Exit(1)
	}

	if *targetURL == "" {
		*targetURL = *judgeURL
	}

	targetClient := NewClient(*targetURL, *model)
	targetClient.MaxTokens = 1024
	judgeClient := NewClient(*judgeURL, *judgeModel)
	judgeClient.MaxTokens = 512
	judge := NewJudge(judgeClient)
	engine := NewEngine(judge, *concurrency, *suites)
	prober := NewProber(targetClient, engine)

	var scorerOutput *ScorerOutput
	var err error

	if *probesFile != "" {
		probes, readErr := ReadResponses(*probesFile)
		if readErr != nil {
			log.Fatalf("read probes: %v", readErr)
		}
		log.Printf("loaded %d custom probes from %s", len(probes), *probesFile)

		scorerOutput, err = prober.ProbeModel(probes, *model)
	} else {
		log.Printf("using %d built-in content probes", len(ContentProbes))
		scorerOutput, err = prober.ProbeContent(*model)
	}

	if err != nil {
		log.Fatalf("probe: %v", err)
	}

	if writeErr := WriteScores(*output, scorerOutput); writeErr != nil {
		log.Fatalf("write scores: %v", writeErr)
	}

	log.Printf("wrote scores to %s", *output)
}

Note: RunCompare already exists in pkg/lem/compare.go with signature RunCompare(oldPath, newPath string) error. No need to move it — the new CLI wrapper will handle arg parsing.

Step 2: Update main.go to use the new exported functions

In main.go, replace:

  • runScore(os.Args[2:])lem.RunScore(os.Args[2:])
  • runProbe(os.Args[2:])lem.RunProbe(os.Args[2:])

Remove the runScore, runProbe, and runCompare functions from main.go. For compare, change the switch case to call through:

	case "compare":
		fs := flag.NewFlagSet("compare", flag.ExitOnError)
		oldFile := fs.String("old", "", "Old score file (required)")
		newFile := fs.String("new", "", "New score file (required)")
		if err := fs.Parse(os.Args[2:]); err != nil {
			log.Fatalf("parse flags: %v", err)
		}
		if *oldFile == "" || *newFile == "" {
			fmt.Fprintln(os.Stderr, "error: --old and --new are required")
			fs.Usage()
			os.Exit(1)
		}
		if err := lem.RunCompare(*oldFile, *newFile); err != nil {
			log.Fatalf("compare: %v", err)
		}

Actually simpler: leave main.go's compare case inline since we're about to replace the whole file anyway. The key change is moving runScore and runProbe to pkg/lem/ and removing them from main.go.

The new main.go (with functions removed but switch intact):

package main

import (
	"flag"
	"fmt"
	"log"
	"os"

	"forge.lthn.ai/lthn/lem/pkg/lem"
)

const usage = `Usage: lem <command> [flags]
...existing usage string...
`

func main() {
	if len(os.Args) < 2 {
		fmt.Fprint(os.Stderr, usage)
		os.Exit(1)
	}

	switch os.Args[1] {
	case "distill":
		lem.RunDistill(os.Args[2:])
	case "score":
		lem.RunScore(os.Args[2:])
	case "probe":
		lem.RunProbe(os.Args[2:])
	case "compare":
		fs := flag.NewFlagSet("compare", flag.ExitOnError)
		oldFile := fs.String("old", "", "Old score file (required)")
		newFile := fs.String("new", "", "New score file (required)")
		if err := fs.Parse(os.Args[2:]); err != nil {
			log.Fatalf("parse flags: %v", err)
		}
		if *oldFile == "" || *newFile == "" {
			fmt.Fprintln(os.Stderr, "error: --old and --new are required")
			fs.Usage()
			os.Exit(1)
		}
		if err := lem.RunCompare(*oldFile, *newFile); err != nil {
			log.Fatalf("compare: %v", err)
		}
	case "status":
		lem.RunStatus(os.Args[2:])
	// ... rest of switch cases unchanged ...
	case "worker":
		lem.RunWorker(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "unknown command: %s\n\n%s", os.Args[1], usage)
		os.Exit(1)
	}
}

Remove "time" from imports (only needed by the moved runScore).

Step 3: Verify build

Run: cd /Users/snider/Code/LEM && go build ./... Expected: Clean build

Step 4: Commit

cd /Users/snider/Code/LEM
git add pkg/lem/score_cmd.go main.go
git commit -m "$(cat <<'EOF'
refactor: move runScore and runProbe to pkg/lem

All 28 commands now accessible as exported lem.Run* functions.
Prerequisite for CLI framework migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 3: Create cmd/lemcmd/lem.go — root registration

Create the root registration file that main.go will call.

Files:

  • Create: cmd/lemcmd/lem.go

Step 1: Create the root file

Create /Users/snider/Code/LEM/cmd/lemcmd/lem.go:

// Package lemcmd provides CLI commands for the LEM binary.
// Commands register through the Core framework's cli.WithCommands lifecycle.
package lemcmd

import (
	"forge.lthn.ai/core/go/pkg/cli"
)

// AddLEMCommands registers all LEM command groups on the root command.
func AddLEMCommands(root *cli.Command) {
	addScoreCommands(root)
	addGenCommands(root)
	addDataCommands(root)
	addExportCommands(root)
	addMonCommands(root)
	addInfraCommands(root)
}

This won't compile yet (the add*Commands functions don't exist). That's fine — we'll add them in Tasks 4-9.

Step 2: Commit

cd /Users/snider/Code/LEM
git add cmd/lemcmd/lem.go
git commit -m "$(cat <<'EOF'
feat(cli): add root command registration for LEM

AddLEMCommands wires 6 command groups through cli.WithCommands.
Group implementations follow in subsequent commits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 4: Create cmd/lemcmd/score.go — Scoring group

5 commands: score, probe, compare, tier-score, agent

Files:

  • Create: cmd/lemcmd/score.go

Step 1: Create the score commands file

Create /Users/snider/Code/LEM/cmd/lemcmd/score.go:

package lemcmd

import (
	"forge.lthn.ai/core/go/pkg/cli"
	"forge.lthn.ai/lthn/lem/pkg/lem"
)

func addScoreCommands(root *cli.Command) {
	scoreGroup := cli.NewGroup("score", "Scoring commands", "Score responses, probe models, compare results.")

	scoreGroup.AddCommand(cli.NewRun("run", "Score existing response files", "", func(cmd *cli.Command, args []string) {
		lem.RunScore(args)
	}))

	scoreGroup.AddCommand(cli.NewRun("probe", "Generate responses and score them", "", func(cmd *cli.Command, args []string) {
		lem.RunProbe(args)
	}))

	scoreGroup.AddCommand(cli.NewCommand("compare", "Compare two score files", "", func(cmd *cli.Command, args []string) error {
		var oldFile, newFile string
		cli.StringFlag(cmd, &oldFile, "old", "", "", "Old score file (required)")
		cli.StringFlag(cmd, &newFile, "new", "", "", "New score file (required)")
		// Flags are parsed by cobra before RunE is called.
		// But since we declared flags on the cmd, they're already available.
		return lem.RunCompare(oldFile, newFile)
	}))

	scoreGroup.AddCommand(cli.NewRun("tier", "Score expansion responses (heuristic/judge tiers)", "", func(cmd *cli.Command, args []string) {
		lem.RunTierScore(args)
	}))

	scoreGroup.AddCommand(cli.NewRun("agent", "ROCm scoring daemon (polls M3, scores checkpoints)", "", func(cmd *cli.Command, args []string) {
		lem.RunAgent(args)
	}))

	root.AddCommand(scoreGroup)
}

Wait — there's a subtlety with compare. RunCompare takes (oldPath, newPath string) error, not []string. The flags need to be declared on the cobra command BEFORE RunE runs. Let me fix that:

package lemcmd

import (
	"fmt"

	"forge.lthn.ai/core/go/pkg/cli"
	"forge.lthn.ai/lthn/lem/pkg/lem"
)

func addScoreCommands(root *cli.Command) {
	scoreGroup := cli.NewGroup("score", "Scoring commands", "Score responses, probe models, compare results.")

	scoreGroup.AddCommand(cli.NewRun("run", "Score existing response files", "", func(cmd *cli.Command, args []string) {
		lem.RunScore(args)
	}))

	scoreGroup.AddCommand(cli.NewRun("probe", "Generate responses and score them", "", func(cmd *cli.Command, args []string) {
		lem.RunProbe(args)
	}))

	// compare has a different signature — it takes two named args, not []string.
	compareCmd := cli.NewCommand("compare", "Compare two score files", "", nil)
	var compareOld, compareNew string
	cli.StringFlag(compareCmd, &compareOld, "old", "", "", "Old score file (required)")
	cli.StringFlag(compareCmd, &compareNew, "new", "", "", "New score file (required)")
	compareCmd.RunE = func(cmd *cli.Command, args []string) error {
		if compareOld == "" || compareNew == "" {
			return fmt.Errorf("--old and --new are required")
		}
		return lem.RunCompare(compareOld, compareNew)
	}
	scoreGroup.AddCommand(compareCmd)

	scoreGroup.AddCommand(cli.NewRun("tier", "Score expansion responses (heuristic/judge tiers)", "", func(cmd *cli.Command, args []string) {
		lem.RunTierScore(args)
	}))

	scoreGroup.AddCommand(cli.NewRun("agent", "ROCm scoring daemon (polls M3, scores checkpoints)", "", func(cmd *cli.Command, args []string) {
		lem.RunAgent(args)
	}))

	root.AddCommand(scoreGroup)
}

Step 2: Commit

cd /Users/snider/Code/LEM
git add cmd/lemcmd/score.go
git commit -m "$(cat <<'EOF'
feat(cli): add score command group

lem score [run|probe|compare|tier|agent]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 5: Create cmd/lemcmd/gen.go — Generation group

3 commands: distill, expand, conv

Files:

  • Create: cmd/lemcmd/gen.go

Step 1: Create the gen commands file

Create /Users/snider/Code/LEM/cmd/lemcmd/gen.go:

package lemcmd

import (
	"forge.lthn.ai/core/go/pkg/cli"
	"forge.lthn.ai/lthn/lem/pkg/lem"
)

func addGenCommands(root *cli.Command) {
	genGroup := cli.NewGroup("gen", "Generation commands", "Distill, expand, and generate training data.")

	genGroup.AddCommand(cli.NewRun("distill", "Native Metal distillation (go-mlx + grammar scoring)", "", func(cmd *cli.Command, args []string) {
		lem.RunDistill(args)
	}))

	genGroup.AddCommand(cli.NewRun("expand", "Generate expansion responses via trained LEM model", "", func(cmd *cli.Command, args []string) {
		lem.RunExpand(args)
	}))

	genGroup.AddCommand(cli.NewRun("conv", "Generate conversational training data (calm phase)", "", func(cmd *cli.Command, args []string) {
		lem.RunConv(args)
	}))

	root.AddCommand(genGroup)
}

Step 2: Commit

cd /Users/snider/Code/LEM
git add cmd/lemcmd/gen.go
git commit -m "$(cat <<'EOF'
feat(cli): add gen command group

lem gen [distill|expand|conv]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 6: Create cmd/lemcmd/data.go — Data Management group

4 commands: import-all, consolidate, normalize, approve

Files:

  • Create: cmd/lemcmd/data.go

Step 1: Create the data commands file

Create /Users/snider/Code/LEM/cmd/lemcmd/data.go:

package lemcmd

import (
	"forge.lthn.ai/core/go/pkg/cli"
	"forge.lthn.ai/lthn/lem/pkg/lem"
)

func addDataCommands(root *cli.Command) {
	dataGroup := cli.NewGroup("data", "Data management commands", "Import, consolidate, normalise, and approve training data.")

	dataGroup.AddCommand(cli.NewRun("import-all", "Import ALL LEM data into DuckDB from M3", "", func(cmd *cli.Command, args []string) {
		lem.RunImport(args)
	}))

	dataGroup.AddCommand(cli.NewRun("consolidate", "Pull worker JSONLs from M3, merge, deduplicate", "", func(cmd *cli.Command, args []string) {
		lem.RunConsolidate(args)
	}))

	dataGroup.AddCommand(cli.NewRun("normalize", "Normalise seeds to deduplicated expansion prompts", "", func(cmd *cli.Command, args []string) {
		lem.RunNormalize(args)
	}))

	dataGroup.AddCommand(cli.NewRun("approve", "Filter scored expansions to training JSONL", "", func(cmd *cli.Command, args []string) {
		lem.RunApprove(args)
	}))

	root.AddCommand(dataGroup)
}

Step 2: Commit

cd /Users/snider/Code/LEM
git add cmd/lemcmd/data.go
git commit -m "$(cat <<'EOF'
feat(cli): add data command group

lem data [import-all|consolidate|normalize|approve]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 7: Create cmd/lemcmd/export.go — Export & Publish group

4 commands: jsonl (was "export"), parquet, publish, convert

Files:

  • Create: cmd/lemcmd/export.go

Step 1: Create the export commands file

Create /Users/snider/Code/LEM/cmd/lemcmd/export.go:

package lemcmd

import (
	"forge.lthn.ai/core/go/pkg/cli"
	"forge.lthn.ai/lthn/lem/pkg/lem"
)

func addExportCommands(root *cli.Command) {
	exportGroup := cli.NewGroup("export", "Export and publish commands", "Export training data to JSONL, Parquet, HuggingFace, and PEFT formats.")

	exportGroup.AddCommand(cli.NewRun("jsonl", "Export golden set to training-format JSONL splits", "", func(cmd *cli.Command, args []string) {
		lem.RunExport(args)
	}))

	exportGroup.AddCommand(cli.NewRun("parquet", "Export JSONL training splits to Parquet", "", func(cmd *cli.Command, args []string) {
		lem.RunParquet(args)
	}))

	exportGroup.AddCommand(cli.NewRun("publish", "Push Parquet files to HuggingFace dataset repo", "", func(cmd *cli.Command, args []string) {
		lem.RunPublish(args)
	}))

	exportGroup.AddCommand(cli.NewRun("convert", "Convert MLX LoRA adapter to PEFT format", "", func(cmd *cli.Command, args []string) {
		lem.RunConvert(args)
	}))

	root.AddCommand(exportGroup)
}

Step 2: Commit

cd /Users/snider/Code/LEM
git add cmd/lemcmd/export.go
git commit -m "$(cat <<'EOF'
feat(cli): add export command group

lem export [jsonl|parquet|publish|convert]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 8: Create cmd/lemcmd/mon.go — Monitoring group

5 commands: status, expand-status, inventory, coverage, metrics

Files:

  • Create: cmd/lemcmd/mon.go

Step 1: Create the monitoring commands file

Create /Users/snider/Code/LEM/cmd/lemcmd/mon.go:

package lemcmd

import (
	"forge.lthn.ai/core/go/pkg/cli"
	"forge.lthn.ai/lthn/lem/pkg/lem"
)

func addMonCommands(root *cli.Command) {
	monGroup := cli.NewGroup("mon", "Monitoring commands", "Training progress, pipeline status, inventory, coverage, and metrics.")

	monGroup.AddCommand(cli.NewRun("status", "Show training and generation progress (InfluxDB)", "", func(cmd *cli.Command, args []string) {
		lem.RunStatus(args)
	}))

	monGroup.AddCommand(cli.NewRun("expand-status", "Show expansion pipeline status (DuckDB)", "", func(cmd *cli.Command, args []string) {
		lem.RunExpandStatus(args)
	}))

	monGroup.AddCommand(cli.NewRun("inventory", "Show DuckDB table inventory", "", func(cmd *cli.Command, args []string) {
		lem.RunInventory(args)
	}))

	monGroup.AddCommand(cli.NewRun("coverage", "Analyse seed coverage gaps", "", func(cmd *cli.Command, args []string) {
		lem.RunCoverage(args)
	}))

	monGroup.AddCommand(cli.NewRun("metrics", "Push DuckDB golden set stats to InfluxDB", "", func(cmd *cli.Command, args []string) {
		lem.RunMetrics(args)
	}))

	root.AddCommand(monGroup)
}

Step 2: Commit

cd /Users/snider/Code/LEM
git add cmd/lemcmd/mon.go
git commit -m "$(cat <<'EOF'
feat(cli): add mon command group

lem mon [status|expand-status|inventory|coverage|metrics]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 9: Create cmd/lemcmd/infra.go — Infrastructure group

4 commands: ingest, seed-influx, query, worker

Files:

  • Create: cmd/lemcmd/infra.go

Step 1: Create the infra commands file

Create /Users/snider/Code/LEM/cmd/lemcmd/infra.go:

package lemcmd

import (
	"forge.lthn.ai/core/go/pkg/cli"
	"forge.lthn.ai/lthn/lem/pkg/lem"
)

func addInfraCommands(root *cli.Command) {
	infraGroup := cli.NewGroup("infra", "Infrastructure commands", "InfluxDB ingestion, DuckDB queries, and distributed workers.")

	infraGroup.AddCommand(cli.NewRun("ingest", "Ingest benchmark data into InfluxDB", "", func(cmd *cli.Command, args []string) {
		lem.RunIngest(args)
	}))

	infraGroup.AddCommand(cli.NewRun("seed-influx", "Seed InfluxDB golden_gen from DuckDB", "", func(cmd *cli.Command, args []string) {
		lem.RunSeedInflux(args)
	}))

	infraGroup.AddCommand(cli.NewRun("query", "Run ad-hoc SQL against DuckDB", "", func(cmd *cli.Command, args []string) {
		lem.RunQuery(args)
	}))

	infraGroup.AddCommand(cli.NewRun("worker", "Run as distributed inference worker node", "", func(cmd *cli.Command, args []string) {
		lem.RunWorker(args)
	}))

	root.AddCommand(infraGroup)
}

Step 2: Commit

cd /Users/snider/Code/LEM
git add cmd/lemcmd/infra.go
git commit -m "$(cat <<'EOF'
feat(cli): add infra command group

lem infra [ingest|seed-influx|query|worker]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 10: Replace main.go with cli.Main

The final step: replace the entire main.go with the framework bootstrap.

Files:

  • Modify: main.go

Step 1: Replace main.go

Replace the entire contents of /Users/snider/Code/LEM/main.go with:

package main

import (
	"forge.lthn.ai/core/go/pkg/cli"
	"forge.lthn.ai/lthn/lem/cmd/lemcmd"
)

func main() {
	cli.Main(
		cli.WithCommands("lem", lemcmd.AddLEMCommands),
	)
}

Step 2: Verify build

Run: cd /Users/snider/Code/LEM && go build ./... Expected: Clean build

Step 3: Verify vet

Run: cd /Users/snider/Code/LEM && go vet ./... Expected: Clean

Step 4: Commit

cd /Users/snider/Code/LEM
git add main.go
git commit -m "$(cat <<'EOF'
feat(cli): replace manual switch with cli.Main + WithCommands

main.go shrinks from 296 lines to 11. All 28 commands register
through Core framework lifecycle via cli.WithCommands. Gets signal
handling, shell completion, grouped help, and TUI primitives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Task 11: Run go mod tidy and final verification

Files:

  • Modify: go.mod, go.sum

Step 1: Run go mod tidy

Run: cd /Users/snider/Code/LEM && go mod tidy

Step 2: Full build

Run: cd /Users/snider/Code/LEM && go build ./... Expected: Clean

Step 3: Run go vet

Run: cd /Users/snider/Code/LEM && go vet ./... Expected: Clean

Step 4: Smoke test — help output

Run: cd /Users/snider/Code/LEM && go run . --help

Expected: Grouped command listing showing score, gen, data, export, mon, infra subgroups.

Step 5: Smoke test — subcommand help

Run: cd /Users/snider/Code/LEM && go run . gen --help

Expected: Lists distill, expand, conv subcommands with descriptions.

Step 6: Smoke test — distill dry-run

Run: cd /Users/snider/Code/LEM && go run . gen distill -- --model gemma3/1b --probes core --dry-run

Note: The -- separator tells cobra to stop parsing flags and pass the rest as args to the Run handler. Since RunDistill does its own flag parsing from the args []string, the flags after -- are passed through.

If cobra swallows the flags (because they're defined on the parent), try without --: Run: cd /Users/snider/Code/LEM && go run . gen distill --model gemma3/1b --probes core --dry-run

Expected: The familiar dry-run output with Memory line.

Step 7: Commit if go.mod/go.sum changed

cd /Users/snider/Code/LEM
git add go.mod go.sum
git commit -m "$(cat <<'EOF'
chore: go mod tidy after CLI migration

core/go now a direct dependency for pkg/cli framework.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Command Mapping Reference

Old command New command Handler
lem score lem score run lem.RunScore(args)
lem probe lem score probe lem.RunProbe(args)
lem compare lem score compare --old X --new Y lem.RunCompare(old, new)
lem tier-score lem score tier lem.RunTierScore(args)
lem agent lem score agent lem.RunAgent(args)
lem distill lem gen distill lem.RunDistill(args)
lem expand lem gen expand lem.RunExpand(args)
lem conv lem gen conv lem.RunConv(args)
lem import-all lem data import-all lem.RunImport(args)
lem consolidate lem data consolidate lem.RunConsolidate(args)
lem normalize lem data normalize lem.RunNormalize(args)
lem approve lem data approve lem.RunApprove(args)
lem export lem export jsonl lem.RunExport(args)
lem parquet lem export parquet lem.RunParquet(args)
lem publish lem export publish lem.RunPublish(args)
lem convert lem export convert lem.RunConvert(args)
lem status lem mon status lem.RunStatus(args)
lem expand-status lem mon expand-status lem.RunExpandStatus(args)
lem inventory lem mon inventory lem.RunInventory(args)
lem coverage lem mon coverage lem.RunCoverage(args)
lem metrics lem mon metrics lem.RunMetrics(args)
lem ingest lem infra ingest lem.RunIngest(args)
lem seed-influx lem infra seed-influx lem.RunSeedInflux(args)
lem query lem infra query lem.RunQuery(args)
lem worker lem infra worker lem.RunWorker(args)

What Stays the Same

  • All pkg/lem/Run* functions — unchanged (they accept []string and do their own flag parsing)
  • All business logic — untouched
  • Config loading, probe loading, scoring — unchanged
  • pkg/lem/backend_metal.go — unchanged