Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2be5340
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
56de173
ci: trigger checks
github-actions[bot] May 27, 2026
03181c2
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
0b16867
ci: trigger checks
github-actions[bot] May 27, 2026
111b7fc
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
f3611b6
ci: trigger checks
github-actions[bot] May 27, 2026
ebbb5fd
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
2172ef5
ci: trigger checks
github-actions[bot] May 27, 2026
3190f24
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
74e323f
ci: trigger checks
github-actions[bot] May 27, 2026
790d91b
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
0d90b64
ci: trigger checks
github-actions[bot] May 27, 2026
b39b32e
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
46b5c61
ci: trigger checks
github-actions[bot] May 27, 2026
81be00e
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
c23e900
ci: trigger checks
github-actions[bot] May 27, 2026
2b79869
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
069400f
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration …
github-actions[bot] May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions cmd/apm/CUTOVER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# APM CLI Go Rewrite -- Cutover Plan

This document describes when and how the Go binary replaces the Python
binary as the shipped `apm` command (hard gate 2 of the completion
framework in issue #78).

## Current State

The Go binary (`cmd/apm`) is built in parallel with the Python CLI
(`src/apm_cli/`). The Python CLI is currently the shipped `apm` command
via PyInstaller packaging and `pip install apm-cli`.

The Go CLI currently implements:
- `apm --help` / `apm --version` (full parity with Python)
- `apm init [--yes] [PROJECT_NAME]` (functional, creates apm.yml)
- Per-command `--help` for all 26 commands (golden-file verified)

Remaining commands return a "not yet fully implemented" message.

## Cutover Trigger Conditions

The Go binary becomes the shipped `apm` command when ALL of the following
are true:

1. All 26 commands respond correctly to `--help` (done)
2. The representative command matrix passes functional tests:
`init`, `install`, `update`, `compile`, `pack`, `run`, `audit`,
`policy`, `mcp`, `runtime`, `targets`, `list`, `view`, `cache`,
`deps`, `marketplace`, `uninstall`, `prune`
3. Python-vs-Go parity tests pass for all commands in the matrix
4. `go build ./cmd/apm` produces a single static binary
5. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`)

## Cutover Steps

When conditions are met:

1. Update `pyproject.toml` to add `[project.scripts]` pointing to the
Go binary wrapper OR replace the `apm` entrypoint with a shim that
calls the Go binary.
2. Update `build/apm.spec` (PyInstaller) to be marked deprecated/archived.
3. Update `install.sh` and `install.ps1` to download the Go binary.
4. Tag a release with `goreleaser` (or equivalent) producing platform
binaries.
5. Update `README.md` install instructions to reference the Go binary.

## Python Compatibility Shim

Until all commands are implemented in Go, the Python CLI remains the
authoritative `apm` command. The Go binary is available as `apm-go`
for testing.

The shim removal plan: once the command matrix passes functional tests,
the Python entrypoint is replaced by the Go binary in the same PR that
passes the final parity tests.

## Timeline

Each Crane iteration advances one or more commands. At the current pace
(one iteration every 20 minutes), full command coverage is expected
within ~10 additional iterations.
191 changes: 191 additions & 0 deletions cmd/apm/apmyml.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// apmyml.go provides a minimal apm.yml parser for the Go CLI rewrite.
// Only the fields needed for read-only CLI commands are parsed.
package main

import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)

// ApmProject holds the parsed apm.yml structure.
type ApmProject struct {
Name string
Version string
Description string
Author string
Targets []string
Scripts map[string]string
Deps []ApmDep
MCPDeps []ApmDep
Marketplaces []ApmMarketplace
}

// ApmDep is a single dependency entry (owner/repo or owner/repo@ref).
type ApmDep struct {
Package string
Ref string
}

// ApmMarketplace is a registered marketplace source.
type ApmMarketplace struct {
Name string
URL string
}

// findApmYML walks up from dir looking for apm.yml.
func findApmYML(dir string) (string, error) {
current := dir
for {
candidate := filepath.Join(current, "apm.yml")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return "", fmt.Errorf("apm.yml not found (searched from %s)", dir)
}

// parseApmYML does a line-by-line best-effort parse of apm.yml.
// It handles simple YAML scalars and list entries only.
func parseApmYML(path string) (*ApmProject, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()

p := &ApmProject{Scripts: map[string]string{}}
scanner := bufio.NewScanner(f)

var section string
var depSection string // "apm" or "mcp"

for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}

// Top-level key detection.
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, "-") {
if idx := strings.Index(trimmed, ":"); idx >= 0 {
key := strings.TrimSpace(trimmed[:idx])
val := strings.TrimSpace(trimmed[idx+1:])
switch key {
case "name":
p.Name = unquote(val)
section = ""
case "version":
p.Version = unquote(val)
section = ""
case "description":
p.Description = unquote(val)
section = ""
case "author":
p.Author = unquote(val)
section = ""
case "targets":
section = "targets"
if val != "" && val != "[]" {
p.Targets = parseInlineList(val)
}
case "scripts":
section = "scripts"
case "dependencies":
section = "dependencies"
depSection = ""
case "marketplace":
section = "marketplace"
default:
section = key
}
continue
}
}

// Section-specific parsing.
indent := len(line) - len(strings.TrimLeft(line, " \t"))

switch section {
case "targets":
if strings.HasPrefix(trimmed, "-") {
val := strings.TrimSpace(trimmed[1:])
if val != "" {
p.Targets = append(p.Targets, unquote(val))
}
}
case "scripts":
if idx := strings.Index(trimmed, ":"); idx >= 0 {
key := strings.TrimSpace(trimmed[:idx])
val := strings.TrimSpace(trimmed[idx+1:])
if key != "" && !strings.HasPrefix(key, "-") {
p.Scripts[key] = unquote(val)
}
}
case "dependencies":
if indent == 2 || indent == 0 {
if strings.HasSuffix(trimmed, ":") {
depSection = strings.TrimSuffix(trimmed, ":")
}
}
if strings.HasPrefix(trimmed, "-") {
val := strings.TrimSpace(trimmed[1:])
if val != "" {
dep := parseDep(unquote(val))
switch depSection {
case "apm":
p.Deps = append(p.Deps, dep)
case "mcp":
p.MCPDeps = append(p.MCPDeps, dep)
}
}
}
case "marketplace":
// Parse marketplace entries (name: URL or - name: url)
if idx := strings.Index(trimmed, ":"); idx >= 0 {
key := strings.TrimSpace(trimmed[:idx])
val := strings.TrimSpace(trimmed[idx+1:])
if key != "" && val != "" && !strings.HasPrefix(key, "-") {
p.Marketplaces = append(p.Marketplaces, ApmMarketplace{Name: key, URL: unquote(val)})
}
}
}
}
return p, scanner.Err()
}

func unquote(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) {
return s[1 : len(s)-1]
}
return s
}

func parseInlineList(s string) []string {
s = strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(s), "]"), "[")
var out []string
for _, part := range strings.Split(s, ",") {
v := strings.TrimSpace(part)
if v != "" {
out = append(out, unquote(v))
}
}
return out
}

func parseDep(s string) ApmDep {
parts := strings.SplitN(s, "@", 2)
if len(parts) == 2 {
return ApmDep{Package: parts[0], Ref: parts[1]}
}
return ApmDep{Package: s}
}
Loading