Skip to content
Merged
33 changes: 33 additions & 0 deletions internal/adapters/client/adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Package client provides MCP client adapter interfaces and types.
package client

import "errors"

// MCPClientAdapter is the base interface for MCP client adapters.
type MCPClientAdapter interface {
// GetCurrentConfig returns the current MCP configuration.
GetCurrentConfig() (map[string]interface{}, error)
// UpdateConfig updates the MCP configuration.
UpdateConfig(config map[string]interface{}) error
// ConfigureMCPServer adds or updates a single MCP server entry.
ConfigureMCPServer(serverName, packageName string, enabled bool) error
// RemoveMCPServer removes a server entry from the configuration.
RemoveMCPServer(serverName string) error
// GetTargetName returns the adapter's target identifier.
GetTargetName() string
}

// ErrServerNotFound is returned when a server is not in the config.
var ErrServerNotFound = errors.New("MCP server not found in config")

// ErrConfigInvalid is returned for malformed configurations.
var ErrConfigInvalid = errors.New("invalid MCP configuration")

// MCPServerEntry represents a single MCP server configuration entry.
type MCPServerEntry struct {
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Type string `json:"type,omitempty"`
}
67 changes: 67 additions & 0 deletions internal/adapters/client/adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package client_test

import (
"testing"

"github.com/githubnext/apm/internal/adapters/client"
)

// mockClient satisfies MCPClientAdapter for interface verification.
type mockClient struct{}

func (m *mockClient) GetCurrentConfig() (map[string]interface{}, error) {
return map[string]interface{}{"mcpServers": map[string]interface{}{}}, nil
}
func (m *mockClient) UpdateConfig(config map[string]interface{}) error { return nil }
func (m *mockClient) ConfigureMCPServer(name, pkg string, en bool) error { return nil }
func (m *mockClient) RemoveMCPServer(name string) error { return nil }
func (m *mockClient) GetTargetName() string { return "mock" }

// TestParityMCPClientAdapterInterface verifies the interface type exists.
func TestParityMCPClientAdapterInterface(t *testing.T) {
var _ client.MCPClientAdapter = (*mockClient)(nil)
}

// TestParityMCPServerEntry verifies struct fields.
func TestParityMCPServerEntry(t *testing.T) {
entry := client.MCPServerEntry{
Command: "npx",
Args: []string{"-y", "@modelcontextprotocol/server-filesystem"},
Env: map[string]string{"TOKEN": "${GITHUB_TOKEN}"},
}
if entry.Command != "npx" {
t.Fatalf("unexpected command: %s", entry.Command)
}
if len(entry.Args) != 2 {
t.Fatalf("expected 2 args, got %d", len(entry.Args))
}
}

// TestParityMCPClientErrors verifies sentinel errors are defined.
func TestParityMCPClientErrors(t *testing.T) {
if client.ErrServerNotFound == nil {
t.Fatal("ErrServerNotFound should not be nil")
}
if client.ErrConfigInvalid == nil {
t.Fatal("ErrConfigInvalid should not be nil")
}
}

// TestParityMCPServerEntryURL verifies URL-based (SSE) server config.
func TestParityMCPServerEntryURL(t *testing.T) {
entry := client.MCPServerEntry{
URL: "http://localhost:3000/sse",
Type: "sse",
}
if entry.URL == "" {
t.Fatal("expected non-empty URL")
}
}

// TestParityMCPClientAdapterMethodGetTargetName verifies method via mock.
func TestParityMCPClientAdapterMethodGetTargetName(t *testing.T) {
var c client.MCPClientAdapter = &mockClient{}
if c.GetTargetName() != "mock" {
t.Fatal("unexpected target name")
}
}
22 changes: 22 additions & 0 deletions internal/adapters/pm/pm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Package pm provides MCP package manager adapter interfaces.
package pm

import "errors"

// MCPPackageManagerAdapter is the base interface for MCP package managers.
type MCPPackageManagerAdapter interface {
// Install installs an MCP package.
Install(packageName string, version string) error
// Uninstall removes an installed MCP package.
Uninstall(packageName string) error
// ListInstalled lists all installed MCP packages.
ListInstalled() ([]string, error)
// Search queries available MCP packages.
Search(query string) ([]string, error)
}

// ErrPackageNotFound is returned when a package is not installed.
var ErrPackageNotFound = errors.New("MCP package not found")

// ErrInstallFailed is returned when package installation fails.
var ErrInstallFailed = errors.New("MCP package install failed")
41 changes: 41 additions & 0 deletions internal/adapters/pm/pm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package pm_test

import (
"testing"

"github.com/githubnext/apm/internal/adapters/pm"
)

type mockPM struct{}

func (m *mockPM) Install(name, ver string) error { return nil }
func (m *mockPM) Uninstall(name string) error { return nil }
func (m *mockPM) ListInstalled() ([]string, error) { return []string{"pkg-a"}, nil }
func (m *mockPM) Search(q string) ([]string, error) { return []string{"pkg-a", "pkg-b"}, nil }

// TestParityPMAdapterInterface verifies the interface type exists.
func TestParityPMAdapterInterface(t *testing.T) {
var _ pm.MCPPackageManagerAdapter = (*mockPM)(nil)
}

// TestParityPMErrors verifies sentinel errors are defined.
func TestParityPMErrors(t *testing.T) {
if pm.ErrPackageNotFound == nil {
t.Fatal("ErrPackageNotFound should not be nil")
}
if pm.ErrInstallFailed == nil {
t.Fatal("ErrInstallFailed should not be nil")
}
}

// TestParityPMListInstalled verifies list via mock.
func TestParityPMListInstalled(t *testing.T) {
var p pm.MCPPackageManagerAdapter = &mockPM{}
pkgs, err := p.ListInstalled()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pkgs) != 1 || pkgs[0] != "pkg-a" {
t.Fatalf("unexpected packages: %v", pkgs)
}
}
34 changes: 34 additions & 0 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Package commands provides CLI command types and helpers for the APM Go rewrite.
package commands

// CommandContext carries shared state for all commands.
type CommandContext struct {
// ConfigPath overrides the default config file path.
ConfigPath string
// Verbose enables verbose output.
Verbose bool
// Global applies operations globally rather than per-project.
Global bool
// DryRun prints what would be done without making changes.
DryRun bool
}

// CommandResult is the result of executing a CLI command.
type CommandResult struct {
// ExitCode is the process exit code (0 = success).
ExitCode int
// Output contains the command's stdout.
Output string
// Error contains any error message.
Error string
}

// NewCommandContext returns a CommandContext with default values.
func NewCommandContext() *CommandContext {
return &CommandContext{}
}

// IsSuccess returns true when ExitCode == 0.
func (r *CommandResult) IsSuccess() bool {
return r.ExitCode == 0
}
105 changes: 105 additions & 0 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package commands_test

import (
"testing"

"github.com/githubnext/apm/internal/commands"
)

// TestParityCommandContextFields verifies field parity with Python CommandContext.
func TestParityCommandContextFields(t *testing.T) {
ctx := commands.NewCommandContext()
if ctx.Verbose {
t.Fatal("expected Verbose=false")
}
if ctx.Global {
t.Fatal("expected Global=false")
}
if ctx.DryRun {
t.Fatal("expected DryRun=false")
}
}

// TestParityCommandContextConfigPath verifies ConfigPath field.
func TestParityCommandContextConfigPath(t *testing.T) {
ctx := &commands.CommandContext{ConfigPath: "/tmp/apm.yml"}
if ctx.ConfigPath != "/tmp/apm.yml" {
t.Fatalf("unexpected ConfigPath: %s", ctx.ConfigPath)
}
}

// TestParityCommandResultSuccess verifies IsSuccess.
func TestParityCommandResultSuccess(t *testing.T) {
r := &commands.CommandResult{ExitCode: 0}
if !r.IsSuccess() {
t.Fatal("expected IsSuccess for exit code 0")
}
}

// TestParityCommandResultFailure verifies failure detection.
func TestParityCommandResultFailure(t *testing.T) {
r := &commands.CommandResult{ExitCode: 1, Error: "something failed"}
if r.IsSuccess() {
t.Fatal("expected !IsSuccess for exit code 1")
}
}

// TestParityCommandResultOutput verifies Output field.
func TestParityCommandResultOutput(t *testing.T) {
r := &commands.CommandResult{ExitCode: 0, Output: "[+] Done"}
if r.Output != "[+] Done" {
t.Fatalf("unexpected output: %s", r.Output)
}
}

// TestParityNewCommandContextDefaults verifies zero values.
func TestParityNewCommandContextDefaults(t *testing.T) {
ctx := commands.NewCommandContext()
if ctx == nil {
t.Fatal("NewCommandContext returned nil")
}
if ctx.ConfigPath != "" {
t.Fatalf("expected empty ConfigPath, got %s", ctx.ConfigPath)
}
}

// TestParityCommandContextVerboseFlag verifies setting verbose.
func TestParityCommandContextVerboseFlag(t *testing.T) {
ctx := commands.NewCommandContext()
ctx.Verbose = true
if !ctx.Verbose {
t.Fatal("expected Verbose=true after set")
}
}

// TestParityCommandContextDryRunFlag verifies DryRun field.
func TestParityCommandContextDryRunFlag(t *testing.T) {
ctx := &commands.CommandContext{DryRun: true}
if !ctx.DryRun {
t.Fatal("expected DryRun=true")
}
}

// TestParityCommandContextGlobalFlag verifies Global field.
func TestParityCommandContextGlobalFlag(t *testing.T) {
ctx := &commands.CommandContext{Global: true}
if !ctx.Global {
t.Fatal("expected Global=true")
}
}

// TestParityCommandResultErrorField verifies Error field.
func TestParityCommandResultErrorField(t *testing.T) {
r := &commands.CommandResult{ExitCode: 2, Error: "permission denied"}
if r.Error != "permission denied" {
t.Fatalf("unexpected error: %s", r.Error)
}
}

// TestParityCommandResultExitCode verifies exit code field.
func TestParityCommandResultExitCode(t *testing.T) {
r := &commands.CommandResult{ExitCode: 42}
if r.ExitCode != 42 {
t.Fatalf("unexpected exit code: %d", r.ExitCode)
}
}
66 changes: 66 additions & 0 deletions internal/compilation/compilation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Package compilation provides compilation pipeline types and utilities for APM.
package compilation

import (
"crypto/sha256"
"fmt"
"strings"
)

// BuildIDPlaceholder is the sentinel string inserted into compiled outputs.
const BuildIDPlaceholder = "<!-- Build ID: __BUILD_ID__ -->"

// ConstitutionMarkerBegin marks the start of an injected constitution block.
const ConstitutionMarkerBegin = "<!-- SPEC-KIT CONSTITUTION: BEGIN -->"

// ConstitutionMarkerEnd marks the end of an injected constitution block.
const ConstitutionMarkerEnd = "<!-- SPEC-KIT CONSTITUTION: END -->"

// ConstitutionRelativePath is the repo-root-relative path to the constitution file.
const ConstitutionRelativePath = ".specify/memory/constitution.md"

// StabilizeBuildID replaces BuildIDPlaceholder in content with a deterministic
// 12-char SHA256 hash computed over the content with the placeholder line removed.
//
// Idempotent: returns content unchanged if no placeholder is present.
// Preserves a trailing newline when the input has one.
func StabilizeBuildID(content string) string {
lines := strings.Split(content, "\n")

// Preserve trailing newline: splitlines leaves an empty last element
// if content ends with "\n". We track it but exclude from hash input.
trailingNL := strings.HasSuffix(content, "\n")

idx := -1
for i, line := range lines {
if line == BuildIDPlaceholder {
idx = i
break
}
}
if idx == -1 {
return content
}

// Build hash input from all lines except the placeholder.
hashLines := make([]string, 0, len(lines)-1)
for i, line := range lines {
if i != idx {
hashLines = append(hashLines, line)
}
}
// Remove the empty trailing element that split adds for "\n"-terminated content.
if trailingNL && len(hashLines) > 0 && hashLines[len(hashLines)-1] == "" {
hashLines = hashLines[:len(hashLines)-1]
}
h := sha256.Sum256([]byte(strings.Join(hashLines, "\n")))
buildID := fmt.Sprintf("%x", h)[:12]

lines[idx] = fmt.Sprintf("<!-- Build ID: %s -->", buildID)

result := strings.Join(lines, "\n")
if trailingNL && !strings.HasSuffix(result, "\n") {
result += "\n"
}
return result
}
Loading