refactor: move 9 cmd packages to ecosystem repos
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 15s

- cmd/go → core/go cmd/gocmd
- cmd/dev, setup, qa, docs, gitcmd, monitor → go-devops
- cmd/lab → go-ai
- cmd/workspace → go-agentic

CLI now imports commands from ecosystem repos via blank imports.
Remaining local: config, doctor, help, module, pkgcmd, plugin, session.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-21 21:46:24 +00:00
parent a9fe9fe04b
commit b6468b8e6f
65 changed files with 12 additions and 13650 deletions

View file

@ -1,22 +0,0 @@
package dev
import (
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// addAPICommands adds the 'api' command and its subcommands to the given parent command.
func addAPICommands(parent *cli.Command) {
// Create the 'api' command
apiCmd := &cli.Command{
Use: "api",
Short: i18n.T("cmd.dev.api.short"),
}
parent.AddCommand(apiCmd)
// Add the 'sync' command to 'api'
addSyncCommand(apiCmd)
// TODO: Add the 'test-gen' command to 'api'
// addTestGenCommand(apiCmd)
}

View file

@ -1,304 +0,0 @@
// cmd_apply.go implements safe command/script execution across repos for AI agents.
//
// Usage:
// core dev apply --command="sed -i 's/old/new/g' README.md"
// core dev apply --script="./scripts/update-version.sh"
// core dev apply --command="..." --commit --message="chore: update"
package dev
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
core "forge.lthn.ai/core/go/pkg/framework/core"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// Apply command flags
var (
applyCommand string
applyScript string
applyRepos string
applyCommit bool
applyMessage string
applyCoAuthor string
applyDryRun bool
applyPush bool
applyContinue bool // Continue on error
applyYes bool // Skip confirmation prompt
)
// AddApplyCommand adds the 'apply' command to dev.
func AddApplyCommand(parent *cli.Command) {
applyCmd := &cli.Command{
Use: "apply",
Short: i18n.T("cmd.dev.apply.short"),
Long: i18n.T("cmd.dev.apply.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runApply()
},
}
applyCmd.Flags().StringVar(&applyCommand, "command", "", i18n.T("cmd.dev.apply.flag.command"))
applyCmd.Flags().StringVar(&applyScript, "script", "", i18n.T("cmd.dev.apply.flag.script"))
applyCmd.Flags().StringVar(&applyRepos, "repos", "", i18n.T("cmd.dev.apply.flag.repos"))
applyCmd.Flags().BoolVar(&applyCommit, "commit", false, i18n.T("cmd.dev.apply.flag.commit"))
applyCmd.Flags().StringVarP(&applyMessage, "message", "m", "", i18n.T("cmd.dev.apply.flag.message"))
applyCmd.Flags().StringVar(&applyCoAuthor, "co-author", "", i18n.T("cmd.dev.apply.flag.co_author"))
applyCmd.Flags().BoolVar(&applyDryRun, "dry-run", false, i18n.T("cmd.dev.apply.flag.dry_run"))
applyCmd.Flags().BoolVar(&applyPush, "push", false, i18n.T("cmd.dev.apply.flag.push"))
applyCmd.Flags().BoolVar(&applyContinue, "continue", false, i18n.T("cmd.dev.apply.flag.continue"))
applyCmd.Flags().BoolVarP(&applyYes, "yes", "y", false, i18n.T("cmd.dev.apply.flag.yes"))
parent.AddCommand(applyCmd)
}
func runApply() error {
ctx := context.Background()
// Validate inputs
if applyCommand == "" && applyScript == "" {
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.no_command"), nil)
}
if applyCommand != "" && applyScript != "" {
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.both_command_script"), nil)
}
if applyCommit && applyMessage == "" {
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.commit_needs_message"), nil)
}
// Validate script exists
if applyScript != "" {
if !io.Local.IsFile(applyScript) {
return core.E("dev.apply", "script not found: "+applyScript, nil) // Error mismatch? IsFile returns bool
}
}
// Get target repos
targetRepos, err := getApplyTargetRepos()
if err != nil {
return err
}
if len(targetRepos) == 0 {
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.no_repos"), nil)
}
// Show plan
action := applyCommand
if applyScript != "" {
action = applyScript
}
cli.Print("%s: %s\n", dimStyle.Render(i18n.T("cmd.dev.apply.action")), action)
cli.Print("%s: %d repos\n", dimStyle.Render(i18n.T("cmd.dev.apply.targets")), len(targetRepos))
if applyDryRun {
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.apply.dry_run_mode")))
}
cli.Blank()
// Require confirmation unless --yes or --dry-run
if !applyYes && !applyDryRun {
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.apply.warning")))
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.apply.confirm"), cli.Required()) {
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.dev.apply.cancelled")))
return nil
}
cli.Blank()
}
var succeeded, skipped, failed int
for _, repo := range targetRepos {
repoName := filepath.Base(repo.Path)
if applyDryRun {
cli.Print(" %s %s\n", dimStyle.Render("[dry-run]"), repoName)
succeeded++
continue
}
// Step 1: Run command or script
var cmdErr error
if applyCommand != "" {
cmdErr = runCommandInRepo(ctx, repo.Path, applyCommand)
} else {
cmdErr = runScriptInRepo(ctx, repo.Path, applyScript)
}
if cmdErr != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, cmdErr)
failed++
if !applyContinue {
return cli.Err("%s", i18n.T("cmd.dev.apply.error.command_failed"))
}
continue
}
// Step 2: Check if anything changed
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repo.Path},
Names: map[string]string{repo.Path: repoName},
})
if len(statuses) == 0 || !statuses[0].IsDirty() {
cli.Print(" %s %s: %s\n", dimStyle.Render("-"), repoName, i18n.T("cmd.dev.apply.no_changes"))
skipped++
continue
}
// Step 3: Commit if requested
if applyCommit {
commitMsg := applyMessage
if applyCoAuthor != "" {
commitMsg += "\n\nCo-Authored-By: " + applyCoAuthor
}
// Stage all changes
if _, err := gitCommandQuiet(ctx, repo.Path, "add", "-A"); err != nil {
cli.Print(" %s %s: stage failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
if !applyContinue {
return err
}
continue
}
// Commit
if _, err := gitCommandQuiet(ctx, repo.Path, "commit", "-m", commitMsg); err != nil {
cli.Print(" %s %s: commit failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
if !applyContinue {
return err
}
continue
}
// Step 4: Push if requested
if applyPush {
if err := safePush(ctx, repo.Path); err != nil {
cli.Print(" %s %s: push failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
if !applyContinue {
return err
}
continue
}
}
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
succeeded++
}
// Summary
cli.Blank()
cli.Print("%s: ", i18n.T("cmd.dev.apply.summary"))
if succeeded > 0 {
cli.Print("%s", successStyle.Render(i18n.T("common.count.succeeded", map[string]interface{}{"Count": succeeded})))
}
if skipped > 0 {
if succeeded > 0 {
cli.Print(", ")
}
cli.Print("%s", dimStyle.Render(i18n.T("common.count.skipped", map[string]interface{}{"Count": skipped})))
}
if failed > 0 {
if succeeded > 0 || skipped > 0 {
cli.Print(", ")
}
cli.Print("%s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// getApplyTargetRepos gets repos to apply command to
func getApplyTargetRepos() ([]*repos.Repo, error) {
// Load registry
registryPath, err := repos.FindRegistry(io.Local)
if err != nil {
return nil, core.E("dev.apply", "failed to find registry", err)
}
registry, err := repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return nil, core.E("dev.apply", "failed to load registry", err)
}
// If --repos specified, filter to those
if applyRepos != "" {
repoNames := strings.Split(applyRepos, ",")
nameSet := make(map[string]bool)
for _, name := range repoNames {
nameSet[strings.TrimSpace(name)] = true
}
var matched []*repos.Repo
for _, repo := range registry.Repos {
if nameSet[repo.Name] {
matched = append(matched, repo)
}
}
return matched, nil
}
// Return all repos as slice
var all []*repos.Repo
for _, repo := range registry.Repos {
all = append(all, repo)
}
return all, nil
}
// runCommandInRepo runs a shell command in a repo directory
func runCommandInRepo(ctx context.Context, repoPath, command string) error {
// Use shell to execute command
var cmd *exec.Cmd
if isWindows() {
cmd = exec.CommandContext(ctx, "cmd", "/C", command)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", command)
}
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// runScriptInRepo runs a script in a repo directory
func runScriptInRepo(ctx context.Context, repoPath, scriptPath string) error {
// Get absolute path to script
absScript, err := filepath.Abs(scriptPath)
if err != nil {
return err
}
var cmd *exec.Cmd
if isWindows() {
cmd = exec.CommandContext(ctx, "cmd", "/C", absScript)
} else {
// Execute script directly to honor shebang
cmd = exec.CommandContext(ctx, absScript)
}
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// isWindows returns true if running on Windows
func isWindows() bool {
return os.PathSeparator == '\\'
}

View file

@ -1,86 +0,0 @@
package dev
import (
"context"
"forge.lthn.ai/core/go-agentic"
"forge.lthn.ai/core/go/pkg/framework"
"forge.lthn.ai/core/go-scm/git"
)
// WorkBundle contains the Core instance for dev work operations.
type WorkBundle struct {
Core *framework.Core
}
// WorkBundleOptions configures the work bundle.
type WorkBundleOptions struct {
RegistryPath string
AllowEdit bool // Allow agentic to use Write/Edit tools
}
// NewWorkBundle creates a bundle for dev work operations.
// Includes: dev (orchestration), git, agentic services.
func NewWorkBundle(opts WorkBundleOptions) (*WorkBundle, error) {
c, err := framework.New(
framework.WithService(NewService(ServiceOptions{
RegistryPath: opts.RegistryPath,
})),
framework.WithService(git.NewService(git.ServiceOptions{})),
framework.WithService(agentic.NewService(agentic.ServiceOptions{
AllowEdit: opts.AllowEdit,
})),
framework.WithServiceLock(),
)
if err != nil {
return nil, err
}
return &WorkBundle{Core: c}, nil
}
// Start initialises the bundle services.
func (b *WorkBundle) Start(ctx context.Context) error {
return b.Core.ServiceStartup(ctx, nil)
}
// Stop shuts down the bundle services.
func (b *WorkBundle) Stop(ctx context.Context) error {
return b.Core.ServiceShutdown(ctx)
}
// StatusBundle contains the Core instance for status-only operations.
type StatusBundle struct {
Core *framework.Core
}
// StatusBundleOptions configures the status bundle.
type StatusBundleOptions struct {
RegistryPath string
}
// NewStatusBundle creates a bundle for status-only operations.
// Includes: dev (orchestration), git services. No agentic - commits not available.
func NewStatusBundle(opts StatusBundleOptions) (*StatusBundle, error) {
c, err := framework.New(
framework.WithService(NewService(ServiceOptions(opts))),
framework.WithService(git.NewService(git.ServiceOptions{})),
// No agentic service - TaskCommit will be unhandled
framework.WithServiceLock(),
)
if err != nil {
return nil, err
}
return &StatusBundle{Core: c}, nil
}
// Start initialises the bundle services.
func (b *StatusBundle) Start(ctx context.Context) error {
return b.Core.ServiceStartup(ctx, nil)
}
// Stop shuts down the bundle services.
func (b *StatusBundle) Stop(ctx context.Context) error {
return b.Core.ServiceShutdown(ctx)
}

View file

@ -1,261 +0,0 @@
package dev
import (
"encoding/json"
"errors"
"os"
"os/exec"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// CI-specific styles (aliases to shared)
var (
ciSuccessStyle = cli.SuccessStyle
ciFailureStyle = cli.ErrorStyle
ciPendingStyle = cli.WarningStyle
ciSkippedStyle = cli.DimStyle
)
// WorkflowRun represents a GitHub Actions workflow run
type WorkflowRun struct {
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
HeadBranch string `json:"headBranch"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
URL string `json:"url"`
// Added by us
RepoName string `json:"-"`
}
// CI command flags
var (
ciRegistryPath string
ciBranch string
ciFailedOnly bool
)
// addCICommand adds the 'ci' command to the given parent command.
func addCICommand(parent *cli.Command) {
ciCmd := &cli.Command{
Use: "ci",
Short: i18n.T("cmd.dev.ci.short"),
Long: i18n.T("cmd.dev.ci.long"),
RunE: func(cmd *cli.Command, args []string) error {
branch := ciBranch
if branch == "" {
branch = "main"
}
return runCI(ciRegistryPath, branch, ciFailedOnly)
},
}
ciCmd.Flags().StringVar(&ciRegistryPath, "registry", "", i18n.T("common.flag.registry"))
ciCmd.Flags().StringVarP(&ciBranch, "branch", "b", "main", i18n.T("cmd.dev.ci.flag.branch"))
ciCmd.Flags().BoolVar(&ciFailedOnly, "failed", false, i18n.T("cmd.dev.ci.flag.failed"))
parent.AddCommand(ciCmd)
}
func runCI(registryPath string, branch string, failedOnly bool) error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return errors.New(i18n.T("error.gh_not_found"))
}
// Find or use provided registry
var reg *repos.Registry
var err error
if registryPath != "" {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return cli.Wrap(err, "failed to load registry")
}
} else {
registryPath, err = repos.FindRegistry(io.Local)
if err == nil {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return cli.Wrap(err, "failed to load registry")
}
} else {
cwd, _ := os.Getwd()
reg, err = repos.ScanDirectory(io.Local, cwd)
if err != nil {
return cli.Wrap(err, "failed to scan directory")
}
}
}
// Fetch CI status sequentially
var allRuns []WorkflowRun
var fetchErrors []error
var noCI []string
repoList := reg.List()
for i, repo := range repoList {
repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name)
cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("i18n.progress.check")), i+1, len(repoList), repo.Name)
runs, err := fetchWorkflowRuns(repoFullName, repo.Name, branch)
if err != nil {
if strings.Contains(err.Error(), "no workflows") {
noCI = append(noCI, repo.Name)
} else {
fetchErrors = append(fetchErrors, cli.Wrap(err, repo.Name))
}
continue
}
if len(runs) > 0 {
// Just get the latest run
allRuns = append(allRuns, runs[0])
} else {
noCI = append(noCI, repo.Name)
}
}
cli.Print("\033[2K\r") // Clear progress line
// Count by status
var success, failed, pending, other int
for _, run := range allRuns {
switch run.Conclusion {
case "success":
success++
case "failure":
failed++
case "":
if run.Status == "in_progress" || run.Status == "queued" {
pending++
} else {
other++
}
default:
other++
}
}
// Print summary
cli.Blank()
cli.Print("%s", i18n.T("cmd.dev.ci.repos_checked", map[string]interface{}{"Count": len(repoList)}))
if success > 0 {
cli.Print(" * %s", ciSuccessStyle.Render(i18n.T("cmd.dev.ci.passing", map[string]interface{}{"Count": success})))
}
if failed > 0 {
cli.Print(" * %s", ciFailureStyle.Render(i18n.T("cmd.dev.ci.failing", map[string]interface{}{"Count": failed})))
}
if pending > 0 {
cli.Print(" * %s", ciPendingStyle.Render(i18n.T("common.count.pending", map[string]interface{}{"Count": pending})))
}
if len(noCI) > 0 {
cli.Print(" * %s", ciSkippedStyle.Render(i18n.T("cmd.dev.ci.no_ci", map[string]interface{}{"Count": len(noCI)})))
}
cli.Blank()
cli.Blank()
// Filter if needed
displayRuns := allRuns
if failedOnly {
displayRuns = nil
for _, run := range allRuns {
if run.Conclusion == "failure" {
displayRuns = append(displayRuns, run)
}
}
}
// Print details
for _, run := range displayRuns {
printWorkflowRun(run)
}
// Print errors
if len(fetchErrors) > 0 {
cli.Blank()
for _, err := range fetchErrors {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), err)
}
}
return nil
}
func fetchWorkflowRuns(repoFullName, repoName string, branch string) ([]WorkflowRun, error) {
args := []string{
"run", "list",
"--repo", repoFullName,
"--branch", branch,
"--limit", "1",
"--json", "name,status,conclusion,headBranch,createdAt,updatedAt,url",
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
return nil, cli.Err("%s", strings.TrimSpace(stderr))
}
return nil, err
}
var runs []WorkflowRun
if err := json.Unmarshal(output, &runs); err != nil {
return nil, err
}
// Tag with repo name
for i := range runs {
runs[i].RepoName = repoName
}
return runs, nil
}
func printWorkflowRun(run WorkflowRun) {
// Status icon
var status string
switch run.Conclusion {
case "success":
status = ciSuccessStyle.Render("v")
case "failure":
status = ciFailureStyle.Render("x")
case "":
switch run.Status {
case "in_progress":
status = ciPendingStyle.Render("*")
case "queued":
status = ciPendingStyle.Render("o")
default:
status = ciSkippedStyle.Render("-")
}
case "skipped":
status = ciSkippedStyle.Render("-")
case "cancelled":
status = ciSkippedStyle.Render("o")
default:
status = ciSkippedStyle.Render("?")
}
// Workflow name (truncated)
workflowName := cli.Truncate(run.Name, 20)
// Age
age := cli.FormatAge(run.UpdatedAt)
cli.Print(" %s %-18s %-22s %s\n",
status,
repoNameStyle.Render(run.RepoName),
dimStyle.Render(workflowName),
issueAgeStyle.Render(age),
)
}

View file

@ -1,201 +0,0 @@
package dev
import (
"context"
"os"
"path/filepath"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io"
)
// Commit command flags
var (
commitRegistryPath string
commitAll bool
)
// AddCommitCommand adds the 'commit' command to the given parent command.
func AddCommitCommand(parent *cli.Command) {
commitCmd := &cli.Command{
Use: "commit",
Short: i18n.T("cmd.dev.commit.short"),
Long: i18n.T("cmd.dev.commit.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runCommit(commitRegistryPath, commitAll)
},
}
commitCmd.Flags().StringVar(&commitRegistryPath, "registry", "", i18n.T("common.flag.registry"))
commitCmd.Flags().BoolVar(&commitAll, "all", false, i18n.T("cmd.dev.commit.flag.all"))
parent.AddCommand(commitCmd)
}
func runCommit(registryPath string, all bool) error {
ctx := context.Background()
cwd, _ := os.Getwd()
// Check if current directory is a git repo (single-repo mode)
if registryPath == "" && isGitRepo(cwd) {
return runCommitSingleRepo(ctx, cwd, all)
}
// Multi-repo mode: find or use provided registry
reg, regDir, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
registryPath = regDir // Use resolved registry directory for relative paths
// Build paths and names for git operations
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
if len(paths) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Get status for all repos
statuses := git.Status(ctx, git.StatusOptions{
Paths: paths,
Names: names,
})
// Find dirty repos
var dirtyRepos []git.RepoStatus
for _, s := range statuses {
if s.Error == nil && s.IsDirty() {
dirtyRepos = append(dirtyRepos, s)
}
}
if len(dirtyRepos) == 0 {
cli.Text(i18n.T("cmd.dev.no_changes"))
return nil
}
// Show dirty repos
cli.Print("\n%s\n\n", i18n.T("cmd.dev.repos_with_changes", map[string]interface{}{"Count": len(dirtyRepos)}))
for _, s := range dirtyRepos {
cli.Print(" %s: ", repoNameStyle.Render(s.Name))
if s.Modified > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.modified", map[string]interface{}{"Count": s.Modified})))
}
if s.Untracked > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.untracked", map[string]interface{}{"Count": s.Untracked})))
}
if s.Staged > 0 {
cli.Print("%s ", aheadStyle.Render(i18n.T("cmd.dev.staged", map[string]interface{}{"Count": s.Staged})))
}
cli.Blank()
}
// Confirm unless --all
if !all {
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.confirm_claude_commit")) {
cli.Text(i18n.T("cli.aborted"))
return nil
}
}
cli.Blank()
// Commit each dirty repo
var succeeded, failed int
for _, s := range dirtyRepos {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.committing")), s.Name)
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
failed++
} else {
cli.Print(" %s %s\n", successStyle.Render("v"), i18n.T("cmd.dev.committed"))
succeeded++
}
cli.Blank()
}
// Summary
cli.Print("%s", successStyle.Render(i18n.T("cmd.dev.done_succeeded", map[string]interface{}{"Count": succeeded})))
if failed > 0 {
cli.Print(", %s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// isGitRepo checks if a directory is a git repository.
func isGitRepo(path string) bool {
gitDir := path + "/.git"
_, err := coreio.Local.List(gitDir)
return err == nil
}
// runCommitSingleRepo handles commit for a single repo (current directory).
func runCommitSingleRepo(ctx context.Context, repoPath string, all bool) error {
repoName := filepath.Base(repoPath)
// Get status
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repoPath},
Names: map[string]string{repoPath: repoName},
})
if len(statuses) == 0 || statuses[0].Error != nil {
if len(statuses) > 0 && statuses[0].Error != nil {
return statuses[0].Error
}
return cli.Err("failed to get repo status")
}
s := statuses[0]
if !s.IsDirty() {
cli.Text(i18n.T("cmd.dev.no_changes"))
return nil
}
// Show status
cli.Print("%s: ", repoNameStyle.Render(s.Name))
if s.Modified > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.modified", map[string]interface{}{"Count": s.Modified})))
}
if s.Untracked > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.untracked", map[string]interface{}{"Count": s.Untracked})))
}
if s.Staged > 0 {
cli.Print("%s ", aheadStyle.Render(i18n.T("cmd.dev.staged", map[string]interface{}{"Count": s.Staged})))
}
cli.Blank()
// Confirm unless --all
if !all {
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.confirm_claude_commit")) {
cli.Text(i18n.T("cli.aborted"))
return nil
}
}
cli.Blank()
// Commit
if err := claudeCommit(ctx, repoPath, repoName, ""); err != nil {
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
return err
}
cli.Print(" %s %s\n", successStyle.Render("v"), i18n.T("cmd.dev.committed"))
return nil
}

View file

@ -1,96 +0,0 @@
// Package dev provides multi-repo development workflow commands.
//
// Git Operations:
// - work: Combined status, commit, and push workflow
// - health: Quick health check across all repos
// - commit: Claude-assisted commit message generation
// - push: Push repos with unpushed commits
// - pull: Pull repos that are behind remote
//
// GitHub Integration (requires gh CLI):
// - issues: List open issues across repos
// - reviews: List PRs needing review
// - ci: Check GitHub Actions CI status
// - impact: Analyse dependency impact of changes
//
// CI/Workflow Management:
// - workflow list: Show table of repos vs workflows
// - workflow sync: Copy workflow template to all repos
//
// API Tools:
// - api sync: Synchronize public service APIs
//
// Dev Environment (VM management):
// - install: Download dev environment image
// - boot: Start dev environment VM
// - stop: Stop dev environment VM
// - status: Check dev VM status
// - shell: Open shell in dev VM
// - serve: Mount project and start dev server
// - test: Run tests in dev environment
// - claude: Start sandboxed Claude session
// - update: Check for and apply updates
package dev
import (
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
func init() {
cli.RegisterCommands(AddDevCommands)
}
// Style aliases from shared package
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
warningStyle = cli.WarningStyle
dimStyle = cli.DimStyle
valueStyle = cli.ValueStyle
headerStyle = cli.HeaderStyle
repoNameStyle = cli.RepoStyle
)
// Table styles for status display (extends shared styles with cell padding)
var (
dirtyStyle = cli.NewStyle().Foreground(cli.ColourRed500)
aheadStyle = cli.NewStyle().Foreground(cli.ColourAmber500)
cleanStyle = cli.NewStyle().Foreground(cli.ColourGreen500)
)
// AddDevCommands registers the 'dev' command and all subcommands.
func AddDevCommands(root *cli.Command) {
devCmd := &cli.Command{
Use: "dev",
Short: i18n.T("cmd.dev.short"),
Long: i18n.T("cmd.dev.long"),
}
root.AddCommand(devCmd)
// Git operations (also available under 'core git')
AddWorkCommand(devCmd)
AddHealthCommand(devCmd)
AddCommitCommand(devCmd)
AddPushCommand(devCmd)
AddPullCommand(devCmd)
// Safe git operations for AI agents (also available under 'core git')
AddFileSyncCommand(devCmd)
AddApplyCommand(devCmd)
// GitHub integration
addIssuesCommand(devCmd)
addReviewsCommand(devCmd)
addCICommand(devCmd)
addImpactCommand(devCmd)
// CI/Workflow management
addWorkflowCommands(devCmd)
// API tools
addAPICommands(devCmd)
// Dev environment
addVMCommands(devCmd)
}

View file

@ -1,340 +0,0 @@
// cmd_file_sync.go implements safe file synchronization across repos for AI agents.
//
// Usage:
// core dev sync workflow.yml --to="packages/core-*"
// core dev sync .github/workflows/ --to="packages/core-*" --message="feat: add CI"
// core dev sync config.yaml --to="packages/core-*" --dry-run
package dev
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/log"
"forge.lthn.ai/core/go/pkg/repos"
)
// File sync command flags
var (
fileSyncTo string
fileSyncMessage string
fileSyncCoAuthor string
fileSyncDryRun bool
fileSyncPush bool
)
// AddFileSyncCommand adds the 'sync' command to dev for file syncing.
func AddFileSyncCommand(parent *cli.Command) {
syncCmd := &cli.Command{
Use: "sync <file-or-dir>",
Short: i18n.T("cmd.dev.file_sync.short"),
Long: i18n.T("cmd.dev.file_sync.long"),
Args: cli.MinimumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runFileSync(args[0])
},
}
syncCmd.Flags().StringVar(&fileSyncTo, "to", "", i18n.T("cmd.dev.file_sync.flag.to"))
syncCmd.Flags().StringVarP(&fileSyncMessage, "message", "m", "", i18n.T("cmd.dev.file_sync.flag.message"))
syncCmd.Flags().StringVar(&fileSyncCoAuthor, "co-author", "", i18n.T("cmd.dev.file_sync.flag.co_author"))
syncCmd.Flags().BoolVar(&fileSyncDryRun, "dry-run", false, i18n.T("cmd.dev.file_sync.flag.dry_run"))
syncCmd.Flags().BoolVar(&fileSyncPush, "push", false, i18n.T("cmd.dev.file_sync.flag.push"))
_ = syncCmd.MarkFlagRequired("to")
parent.AddCommand(syncCmd)
}
func runFileSync(source string) error {
ctx := context.Background()
// Security: Reject path traversal attempts
if strings.Contains(source, "..") {
return log.E("dev.sync", "path traversal not allowed", nil)
}
// Validate source exists
sourceInfo, err := os.Stat(source) // Keep os.Stat for local source check or use coreio? coreio.Local.IsFile is bool.
// If source is local file on disk (not in medium), we can use os.Stat.
// But concept is everything is via Medium?
// User is running CLI on host. `source` is relative to CWD.
// coreio.Local uses absolute path or relative to root (which is "/" by default).
// So coreio.Local works.
if !coreio.Local.IsFile(source) {
// Might be directory
// IsFile returns false for directory.
}
// Let's rely on os.Stat for initial source check to distinguish dir vs file easily if coreio doesn't expose Stat.
// coreio doesn't expose Stat.
// Check using standard os for source determination as we are outside strict sandbox for input args potentially?
// But we should use coreio where possible.
// coreio.Local.List worked for dirs.
// Let's stick to os.Stat for source properties finding as typically allowed for CLI args.
if err != nil {
return log.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err)
}
// Find target repos
targetRepos, err := resolveTargetRepos(fileSyncTo)
if err != nil {
return err
}
if len(targetRepos) == 0 {
return cli.Err("%s", i18n.T("cmd.dev.file_sync.error.no_targets"))
}
// Show plan
cli.Print("%s: %s\n", dimStyle.Render(i18n.T("cmd.dev.file_sync.source")), source)
cli.Print("%s: %d repos\n", dimStyle.Render(i18n.T("cmd.dev.file_sync.targets")), len(targetRepos))
if fileSyncDryRun {
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.file_sync.dry_run_mode")))
}
cli.Blank()
var succeeded, skipped, failed int
for _, repo := range targetRepos {
repoName := filepath.Base(repo.Path)
if fileSyncDryRun {
cli.Print(" %s %s\n", dimStyle.Render("[dry-run]"), repoName)
succeeded++
continue
}
// Step 1: Pull latest (safe sync)
if err := safePull(ctx, repo.Path); err != nil {
cli.Print(" %s %s: pull failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
// Step 2: Copy file(s)
destPath := filepath.Join(repo.Path, source)
if sourceInfo.IsDir() {
if err := copyDir(source, destPath); err != nil {
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
} else {
// Ensure dir exists
if err := coreio.Local.EnsureDir(filepath.Dir(destPath)); err != nil {
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
if err := coreio.Copy(coreio.Local, source, coreio.Local, destPath); err != nil {
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
}
// Step 3: Check if anything changed
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repo.Path},
Names: map[string]string{repo.Path: repoName},
})
if len(statuses) == 0 || !statuses[0].IsDirty() {
cli.Print(" %s %s: %s\n", dimStyle.Render("-"), repoName, i18n.T("cmd.dev.file_sync.no_changes"))
skipped++
continue
}
// Step 4: Commit if message provided
if fileSyncMessage != "" {
commitMsg := fileSyncMessage
if fileSyncCoAuthor != "" {
commitMsg += "\n\nCo-Authored-By: " + fileSyncCoAuthor
}
if err := gitAddCommit(ctx, repo.Path, source, commitMsg); err != nil {
cli.Print(" %s %s: commit failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
// Step 5: Push if requested
if fileSyncPush {
if err := safePush(ctx, repo.Path); err != nil {
cli.Print(" %s %s: push failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
}
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
succeeded++
}
// Summary
cli.Blank()
cli.Print("%s: ", i18n.T("cmd.dev.file_sync.summary"))
if succeeded > 0 {
cli.Print("%s", successStyle.Render(i18n.T("common.count.succeeded", map[string]interface{}{"Count": succeeded})))
}
if skipped > 0 {
if succeeded > 0 {
cli.Print(", ")
}
cli.Print("%s", dimStyle.Render(i18n.T("common.count.skipped", map[string]interface{}{"Count": skipped})))
}
if failed > 0 {
if succeeded > 0 || skipped > 0 {
cli.Print(", ")
}
cli.Print("%s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// resolveTargetRepos resolves the --to pattern to actual repos
func resolveTargetRepos(pattern string) ([]*repos.Repo, error) {
// Load registry
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return nil, log.E("dev.sync", "failed to find registry", err)
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return nil, log.E("dev.sync", "failed to load registry", err)
}
// Match pattern against repo names
var matched []*repos.Repo
for _, repo := range registry.Repos {
if matchGlob(repo.Name, pattern) || matchGlob(repo.Path, pattern) {
matched = append(matched, repo)
}
}
return matched, nil
}
// matchGlob performs simple glob matching with * wildcards
func matchGlob(s, pattern string) bool {
// Handle exact match
if s == pattern {
return true
}
// Handle * at end
if strings.HasSuffix(pattern, "*") {
prefix := strings.TrimSuffix(pattern, "*")
return strings.HasPrefix(s, prefix)
}
// Handle * at start
if strings.HasPrefix(pattern, "*") {
suffix := strings.TrimPrefix(pattern, "*")
return strings.HasSuffix(s, suffix)
}
// Handle * in middle
if strings.Contains(pattern, "*") {
parts := strings.SplitN(pattern, "*", 2)
return strings.HasPrefix(s, parts[0]) && strings.HasSuffix(s, parts[1])
}
return false
}
// safePull pulls with rebase, handling errors gracefully
func safePull(ctx context.Context, path string) error {
// Check if we have upstream
_, err := gitCommandQuiet(ctx, path, "rev-parse", "--abbrev-ref", "@{u}")
if err != nil {
// No upstream set, skip pull
return nil
}
return git.Pull(ctx, path)
}
// safePush pushes with automatic pull-rebase on rejection
func safePush(ctx context.Context, path string) error {
err := git.Push(ctx, path)
if err == nil {
return nil
}
// If non-fast-forward, try pull and push again
if git.IsNonFastForward(err) {
if pullErr := git.Pull(ctx, path); pullErr != nil {
return pullErr
}
return git.Push(ctx, path)
}
return err
}
// gitAddCommit stages and commits a file/directory
func gitAddCommit(ctx context.Context, repoPath, filePath, message string) error {
// Stage the file(s)
if _, err := gitCommandQuiet(ctx, repoPath, "add", filePath); err != nil {
return err
}
// Commit
_, err := gitCommandQuiet(ctx, repoPath, "commit", "-m", message)
return err
}
// gitCommandQuiet runs a git command without output
func gitCommandQuiet(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
return "", cli.Err("%s", strings.TrimSpace(string(output)))
}
return string(output), nil
}
// copyDir recursively copies a directory
func copyDir(src, dst string) error {
entries, err := coreio.Local.List(src)
if err != nil {
return err
}
if err := coreio.Local.EnsureDir(dst); err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
if err := coreio.Copy(coreio.Local, srcPath, coreio.Local, dstPath); err != nil {
return err
}
}
}
return nil
}

View file

@ -1,185 +0,0 @@
package dev
import (
"context"
"fmt"
"sort"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Health command flags
var (
healthRegistryPath string
healthVerbose bool
)
// AddHealthCommand adds the 'health' command to the given parent command.
func AddHealthCommand(parent *cli.Command) {
healthCmd := &cli.Command{
Use: "health",
Short: i18n.T("cmd.dev.health.short"),
Long: i18n.T("cmd.dev.health.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runHealth(healthRegistryPath, healthVerbose)
},
}
healthCmd.Flags().StringVar(&healthRegistryPath, "registry", "", i18n.T("common.flag.registry"))
healthCmd.Flags().BoolVarP(&healthVerbose, "verbose", "v", false, i18n.T("cmd.dev.health.flag.verbose"))
parent.AddCommand(healthCmd)
}
func runHealth(registryPath string, verbose bool) error {
ctx := context.Background()
// Load registry and get paths
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Build paths and names for git operations
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
if len(paths) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Get status for all repos
statuses := git.Status(ctx, git.StatusOptions{
Paths: paths,
Names: names,
})
// Sort for consistent verbose output
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Name < statuses[j].Name
})
// Aggregate stats
var (
totalRepos = len(statuses)
dirtyRepos []string
aheadRepos []string
behindRepos []string
errorRepos []string
)
for _, s := range statuses {
if s.Error != nil {
errorRepos = append(errorRepos, s.Name)
continue
}
if s.IsDirty() {
dirtyRepos = append(dirtyRepos, s.Name)
}
if s.HasUnpushed() {
aheadRepos = append(aheadRepos, s.Name)
}
if s.HasUnpulled() {
behindRepos = append(behindRepos, s.Name)
}
}
// Print summary line
cli.Blank()
printHealthSummary(totalRepos, dirtyRepos, aheadRepos, behindRepos, errorRepos)
cli.Blank()
// Verbose output
if verbose {
if len(dirtyRepos) > 0 {
cli.Print("%s %s\n", warningStyle.Render(i18n.T("cmd.dev.health.dirty_label")), formatRepoList(dirtyRepos))
}
if len(aheadRepos) > 0 {
cli.Print("%s %s\n", successStyle.Render(i18n.T("cmd.dev.health.ahead_label")), formatRepoList(aheadRepos))
}
if len(behindRepos) > 0 {
cli.Print("%s %s\n", warningStyle.Render(i18n.T("cmd.dev.health.behind_label")), formatRepoList(behindRepos))
}
if len(errorRepos) > 0 {
cli.Print("%s %s\n", errorStyle.Render(i18n.T("cmd.dev.health.errors_label")), formatRepoList(errorRepos))
}
cli.Blank()
}
return nil
}
func printHealthSummary(total int, dirty, ahead, behind, errors []string) {
parts := []string{
statusPart(total, i18n.T("cmd.dev.health.repos"), cli.ValueStyle),
}
// Dirty status
if len(dirty) > 0 {
parts = append(parts, statusPart(len(dirty), i18n.T("common.status.dirty"), cli.WarningStyle))
} else {
parts = append(parts, statusText(i18n.T("cmd.dev.status.clean"), cli.SuccessStyle))
}
// Push status
if len(ahead) > 0 {
parts = append(parts, statusPart(len(ahead), i18n.T("cmd.dev.health.to_push"), cli.ValueStyle))
} else {
parts = append(parts, statusText(i18n.T("common.status.synced"), cli.SuccessStyle))
}
// Pull status
if len(behind) > 0 {
parts = append(parts, statusPart(len(behind), i18n.T("cmd.dev.health.to_pull"), cli.WarningStyle))
} else {
parts = append(parts, statusText(i18n.T("common.status.up_to_date"), cli.SuccessStyle))
}
// Errors (only if any)
if len(errors) > 0 {
parts = append(parts, statusPart(len(errors), i18n.T("cmd.dev.health.errors"), cli.ErrorStyle))
}
cli.Text(statusLine(parts...))
}
func formatRepoList(reposList []string) string {
if len(reposList) <= 5 {
return joinRepos(reposList)
}
return joinRepos(reposList[:5]) + " " + i18n.T("cmd.dev.health.more", map[string]interface{}{"Count": len(reposList) - 5})
}
func joinRepos(reposList []string) string {
result := ""
for i, r := range reposList {
if i > 0 {
result += ", "
}
result += r
}
return result
}
func statusPart(count int, label string, style *cli.AnsiStyle) string {
return style.Render(fmt.Sprintf("%d %s", count, label))
}
func statusText(text string, style *cli.AnsiStyle) string {
return style.Render(text)
}
func statusLine(parts ...string) string {
return strings.Join(parts, " | ")
}

View file

@ -1,184 +0,0 @@
package dev
import (
"errors"
"sort"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// Impact-specific styles (aliases to shared)
var (
impactDirectStyle = cli.ErrorStyle
impactIndirectStyle = cli.WarningStyle
impactSafeStyle = cli.SuccessStyle
)
// Impact command flags
var impactRegistryPath string
// addImpactCommand adds the 'impact' command to the given parent command.
func addImpactCommand(parent *cli.Command) {
impactCmd := &cli.Command{
Use: "impact <repo-name>",
Short: i18n.T("cmd.dev.impact.short"),
Long: i18n.T("cmd.dev.impact.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runImpact(impactRegistryPath, args[0])
},
}
impactCmd.Flags().StringVar(&impactRegistryPath, "registry", "", i18n.T("common.flag.registry"))
parent.AddCommand(impactCmd)
}
func runImpact(registryPath string, repoName string) error {
// Find or use provided registry
var reg *repos.Registry
var err error
if registryPath != "" {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return cli.Wrap(err, "failed to load registry")
}
} else {
registryPath, err = repos.FindRegistry(io.Local)
if err == nil {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return cli.Wrap(err, "failed to load registry")
}
} else {
return errors.New(i18n.T("cmd.dev.impact.requires_registry"))
}
}
// Check repo exists
repo, exists := reg.Get(repoName)
if !exists {
return errors.New(i18n.T("error.repo_not_found", map[string]interface{}{"Name": repoName}))
}
// Build reverse dependency graph
dependents := buildDependentsGraph(reg)
// Find all affected repos (direct and transitive)
direct := dependents[repoName]
allAffected := findAllDependents(repoName, dependents)
// Separate direct vs indirect
directSet := make(map[string]bool)
for _, d := range direct {
directSet[d] = true
}
var indirect []string
for _, a := range allAffected {
if !directSet[a] {
indirect = append(indirect, a)
}
}
// Sort for consistent output
sort.Strings(direct)
sort.Strings(indirect)
// Print results
cli.Blank()
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.impact.analysis_for")), repoNameStyle.Render(repoName))
if repo.Description != "" {
cli.Print("%s\n", dimStyle.Render(repo.Description))
}
cli.Blank()
if len(allAffected) == 0 {
cli.Print("%s %s\n", impactSafeStyle.Render("v"), i18n.T("cmd.dev.impact.no_dependents", map[string]interface{}{"Name": repoName}))
return nil
}
// Direct dependents
if len(direct) > 0 {
cli.Print("%s %s\n",
impactDirectStyle.Render("*"),
i18n.T("cmd.dev.impact.direct_dependents", map[string]interface{}{"Count": len(direct)}),
)
for _, d := range direct {
r, _ := reg.Get(d)
desc := ""
if r != nil && r.Description != "" {
desc = dimStyle.Render(" - " + cli.Truncate(r.Description, 40))
}
cli.Print(" %s%s\n", d, desc)
}
cli.Blank()
}
// Indirect dependents
if len(indirect) > 0 {
cli.Print("%s %s\n",
impactIndirectStyle.Render("o"),
i18n.T("cmd.dev.impact.transitive_dependents", map[string]interface{}{"Count": len(indirect)}),
)
for _, d := range indirect {
r, _ := reg.Get(d)
desc := ""
if r != nil && r.Description != "" {
desc = dimStyle.Render(" - " + cli.Truncate(r.Description, 40))
}
cli.Print(" %s%s\n", d, desc)
}
cli.Blank()
}
// Summary
cli.Print("%s %s\n",
dimStyle.Render(i18n.Label("summary")),
i18n.T("cmd.dev.impact.changes_affect", map[string]interface{}{
"Repo": repoNameStyle.Render(repoName),
"Affected": len(allAffected),
"Total": len(reg.Repos) - 1,
}),
)
return nil
}
// buildDependentsGraph creates a reverse dependency map
// key = repo, value = repos that depend on it
func buildDependentsGraph(reg *repos.Registry) map[string][]string {
dependents := make(map[string][]string)
for name, repo := range reg.Repos {
for _, dep := range repo.DependsOn {
dependents[dep] = append(dependents[dep], name)
}
}
return dependents
}
// findAllDependents recursively finds all repos that depend on the given repo
func findAllDependents(repoName string, dependents map[string][]string) []string {
visited := make(map[string]bool)
var result []string
var visit func(name string)
visit = func(name string) {
for _, dep := range dependents[name] {
if !visited[dep] {
visited[dep] = true
result = append(result, dep)
visit(dep) // Recurse for transitive deps
}
}
}
visit(repoName)
return result
}

View file

@ -1,208 +0,0 @@
package dev
import (
"encoding/json"
"errors"
"os/exec"
"sort"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Issue-specific styles (aliases to shared)
var (
issueRepoStyle = cli.DimStyle
issueNumberStyle = cli.TitleStyle
issueTitleStyle = cli.ValueStyle
issueLabelStyle = cli.WarningStyle
issueAssigneeStyle = cli.SuccessStyle
issueAgeStyle = cli.DimStyle
)
// GitHubIssue represents a GitHub issue from the API.
type GitHubIssue struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Assignees struct {
Nodes []struct {
Login string `json:"login"`
} `json:"nodes"`
} `json:"assignees"`
Labels struct {
Nodes []struct {
Name string `json:"name"`
} `json:"nodes"`
} `json:"labels"`
URL string `json:"url"`
// Added by us
RepoName string `json:"-"`
}
// Issues command flags
var (
issuesRegistryPath string
issuesLimit int
issuesAssignee string
)
// addIssuesCommand adds the 'issues' command to the given parent command.
func addIssuesCommand(parent *cli.Command) {
issuesCmd := &cli.Command{
Use: "issues",
Short: i18n.T("cmd.dev.issues.short"),
Long: i18n.T("cmd.dev.issues.long"),
RunE: func(cmd *cli.Command, args []string) error {
limit := issuesLimit
if limit == 0 {
limit = 10
}
return runIssues(issuesRegistryPath, limit, issuesAssignee)
},
}
issuesCmd.Flags().StringVar(&issuesRegistryPath, "registry", "", i18n.T("common.flag.registry"))
issuesCmd.Flags().IntVarP(&issuesLimit, "limit", "l", 10, i18n.T("cmd.dev.issues.flag.limit"))
issuesCmd.Flags().StringVarP(&issuesAssignee, "assignee", "a", "", i18n.T("cmd.dev.issues.flag.assignee"))
parent.AddCommand(issuesCmd)
}
func runIssues(registryPath string, limit int, assignee string) error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return errors.New(i18n.T("error.gh_not_found"))
}
// Find or use provided registry
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Fetch issues sequentially (avoid GitHub rate limits)
var allIssues []GitHubIssue
var fetchErrors []error
repoList := reg.List()
for i, repo := range repoList {
repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name)
cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("i18n.progress.fetch")), i+1, len(repoList), repo.Name)
issues, err := fetchIssues(repoFullName, repo.Name, limit, assignee)
if err != nil {
fetchErrors = append(fetchErrors, cli.Wrap(err, repo.Name))
continue
}
allIssues = append(allIssues, issues...)
}
cli.Print("\033[2K\r") // Clear progress line
// Sort by created date (newest first)
sort.Slice(allIssues, func(i, j int) bool {
return allIssues[i].CreatedAt.After(allIssues[j].CreatedAt)
})
// Print issues
if len(allIssues) == 0 {
cli.Text(i18n.T("cmd.dev.issues.no_issues"))
return nil
}
cli.Print("\n%s\n\n", i18n.T("cmd.dev.issues.open_issues", map[string]interface{}{"Count": len(allIssues)}))
for _, issue := range allIssues {
printIssue(issue)
}
// Print any errors
if len(fetchErrors) > 0 {
cli.Blank()
for _, err := range fetchErrors {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), err)
}
}
return nil
}
func fetchIssues(repoFullName, repoName string, limit int, assignee string) ([]GitHubIssue, error) {
args := []string{
"issue", "list",
"--repo", repoFullName,
"--state", "open",
"--limit", cli.Sprintf("%d", limit),
"--json", "number,title,state,createdAt,author,assignees,labels,url",
}
if assignee != "" {
args = append(args, "--assignee", assignee)
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
// Check if it's just "no issues" vs actual error
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
if strings.Contains(stderr, "no issues") || strings.Contains(stderr, "Could not resolve") {
return nil, nil
}
return nil, cli.Err("%s", stderr)
}
return nil, err
}
var issues []GitHubIssue
if err := json.Unmarshal(output, &issues); err != nil {
return nil, err
}
// Tag with repo name
for i := range issues {
issues[i].RepoName = repoName
}
return issues, nil
}
func printIssue(issue GitHubIssue) {
// #42 [core-bio] Fix avatar upload
num := issueNumberStyle.Render(cli.Sprintf("#%d", issue.Number))
repo := issueRepoStyle.Render(cli.Sprintf("[%s]", issue.RepoName))
title := issueTitleStyle.Render(cli.Truncate(issue.Title, 60))
line := cli.Sprintf(" %s %s %s", num, repo, title)
// Add labels if any
if len(issue.Labels.Nodes) > 0 {
var labels []string
for _, l := range issue.Labels.Nodes {
labels = append(labels, l.Name)
}
line += " " + issueLabelStyle.Render("["+strings.Join(labels, ", ")+"]")
}
// Add assignee if any
if len(issue.Assignees.Nodes) > 0 {
var assignees []string
for _, a := range issue.Assignees.Nodes {
assignees = append(assignees, "@"+a.Login)
}
line += " " + issueAssigneeStyle.Render(strings.Join(assignees, ", "))
}
// Add age
age := cli.FormatAge(issue.CreatedAt)
line += " " + issueAgeStyle.Render(age)
cli.Text(line)
}

View file

@ -1,130 +0,0 @@
package dev
import (
"context"
"os/exec"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Pull command flags
var (
pullRegistryPath string
pullAll bool
)
// AddPullCommand adds the 'pull' command to the given parent command.
func AddPullCommand(parent *cli.Command) {
pullCmd := &cli.Command{
Use: "pull",
Short: i18n.T("cmd.dev.pull.short"),
Long: i18n.T("cmd.dev.pull.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runPull(pullRegistryPath, pullAll)
},
}
pullCmd.Flags().StringVar(&pullRegistryPath, "registry", "", i18n.T("common.flag.registry"))
pullCmd.Flags().BoolVar(&pullAll, "all", false, i18n.T("cmd.dev.pull.flag.all"))
parent.AddCommand(pullCmd)
}
func runPull(registryPath string, all bool) error {
ctx := context.Background()
// Find or use provided registry
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Build paths and names for git operations
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
if len(paths) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Get status for all repos
statuses := git.Status(ctx, git.StatusOptions{
Paths: paths,
Names: names,
})
// Find repos to pull
var toPull []git.RepoStatus
for _, s := range statuses {
if s.Error != nil {
continue
}
if all || s.HasUnpulled() {
toPull = append(toPull, s)
}
}
if len(toPull) == 0 {
cli.Text(i18n.T("cmd.dev.pull.all_up_to_date"))
return nil
}
// Show what we're pulling
if all {
cli.Print("\n%s\n\n", i18n.T("cmd.dev.pull.pulling_repos", map[string]interface{}{"Count": len(toPull)}))
} else {
cli.Print("\n%s\n\n", i18n.T("cmd.dev.pull.repos_behind", map[string]interface{}{"Count": len(toPull)}))
for _, s := range toPull {
cli.Print(" %s: %s\n",
repoNameStyle.Render(s.Name),
dimStyle.Render(i18n.T("cmd.dev.pull.commits_behind", map[string]interface{}{"Count": s.Behind})),
)
}
cli.Blank()
}
// Pull each repo
var succeeded, failed int
for _, s := range toPull {
cli.Print(" %s %s... ", dimStyle.Render(i18n.T("cmd.dev.pull.pulling")), s.Name)
err := gitPull(ctx, s.Path)
if err != nil {
cli.Print("%s\n", errorStyle.Render("x "+err.Error()))
failed++
} else {
cli.Print("%s\n", successStyle.Render("v"))
succeeded++
}
}
// Summary
cli.Blank()
cli.Print("%s", successStyle.Render(i18n.T("cmd.dev.pull.done_pulled", map[string]interface{}{"Count": succeeded})))
if failed > 0 {
cli.Print(", %s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
func gitPull(ctx context.Context, path string) error {
cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only")
cmd.Dir = path
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", string(output))
}
return nil
}

View file

@ -1,275 +0,0 @@
package dev
import (
"context"
"os"
"path/filepath"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Push command flags
var (
pushRegistryPath string
pushForce bool
)
// AddPushCommand adds the 'push' command to the given parent command.
func AddPushCommand(parent *cli.Command) {
pushCmd := &cli.Command{
Use: "push",
Short: i18n.T("cmd.dev.push.short"),
Long: i18n.T("cmd.dev.push.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runPush(pushRegistryPath, pushForce)
},
}
pushCmd.Flags().StringVar(&pushRegistryPath, "registry", "", i18n.T("common.flag.registry"))
pushCmd.Flags().BoolVarP(&pushForce, "force", "f", false, i18n.T("cmd.dev.push.flag.force"))
parent.AddCommand(pushCmd)
}
func runPush(registryPath string, force bool) error {
ctx := context.Background()
cwd, _ := os.Getwd()
// Check if current directory is a git repo (single-repo mode)
if registryPath == "" && isGitRepo(cwd) {
return runPushSingleRepo(ctx, cwd, force)
}
// Multi-repo mode: find or use provided registry
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Build paths and names for git operations
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
if len(paths) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Get status for all repos
statuses := git.Status(ctx, git.StatusOptions{
Paths: paths,
Names: names,
})
// Find repos with unpushed commits
var aheadRepos []git.RepoStatus
for _, s := range statuses {
if s.Error == nil && s.HasUnpushed() {
aheadRepos = append(aheadRepos, s)
}
}
if len(aheadRepos) == 0 {
cli.Text(i18n.T("cmd.dev.push.all_up_to_date"))
return nil
}
// Show repos to push
cli.Print("\n%s\n\n", i18n.T("common.count.repos_unpushed", map[string]interface{}{"Count": len(aheadRepos)}))
totalCommits := 0
for _, s := range aheadRepos {
cli.Print(" %s: %s\n",
repoNameStyle.Render(s.Name),
aheadStyle.Render(i18n.T("common.count.commits", map[string]interface{}{"Count": s.Ahead})),
)
totalCommits += s.Ahead
}
// Confirm unless --force
if !force {
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.push.confirm_push", map[string]interface{}{"Commits": totalCommits, "Repos": len(aheadRepos)})) {
cli.Text(i18n.T("cli.aborted"))
return nil
}
}
cli.Blank()
// Push sequentially (SSH passphrase needs interaction)
var pushPaths []string
for _, s := range aheadRepos {
pushPaths = append(pushPaths, s.Path)
}
results := git.PushMultiple(ctx, pushPaths, names)
var succeeded, failed int
var divergedRepos []git.PushResult
for _, r := range results {
if r.Success {
cli.Print(" %s %s\n", successStyle.Render("v"), r.Name)
succeeded++
} else {
// Check if this is a non-fast-forward error (diverged branch)
if git.IsNonFastForward(r.Error) {
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), r.Name, i18n.T("cmd.dev.push.diverged"))
divergedRepos = append(divergedRepos, r)
} else {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error)
}
failed++
}
}
// Handle diverged repos - offer to pull and retry
if len(divergedRepos) > 0 {
cli.Blank()
cli.Print("%s\n", i18n.T("cmd.dev.push.diverged_help"))
if cli.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
cli.Blank()
for _, r := range divergedRepos {
cli.Print(" %s %s...\n", dimStyle.Render("↓"), r.Name)
if err := git.Pull(ctx, r.Path); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), r.Name, err)
continue
}
cli.Print(" %s %s...\n", dimStyle.Render("↑"), r.Name)
if err := git.Push(ctx, r.Path); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), r.Name, err)
continue
}
cli.Print(" %s %s\n", successStyle.Render("v"), r.Name)
succeeded++
failed--
}
}
}
// Summary
cli.Blank()
cli.Print("%s", successStyle.Render(i18n.T("cmd.dev.push.done_pushed", map[string]interface{}{"Count": succeeded})))
if failed > 0 {
cli.Print(", %s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// runPushSingleRepo handles push for a single repo (current directory).
func runPushSingleRepo(ctx context.Context, repoPath string, force bool) error {
repoName := filepath.Base(repoPath)
// Get status
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repoPath},
Names: map[string]string{repoPath: repoName},
})
if len(statuses) == 0 {
return cli.Err("failed to get repo status")
}
s := statuses[0]
if s.Error != nil {
return s.Error
}
if !s.HasUnpushed() {
// Check if there are uncommitted changes
if s.IsDirty() {
cli.Print("%s: ", repoNameStyle.Render(s.Name))
if s.Modified > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.modified", map[string]interface{}{"Count": s.Modified})))
}
if s.Untracked > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.untracked", map[string]interface{}{"Count": s.Untracked})))
}
if s.Staged > 0 {
cli.Print("%s ", aheadStyle.Render(i18n.T("cmd.dev.staged", map[string]interface{}{"Count": s.Staged})))
}
cli.Blank()
cli.Blank()
if cli.Confirm(i18n.T("cmd.dev.push.uncommitted_changes_commit")) {
cli.Blank()
// Use edit-enabled commit if only untracked files (may need .gitignore fix)
var err error
if s.Modified == 0 && s.Staged == 0 && s.Untracked > 0 {
err = claudeEditCommit(ctx, repoPath, repoName, "")
} else {
err = runCommitSingleRepo(ctx, repoPath, false)
}
if err != nil {
return err
}
// Re-check - only push if Claude created commits
newStatuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repoPath},
Names: map[string]string{repoPath: repoName},
})
if len(newStatuses) > 0 && newStatuses[0].HasUnpushed() {
return runPushSingleRepo(ctx, repoPath, force)
}
}
return nil
}
cli.Text(i18n.T("cmd.dev.push.all_up_to_date"))
return nil
}
// Show commits to push
cli.Print("%s: %s\n", repoNameStyle.Render(s.Name),
aheadStyle.Render(i18n.T("common.count.commits", map[string]interface{}{"Count": s.Ahead})))
// Confirm unless --force
if !force {
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.push.confirm_push", map[string]interface{}{"Commits": s.Ahead, "Repos": 1})) {
cli.Text(i18n.T("cli.aborted"))
return nil
}
}
cli.Blank()
// Push
err := git.Push(ctx, repoPath)
if err != nil {
if git.IsNonFastForward(err) {
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), repoName, i18n.T("cmd.dev.push.diverged"))
cli.Blank()
cli.Print("%s\n", i18n.T("cmd.dev.push.diverged_help"))
if cli.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
cli.Blank()
cli.Print(" %s %s...\n", dimStyle.Render("↓"), repoName)
if pullErr := git.Pull(ctx, repoPath); pullErr != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, pullErr)
return pullErr
}
cli.Print(" %s %s...\n", dimStyle.Render("↑"), repoName)
if pushErr := git.Push(ctx, repoPath); pushErr != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, pushErr)
return pushErr
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
return nil
}
}
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, err)
return err
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
return nil
}

View file

@ -1,237 +0,0 @@
package dev
import (
"encoding/json"
"errors"
"os/exec"
"sort"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// PR-specific styles (aliases to shared)
var (
prNumberStyle = cli.NumberStyle
prTitleStyle = cli.ValueStyle
prAuthorStyle = cli.InfoStyle
prApprovedStyle = cli.SuccessStyle
prChangesStyle = cli.WarningStyle
prPendingStyle = cli.DimStyle
prDraftStyle = cli.DimStyle
)
// GitHubPR represents a GitHub pull request.
type GitHubPR struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
IsDraft bool `json:"isDraft"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
ReviewDecision string `json:"reviewDecision"`
Reviews struct {
Nodes []struct {
State string `json:"state"`
Author struct {
Login string `json:"login"`
} `json:"author"`
} `json:"nodes"`
} `json:"reviews"`
URL string `json:"url"`
// Added by us
RepoName string `json:"-"`
}
// Reviews command flags
var (
reviewsRegistryPath string
reviewsAuthor string
reviewsShowAll bool
)
// addReviewsCommand adds the 'reviews' command to the given parent command.
func addReviewsCommand(parent *cli.Command) {
reviewsCmd := &cli.Command{
Use: "reviews",
Short: i18n.T("cmd.dev.reviews.short"),
Long: i18n.T("cmd.dev.reviews.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runReviews(reviewsRegistryPath, reviewsAuthor, reviewsShowAll)
},
}
reviewsCmd.Flags().StringVar(&reviewsRegistryPath, "registry", "", i18n.T("common.flag.registry"))
reviewsCmd.Flags().StringVar(&reviewsAuthor, "author", "", i18n.T("cmd.dev.reviews.flag.author"))
reviewsCmd.Flags().BoolVar(&reviewsShowAll, "all", false, i18n.T("cmd.dev.reviews.flag.all"))
parent.AddCommand(reviewsCmd)
}
func runReviews(registryPath string, author string, showAll bool) error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return errors.New(i18n.T("error.gh_not_found"))
}
// Find or use provided registry
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Fetch PRs sequentially (avoid GitHub rate limits)
var allPRs []GitHubPR
var fetchErrors []error
repoList := reg.List()
for i, repo := range repoList {
repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name)
cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("i18n.progress.fetch")), i+1, len(repoList), repo.Name)
prs, err := fetchPRs(repoFullName, repo.Name, author)
if err != nil {
fetchErrors = append(fetchErrors, cli.Wrap(err, repo.Name))
continue
}
for _, pr := range prs {
// Filter drafts unless --all
if !showAll && pr.IsDraft {
continue
}
allPRs = append(allPRs, pr)
}
}
cli.Print("\033[2K\r") // Clear progress line
// Sort: pending review first, then by date
sort.Slice(allPRs, func(i, j int) bool {
// Pending reviews come first
iPending := allPRs[i].ReviewDecision == "" || allPRs[i].ReviewDecision == "REVIEW_REQUIRED"
jPending := allPRs[j].ReviewDecision == "" || allPRs[j].ReviewDecision == "REVIEW_REQUIRED"
if iPending != jPending {
return iPending
}
return allPRs[i].CreatedAt.After(allPRs[j].CreatedAt)
})
// Print PRs
if len(allPRs) == 0 {
cli.Text(i18n.T("cmd.dev.reviews.no_prs"))
return nil
}
// Count by status
var pending, approved, changesRequested int
for _, pr := range allPRs {
switch pr.ReviewDecision {
case "APPROVED":
approved++
case "CHANGES_REQUESTED":
changesRequested++
default:
pending++
}
}
cli.Blank()
cli.Print("%s", i18n.T("cmd.dev.reviews.open_prs", map[string]interface{}{"Count": len(allPRs)}))
if pending > 0 {
cli.Print(" * %s", prPendingStyle.Render(i18n.T("common.count.pending", map[string]interface{}{"Count": pending})))
}
if approved > 0 {
cli.Print(" * %s", prApprovedStyle.Render(i18n.T("cmd.dev.reviews.approved", map[string]interface{}{"Count": approved})))
}
if changesRequested > 0 {
cli.Print(" * %s", prChangesStyle.Render(i18n.T("cmd.dev.reviews.changes_requested", map[string]interface{}{"Count": changesRequested})))
}
cli.Blank()
cli.Blank()
for _, pr := range allPRs {
printPR(pr)
}
// Print any errors
if len(fetchErrors) > 0 {
cli.Blank()
for _, err := range fetchErrors {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), err)
}
}
return nil
}
func fetchPRs(repoFullName, repoName string, author string) ([]GitHubPR, error) {
args := []string{
"pr", "list",
"--repo", repoFullName,
"--state", "open",
"--json", "number,title,state,isDraft,createdAt,author,reviewDecision,reviews,url",
}
if author != "" {
args = append(args, "--author", author)
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
if strings.Contains(stderr, "no pull requests") || strings.Contains(stderr, "Could not resolve") {
return nil, nil
}
return nil, cli.Err("%s", stderr)
}
return nil, err
}
var prs []GitHubPR
if err := json.Unmarshal(output, &prs); err != nil {
return nil, err
}
// Tag with repo name
for i := range prs {
prs[i].RepoName = repoName
}
return prs, nil
}
func printPR(pr GitHubPR) {
// #12 [core-php] Webhook validation
num := prNumberStyle.Render(cli.Sprintf("#%d", pr.Number))
repo := issueRepoStyle.Render(cli.Sprintf("[%s]", pr.RepoName))
title := prTitleStyle.Render(cli.Truncate(pr.Title, 50))
author := prAuthorStyle.Render("@" + pr.Author.Login)
// Review status
var status string
switch pr.ReviewDecision {
case "APPROVED":
status = prApprovedStyle.Render(i18n.T("cmd.dev.reviews.status_approved"))
case "CHANGES_REQUESTED":
status = prChangesStyle.Render(i18n.T("cmd.dev.reviews.status_changes"))
default:
status = prPendingStyle.Render(i18n.T("cmd.dev.reviews.status_pending"))
}
// Draft indicator
draft := ""
if pr.IsDraft {
draft = prDraftStyle.Render(" " + i18n.T("cmd.dev.reviews.draft"))
}
age := cli.FormatAge(pr.CreatedAt)
cli.Print(" %s %s %s%s %s %s %s\n", num, repo, title, draft, author, status, issueAgeStyle.Render(age))
}

View file

@ -1,174 +0,0 @@
package dev
import (
"bytes"
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"text/template"
"forge.lthn.ai/core/go/pkg/cli" // Added
"forge.lthn.ai/core/go/pkg/i18n" // Added
coreio "forge.lthn.ai/core/go/pkg/io"
// Added
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// addSyncCommand adds the 'sync' command to the given parent command.
func addSyncCommand(parent *cli.Command) {
syncCmd := &cli.Command{
Use: "sync",
Short: i18n.T("cmd.dev.sync.short"),
Long: i18n.T("cmd.dev.sync.long"),
RunE: func(cmd *cli.Command, args []string) error {
if err := runSync(); err != nil {
return cli.Wrap(err, i18n.Label("error"))
}
cli.Text(i18n.T("i18n.done.sync", "public APIs"))
return nil
},
}
parent.AddCommand(syncCmd)
}
type symbolInfo struct {
Name string
Kind string // "var", "func", "type", "const"
}
func runSync() error {
pkgDir := "pkg"
internalDirs, err := coreio.Local.List(pkgDir)
if err != nil {
return cli.Wrap(err, "failed to read pkg directory")
}
for _, dir := range internalDirs {
if !dir.IsDir() || dir.Name() == "core" {
continue
}
serviceName := dir.Name()
internalFile := filepath.Join(pkgDir, serviceName, serviceName+".go")
publicDir := serviceName
publicFile := filepath.Join(publicDir, serviceName+".go")
if !coreio.Local.IsFile(internalFile) {
continue
}
symbols, err := getExportedSymbols(internalFile)
if err != nil {
return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName))
}
if err := generatePublicAPIFile(publicDir, publicFile, serviceName, symbols); err != nil {
return cli.Wrap(err, cli.Sprintf("error generating public API file for service '%s'", serviceName))
}
}
return nil
}
func getExportedSymbols(path string) ([]symbolInfo, error) {
// ParseFile expects a filename/path and reads it using os.Open by default if content is nil.
// Since we want to use our Medium abstraction, we should read the file content first.
content, err := coreio.Local.Read(path)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
// ParseFile can take content as string (src argument).
node, err := parser.ParseFile(fset, path, content, parser.ParseComments)
if err != nil {
return nil, err
}
var symbols []symbolInfo
for name, obj := range node.Scope.Objects {
if ast.IsExported(name) {
kind := "unknown"
switch obj.Kind {
case ast.Con:
kind = "const"
case ast.Var:
kind = "var"
case ast.Fun:
kind = "func"
case ast.Typ:
kind = "type"
}
if kind != "unknown" {
symbols = append(symbols, symbolInfo{Name: name, Kind: kind})
}
}
}
return symbols, nil
}
const publicAPITemplate = `// package {{.ServiceName}} provides the public API for the {{.ServiceName}} service.
package {{.ServiceName}}
import (
// Import the internal implementation with an alias.
impl "forge.lthn.ai/core/cli/{{.ServiceName}}"
// Import the core contracts to re-export the interface.
"forge.lthn.ai/core/cli/core"
)
{{range .Symbols}}
{{- if eq .Kind "type"}}
// {{.Name}} is the public type for the {{.Name}} service. It is a type alias
// to the underlying implementation, making it transparent to the user.
type {{.Name}} = impl.{{.Name}}
{{else if eq .Kind "const"}}
// {{.Name}} is a public constant that points to the real constant in the implementation package.
const {{.Name}} = impl.{{.Name}}
{{else if eq .Kind "var"}}
// {{.Name}} is a public variable that points to the real variable in the implementation package.
var {{.Name}} = impl.{{.Name}}
{{else if eq .Kind "func"}}
// {{.Name}} is a public function that points to the real function in the implementation package.
var {{.Name}} = impl.{{.Name}}
{{end}}
{{end}}
// {{.InterfaceName}} is the public interface for the {{.ServiceName}} service.
type {{.InterfaceName}} = core.{{.InterfaceName}}
`
func generatePublicAPIFile(dir, path, serviceName string, symbols []symbolInfo) error {
if err := coreio.Local.EnsureDir(dir); err != nil {
return err
}
tmpl, err := template.New("publicAPI").Parse(publicAPITemplate)
if err != nil {
return err
}
tcaser := cases.Title(language.English)
interfaceName := tcaser.String(serviceName)
data := struct {
ServiceName string
Symbols []symbolInfo
InterfaceName string
}{
ServiceName: serviceName,
Symbols: symbols,
InterfaceName: interfaceName,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return err
}
return coreio.Local.Write(path, buf.String())
}

View file

@ -1,510 +0,0 @@
package dev
import (
"context"
"errors"
"os"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go-devops/devops"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
)
// addVMCommands adds the dev environment VM commands to the dev parent command.
// These are added as direct subcommands: core dev install, core dev boot, etc.
func addVMCommands(parent *cli.Command) {
addVMInstallCommand(parent)
addVMBootCommand(parent)
addVMStopCommand(parent)
addVMStatusCommand(parent)
addVMShellCommand(parent)
addVMServeCommand(parent)
addVMTestCommand(parent)
addVMClaudeCommand(parent)
addVMUpdateCommand(parent)
}
// addVMInstallCommand adds the 'dev install' command.
func addVMInstallCommand(parent *cli.Command) {
installCmd := &cli.Command{
Use: "install",
Short: i18n.T("cmd.dev.vm.install.short"),
Long: i18n.T("cmd.dev.vm.install.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMInstall()
},
}
parent.AddCommand(installCmd)
}
func runVMInstall() error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
if d.IsInstalled() {
cli.Text(successStyle.Render(i18n.T("cmd.dev.vm.already_installed")))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.check_updates", map[string]interface{}{"Command": dimStyle.Render("core dev update")}))
return nil
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("image")), devops.ImageName())
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.downloading"))
cli.Blank()
ctx := context.Background()
start := time.Now()
var lastProgress int64
err = d.Install(ctx, func(downloaded, total int64) {
if total > 0 {
pct := int(float64(downloaded) / float64(total) * 100)
if pct != int(float64(lastProgress)/float64(total)*100) {
cli.Print("\r%s %d%%", dimStyle.Render(i18n.T("cmd.dev.vm.progress_label")), pct)
lastProgress = downloaded
}
}
})
cli.Blank() // Clear progress line
if err != nil {
return cli.Wrap(err, "install failed")
}
elapsed := time.Since(start).Round(time.Second)
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.installed_in", map[string]interface{}{"Duration": elapsed}))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.start_with", map[string]interface{}{"Command": dimStyle.Render("core dev boot")}))
return nil
}
// VM boot command flags
var (
vmBootMemory int
vmBootCPUs int
vmBootFresh bool
)
// addVMBootCommand adds the 'devops boot' command.
func addVMBootCommand(parent *cli.Command) {
bootCmd := &cli.Command{
Use: "boot",
Short: i18n.T("cmd.dev.vm.boot.short"),
Long: i18n.T("cmd.dev.vm.boot.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMBoot(vmBootMemory, vmBootCPUs, vmBootFresh)
},
}
bootCmd.Flags().IntVar(&vmBootMemory, "memory", 0, i18n.T("cmd.dev.vm.boot.flag.memory"))
bootCmd.Flags().IntVar(&vmBootCPUs, "cpus", 0, i18n.T("cmd.dev.vm.boot.flag.cpus"))
bootCmd.Flags().BoolVar(&vmBootFresh, "fresh", false, i18n.T("cmd.dev.vm.boot.flag.fresh"))
parent.AddCommand(bootCmd)
}
func runVMBoot(memory, cpus int, fresh bool) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
if !d.IsInstalled() {
return errors.New(i18n.T("cmd.dev.vm.not_installed"))
}
opts := devops.DefaultBootOptions()
if memory > 0 {
opts.Memory = memory
}
if cpus > 0 {
opts.CPUs = cpus
}
opts.Fresh = fresh
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.config_label")), i18n.T("cmd.dev.vm.config_value", map[string]interface{}{"Memory": opts.Memory, "CPUs": opts.CPUs}))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.booting"))
ctx := context.Background()
if err := d.Boot(ctx, opts); err != nil {
return err
}
cli.Blank()
cli.Text(successStyle.Render(i18n.T("cmd.dev.vm.running")))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.connect_with", map[string]interface{}{"Command": dimStyle.Render("core dev shell")}))
cli.Print("%s %s\n", i18n.T("cmd.dev.vm.ssh_port"), dimStyle.Render("2222"))
return nil
}
// addVMStopCommand adds the 'devops stop' command.
func addVMStopCommand(parent *cli.Command) {
stopCmd := &cli.Command{
Use: "stop",
Short: i18n.T("cmd.dev.vm.stop.short"),
Long: i18n.T("cmd.dev.vm.stop.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMStop()
},
}
parent.AddCommand(stopCmd)
}
func runVMStop() error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
ctx := context.Background()
running, err := d.IsRunning(ctx)
if err != nil {
return err
}
if !running {
cli.Text(dimStyle.Render(i18n.T("cmd.dev.vm.not_running")))
return nil
}
cli.Text(i18n.T("cmd.dev.vm.stopping"))
if err := d.Stop(ctx); err != nil {
return err
}
cli.Text(successStyle.Render(i18n.T("common.status.stopped")))
return nil
}
// addVMStatusCommand adds the 'devops status' command.
func addVMStatusCommand(parent *cli.Command) {
statusCmd := &cli.Command{
Use: "vm-status",
Short: i18n.T("cmd.dev.vm.status.short"),
Long: i18n.T("cmd.dev.vm.status.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMStatus()
},
}
parent.AddCommand(statusCmd)
}
func runVMStatus() error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
ctx := context.Background()
status, err := d.Status(ctx)
if err != nil {
return err
}
cli.Text(headerStyle.Render(i18n.T("cmd.dev.vm.status_title")))
cli.Blank()
// Installation status
if status.Installed {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.installed_label")), successStyle.Render(i18n.T("cmd.dev.vm.installed_yes")))
if status.ImageVersion != "" {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("version")), status.ImageVersion)
}
} else {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.installed_label")), errorStyle.Render(i18n.T("cmd.dev.vm.installed_no")))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.install_with", map[string]interface{}{"Command": dimStyle.Render("core dev install")}))
return nil
}
cli.Blank()
// Running status
if status.Running {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), successStyle.Render(i18n.T("common.status.running")))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.container_label")), status.ContainerID[:8])
cli.Print("%s %dMB\n", dimStyle.Render(i18n.T("cmd.dev.vm.memory_label")), status.Memory)
cli.Print("%s %d\n", dimStyle.Render(i18n.T("cmd.dev.vm.cpus_label")), status.CPUs)
cli.Print("%s %d\n", dimStyle.Render(i18n.T("cmd.dev.vm.ssh_port")), status.SSHPort)
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.uptime_label")), formatVMUptime(status.Uptime))
} else {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), dimStyle.Render(i18n.T("common.status.stopped")))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.start_with", map[string]interface{}{"Command": dimStyle.Render("core dev boot")}))
}
return nil
}
func formatVMUptime(d time.Duration) string {
if d < time.Minute {
return cli.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return cli.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
return cli.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
return cli.Sprintf("%dd %dh", int(d.Hours()/24), int(d.Hours())%24)
}
// VM shell command flags
var vmShellConsole bool
// addVMShellCommand adds the 'devops shell' command.
func addVMShellCommand(parent *cli.Command) {
shellCmd := &cli.Command{
Use: "shell [-- command...]",
Short: i18n.T("cmd.dev.vm.shell.short"),
Long: i18n.T("cmd.dev.vm.shell.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMShell(vmShellConsole, args)
},
}
shellCmd.Flags().BoolVar(&vmShellConsole, "console", false, i18n.T("cmd.dev.vm.shell.flag.console"))
parent.AddCommand(shellCmd)
}
func runVMShell(console bool, command []string) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
opts := devops.ShellOptions{
Console: console,
Command: command,
}
ctx := context.Background()
return d.Shell(ctx, opts)
}
// VM serve command flags
var (
vmServePort int
vmServePath string
)
// addVMServeCommand adds the 'devops serve' command.
func addVMServeCommand(parent *cli.Command) {
serveCmd := &cli.Command{
Use: "serve",
Short: i18n.T("cmd.dev.vm.serve.short"),
Long: i18n.T("cmd.dev.vm.serve.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMServe(vmServePort, vmServePath)
},
}
serveCmd.Flags().IntVarP(&vmServePort, "port", "p", 0, i18n.T("cmd.dev.vm.serve.flag.port"))
serveCmd.Flags().StringVar(&vmServePath, "path", "", i18n.T("cmd.dev.vm.serve.flag.path"))
parent.AddCommand(serveCmd)
}
func runVMServe(port int, path string) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
opts := devops.ServeOptions{
Port: port,
Path: path,
}
ctx := context.Background()
return d.Serve(ctx, projectDir, opts)
}
// VM test command flags
var vmTestName string
// addVMTestCommand adds the 'devops test' command.
func addVMTestCommand(parent *cli.Command) {
testCmd := &cli.Command{
Use: "test [-- command...]",
Short: i18n.T("cmd.dev.vm.test.short"),
Long: i18n.T("cmd.dev.vm.test.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMTest(vmTestName, args)
},
}
testCmd.Flags().StringVarP(&vmTestName, "name", "n", "", i18n.T("cmd.dev.vm.test.flag.name"))
parent.AddCommand(testCmd)
}
func runVMTest(name string, command []string) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
opts := devops.TestOptions{
Name: name,
Command: command,
}
ctx := context.Background()
return d.Test(ctx, projectDir, opts)
}
// VM claude command flags
var (
vmClaudeNoAuth bool
vmClaudeModel string
vmClaudeAuthFlags []string
)
// addVMClaudeCommand adds the 'devops claude' command.
func addVMClaudeCommand(parent *cli.Command) {
claudeCmd := &cli.Command{
Use: "claude",
Short: i18n.T("cmd.dev.vm.claude.short"),
Long: i18n.T("cmd.dev.vm.claude.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMClaude(vmClaudeNoAuth, vmClaudeModel, vmClaudeAuthFlags)
},
}
claudeCmd.Flags().BoolVar(&vmClaudeNoAuth, "no-auth", false, i18n.T("cmd.dev.vm.claude.flag.no_auth"))
claudeCmd.Flags().StringVarP(&vmClaudeModel, "model", "m", "", i18n.T("cmd.dev.vm.claude.flag.model"))
claudeCmd.Flags().StringSliceVar(&vmClaudeAuthFlags, "auth", nil, i18n.T("cmd.dev.vm.claude.flag.auth"))
parent.AddCommand(claudeCmd)
}
func runVMClaude(noAuth bool, model string, authFlags []string) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
opts := devops.ClaudeOptions{
NoAuth: noAuth,
Model: model,
Auth: authFlags,
}
ctx := context.Background()
return d.Claude(ctx, projectDir, opts)
}
// VM update command flags
var vmUpdateApply bool
// addVMUpdateCommand adds the 'devops update' command.
func addVMUpdateCommand(parent *cli.Command) {
updateCmd := &cli.Command{
Use: "update",
Short: i18n.T("cmd.dev.vm.update.short"),
Long: i18n.T("cmd.dev.vm.update.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMUpdate(vmUpdateApply)
},
}
updateCmd.Flags().BoolVar(&vmUpdateApply, "apply", false, i18n.T("cmd.dev.vm.update.flag.apply"))
parent.AddCommand(updateCmd)
}
func runVMUpdate(apply bool) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
ctx := context.Background()
cli.Text(i18n.T("common.progress.checking_updates"))
cli.Blank()
current, latest, hasUpdate, err := d.CheckUpdate(ctx)
if err != nil {
return cli.Wrap(err, "failed to check for updates")
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("current")), valueStyle.Render(current))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.latest_label")), valueStyle.Render(latest))
cli.Blank()
if !hasUpdate {
cli.Text(successStyle.Render(i18n.T("cmd.dev.vm.up_to_date")))
return nil
}
cli.Text(warningStyle.Render(i18n.T("cmd.dev.vm.update_available")))
cli.Blank()
if !apply {
cli.Text(i18n.T("cmd.dev.vm.run_to_update", map[string]interface{}{"Command": dimStyle.Render("core dev update --apply")}))
return nil
}
// Stop if running
running, _ := d.IsRunning(ctx)
if running {
cli.Text(i18n.T("cmd.dev.vm.stopping_current"))
_ = d.Stop(ctx)
}
cli.Text(i18n.T("cmd.dev.vm.downloading_update"))
cli.Blank()
start := time.Now()
err = d.Install(ctx, func(downloaded, total int64) {
if total > 0 {
pct := int(float64(downloaded) / float64(total) * 100)
cli.Print("\r%s %d%%", dimStyle.Render(i18n.T("cmd.dev.vm.progress_label")), pct)
}
})
cli.Blank()
if err != nil {
return cli.Wrap(err, "update failed")
}
elapsed := time.Since(start).Round(time.Second)
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.updated_in", map[string]interface{}{"Duration": elapsed}))
return nil
}

View file

@ -1,344 +0,0 @@
package dev
import (
"context"
"os"
"os/exec"
"sort"
"strings"
"forge.lthn.ai/core/go-agentic"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Work command flags
var (
workStatusOnly bool
workAutoCommit bool
workRegistryPath string
)
// AddWorkCommand adds the 'work' command to the given parent command.
func AddWorkCommand(parent *cli.Command) {
workCmd := &cli.Command{
Use: "work",
Short: i18n.T("cmd.dev.work.short"),
Long: i18n.T("cmd.dev.work.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runWork(workRegistryPath, workStatusOnly, workAutoCommit)
},
}
workCmd.Flags().BoolVar(&workStatusOnly, "status", false, i18n.T("cmd.dev.work.flag.status"))
workCmd.Flags().BoolVar(&workAutoCommit, "commit", false, i18n.T("cmd.dev.work.flag.commit"))
workCmd.Flags().StringVar(&workRegistryPath, "registry", "", i18n.T("common.flag.registry"))
parent.AddCommand(workCmd)
}
func runWork(registryPath string, statusOnly, autoCommit bool) error {
ctx := context.Background()
// Build worker bundle with required services
bundle, err := NewWorkBundle(WorkBundleOptions{
RegistryPath: registryPath,
})
if err != nil {
return err
}
// Start services (registers handlers)
if err := bundle.Start(ctx); err != nil {
return err
}
defer func() { _ = bundle.Stop(ctx) }()
// Load registry and get paths
paths, names, err := func() ([]string, map[string]string, error) {
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return nil, nil, err
}
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
return paths, names, nil
}()
if err != nil {
return err
}
if len(paths) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// QUERY git status
result, handled, err := bundle.Core.QUERY(git.QueryStatus{
Paths: paths,
Names: names,
})
if !handled {
return cli.Err("git service not available")
}
if err != nil {
return err
}
statuses := result.([]git.RepoStatus)
// Sort by repo name for consistent output
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Name < statuses[j].Name
})
// Display status table
printStatusTable(statuses)
// Collect dirty and ahead repos
var dirtyRepos []git.RepoStatus
var aheadRepos []git.RepoStatus
for _, s := range statuses {
if s.Error != nil {
continue
}
if s.IsDirty() {
dirtyRepos = append(dirtyRepos, s)
}
if s.HasUnpushed() {
aheadRepos = append(aheadRepos, s)
}
}
// Auto-commit dirty repos if requested
if autoCommit && len(dirtyRepos) > 0 {
cli.Blank()
cli.Print("%s\n", cli.TitleStyle.Render(i18n.T("cmd.dev.commit.committing")))
cli.Blank()
for _, s := range dirtyRepos {
// PERFORM commit via agentic service
_, handled, err := bundle.Core.PERFORM(agentic.TaskCommit{
Path: s.Path,
Name: s.Name,
})
if !handled {
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, "agentic service not available")
continue
}
if err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
} else {
cli.Print(" %s %s\n", successStyle.Render("v"), s.Name)
}
}
// Re-QUERY status after commits
result, _, _ = bundle.Core.QUERY(git.QueryStatus{
Paths: paths,
Names: names,
})
statuses = result.([]git.RepoStatus)
// Rebuild ahead repos list
aheadRepos = nil
for _, s := range statuses {
if s.Error == nil && s.HasUnpushed() {
aheadRepos = append(aheadRepos, s)
}
}
}
// If status only, we're done
if statusOnly {
if len(dirtyRepos) > 0 && !autoCommit {
cli.Blank()
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.dev.work.use_commit_flag")))
}
return nil
}
// Push repos with unpushed commits
if len(aheadRepos) == 0 {
cli.Blank()
cli.Text(i18n.T("cmd.dev.work.all_up_to_date"))
return nil
}
cli.Blank()
cli.Print("%s\n", i18n.T("common.count.repos_unpushed", map[string]interface{}{"Count": len(aheadRepos)}))
for _, s := range aheadRepos {
cli.Print(" %s: %s\n", s.Name, i18n.T("common.count.commits", map[string]interface{}{"Count": s.Ahead}))
}
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.push.confirm")) {
cli.Text(i18n.T("cli.aborted"))
return nil
}
cli.Blank()
// PERFORM push for each repo
var divergedRepos []git.RepoStatus
for _, s := range aheadRepos {
_, handled, err := bundle.Core.PERFORM(git.TaskPush{
Path: s.Path,
Name: s.Name,
})
if !handled {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, "git service not available")
continue
}
if err != nil {
if git.IsNonFastForward(err) {
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, i18n.T("cmd.dev.push.diverged"))
divergedRepos = append(divergedRepos, s)
} else {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
}
} else {
cli.Print(" %s %s\n", successStyle.Render("v"), s.Name)
}
}
// Handle diverged repos - offer to pull and retry
if len(divergedRepos) > 0 {
cli.Blank()
cli.Print("%s\n", i18n.T("cmd.dev.push.diverged_help"))
if cli.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
cli.Blank()
for _, s := range divergedRepos {
cli.Print(" %s %s...\n", dimStyle.Render("↓"), s.Name)
// PERFORM pull
_, _, err := bundle.Core.PERFORM(git.TaskPull{Path: s.Path, Name: s.Name})
if err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
continue
}
cli.Print(" %s %s...\n", dimStyle.Render("↑"), s.Name)
// PERFORM push
_, _, err = bundle.Core.PERFORM(git.TaskPush{Path: s.Path, Name: s.Name})
if err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
continue
}
cli.Print(" %s %s\n", successStyle.Render("v"), s.Name)
}
}
}
return nil
}
func printStatusTable(statuses []git.RepoStatus) {
// Calculate column widths
nameWidth := 4 // "Repo"
for _, s := range statuses {
if len(s.Name) > nameWidth {
nameWidth = len(s.Name)
}
}
// Print header with fixed-width formatting
cli.Print("%-*s %8s %9s %6s %5s\n",
nameWidth,
cli.TitleStyle.Render(i18n.Label("repo")),
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_modified")),
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_untracked")),
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_staged")),
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_ahead")),
)
// Print separator
cli.Text(strings.Repeat("-", nameWidth+2+10+11+8+7))
// Print rows
for _, s := range statuses {
if s.Error != nil {
paddedName := cli.Sprintf("%-*s", nameWidth, s.Name)
cli.Print("%s %s\n",
repoNameStyle.Render(paddedName),
errorStyle.Render(i18n.T("cmd.dev.work.error_prefix")+" "+s.Error.Error()),
)
continue
}
// Style numbers based on values
modStr := cli.Sprintf("%d", s.Modified)
if s.Modified > 0 {
modStr = dirtyStyle.Render(modStr)
} else {
modStr = cleanStyle.Render(modStr)
}
untrackedStr := cli.Sprintf("%d", s.Untracked)
if s.Untracked > 0 {
untrackedStr = dirtyStyle.Render(untrackedStr)
} else {
untrackedStr = cleanStyle.Render(untrackedStr)
}
stagedStr := cli.Sprintf("%d", s.Staged)
if s.Staged > 0 {
stagedStr = aheadStyle.Render(stagedStr)
} else {
stagedStr = cleanStyle.Render(stagedStr)
}
aheadStr := cli.Sprintf("%d", s.Ahead)
if s.Ahead > 0 {
aheadStr = aheadStyle.Render(aheadStr)
} else {
aheadStr = cleanStyle.Render(aheadStr)
}
// Pad name before styling to avoid ANSI code length issues
paddedName := cli.Sprintf("%-*s", nameWidth, s.Name)
cli.Print("%s %8s %9s %6s %5s\n",
repoNameStyle.Render(paddedName),
modStr,
untrackedStr,
stagedStr,
aheadStr,
)
}
}
// claudeCommit shells out to claude for committing (legacy helper for other commands)
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
prompt := agentic.Prompt("commit")
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep")
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
// claudeEditCommit shells out to claude with edit permissions (legacy helper)
func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
prompt := agentic.Prompt("commit")
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Write,Edit,Glob,Grep")
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}

View file

@ -1,307 +0,0 @@
package dev
import (
"path/filepath"
"sort"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
)
// Workflow command flags
var (
workflowRegistryPath string
workflowDryRun bool
)
// addWorkflowCommands adds the 'workflow' subcommand and its subcommands.
func addWorkflowCommands(parent *cli.Command) {
workflowCmd := &cli.Command{
Use: "workflow",
Short: i18n.T("cmd.dev.workflow.short"),
Long: i18n.T("cmd.dev.workflow.long"),
}
// Shared flags
workflowCmd.PersistentFlags().StringVar(&workflowRegistryPath, "registry", "", i18n.T("common.flag.registry"))
// Subcommands
addWorkflowListCommand(workflowCmd)
addWorkflowSyncCommand(workflowCmd)
parent.AddCommand(workflowCmd)
}
// addWorkflowListCommand adds the 'workflow list' subcommand.
func addWorkflowListCommand(parent *cli.Command) {
listCmd := &cli.Command{
Use: "list",
Short: i18n.T("cmd.dev.workflow.list.short"),
Long: i18n.T("cmd.dev.workflow.list.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runWorkflowList(workflowRegistryPath)
},
}
parent.AddCommand(listCmd)
}
// addWorkflowSyncCommand adds the 'workflow sync' subcommand.
func addWorkflowSyncCommand(parent *cli.Command) {
syncCmd := &cli.Command{
Use: "sync <workflow>",
Short: i18n.T("cmd.dev.workflow.sync.short"),
Long: i18n.T("cmd.dev.workflow.sync.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runWorkflowSync(workflowRegistryPath, args[0], workflowDryRun)
},
}
syncCmd.Flags().BoolVar(&workflowDryRun, "dry-run", false, i18n.T("cmd.dev.workflow.sync.flag.dry_run"))
parent.AddCommand(syncCmd)
}
// runWorkflowList shows a table of repos vs workflows.
func runWorkflowList(registryPath string) error {
reg, registryDir, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
repoList := reg.List()
if len(repoList) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Sort repos by name for consistent output
sort.Slice(repoList, func(i, j int) bool {
return repoList[i].Name < repoList[j].Name
})
// Collect all unique workflow files across all repos
workflowSet := make(map[string]bool)
repoWorkflows := make(map[string]map[string]bool)
for _, repo := range repoList {
workflows := findWorkflows(repo.Path)
repoWorkflows[repo.Name] = make(map[string]bool)
for _, wf := range workflows {
workflowSet[wf] = true
repoWorkflows[repo.Name][wf] = true
}
}
// Sort workflow names
var workflowNames []string
for wf := range workflowSet {
workflowNames = append(workflowNames, wf)
}
sort.Strings(workflowNames)
if len(workflowNames) == 0 {
cli.Text(i18n.T("cmd.dev.workflow.no_workflows"))
return nil
}
// Check for template workflows in the registry directory
templateWorkflows := findWorkflows(filepath.Join(registryDir, ".github", "workflow-templates"))
if len(templateWorkflows) == 0 {
// Also check .github/workflows in the devops repo itself
templateWorkflows = findWorkflows(filepath.Join(registryDir, ".github", "workflows"))
}
templateSet := make(map[string]bool)
for _, wf := range templateWorkflows {
templateSet[wf] = true
}
// Build table
headers := []string{i18n.T("cmd.dev.workflow.header.repo")}
headers = append(headers, workflowNames...)
table := cli.NewTable(headers...)
for _, repo := range repoList {
row := []string{repo.Name}
for _, wf := range workflowNames {
if repoWorkflows[repo.Name][wf] {
row = append(row, successStyle.Render(cli.Glyph(":check:")))
} else {
row = append(row, errorStyle.Render(cli.Glyph(":cross:")))
}
}
table.AddRow(row...)
}
cli.Blank()
table.Render()
return nil
}
// runWorkflowSync copies a workflow template to all repos.
func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) error {
reg, registryDir, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Find the template workflow
templatePath := findTemplateWorkflow(registryDir, workflowFile)
if templatePath == "" {
return cli.Err("%s", i18n.T("cmd.dev.workflow.template_not_found", map[string]interface{}{"File": workflowFile}))
}
// Read template content
templateContent, err := io.Local.Read(templatePath)
if err != nil {
return cli.Wrap(err, i18n.T("cmd.dev.workflow.read_template_error"))
}
repoList := reg.List()
if len(repoList) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Sort repos by name for consistent output
sort.Slice(repoList, func(i, j int) bool {
return repoList[i].Name < repoList[j].Name
})
if dryRun {
cli.Text(i18n.T("cmd.dev.workflow.dry_run_mode"))
cli.Blank()
}
var synced, skipped, failed int
for _, repo := range repoList {
if !repo.IsGitRepo() {
skipped++
continue
}
destDir := filepath.Join(repo.Path, ".github", "workflows")
destPath := filepath.Join(destDir, workflowFile)
// Check if workflow already exists and is identical
if existingContent, err := io.Local.Read(destPath); err == nil {
if existingContent == templateContent {
cli.Print(" %s %s %s\n",
dimStyle.Render("-"),
repoNameStyle.Render(repo.Name),
dimStyle.Render(i18n.T("cmd.dev.workflow.up_to_date")))
skipped++
continue
}
}
if dryRun {
cli.Print(" %s %s %s\n",
warningStyle.Render("*"),
repoNameStyle.Render(repo.Name),
i18n.T("cmd.dev.workflow.would_sync"))
synced++
continue
}
// Create .github/workflows directory if needed
if err := io.Local.EnsureDir(destDir); err != nil {
cli.Print(" %s %s %s\n",
errorStyle.Render(cli.Glyph(":cross:")),
repoNameStyle.Render(repo.Name),
err.Error())
failed++
continue
}
// Write workflow file
if err := io.Local.Write(destPath, templateContent); err != nil {
cli.Print(" %s %s %s\n",
errorStyle.Render(cli.Glyph(":cross:")),
repoNameStyle.Render(repo.Name),
err.Error())
failed++
continue
}
cli.Print(" %s %s %s\n",
successStyle.Render(cli.Glyph(":check:")),
repoNameStyle.Render(repo.Name),
i18n.T("cmd.dev.workflow.synced"))
synced++
}
cli.Blank()
// Summary
if dryRun {
cli.Print("%s %s\n",
i18n.T("cmd.dev.workflow.would_sync_count", map[string]interface{}{"Count": synced}),
dimStyle.Render(i18n.T("cmd.dev.workflow.skipped_count", map[string]interface{}{"Count": skipped})))
cli.Text(i18n.T("cmd.dev.workflow.run_without_dry_run"))
} else {
cli.Print("%s %s\n",
successStyle.Render(i18n.T("cmd.dev.workflow.synced_count", map[string]interface{}{"Count": synced})),
dimStyle.Render(i18n.T("cmd.dev.workflow.skipped_count", map[string]interface{}{"Count": skipped})))
if failed > 0 {
cli.Print("%s\n", errorStyle.Render(i18n.T("cmd.dev.workflow.failed_count", map[string]interface{}{"Count": failed})))
}
}
return nil
}
// findWorkflows returns a list of workflow file names in a directory.
func findWorkflows(dir string) []string {
workflowsDir := filepath.Join(dir, ".github", "workflows")
// If dir already ends with workflows path, use it directly
if strings.HasSuffix(dir, "workflows") || strings.HasSuffix(dir, "workflow-templates") {
workflowsDir = dir
}
entries, err := io.Local.List(workflowsDir)
if err != nil {
return nil
}
var workflows []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
workflows = append(workflows, name)
}
}
return workflows
}
// findTemplateWorkflow finds a workflow template file in common locations.
func findTemplateWorkflow(registryDir, workflowFile string) string {
// Ensure .yml extension
if !strings.HasSuffix(workflowFile, ".yml") && !strings.HasSuffix(workflowFile, ".yaml") {
workflowFile = workflowFile + ".yml"
}
// Check common template locations
candidates := []string{
filepath.Join(registryDir, ".github", "workflow-templates", workflowFile),
filepath.Join(registryDir, ".github", "workflows", workflowFile),
filepath.Join(registryDir, "workflow-templates", workflowFile),
}
for _, candidate := range candidates {
if io.Local.IsFile(candidate) {
return candidate
}
}
return ""
}

View file

@ -1,108 +0,0 @@
package dev
import (
"path/filepath"
"testing"
"forge.lthn.ai/core/go/pkg/io"
)
func TestFindWorkflows_Good(t *testing.T) {
// Create a temp directory with workflow files
tmpDir := t.TempDir()
workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
if err := io.Local.EnsureDir(workflowsDir); err != nil {
t.Fatalf("Failed to create workflows dir: %v", err)
}
// Create some workflow files
for _, name := range []string{"qa.yml", "tests.yml", "codeql.yaml"} {
if err := io.Local.Write(filepath.Join(workflowsDir, name), "name: Test"); err != nil {
t.Fatalf("Failed to create workflow file: %v", err)
}
}
// Create a non-workflow file (should be ignored)
if err := io.Local.Write(filepath.Join(workflowsDir, "readme.md"), "# Workflows"); err != nil {
t.Fatalf("Failed to create readme file: %v", err)
}
workflows := findWorkflows(tmpDir)
if len(workflows) != 3 {
t.Errorf("Expected 3 workflows, got %d", len(workflows))
}
// Check that all expected workflows are found
found := make(map[string]bool)
for _, wf := range workflows {
found[wf] = true
}
for _, expected := range []string{"qa.yml", "tests.yml", "codeql.yaml"} {
if !found[expected] {
t.Errorf("Expected to find workflow %s", expected)
}
}
}
func TestFindWorkflows_NoWorkflowsDir(t *testing.T) {
tmpDir := t.TempDir()
workflows := findWorkflows(tmpDir)
if len(workflows) != 0 {
t.Errorf("Expected 0 workflows for non-existent dir, got %d", len(workflows))
}
}
func TestFindTemplateWorkflow_Good(t *testing.T) {
tmpDir := t.TempDir()
templatesDir := filepath.Join(tmpDir, ".github", "workflow-templates")
if err := io.Local.EnsureDir(templatesDir); err != nil {
t.Fatalf("Failed to create templates dir: %v", err)
}
templateContent := "name: QA\non: [push]"
if err := io.Local.Write(filepath.Join(templatesDir, "qa.yml"), templateContent); err != nil {
t.Fatalf("Failed to create template file: %v", err)
}
// Test finding with .yml extension
result := findTemplateWorkflow(tmpDir, "qa.yml")
if result == "" {
t.Error("Expected to find qa.yml template")
}
// Test finding without extension (should auto-add .yml)
result = findTemplateWorkflow(tmpDir, "qa")
if result == "" {
t.Error("Expected to find qa template without extension")
}
}
func TestFindTemplateWorkflow_FallbackToWorkflows(t *testing.T) {
tmpDir := t.TempDir()
workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
if err := io.Local.EnsureDir(workflowsDir); err != nil {
t.Fatalf("Failed to create workflows dir: %v", err)
}
templateContent := "name: Tests\non: [push]"
if err := io.Local.Write(filepath.Join(workflowsDir, "tests.yml"), templateContent); err != nil {
t.Fatalf("Failed to create workflow file: %v", err)
}
result := findTemplateWorkflow(tmpDir, "tests.yml")
if result == "" {
t.Error("Expected to find tests.yml in workflows dir")
}
}
func TestFindTemplateWorkflow_NotFound(t *testing.T) {
tmpDir := t.TempDir()
result := findTemplateWorkflow(tmpDir, "nonexistent.yml")
if result != "" {
t.Errorf("Expected empty string for non-existent template, got %s", result)
}
}

View file

@ -1,69 +0,0 @@
package dev
import (
"os"
"path/filepath"
"strings"
"forge.lthn.ai/core/cli/cmd/workspace"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// loadRegistryWithConfig loads the registry and applies workspace configuration.
func loadRegistryWithConfig(registryPath string) (*repos.Registry, string, error) {
var reg *repos.Registry
var err error
var registryDir string
if registryPath != "" {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return nil, "", cli.Wrap(err, "failed to load registry")
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("registry")), registryPath)
registryDir = filepath.Dir(registryPath)
} else {
registryPath, err = repos.FindRegistry(io.Local)
if err == nil {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return nil, "", cli.Wrap(err, "failed to load registry")
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("registry")), registryPath)
registryDir = filepath.Dir(registryPath)
} else {
// Fallback: scan current directory
cwd, _ := os.Getwd()
reg, err = repos.ScanDirectory(io.Local, cwd)
if err != nil {
return nil, "", cli.Wrap(err, "failed to scan directory")
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.dev.scanning_label")), cwd)
registryDir = cwd
}
}
// Load workspace config to respect packages_dir (only if config exists)
if wsConfig, err := workspace.LoadConfig(registryDir); err == nil && wsConfig != nil {
if wsConfig.PackagesDir != "" {
pkgDir := wsConfig.PackagesDir
// Expand ~
if strings.HasPrefix(pkgDir, "~/") {
home, _ := os.UserHomeDir()
pkgDir = filepath.Join(home, pkgDir[2:])
}
if !filepath.IsAbs(pkgDir) {
pkgDir = filepath.Join(registryDir, pkgDir)
}
// Update repo paths
for _, repo := range reg.Repos {
repo.Path = filepath.Join(pkgDir, repo.Name)
}
}
}
return reg, registryDir, nil
}

View file

@ -1,291 +0,0 @@
package dev
import (
"context"
"sort"
"strings"
"forge.lthn.ai/core/go-agentic"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/framework"
"forge.lthn.ai/core/go-scm/git"
)
// Tasks for dev service
// TaskWork runs the full dev workflow: status, commit, push.
type TaskWork struct {
RegistryPath string
StatusOnly bool
AutoCommit bool
AutoPush bool
}
// TaskStatus displays git status for all repos.
type TaskStatus struct {
RegistryPath string
}
// ServiceOptions for configuring the dev service.
type ServiceOptions struct {
RegistryPath string
}
// Service provides dev workflow orchestration as a Core service.
type Service struct {
*framework.ServiceRuntime[ServiceOptions]
}
// NewService creates a dev service factory.
func NewService(opts ServiceOptions) func(*framework.Core) (any, error) {
return func(c *framework.Core) (any, error) {
return &Service{
ServiceRuntime: framework.NewServiceRuntime(c, opts),
}, nil
}
}
// OnStartup registers task handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
switch m := t.(type) {
case TaskWork:
err := s.runWork(m)
return nil, true, err
case TaskStatus:
err := s.runStatus(m)
return nil, true, err
}
return nil, false, nil
}
func (s *Service) runWork(task TaskWork) error {
// Load registry
paths, names, err := s.loadRegistry(task.RegistryPath)
if err != nil {
return err
}
if len(paths) == 0 {
cli.Println("No git repositories found")
return nil
}
// QUERY git status
result, handled, err := s.Core().QUERY(git.QueryStatus{
Paths: paths,
Names: names,
})
if !handled {
return cli.Err("git service not available")
}
if err != nil {
return err
}
statuses := result.([]git.RepoStatus)
// Sort by name
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Name < statuses[j].Name
})
// Display status table
s.printStatusTable(statuses)
// Collect dirty and ahead repos
var dirtyRepos []git.RepoStatus
var aheadRepos []git.RepoStatus
for _, st := range statuses {
if st.Error != nil {
continue
}
if st.IsDirty() {
dirtyRepos = append(dirtyRepos, st)
}
if st.HasUnpushed() {
aheadRepos = append(aheadRepos, st)
}
}
// Auto-commit dirty repos if requested
if task.AutoCommit && len(dirtyRepos) > 0 {
cli.Blank()
cli.Println("Committing changes...")
cli.Blank()
for _, repo := range dirtyRepos {
_, handled, err := s.Core().PERFORM(agentic.TaskCommit{
Path: repo.Path,
Name: repo.Name,
})
if !handled {
// Agentic service not available - skip silently
cli.Print(" - %s: agentic service not available\n", repo.Name)
continue
}
if err != nil {
cli.Print(" x %s: %s\n", repo.Name, err)
} else {
cli.Print(" v %s\n", repo.Name)
}
}
// Re-query status after commits
result, _, _ = s.Core().QUERY(git.QueryStatus{
Paths: paths,
Names: names,
})
statuses = result.([]git.RepoStatus)
// Rebuild ahead repos list
aheadRepos = nil
for _, st := range statuses {
if st.Error == nil && st.HasUnpushed() {
aheadRepos = append(aheadRepos, st)
}
}
}
// If status only, we're done
if task.StatusOnly {
if len(dirtyRepos) > 0 && !task.AutoCommit {
cli.Blank()
cli.Println("Use --commit flag to auto-commit dirty repos")
}
return nil
}
// Push repos with unpushed commits
if len(aheadRepos) == 0 {
cli.Blank()
cli.Println("All repositories are up to date")
return nil
}
cli.Blank()
cli.Print("%d repos with unpushed commits:\n", len(aheadRepos))
for _, st := range aheadRepos {
cli.Print(" %s: %d commits\n", st.Name, st.Ahead)
}
if !task.AutoPush {
cli.Blank()
cli.Print("Push all? [y/N] ")
var answer string
_, _ = cli.Scanln(&answer)
if strings.ToLower(answer) != "y" {
cli.Println("Aborted")
return nil
}
}
cli.Blank()
// Push each repo
for _, st := range aheadRepos {
_, handled, err := s.Core().PERFORM(git.TaskPush{
Path: st.Path,
Name: st.Name,
})
if !handled {
cli.Print(" x %s: git service not available\n", st.Name)
continue
}
if err != nil {
if git.IsNonFastForward(err) {
cli.Print(" ! %s: branch has diverged\n", st.Name)
} else {
cli.Print(" x %s: %s\n", st.Name, err)
}
} else {
cli.Print(" v %s\n", st.Name)
}
}
return nil
}
func (s *Service) runStatus(task TaskStatus) error {
paths, names, err := s.loadRegistry(task.RegistryPath)
if err != nil {
return err
}
if len(paths) == 0 {
cli.Println("No git repositories found")
return nil
}
result, handled, err := s.Core().QUERY(git.QueryStatus{
Paths: paths,
Names: names,
})
if !handled {
return cli.Err("git service not available")
}
if err != nil {
return err
}
statuses := result.([]git.RepoStatus)
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Name < statuses[j].Name
})
s.printStatusTable(statuses)
return nil
}
func (s *Service) loadRegistry(registryPath string) ([]string, map[string]string, error) {
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return nil, nil, err
}
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
return paths, names, nil
}
func (s *Service) printStatusTable(statuses []git.RepoStatus) {
// Calculate column widths
nameWidth := 4 // "Repo"
for _, st := range statuses {
if len(st.Name) > nameWidth {
nameWidth = len(st.Name)
}
}
// Print header
cli.Print("%-*s %8s %9s %6s %5s\n",
nameWidth, "Repo", "Modified", "Untracked", "Staged", "Ahead")
// Print separator
cli.Text(strings.Repeat("-", nameWidth+2+10+11+8+7))
// Print rows
for _, st := range statuses {
if st.Error != nil {
cli.Print("%-*s error: %s\n", nameWidth, st.Name, st.Error)
continue
}
cli.Print("%-*s %8d %9d %6d %5d\n",
nameWidth, st.Name,
st.Modified, st.Untracked, st.Staged, st.Ahead)
}
}

View file

@ -1,20 +0,0 @@
// Package docs provides documentation management commands for multi-repo workspaces.
//
// Commands:
// - list: Scan repos for README.md, CLAUDE.md, CHANGELOG.md, docs/
// - sync: Copy docs/ files from all repos to core-php/docs/packages/
//
// Works with repos.yaml to discover repositories and sync documentation
// to a central location for unified documentation builds.
package docs
import "forge.lthn.ai/core/go/pkg/cli"
func init() {
cli.RegisterCommands(AddDocsCommands)
}
// AddDocsCommands registers the 'docs' command and all subcommands.
func AddDocsCommands(root *cli.Command) {
root.AddCommand(docsCmd)
}

View file

@ -1,30 +0,0 @@
// Package docs provides documentation management commands.
package docs
import (
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Style and utility aliases from shared
var (
repoNameStyle = cli.RepoStyle
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
dimStyle = cli.DimStyle
headerStyle = cli.HeaderStyle
confirm = cli.Confirm
docsFoundStyle = cli.SuccessStyle
docsFileStyle = cli.InfoStyle
)
var docsCmd = &cli.Command{
Use: "docs",
Short: i18n.T("cmd.docs.short"),
Long: i18n.T("cmd.docs.long"),
}
func init() {
docsCmd.AddCommand(docsSyncCmd)
docsCmd.AddCommand(docsListCmd)
}

View file

@ -1,83 +0,0 @@
package docs
import (
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Flag variable for list command
var docsListRegistryPath string
var docsListCmd = &cli.Command{
Use: "list",
Short: i18n.T("cmd.docs.list.short"),
Long: i18n.T("cmd.docs.list.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runDocsList(docsListRegistryPath)
},
}
func init() {
docsListCmd.Flags().StringVar(&docsListRegistryPath, "registry", "", i18n.T("common.flag.registry"))
}
func runDocsList(registryPath string) error {
reg, _, err := loadRegistry(registryPath)
if err != nil {
return err
}
cli.Print("\n%-20s %-8s %-8s %-10s %s\n",
headerStyle.Render(i18n.Label("repo")),
headerStyle.Render(i18n.T("cmd.docs.list.header.readme")),
headerStyle.Render(i18n.T("cmd.docs.list.header.claude")),
headerStyle.Render(i18n.T("cmd.docs.list.header.changelog")),
headerStyle.Render(i18n.T("cmd.docs.list.header.docs")),
)
cli.Text(strings.Repeat("─", 70))
var withDocs, withoutDocs int
for _, repo := range reg.List() {
info := scanRepoDocs(repo)
readme := checkMark(info.Readme != "")
claude := checkMark(info.ClaudeMd != "")
changelog := checkMark(info.Changelog != "")
docsDir := checkMark(false)
if len(info.DocsFiles) > 0 {
docsDir = docsFoundStyle.Render(i18n.T("common.count.files", map[string]interface{}{"Count": len(info.DocsFiles)}))
}
cli.Print("%-20s %-8s %-8s %-10s %s\n",
repoNameStyle.Render(info.Name),
readme,
claude,
changelog,
docsDir,
)
if info.HasDocs {
withDocs++
} else {
withoutDocs++
}
}
cli.Blank()
cli.Print("%s %s\n",
cli.KeyStyle.Render(i18n.Label("coverage")),
i18n.T("cmd.docs.list.coverage_summary", map[string]interface{}{"WithDocs": withDocs, "WithoutDocs": withoutDocs}),
)
return nil
}
func checkMark(ok bool) string {
if ok {
return cli.Glyph(":check:")
}
return cli.Glyph(":cross:")
}

View file

@ -1,159 +0,0 @@
package docs
import (
"io/fs"
"os"
"path/filepath"
"strings"
"forge.lthn.ai/core/cli/cmd/workspace"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// RepoDocInfo holds documentation info for a repo
type RepoDocInfo struct {
Name string
Path string
HasDocs bool
Readme string
ClaudeMd string
Changelog string
DocsFiles []string // All files in docs/ directory (recursive)
KBFiles []string // All files in KB/ directory (recursive)
}
func loadRegistry(registryPath string) (*repos.Registry, string, error) {
var reg *repos.Registry
var err error
var registryDir string
if registryPath != "" {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
registryDir = filepath.Dir(registryPath)
} else {
registryPath, err = repos.FindRegistry(io.Local)
if err == nil {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
registryDir = filepath.Dir(registryPath)
} else {
cwd, _ := os.Getwd()
reg, err = repos.ScanDirectory(io.Local, cwd)
if err != nil {
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.scan", "directory"))
}
registryDir = cwd
}
}
// Load workspace config to respect packages_dir
wsConfig, err := workspace.LoadConfig(registryDir)
if err != nil {
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "workspace config"))
}
basePath := registryDir
if wsConfig != nil && wsConfig.PackagesDir != "" && wsConfig.PackagesDir != "./packages" {
pkgDir := wsConfig.PackagesDir
// Expand ~
if strings.HasPrefix(pkgDir, "~/") {
home, _ := os.UserHomeDir()
pkgDir = filepath.Join(home, pkgDir[2:])
}
if !filepath.IsAbs(pkgDir) {
pkgDir = filepath.Join(registryDir, pkgDir)
}
basePath = pkgDir
// Update repo paths if they were relative to registry
// This ensures consistency when packages_dir overrides the default
reg.BasePath = basePath
for _, repo := range reg.Repos {
repo.Path = filepath.Join(basePath, repo.Name)
}
}
return reg, basePath, nil
}
func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
info := RepoDocInfo{
Name: repo.Name,
Path: repo.Path,
}
// Check for README.md
readme := filepath.Join(repo.Path, "README.md")
if io.Local.IsFile(readme) {
info.Readme = readme
info.HasDocs = true
}
// Check for CLAUDE.md
claudeMd := filepath.Join(repo.Path, "CLAUDE.md")
if io.Local.IsFile(claudeMd) {
info.ClaudeMd = claudeMd
info.HasDocs = true
}
// Check for CHANGELOG.md
changelog := filepath.Join(repo.Path, "CHANGELOG.md")
if io.Local.IsFile(changelog) {
info.Changelog = changelog
info.HasDocs = true
}
// Recursively scan docs/ directory for .md files
docsDir := filepath.Join(repo.Path, "docs")
// Check if directory exists by listing it
if _, err := io.Local.List(docsDir); err == nil {
_ = filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
// Skip plans/ directory
if d.IsDir() && d.Name() == "plans" {
return filepath.SkipDir
}
// Skip non-markdown files
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
return nil
}
// Get relative path from docs/
relPath, _ := filepath.Rel(docsDir, path)
info.DocsFiles = append(info.DocsFiles, relPath)
info.HasDocs = true
return nil
})
}
// Recursively scan KB/ directory for .md files
kbDir := filepath.Join(repo.Path, "KB")
if _, err := io.Local.List(kbDir); err == nil {
_ = filepath.WalkDir(kbDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
return nil
}
relPath, _ := filepath.Rel(kbDir, path)
info.KBFiles = append(info.KBFiles, relPath)
info.HasDocs = true
return nil
})
}
return info
}

View file

@ -1,327 +0,0 @@
package docs
import (
"bytes"
"fmt"
"path/filepath"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// Flag variables for sync command
var (
docsSyncRegistryPath string
docsSyncDryRun bool
docsSyncOutputDir string
docsSyncTarget string
)
var docsSyncCmd = &cli.Command{
Use: "sync",
Short: i18n.T("cmd.docs.sync.short"),
Long: i18n.T("cmd.docs.sync.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runDocsSync(docsSyncRegistryPath, docsSyncOutputDir, docsSyncDryRun, docsSyncTarget)
},
}
func init() {
docsSyncCmd.Flags().StringVar(&docsSyncRegistryPath, "registry", "", i18n.T("common.flag.registry"))
docsSyncCmd.Flags().BoolVar(&docsSyncDryRun, "dry-run", false, i18n.T("cmd.docs.sync.flag.dry_run"))
docsSyncCmd.Flags().StringVar(&docsSyncOutputDir, "output", "", i18n.T("cmd.docs.sync.flag.output"))
docsSyncCmd.Flags().StringVar(&docsSyncTarget, "target", "php", "Target format: php (default) or hugo")
}
// packageOutputName maps repo name to output folder name
func packageOutputName(repoName string) string {
// core -> go (the Go framework)
if repoName == "core" {
return "go"
}
// core-admin -> admin, core-api -> api, etc.
if strings.HasPrefix(repoName, "core-") {
return strings.TrimPrefix(repoName, "core-")
}
return repoName
}
// shouldSyncRepo returns true if this repo should be synced
func shouldSyncRepo(repoName string) bool {
// Skip core-php (it's the destination)
if repoName == "core-php" {
return false
}
// Skip template
if repoName == "core-template" {
return false
}
return true
}
func runDocsSync(registryPath string, outputDir string, dryRun bool, target string) error {
reg, basePath, err := loadRegistry(registryPath)
if err != nil {
return err
}
switch target {
case "hugo":
return runHugoSync(reg, basePath, outputDir, dryRun)
default:
return runPHPSync(reg, basePath, outputDir, dryRun)
}
}
func runPHPSync(reg *repos.Registry, basePath string, outputDir string, dryRun bool) error {
// Default output to core-php/docs/packages relative to registry
if outputDir == "" {
outputDir = filepath.Join(basePath, "core-php", "docs", "packages")
}
// Scan all repos for docs
var docsInfo []RepoDocInfo
for _, repo := range reg.List() {
if !shouldSyncRepo(repo.Name) {
continue
}
info := scanRepoDocs(repo)
if info.HasDocs && len(info.DocsFiles) > 0 {
docsInfo = append(docsInfo, info)
}
}
if len(docsInfo) == 0 {
cli.Text(i18n.T("cmd.docs.sync.no_docs_found"))
return nil
}
cli.Print("\n%s %s\n\n", dimStyle.Render(i18n.T("cmd.docs.sync.found_label")), i18n.T("cmd.docs.sync.repos_with_docs", map[string]interface{}{"Count": len(docsInfo)}))
// Show what will be synced
var totalFiles int
for _, info := range docsInfo {
totalFiles += len(info.DocsFiles)
outName := packageOutputName(info.Name)
cli.Print(" %s → %s %s\n",
repoNameStyle.Render(info.Name),
docsFileStyle.Render("packages/"+outName+"/"),
dimStyle.Render(i18n.T("cmd.docs.sync.files_count", map[string]interface{}{"Count": len(info.DocsFiles)})))
for _, f := range info.DocsFiles {
cli.Print(" %s\n", dimStyle.Render(f))
}
}
cli.Print("\n%s %s\n",
dimStyle.Render(i18n.Label("total")),
i18n.T("cmd.docs.sync.total_summary", map[string]interface{}{"Files": totalFiles, "Repos": len(docsInfo), "Output": outputDir}))
if dryRun {
cli.Print("\n%s\n", dimStyle.Render(i18n.T("cmd.docs.sync.dry_run_notice")))
return nil
}
// Confirm
cli.Blank()
if !confirm(i18n.T("cmd.docs.sync.confirm")) {
cli.Text(i18n.T("common.prompt.abort"))
return nil
}
// Sync docs
cli.Blank()
var synced int
for _, info := range docsInfo {
outName := packageOutputName(info.Name)
repoOutDir := filepath.Join(outputDir, outName)
// Clear existing directory (recursively)
_ = io.Local.DeleteAll(repoOutDir)
if err := io.Local.EnsureDir(repoOutDir); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
continue
}
// Copy all docs files
docsDir := filepath.Join(info.Path, "docs")
for _, f := range info.DocsFiles {
src := filepath.Join(docsDir, f)
dst := filepath.Join(repoOutDir, f)
// Ensure parent dir
if err := io.Local.EnsureDir(filepath.Dir(dst)); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
continue
}
if err := io.Copy(io.Local, src, io.Local, dst); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
}
}
cli.Print(" %s %s → packages/%s/\n", successStyle.Render("✓"), info.Name, outName)
synced++
}
cli.Print("\n%s %s\n", successStyle.Render(i18n.T("i18n.done.sync")), i18n.T("cmd.docs.sync.synced_packages", map[string]interface{}{"Count": synced}))
return nil
}
// hugoOutputName maps repo name to Hugo content section and folder.
func hugoOutputName(repoName string) (string, string) {
if repoName == "cli" {
return "getting-started", ""
}
if repoName == "core" {
return "cli", ""
}
if strings.HasPrefix(repoName, "go-") {
return "go", repoName
}
if strings.HasPrefix(repoName, "core-") {
return "php", strings.TrimPrefix(repoName, "core-")
}
return "go", repoName
}
// injectFrontMatter prepends Hugo front matter to markdown content if missing.
func injectFrontMatter(content []byte, title string, weight int) []byte {
if bytes.HasPrefix(bytes.TrimSpace(content), []byte("---")) {
return content
}
fm := fmt.Sprintf("---\ntitle: %q\nweight: %d\n---\n\n", title, weight)
return append([]byte(fm), content...)
}
// titleFromFilename derives a human-readable title from a filename.
func titleFromFilename(filename string) string {
name := strings.TrimSuffix(filepath.Base(filename), ".md")
name = strings.ReplaceAll(name, "-", " ")
name = strings.ReplaceAll(name, "_", " ")
words := strings.Fields(name)
for i, w := range words {
if len(w) > 0 {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(words, " ")
}
// copyWithFrontMatter copies a markdown file, injecting front matter if missing.
func copyWithFrontMatter(src, dst string, weight int) error {
if err := io.Local.EnsureDir(filepath.Dir(dst)); err != nil {
return err
}
content, err := io.Local.Read(src)
if err != nil {
return err
}
title := titleFromFilename(src)
result := injectFrontMatter([]byte(content), title, weight)
return io.Local.Write(dst, string(result))
}
func runHugoSync(reg *repos.Registry, basePath string, outputDir string, dryRun bool) error {
if outputDir == "" {
outputDir = filepath.Join(basePath, "docs-site", "content")
}
var docsInfo []RepoDocInfo
for _, repo := range reg.List() {
if repo.Name == "core-template" || repo.Name == "core-claude" {
continue
}
info := scanRepoDocs(repo)
if info.HasDocs {
docsInfo = append(docsInfo, info)
}
}
if len(docsInfo) == 0 {
cli.Text("No documentation found")
return nil
}
cli.Print("\n Hugo sync: %d repos with docs → %s\n\n", len(docsInfo), outputDir)
for _, info := range docsInfo {
section, folder := hugoOutputName(info.Name)
target := section
if folder != "" {
target = section + "/" + folder
}
fileCount := len(info.DocsFiles) + len(info.KBFiles)
if info.Readme != "" {
fileCount++
}
cli.Print(" %s → %s/ (%d files)\n", repoNameStyle.Render(info.Name), target, fileCount)
}
if dryRun {
cli.Print("\n Dry run — no files written\n")
return nil
}
cli.Blank()
if !confirm("Sync to Hugo content directory?") {
cli.Text("Aborted")
return nil
}
cli.Blank()
var synced int
for _, info := range docsInfo {
section, folder := hugoOutputName(info.Name)
destDir := filepath.Join(outputDir, section)
if folder != "" {
destDir = filepath.Join(destDir, folder)
}
weight := 10
docsDir := filepath.Join(info.Path, "docs")
for _, f := range info.DocsFiles {
src := filepath.Join(docsDir, f)
dst := filepath.Join(destDir, f)
if err := copyWithFrontMatter(src, dst, weight); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
continue
}
weight += 10
}
if info.Readme != "" && folder != "" {
dst := filepath.Join(destDir, "_index.md")
if err := copyWithFrontMatter(info.Readme, dst, 1); err != nil {
cli.Print(" %s README: %s\n", errorStyle.Render("✗"), err)
}
}
if len(info.KBFiles) > 0 {
suffix := strings.TrimPrefix(info.Name, "go-")
kbDestDir := filepath.Join(outputDir, "kb", suffix)
kbDir := filepath.Join(info.Path, "KB")
kbWeight := 10
for _, f := range info.KBFiles {
src := filepath.Join(kbDir, f)
dst := filepath.Join(kbDestDir, f)
if err := copyWithFrontMatter(src, dst, kbWeight); err != nil {
cli.Print(" %s KB/%s: %s\n", errorStyle.Render("✗"), f, err)
continue
}
kbWeight += 10
}
}
cli.Print(" %s %s\n", successStyle.Render("✓"), info.Name)
synced++
}
cli.Print("\n Synced %d repos to Hugo content\n", synced)
return nil
}

View file

@ -1,44 +0,0 @@
// Package gitcmd provides git workflow commands as a root-level command.
//
// Git Operations:
// - health: Show status across repos
// - commit: Claude-assisted commit message generation
// - push: Push repos with unpushed commits
// - pull: Pull repos that are behind remote
// - work: Combined status, commit, and push workflow
//
// Safe Operations (for AI agents):
// - file-sync: Sync files across repos with auto commit/push
// - apply: Run command across repos with auto commit/push
package gitcmd
import (
"forge.lthn.ai/core/cli/cmd/dev"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
func init() {
cli.RegisterCommands(AddGitCommands)
}
// AddGitCommands registers the 'git' command and all subcommands.
func AddGitCommands(root *cli.Command) {
gitCmd := &cli.Command{
Use: "git",
Short: i18n.T("cmd.git.short"),
Long: i18n.T("cmd.git.long"),
}
root.AddCommand(gitCmd)
// Import git commands from dev package
dev.AddHealthCommand(gitCmd) // Shows repo status
dev.AddCommitCommand(gitCmd)
dev.AddPushCommand(gitCmd)
dev.AddPullCommand(gitCmd)
dev.AddWorkCommand(gitCmd)
// Safe operations for AI agents
dev.AddFileSyncCommand(gitCmd)
dev.AddApplyCommand(gitCmd)
}

View file

@ -1,21 +0,0 @@
// Package gocmd provides Go development commands with enhanced output.
//
// Note: Package named gocmd because 'go' is a reserved keyword.
//
// Commands:
// - test: Run tests with colour-coded coverage summary
// - cov: Run tests with detailed coverage reports (HTML, thresholds)
// - fmt: Format code using goimports or gofmt
// - lint: Run golangci-lint
// - install: Install binary to $GOPATH/bin
// - mod: Module management (tidy, download, verify, graph)
// - work: Workspace management (sync, init, use)
//
// Sets MACOSX_DEPLOYMENT_TARGET to suppress linker warnings on macOS.
package gocmd
import "forge.lthn.ai/core/go/pkg/cli"
func init() {
cli.RegisterCommands(AddGoCommands)
}

View file

@ -1,177 +0,0 @@
package gocmd
import (
"bufio"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
var (
fmtFix bool
fmtDiff bool
fmtCheck bool
fmtAll bool
)
func addGoFmtCommand(parent *cli.Command) {
fmtCmd := &cli.Command{
Use: "fmt",
Short: "Format Go code",
Long: "Format Go code using goimports or gofmt. By default only checks changed files.",
RunE: func(cmd *cli.Command, args []string) error {
// Get list of files to check
var files []string
if fmtAll {
// Check all Go files
files = []string{"."}
} else {
// Only check changed Go files (git-aware)
files = getChangedGoFiles()
if len(files) == 0 {
cli.Print("%s\n", i18n.T("cmd.go.fmt.no_changes"))
return nil
}
}
// Validate flag combinations
if fmtCheck && fmtFix {
return cli.Err("--check and --fix are mutually exclusive")
}
fmtArgs := []string{}
if fmtFix {
fmtArgs = append(fmtArgs, "-w")
}
if fmtDiff {
fmtArgs = append(fmtArgs, "-d")
}
if !fmtFix && !fmtDiff {
fmtArgs = append(fmtArgs, "-l")
}
fmtArgs = append(fmtArgs, files...)
// Try goimports first, fall back to gofmt
var execCmd *exec.Cmd
if _, err := exec.LookPath("goimports"); err == nil {
execCmd = exec.Command("goimports", fmtArgs...)
} else {
execCmd = exec.Command("gofmt", fmtArgs...)
}
// For --check mode, capture output to detect unformatted files
if fmtCheck {
output, err := execCmd.CombinedOutput()
if err != nil {
_, _ = os.Stderr.Write(output)
return err
}
if len(output) > 0 {
_, _ = os.Stdout.Write(output)
return cli.Err("files need formatting (use --fix)")
}
return nil
}
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix"))
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, i18n.T("cmd.go.fmt.flag.check"))
fmtCmd.Flags().BoolVar(&fmtAll, "all", false, i18n.T("cmd.go.fmt.flag.all"))
parent.AddCommand(fmtCmd)
}
// getChangedGoFiles returns Go files that have been modified, staged, or are untracked.
func getChangedGoFiles() []string {
var files []string
// Get modified and staged files
cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD")
output, err := cmd.Output()
if err == nil {
files = append(files, filterGoFiles(string(output))...)
}
// Get untracked files
cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
output, err = cmd.Output()
if err == nil {
files = append(files, filterGoFiles(string(output))...)
}
// Deduplicate
seen := make(map[string]bool)
var unique []string
for _, f := range files {
if !seen[f] {
seen[f] = true
// Verify file exists (might have been deleted)
if _, err := os.Stat(f); err == nil {
unique = append(unique, f)
}
}
}
return unique
}
// filterGoFiles filters a newline-separated list of files to only include .go files.
func filterGoFiles(output string) []string {
var goFiles []string
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
file := strings.TrimSpace(scanner.Text())
if file != "" && filepath.Ext(file) == ".go" {
goFiles = append(goFiles, file)
}
}
return goFiles
}
var (
lintFix bool
lintAll bool
)
func addGoLintCommand(parent *cli.Command) {
lintCmd := &cli.Command{
Use: "lint",
Short: "Run golangci-lint",
Long: "Run golangci-lint for comprehensive static analysis. By default only lints changed files.",
RunE: func(cmd *cli.Command, args []string) error {
lintArgs := []string{"run"}
if lintFix {
lintArgs = append(lintArgs, "--fix")
}
if !lintAll {
// Use --new-from-rev=HEAD to only report issues in uncommitted changes
// This is golangci-lint's native way to handle incremental linting
lintArgs = append(lintArgs, "--new-from-rev=HEAD")
}
// Always lint all packages
lintArgs = append(lintArgs, "./...")
execCmd := exec.Command("golangci-lint", lintArgs...)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
lintCmd.Flags().BoolVar(&lintFix, "fix", false, i18n.T("common.flag.fix"))
lintCmd.Flags().BoolVar(&lintAll, "all", false, i18n.T("cmd.go.lint.flag.all"))
parent.AddCommand(lintCmd)
}

View file

@ -1,169 +0,0 @@
package gocmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
var (
fuzzDuration time.Duration
fuzzPkg string
fuzzRun string
fuzzVerbose bool
)
func addGoFuzzCommand(parent *cli.Command) {
fuzzCmd := &cli.Command{
Use: "fuzz",
Short: "Run Go fuzz tests",
Long: `Run Go fuzz tests with configurable duration.
Discovers Fuzz* functions across the project and runs each with go test -fuzz.
Examples:
core go fuzz # Run all fuzz targets for 10s each
core go fuzz --duration=30s # Run each target for 30s
core go fuzz --pkg=./pkg/... # Fuzz specific package
core go fuzz --run=FuzzE # Run only matching fuzz targets`,
RunE: func(cmd *cli.Command, args []string) error {
return runGoFuzz(fuzzDuration, fuzzPkg, fuzzRun, fuzzVerbose)
},
}
fuzzCmd.Flags().DurationVar(&fuzzDuration, "duration", 10*time.Second, "Duration per fuzz target")
fuzzCmd.Flags().StringVar(&fuzzPkg, "pkg", "", "Package to fuzz (default: auto-discover)")
fuzzCmd.Flags().StringVar(&fuzzRun, "run", "", "Only run fuzz targets matching pattern")
fuzzCmd.Flags().BoolVarP(&fuzzVerbose, "verbose", "v", false, "Verbose output")
parent.AddCommand(fuzzCmd)
}
// fuzzTarget represents a discovered fuzz function and its package.
type fuzzTarget struct {
Pkg string
Name string
}
func runGoFuzz(duration time.Duration, pkg, run string, verbose bool) error {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fuzz")), i18n.ProgressSubject("run", "fuzz tests"))
cli.Blank()
targets, err := discoverFuzzTargets(pkg, run)
if err != nil {
return cli.Wrap(err, "discover fuzz targets")
}
if len(targets) == 0 {
cli.Print(" %s no fuzz targets found\n", dimStyle.Render("—"))
return nil
}
cli.Print(" %s %d target(s), %s each\n", dimStyle.Render(i18n.Label("targets")), len(targets), duration)
cli.Blank()
passed := 0
failed := 0
for _, t := range targets {
cli.Print(" %s %s in %s\n", dimStyle.Render("→"), t.Name, t.Pkg)
args := []string{
"test",
fmt.Sprintf("-fuzz=^%s$", t.Name),
fmt.Sprintf("-fuzztime=%s", duration),
"-run=^$", // Don't run unit tests
}
if verbose {
args = append(args, "-v")
}
args = append(args, t.Pkg)
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
cmd.Dir, _ = os.Getwd()
output, runErr := cmd.CombinedOutput()
outputStr := string(output)
if runErr != nil {
failed++
cli.Print(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), runErr.Error())
if outputStr != "" {
cli.Text(outputStr)
}
} else {
passed++
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
if verbose && outputStr != "" {
cli.Text(outputStr)
}
}
}
cli.Blank()
if failed > 0 {
cli.Print("%s %d passed, %d failed\n", errorStyle.Render(cli.Glyph(":cross:")), passed, failed)
return cli.Err("fuzz: %d target(s) failed", failed)
}
cli.Print("%s %d passed\n", successStyle.Render(cli.Glyph(":check:")), passed)
return nil
}
// discoverFuzzTargets scans for Fuzz* functions in test files.
func discoverFuzzTargets(pkg, pattern string) ([]fuzzTarget, error) {
root := "."
if pkg != "" {
// Convert Go package pattern to filesystem path
root = strings.TrimPrefix(pkg, "./")
root = strings.TrimSuffix(root, "/...")
}
fuzzRe := regexp.MustCompile(`^func\s+(Fuzz\w+)\s*\(\s*\w+\s+\*testing\.F\s*\)`)
var matchRe *regexp.Regexp
if pattern != "" {
var err error
matchRe, err = regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid --run pattern: %w", err)
}
}
var targets []fuzzTarget
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() || !strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
data, readErr := os.ReadFile(path)
if readErr != nil {
return nil
}
dir := "./" + filepath.Dir(path)
for line := range strings.SplitSeq(string(data), "\n") {
m := fuzzRe.FindStringSubmatch(line)
if m == nil {
continue
}
name := m[1]
if matchRe != nil && !matchRe.MatchString(name) {
continue
}
targets = append(targets, fuzzTarget{Pkg: dir, Name: name})
}
return nil
})
return targets, err
}

View file

@ -1,36 +0,0 @@
// Package gocmd provides Go development commands.
//
// Note: Package named gocmd because 'go' is a reserved keyword.
package gocmd
import (
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Style aliases for shared styles
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
dimStyle = cli.DimStyle
)
// AddGoCommands adds Go development commands.
func AddGoCommands(root *cli.Command) {
goCmd := &cli.Command{
Use: "go",
Short: i18n.T("cmd.go.short"),
Long: i18n.T("cmd.go.long"),
}
root.AddCommand(goCmd)
addGoQACommand(goCmd)
addGoTestCommand(goCmd)
addGoCovCommand(goCmd)
addGoFmtCommand(goCmd)
addGoLintCommand(goCmd)
addGoInstallCommand(goCmd)
addGoModCommand(goCmd)
addGoWorkCommand(goCmd)
addGoFuzzCommand(goCmd)
}

View file

@ -1,430 +0,0 @@
package gocmd
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
var (
testCoverage bool
testPkg string
testRun string
testShort bool
testRace bool
testJSON bool
testVerbose bool
)
func addGoTestCommand(parent *cli.Command) {
testCmd := &cli.Command{
Use: "test",
Short: "Run Go tests",
Long: "Run Go tests with optional coverage, filtering, and race detection",
RunE: func(cmd *cli.Command, args []string) error {
return runGoTest(testCoverage, testPkg, testRun, testShort, testRace, testJSON, testVerbose)
},
}
testCmd.Flags().BoolVar(&testCoverage, "coverage", false, "Generate coverage report")
testCmd.Flags().StringVar(&testPkg, "pkg", "", "Package to test")
testCmd.Flags().StringVar(&testRun, "run", "", "Run only tests matching pattern")
testCmd.Flags().BoolVar(&testShort, "short", false, "Run only short tests")
testCmd.Flags().BoolVar(&testRace, "race", false, "Enable race detector")
testCmd.Flags().BoolVar(&testJSON, "json", false, "Output as JSON")
testCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, "Verbose output")
parent.AddCommand(testCmd)
}
func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error {
if pkg == "" {
pkg = "./..."
}
args := []string{"test"}
var covPath string
if coverage {
args = append(args, "-cover", "-covermode=atomic")
covFile, err := os.CreateTemp("", "coverage-*.out")
if err == nil {
covPath = covFile.Name()
_ = covFile.Close()
args = append(args, "-coverprofile="+covPath)
defer os.Remove(covPath)
}
}
if run != "" {
args = append(args, "-run", run)
}
if short {
args = append(args, "-short")
}
if race {
args = append(args, "-race")
}
if verbose {
args = append(args, "-v")
}
args = append(args, pkg)
if !jsonOut {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("test")), i18n.ProgressSubject("run", "tests"))
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), pkg)
cli.Blank()
}
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
cmd.Dir, _ = os.Getwd()
output, err := cmd.CombinedOutput()
outputStr := string(output)
// Filter linker warnings
lines := strings.Split(outputStr, "\n")
var filtered []string
for _, line := range lines {
if !strings.Contains(line, "ld: warning:") {
filtered = append(filtered, line)
}
}
outputStr = strings.Join(filtered, "\n")
// Parse results
passed, failed, skipped := parseTestResults(outputStr)
cov := parseOverallCoverage(outputStr)
if jsonOut {
cli.Print(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`,
passed, failed, skipped, cov, cmd.ProcessState.ExitCode())
cli.Blank()
return err
}
// Print filtered output if verbose or failed
if verbose || err != nil {
cli.Text(outputStr)
}
// Summary
if err == nil {
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"))
} else {
cli.Print(" %s %s, %s\n", errorStyle.Render(cli.Glyph(":cross:")),
i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"),
i18n.T("i18n.count.test", failed)+" "+i18n.T("i18n.done.fail"))
}
if cov > 0 {
cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(cov))
if covPath != "" {
branchCov, err := calculateBlockCoverage(covPath)
if err != nil {
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), cli.ErrorStyle.Render("unable to calculate"))
} else {
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
}
}
}
if err == nil {
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
} else {
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.done.fail")))
}
return err
}
func parseTestResults(output string) (passed, failed, skipped int) {
passRe := regexp.MustCompile(`(?m)^ok\s+`)
failRe := regexp.MustCompile(`(?m)^FAIL\s+`)
skipRe := regexp.MustCompile(`(?m)^\?\s+`)
passed = len(passRe.FindAllString(output, -1))
failed = len(failRe.FindAllString(output, -1))
skipped = len(skipRe.FindAllString(output, -1))
return
}
func parseOverallCoverage(output string) float64 {
re := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
matches := re.FindAllStringSubmatch(output, -1)
if len(matches) == 0 {
return 0
}
var total float64
for _, m := range matches {
var cov float64
_, _ = fmt.Sscanf(m[1], "%f", &cov)
total += cov
}
return total / float64(len(matches))
}
var (
covPkg string
covHTML bool
covOpen bool
covThreshold float64
covBranchThreshold float64
covOutput string
)
func addGoCovCommand(parent *cli.Command) {
covCmd := &cli.Command{
Use: "cov",
Short: "Run tests with coverage report",
Long: "Run tests with detailed coverage reports, HTML output, and threshold checking",
RunE: func(cmd *cli.Command, args []string) error {
pkg := covPkg
if pkg == "" {
// Auto-discover packages with tests
pkgs, err := findTestPackages(".")
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.find", "test packages"))
}
if len(pkgs) == 0 {
return errors.New("no test packages found")
}
pkg = strings.Join(pkgs, " ")
}
// Create temp file for coverage data
covFile, err := os.CreateTemp("", "coverage-*.out")
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.create", "coverage file"))
}
covPath := covFile.Name()
_ = covFile.Close()
defer func() {
if covOutput == "" {
_ = os.Remove(covPath)
} else {
// Copy to output destination before removing
src, _ := os.Open(covPath)
dst, _ := os.Create(covOutput)
if src != nil && dst != nil {
_, _ = io.Copy(dst, src)
_ = src.Close()
_ = dst.Close()
}
_ = os.Remove(covPath)
}
}()
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("coverage")), i18n.ProgressSubject("run", "tests"))
// Truncate package list if too long for display
displayPkg := pkg
if len(displayPkg) > 60 {
displayPkg = displayPkg[:57] + "..."
}
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), displayPkg)
cli.Blank()
// Run tests with coverage
// We need to split pkg into individual arguments if it contains spaces
pkgArgs := strings.Fields(pkg)
cmdArgs := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...)
goCmd := exec.Command("go", cmdArgs...)
goCmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0")
goCmd.Stdout = os.Stdout
goCmd.Stderr = os.Stderr
testErr := goCmd.Run()
// Get coverage percentage
coverCmd := exec.Command("go", "tool", "cover", "-func="+covPath)
covOutput, err := coverCmd.Output()
if err != nil {
if testErr != nil {
return testErr
}
return cli.Wrap(err, i18n.T("i18n.fail.get", "coverage"))
}
// Parse total coverage from last line
lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n")
var statementCov float64
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
// Format: "total: (statements) XX.X%"
if strings.Contains(lastLine, "total:") {
parts := strings.Fields(lastLine)
if len(parts) >= 3 {
covStr := strings.TrimSuffix(parts[len(parts)-1], "%")
_, _ = fmt.Sscanf(covStr, "%f", &statementCov)
}
}
}
// Calculate branch coverage (block coverage)
branchCov, err := calculateBlockCoverage(covPath)
if err != nil {
return cli.Wrap(err, "calculate branch coverage")
}
// Print coverage summary
cli.Blank()
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(statementCov))
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
// Generate HTML if requested
if covHTML || covOpen {
htmlPath := "coverage.html"
htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath)
if err := htmlCmd.Run(); err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.generate", "HTML"))
}
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("html")), htmlPath)
if covOpen {
// Open in browser
var openCmd *exec.Cmd
switch {
case exec.Command("which", "open").Run() == nil:
openCmd = exec.Command("open", htmlPath)
case exec.Command("which", "xdg-open").Run() == nil:
openCmd = exec.Command("xdg-open", htmlPath)
default:
cli.Print(" %s\n", dimStyle.Render("Open coverage.html in your browser"))
}
if openCmd != nil {
_ = openCmd.Run()
}
}
}
// Check thresholds
if covThreshold > 0 && statementCov < covThreshold {
cli.Print("\n%s Statements: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), statementCov, covThreshold)
return errors.New("statement coverage below threshold")
}
if covBranchThreshold > 0 && branchCov < covBranchThreshold {
cli.Print("\n%s Branches: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), branchCov, covBranchThreshold)
return errors.New("branch coverage below threshold")
}
if testErr != nil {
return testErr
}
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
return nil
},
}
covCmd.Flags().StringVar(&covPkg, "pkg", "", "Package to test")
covCmd.Flags().BoolVar(&covHTML, "html", false, "Generate HTML report")
covCmd.Flags().BoolVar(&covOpen, "open", false, "Open HTML report in browser")
covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum statement coverage percentage")
covCmd.Flags().Float64Var(&covBranchThreshold, "branch-threshold", 0, "Minimum branch coverage percentage")
covCmd.Flags().StringVarP(&covOutput, "output", "o", "", "Output file for coverage profile")
parent.AddCommand(covCmd)
}
// calculateBlockCoverage parses a Go coverage profile and returns the percentage of basic
// blocks that have a non-zero execution count. Go's coverage profile contains one line per
// basic block, where the last field is the execution count, not explicit branch coverage.
// The resulting block coverage is used here only as a proxy for branch coverage; computing
// true branch coverage would require more detailed control-flow analysis.
func calculateBlockCoverage(path string) (float64, error) {
file, err := os.Open(path)
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
var totalBlocks, coveredBlocks int
// Skip the first line (mode: atomic/set/count)
if !scanner.Scan() {
return 0, nil
}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
// Last field is the count
count, err := strconv.Atoi(fields[len(fields)-1])
if err != nil {
continue
}
totalBlocks++
if count > 0 {
coveredBlocks++
}
}
if err := scanner.Err(); err != nil {
return 0, err
}
if totalBlocks == 0 {
return 0, nil
}
return (float64(coveredBlocks) / float64(totalBlocks)) * 100, nil
}
func findTestPackages(root string) ([]string, error) {
pkgMap := make(map[string]bool)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") {
dir := filepath.Dir(path)
if !strings.HasPrefix(dir, ".") {
dir = "./" + dir
}
pkgMap[dir] = true
}
return nil
})
if err != nil {
return nil, err
}
var pkgs []string
for pkg := range pkgMap {
pkgs = append(pkgs, pkg)
}
return pkgs, nil
}
func formatCoverage(cov float64) string {
s := fmt.Sprintf("%.1f%%", cov)
if cov >= 80 {
return cli.SuccessStyle.Render(s)
} else if cov >= 50 {
return cli.WarningStyle.Render(s)
}
return cli.ErrorStyle.Render(s)
}

View file

@ -1,639 +0,0 @@
package gocmd
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
"forge.lthn.ai/core/cli/cmd/qa"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// QA command flags - comprehensive options for all agents
var (
qaFix bool
qaChanged bool
qaAll bool
qaSkip string
qaOnly string
qaCoverage bool
qaThreshold float64
qaBranchThreshold float64
qaDocblockThreshold float64
qaJSON bool
qaVerbose bool
qaQuiet bool
qaTimeout time.Duration
qaShort bool
qaRace bool
qaBench bool
qaFailFast bool
qaMod bool
qaCI bool
)
func addGoQACommand(parent *cli.Command) {
qaCmd := &cli.Command{
Use: "qa",
Short: "Run QA checks",
Long: `Run comprehensive code quality checks for Go projects.
Checks available: fmt, vet, lint, test, race, fuzz, vuln, sec, bench, docblock
Examples:
core go qa # Default: fmt, lint, test
core go qa --fix # Auto-fix formatting and lint issues
core go qa --only=test # Only run tests
core go qa --skip=vuln,sec # Skip vulnerability and security scans
core go qa --coverage --threshold=80 # Require 80% coverage
core go qa --changed # Only check changed files (git-aware)
core go qa --ci # CI mode: strict, coverage, fail-fast
core go qa --race --short # Quick tests with race detection
core go qa --json # Output results as JSON`,
RunE: runGoQA,
}
// Fix and modification flags (persistent so subcommands inherit them)
qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible")
qaCmd.PersistentFlags().BoolVar(&qaMod, "mod", false, "Run go mod tidy before checks")
// Scope flags
qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)")
qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)")
qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,fuzz,vuln,sec,bench)")
qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)")
// Coverage flags
qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting")
qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)")
qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum statement coverage threshold (0-100), fail if below")
qaCmd.PersistentFlags().Float64Var(&qaBranchThreshold, "branch-threshold", 0, "Minimum branch coverage threshold (0-100), fail if below")
qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)")
// Test flags
qaCmd.PersistentFlags().BoolVar(&qaShort, "short", false, "Run tests with -short flag")
qaCmd.PersistentFlags().BoolVar(&qaRace, "race", false, "Include race detection in tests")
qaCmd.PersistentFlags().BoolVar(&qaBench, "bench", false, "Include benchmarks")
// Output flags
qaCmd.PersistentFlags().BoolVar(&qaJSON, "json", false, "Output results as JSON")
qaCmd.PersistentFlags().BoolVarP(&qaVerbose, "verbose", "v", false, "Show verbose output")
qaCmd.PersistentFlags().BoolVarP(&qaQuiet, "quiet", "q", false, "Only show errors")
// Control flags
qaCmd.PersistentFlags().DurationVar(&qaTimeout, "timeout", 10*time.Minute, "Timeout for all checks")
qaCmd.PersistentFlags().BoolVar(&qaFailFast, "fail-fast", false, "Stop on first failure")
qaCmd.PersistentFlags().BoolVar(&qaCI, "ci", false, "CI mode: strict checks, coverage required, fail-fast")
// Preset subcommands for convenience
qaCmd.AddCommand(&cli.Command{
Use: "quick",
Short: "Quick QA: fmt, vet, lint (no tests)",
RunE: func(cmd *cli.Command, args []string) error { qaOnly = "fmt,vet,lint"; return runGoQA(cmd, args) },
})
qaCmd.AddCommand(&cli.Command{
Use: "full",
Short: "Full QA: all checks including race, vuln, sec",
RunE: func(cmd *cli.Command, args []string) error {
qaOnly = "fmt,vet,lint,test,race,vuln,sec"
return runGoQA(cmd, args)
},
})
qaCmd.AddCommand(&cli.Command{
Use: "pre-commit",
Short: "Pre-commit checks: fmt --fix, lint --fix, test --short",
RunE: func(cmd *cli.Command, args []string) error {
qaFix = true
qaShort = true
qaOnly = "fmt,lint,test"
return runGoQA(cmd, args)
},
})
qaCmd.AddCommand(&cli.Command{
Use: "pr",
Short: "PR checks: full QA with coverage threshold",
RunE: func(cmd *cli.Command, args []string) error {
qaCoverage = true
if qaThreshold == 0 {
qaThreshold = 50 // Default PR threshold
}
qaOnly = "fmt,vet,lint,test"
return runGoQA(cmd, args)
},
})
parent.AddCommand(qaCmd)
}
// QAResult holds the result of a QA run for JSON output
type QAResult struct {
Success bool `json:"success"`
Duration string `json:"duration"`
Checks []CheckResult `json:"checks"`
Coverage *float64 `json:"coverage,omitempty"`
BranchCoverage *float64 `json:"branch_coverage,omitempty"`
Threshold *float64 `json:"threshold,omitempty"`
BranchThreshold *float64 `json:"branch_threshold,omitempty"`
}
// CheckResult holds the result of a single check
type CheckResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Duration string `json:"duration"`
Error string `json:"error,omitempty"`
Output string `json:"output,omitempty"`
FixHint string `json:"fix_hint,omitempty"`
}
func runGoQA(cmd *cli.Command, args []string) error {
// Apply CI mode defaults
if qaCI {
qaCoverage = true
qaFailFast = true
if qaThreshold == 0 {
qaThreshold = 50
}
}
cwd, err := os.Getwd()
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory"))
}
// Detect if this is a Go project
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
return cli.Err("not a Go project (no go.mod found)")
}
// Determine which checks to run
checkNames := determineChecks()
if !qaJSON && !qaQuiet {
cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA"))
}
// Run go mod tidy if requested
if qaMod {
if !qaQuiet {
cli.Print("%s %s\n", cli.DimStyle.Render("→"), "Running go mod tidy...")
}
modCmd := exec.Command("go", "mod", "tidy")
modCmd.Dir = cwd
if err := modCmd.Run(); err != nil {
return cli.Wrap(err, "go mod tidy failed")
}
}
ctx, cancel := context.WithTimeout(context.Background(), qaTimeout)
defer cancel()
startTime := time.Now()
checks := buildChecks(checkNames)
results := make([]CheckResult, 0, len(checks))
passed := 0
failed := 0
for _, check := range checks {
checkStart := time.Now()
if !qaJSON && !qaQuiet {
cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name))
}
output, err := runCheckCapture(ctx, cwd, check)
checkDuration := time.Since(checkStart)
result := CheckResult{
Name: check.Name,
Duration: checkDuration.Round(time.Millisecond).String(),
}
if err != nil {
result.Passed = false
result.Error = err.Error()
if qaVerbose {
result.Output = output
}
result.FixHint = fixHintFor(check.Name, output)
failed++
if !qaJSON && !qaQuiet {
cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error())
if qaVerbose && output != "" {
cli.Text(output)
}
if result.FixHint != "" {
cli.Hint("fix", result.FixHint)
}
}
if qaFailFast {
results = append(results, result)
break
}
} else {
result.Passed = true
if qaVerbose {
result.Output = output
}
passed++
if !qaJSON && !qaQuiet {
cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
}
}
results = append(results, result)
}
// Run coverage if requested
var coverageVal *float64
var branchVal *float64
if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) {
cov, branch, err := runCoverage(ctx, cwd)
if err == nil {
coverageVal = &cov
branchVal = &branch
if !qaJSON && !qaQuiet {
cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Statement Coverage:"), cov)
cli.Print("%s %.1f%%\n", cli.DimStyle.Render("Branch Coverage:"), branch)
}
if qaThreshold > 0 && cov < qaThreshold {
failed++
if !qaJSON && !qaQuiet {
cli.Print(" %s Statement coverage %.1f%% below threshold %.1f%%\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold)
}
}
if qaBranchThreshold > 0 && branch < qaBranchThreshold {
failed++
if !qaJSON && !qaQuiet {
cli.Print(" %s Branch coverage %.1f%% below threshold %.1f%%\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")), branch, qaBranchThreshold)
}
}
if failed > 0 && !qaJSON && !qaQuiet {
cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.")
}
}
}
duration := time.Since(startTime).Round(time.Millisecond)
// JSON output
if qaJSON {
qaResult := QAResult{
Success: failed == 0,
Duration: duration.String(),
Checks: results,
Coverage: coverageVal,
BranchCoverage: branchVal,
}
if qaThreshold > 0 {
qaResult.Threshold = &qaThreshold
}
if qaBranchThreshold > 0 {
qaResult.BranchThreshold = &qaBranchThreshold
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(qaResult)
}
// Summary
if !qaQuiet {
cli.Blank()
if failed > 0 {
cli.Print("%s %s, %s (%s)\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")),
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"),
duration)
} else {
cli.Print("%s %s (%s)\n",
cli.SuccessStyle.Render(cli.Glyph(":check:")),
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
duration)
}
}
if failed > 0 {
return cli.Err("QA checks failed: %d passed, %d failed", passed, failed)
}
return nil
}
func determineChecks() []string {
// If --only is specified, use those
if qaOnly != "" {
return strings.Split(qaOnly, ",")
}
// Default checks
checks := []string{"fmt", "lint", "test", "fuzz", "docblock"}
// Add race if requested
if qaRace {
// Replace test with race (which includes test)
for i, c := range checks {
if c == "test" {
checks[i] = "race"
break
}
}
}
// Add bench if requested
if qaBench {
checks = append(checks, "bench")
}
// Remove skipped checks
if qaSkip != "" {
skipMap := make(map[string]bool)
for _, s := range strings.Split(qaSkip, ",") {
skipMap[strings.TrimSpace(s)] = true
}
filtered := make([]string, 0, len(checks))
for _, c := range checks {
if !skipMap[c] {
filtered = append(filtered, c)
}
}
checks = filtered
}
return checks
}
// QACheck represents a single QA check.
type QACheck struct {
Name string
Command string
Args []string
}
func buildChecks(names []string) []QACheck {
var checks []QACheck
for _, name := range names {
name = strings.TrimSpace(name)
check := buildCheck(name)
if check.Command != "" {
checks = append(checks, check)
}
}
return checks
}
func buildCheck(name string) QACheck {
switch name {
case "fmt", "format":
args := []string{"-l", "."}
if qaFix {
args = []string{"-w", "."}
}
return QACheck{Name: "format", Command: "gofmt", Args: args}
case "vet":
return QACheck{Name: "vet", Command: "go", Args: []string{"vet", "./..."}}
case "lint":
args := []string{"run"}
if qaFix {
args = append(args, "--fix")
}
if qaChanged && !qaAll {
args = append(args, "--new-from-rev=HEAD")
}
args = append(args, "./...")
return QACheck{Name: "lint", Command: "golangci-lint", Args: args}
case "test":
args := []string{"test"}
if qaShort {
args = append(args, "-short")
}
if qaVerbose {
args = append(args, "-v")
}
args = append(args, "./...")
return QACheck{Name: "test", Command: "go", Args: args}
case "race":
args := []string{"test", "-race"}
if qaShort {
args = append(args, "-short")
}
if qaVerbose {
args = append(args, "-v")
}
args = append(args, "./...")
return QACheck{Name: "race", Command: "go", Args: args}
case "bench":
args := []string{"test", "-bench=.", "-benchmem", "-run=^$"}
args = append(args, "./...")
return QACheck{Name: "bench", Command: "go", Args: args}
case "vuln":
return QACheck{Name: "vuln", Command: "govulncheck", Args: []string{"./..."}}
case "sec":
return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}}
case "fuzz":
return QACheck{Name: "fuzz", Command: "_internal_"}
case "docblock":
// Special internal check - handled separately
return QACheck{Name: "docblock", Command: "_internal_"}
default:
return QACheck{}
}
}
// fixHintFor returns an actionable fix instruction for a given check failure.
func fixHintFor(checkName, output string) string {
switch checkName {
case "format", "fmt":
return "Run 'core go qa fmt --fix' to auto-format."
case "vet":
return "Fix the issues reported by go vet — typically genuine bugs."
case "lint":
return "Run 'core go qa lint --fix' for auto-fixable issues."
case "test":
if name := extractFailingTest(output); name != "" {
return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name)
}
return "Run 'go test -run <TestName> -v ./path/' to debug."
case "race":
return "Data race detected. Add mutex, channel, or atomic to synchronise shared state."
case "bench":
return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce."
case "vuln":
return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'."
case "sec":
return "Review gosec findings. Common fixes: validate inputs, parameterised queries."
case "fuzz":
return "Add a regression test for the crashing input in testdata/fuzz/<Target>/."
case "docblock":
return "Add doc comments to exported symbols: '// Name does X.' before each declaration."
default:
return ""
}
}
var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`)
// extractFailingTest parses the first failing test name from go test output.
func extractFailingTest(output string) string {
if m := failTestRe.FindStringSubmatch(output); len(m) > 1 {
return m[1]
}
return ""
}
func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
// Handle internal checks
if check.Command == "_internal_" {
return runInternalCheck(check)
}
// Check if command exists
if _, err := exec.LookPath(check.Command); err != nil {
return "", cli.Err("%s: not installed", check.Command)
}
cmd := exec.CommandContext(ctx, check.Command, check.Args...)
cmd.Dir = dir
// For gofmt -l, capture output to check if files need formatting
if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" {
output, err := cmd.Output()
if err != nil {
return string(output), err
}
if len(output) > 0 {
// Show files that need formatting
if !qaQuiet && !qaJSON {
cli.Text(string(output))
}
return string(output), cli.Err("files need formatting (use --fix)")
}
return "", nil
}
// For other commands, stream or capture based on quiet mode
if qaQuiet || qaJSON {
output, err := cmd.CombinedOutput()
return string(output), err
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return "", cmd.Run()
}
func runCoverage(ctx context.Context, dir string) (float64, float64, error) {
// Create temp file for coverage data
covFile, err := os.CreateTemp("", "coverage-*.out")
if err != nil {
return 0, 0, err
}
covPath := covFile.Name()
_ = covFile.Close()
defer os.Remove(covPath)
args := []string{"test", "-cover", "-covermode=atomic", "-coverprofile=" + covPath}
if qaShort {
args = append(args, "-short")
}
args = append(args, "./...")
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
if !qaQuiet && !qaJSON {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
if err := cmd.Run(); err != nil {
return 0, 0, err
}
// Parse statement coverage
coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+covPath)
output, err := coverCmd.Output()
if err != nil {
return 0, 0, err
}
// Parse last line for total coverage
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var statementPct float64
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
fields := strings.Fields(lastLine)
if len(fields) >= 3 {
// Parse percentage (e.g., "45.6%")
pctStr := strings.TrimSuffix(fields[len(fields)-1], "%")
_, _ = fmt.Sscanf(pctStr, "%f", &statementPct)
}
}
// Parse branch coverage
branchPct, err := calculateBlockCoverage(covPath)
if err != nil {
return statementPct, 0, err
}
return statementPct, branchPct, nil
}
// runInternalCheck runs internal Go-based checks (not external commands).
func runInternalCheck(check QACheck) (string, error) {
switch check.Name {
case "fuzz":
// Short burst fuzz in QA (3s per target)
duration := 3 * time.Second
if qaTimeout > 0 && qaTimeout < 30*time.Second {
duration = 2 * time.Second
}
return "", runGoFuzz(duration, "", "", qaVerbose)
case "docblock":
result, err := qa.CheckDocblockCoverage([]string{"./..."})
if err != nil {
return "", err
}
result.Threshold = qaDocblockThreshold
result.Passed = result.Coverage >= qaDocblockThreshold
if !result.Passed {
var output strings.Builder
output.WriteString(fmt.Sprintf("Docblock coverage: %.1f%% (threshold: %.1f%%)\n",
result.Coverage, qaDocblockThreshold))
for _, m := range result.Missing {
output.WriteString(fmt.Sprintf("%s:%d\n", m.File, m.Line))
}
return output.String(), cli.Err("docblock coverage %.1f%% below threshold %.1f%%",
result.Coverage, qaDocblockThreshold)
}
return fmt.Sprintf("Docblock coverage: %.1f%%", result.Coverage), nil
default:
return "", cli.Err("unknown internal check: %s", check.Name)
}
}

View file

@ -1,236 +0,0 @@
package gocmd
import (
"errors"
"os"
"os/exec"
"path/filepath"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
var (
installVerbose bool
installNoCgo bool
)
func addGoInstallCommand(parent *cli.Command) {
installCmd := &cli.Command{
Use: "install [path]",
Short: "Install Go binary",
Long: "Install Go binary to $GOPATH/bin",
RunE: func(cmd *cli.Command, args []string) error {
// Get install path from args or default to current dir
installPath := "./..."
if len(args) > 0 {
installPath = args[0]
}
// Detect if we're in a module with cmd/ subdirectories or a root main.go
if installPath == "./..." {
if _, err := os.Stat("core.go"); err == nil {
installPath = "."
} else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 {
installPath = "./cmd/..."
} else if _, err := os.Stat("main.go"); err == nil {
installPath = "."
}
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.Progress("install"))
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), installPath)
if installNoCgo {
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("cgo")), "disabled")
}
cmdArgs := []string{"install"}
if installVerbose {
cmdArgs = append(cmdArgs, "-v")
}
cmdArgs = append(cmdArgs, installPath)
execCmd := exec.Command("go", cmdArgs...)
if installNoCgo {
execCmd.Env = append(os.Environ(), "CGO_ENABLED=0")
}
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.fail.install", "binary")))
return err
}
// Show where it was installed
gopath := os.Getenv("GOPATH")
if gopath == "" {
home, _ := os.UserHomeDir()
gopath = filepath.Join(home, "go")
}
binDir := filepath.Join(gopath, "bin")
cli.Print("\n%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), binDir)
return nil
},
}
installCmd.Flags().BoolVarP(&installVerbose, "verbose", "v", false, "Verbose output")
installCmd.Flags().BoolVar(&installNoCgo, "no-cgo", false, "Disable CGO")
parent.AddCommand(installCmd)
}
func addGoModCommand(parent *cli.Command) {
modCmd := &cli.Command{
Use: "mod",
Short: "Module management",
Long: "Go module management commands",
}
// tidy
tidyCmd := &cli.Command{
Use: "tidy",
Short: "Run go mod tidy",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "tidy")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// download
downloadCmd := &cli.Command{
Use: "download",
Short: "Download module dependencies",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "download")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// verify
verifyCmd := &cli.Command{
Use: "verify",
Short: "Verify module checksums",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "verify")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// graph
graphCmd := &cli.Command{
Use: "graph",
Short: "Print module dependency graph",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "graph")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
modCmd.AddCommand(tidyCmd)
modCmd.AddCommand(downloadCmd)
modCmd.AddCommand(verifyCmd)
modCmd.AddCommand(graphCmd)
parent.AddCommand(modCmd)
}
func addGoWorkCommand(parent *cli.Command) {
workCmd := &cli.Command{
Use: "work",
Short: "Workspace management",
Long: "Go workspace management commands",
}
// sync
syncCmd := &cli.Command{
Use: "sync",
Short: "Sync workspace modules",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "work", "sync")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// init
initCmd := &cli.Command{
Use: "init",
Short: "Initialise a new workspace",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "work", "init")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
return err
}
// Auto-add current module if go.mod exists
if _, err := os.Stat("go.mod"); err == nil {
execCmd = exec.Command("go", "work", "use", ".")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
}
return nil
},
}
// use
useCmd := &cli.Command{
Use: "use [modules...]",
Short: "Add modules to workspace",
RunE: func(cmd *cli.Command, args []string) error {
if len(args) == 0 {
// Auto-detect modules
modules := findGoModules(".")
if len(modules) == 0 {
return errors.New("no Go modules found")
}
for _, mod := range modules {
execCmd := exec.Command("go", "work", "use", mod)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
return err
}
cli.Print("%s %s\n", successStyle.Render(i18n.T("i18n.done.add")), mod)
}
return nil
}
cmdArgs := append([]string{"work", "use"}, args...)
execCmd := exec.Command("go", cmdArgs...)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
workCmd.AddCommand(syncCmd)
workCmd.AddCommand(initCmd)
workCmd.AddCommand(useCmd)
parent.AddCommand(workCmd)
}
func findGoModules(root string) []string {
var modules []string
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.Name() == "go.mod" && path != "go.mod" {
modules = append(modules, filepath.Dir(path))
}
return nil
})
return modules
}

View file

@ -1,229 +0,0 @@
package gocmd
import (
"os"
"testing"
"forge.lthn.ai/core/go/pkg/cli"
"github.com/stretchr/testify/assert"
)
func TestCalculateBlockCoverage(t *testing.T) {
// Create a dummy coverage profile
content := `mode: set
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 1
forge.lthn.ai/core/go/pkg/foo.go:5.6,7.8 2 0
forge.lthn.ai/core/go/pkg/bar.go:10.1,12.20 10 5
`
tmpfile, err := os.CreateTemp("", "test-coverage-*.out")
assert.NoError(t, err)
defer os.Remove(tmpfile.Name())
_, err = tmpfile.Write([]byte(content))
assert.NoError(t, err)
err = tmpfile.Close()
assert.NoError(t, err)
// Test calculation
// 3 blocks total, 2 covered (count > 0)
// Expect (2/3) * 100 = 66.666...
pct, err := calculateBlockCoverage(tmpfile.Name())
assert.NoError(t, err)
assert.InDelta(t, 66.67, pct, 0.01)
// Test empty file (only header)
contentEmpty := "mode: atomic\n"
tmpfileEmpty, _ := os.CreateTemp("", "test-coverage-empty-*.out")
defer os.Remove(tmpfileEmpty.Name())
tmpfileEmpty.Write([]byte(contentEmpty))
tmpfileEmpty.Close()
pct, err = calculateBlockCoverage(tmpfileEmpty.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
// Test non-existent file
pct, err = calculateBlockCoverage("non-existent-file")
assert.Error(t, err)
assert.Equal(t, 0.0, pct)
// Test malformed file
contentMalformed := `mode: set
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 notanumber
`
tmpfileMalformed, _ := os.CreateTemp("", "test-coverage-malformed-*.out")
defer os.Remove(tmpfileMalformed.Name())
tmpfileMalformed.Write([]byte(contentMalformed))
tmpfileMalformed.Close()
pct, err = calculateBlockCoverage(tmpfileMalformed.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
// Test malformed file - missing fields
contentMalformed2 := `mode: set
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
`
tmpfileMalformed2, _ := os.CreateTemp("", "test-coverage-malformed2-*.out")
defer os.Remove(tmpfileMalformed2.Name())
tmpfileMalformed2.Write([]byte(contentMalformed2))
tmpfileMalformed2.Close()
pct, err = calculateBlockCoverage(tmpfileMalformed2.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
// Test completely empty file
tmpfileEmpty2, _ := os.CreateTemp("", "test-coverage-empty2-*.out")
defer os.Remove(tmpfileEmpty2.Name())
tmpfileEmpty2.Close()
pct, err = calculateBlockCoverage(tmpfileEmpty2.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
}
func TestParseOverallCoverage(t *testing.T) {
output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements
ok forge.lthn.ai/core/go/pkg/bar 0.200s coverage: 100.0% of statements
`
pct := parseOverallCoverage(output)
assert.Equal(t, 75.0, pct)
outputNoCov := "ok forge.lthn.ai/core/go/pkg/foo 0.100s"
pct = parseOverallCoverage(outputNoCov)
assert.Equal(t, 0.0, pct)
}
func TestFormatCoverage(t *testing.T) {
assert.Contains(t, formatCoverage(85.0), "85.0%")
assert.Contains(t, formatCoverage(65.0), "65.0%")
assert.Contains(t, formatCoverage(25.0), "25.0%")
}
func TestAddGoCovCommand(t *testing.T) {
cmd := &cli.Command{Use: "test"}
addGoCovCommand(cmd)
assert.True(t, cmd.HasSubCommands())
sub := cmd.Commands()[0]
assert.Equal(t, "cov", sub.Name())
}
func TestAddGoQACommand(t *testing.T) {
cmd := &cli.Command{Use: "test"}
addGoQACommand(cmd)
assert.True(t, cmd.HasSubCommands())
sub := cmd.Commands()[0]
assert.Equal(t, "qa", sub.Name())
}
func TestDetermineChecks(t *testing.T) {
// Default checks
qaOnly = ""
qaSkip = ""
qaRace = false
qaBench = false
checks := determineChecks()
assert.Contains(t, checks, "fmt")
assert.Contains(t, checks, "test")
// Only
qaOnly = "fmt,lint"
checks = determineChecks()
assert.Equal(t, []string{"fmt", "lint"}, checks)
// Skip
qaOnly = ""
qaSkip = "fmt,lint"
checks = determineChecks()
assert.NotContains(t, checks, "fmt")
assert.NotContains(t, checks, "lint")
assert.Contains(t, checks, "test")
// Race
qaSkip = ""
qaRace = true
checks = determineChecks()
assert.Contains(t, checks, "race")
assert.NotContains(t, checks, "test")
// Reset
qaRace = false
}
func TestBuildCheck(t *testing.T) {
qaFix = false
c := buildCheck("fmt")
assert.Equal(t, "format", c.Name)
assert.Equal(t, []string{"-l", "."}, c.Args)
qaFix = true
c = buildCheck("fmt")
assert.Equal(t, []string{"-w", "."}, c.Args)
c = buildCheck("vet")
assert.Equal(t, "vet", c.Name)
c = buildCheck("lint")
assert.Equal(t, "lint", c.Name)
c = buildCheck("test")
assert.Equal(t, "test", c.Name)
c = buildCheck("race")
assert.Equal(t, "race", c.Name)
c = buildCheck("bench")
assert.Equal(t, "bench", c.Name)
c = buildCheck("vuln")
assert.Equal(t, "vuln", c.Name)
c = buildCheck("sec")
assert.Equal(t, "sec", c.Name)
c = buildCheck("fuzz")
assert.Equal(t, "fuzz", c.Name)
c = buildCheck("docblock")
assert.Equal(t, "docblock", c.Name)
c = buildCheck("unknown")
assert.Equal(t, "", c.Name)
}
func TestBuildChecks(t *testing.T) {
checks := buildChecks([]string{"fmt", "vet", "unknown"})
assert.Equal(t, 2, len(checks))
assert.Equal(t, "format", checks[0].Name)
assert.Equal(t, "vet", checks[1].Name)
}
func TestFixHintFor(t *testing.T) {
assert.Contains(t, fixHintFor("format", ""), "core go qa fmt --fix")
assert.Contains(t, fixHintFor("vet", ""), "go vet")
assert.Contains(t, fixHintFor("lint", ""), "core go qa lint --fix")
assert.Contains(t, fixHintFor("test", "--- FAIL: TestFoo"), "TestFoo")
assert.Contains(t, fixHintFor("race", ""), "Data race")
assert.Contains(t, fixHintFor("bench", ""), "Benchmark regression")
assert.Contains(t, fixHintFor("vuln", ""), "govulncheck")
assert.Contains(t, fixHintFor("sec", ""), "gosec")
assert.Contains(t, fixHintFor("fuzz", ""), "crashing input")
assert.Contains(t, fixHintFor("docblock", ""), "doc comments")
assert.Equal(t, "", fixHintFor("unknown", ""))
}
func TestRunGoQA_NoGoMod(t *testing.T) {
// runGoQA should fail if go.mod is not present in CWD
// We run it in a temp dir without go.mod
tmpDir, _ := os.MkdirTemp("", "test-qa-*")
defer os.RemoveAll(tmpDir)
cwd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(cwd)
cmd := &cli.Command{Use: "qa"}
err := runGoQA(cmd, []string{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no go.mod found")
}

View file

@ -1,138 +0,0 @@
package lab
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/lab"
"forge.lthn.ai/core/go/pkg/lab/collector"
"forge.lthn.ai/core/go/pkg/lab/handler"
)
func init() {
cli.RegisterCommands(AddLabCommands)
}
var labCmd = &cli.Command{
Use: "lab",
Short: "Homelab monitoring dashboard",
Long: "Lab dashboard with real-time monitoring of machines, training runs, models, and services.",
}
var (
labBind string
)
var serveCmd = &cli.Command{
Use: "serve",
Short: "Start the lab dashboard web server",
Long: "Starts the lab dashboard HTTP server with live-updating collectors for system stats, Docker, Forgejo, HuggingFace, InfluxDB, and more.",
RunE: runServe,
}
func init() {
serveCmd.Flags().StringVar(&labBind, "bind", ":8080", "HTTP listen address")
}
// AddLabCommands registers the 'lab' command and subcommands.
func AddLabCommands(root *cli.Command) {
labCmd.AddCommand(serveCmd)
root.AddCommand(labCmd)
}
func runServe(cmd *cli.Command, args []string) error {
cfg := lab.LoadConfig()
cfg.Addr = labBind
store := lab.NewStore()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Setup collectors.
reg := collector.NewRegistry(logger)
reg.Register(collector.NewSystem(cfg, store), 60*time.Second)
reg.Register(collector.NewPrometheus(cfg.PrometheusURL, store),
time.Duration(cfg.PrometheusInterval)*time.Second)
reg.Register(collector.NewHuggingFace(cfg.HFAuthor, store),
time.Duration(cfg.HFInterval)*time.Second)
reg.Register(collector.NewDocker(store),
time.Duration(cfg.DockerInterval)*time.Second)
if cfg.ForgeToken != "" {
reg.Register(collector.NewForgejo(cfg.ForgeURL, cfg.ForgeToken, store),
time.Duration(cfg.ForgeInterval)*time.Second)
}
reg.Register(collector.NewTraining(cfg, store),
time.Duration(cfg.TrainingInterval)*time.Second)
reg.Register(collector.NewServices(store), 60*time.Second)
if cfg.InfluxToken != "" {
reg.Register(collector.NewInfluxDB(cfg, store),
time.Duration(cfg.InfluxInterval)*time.Second)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
reg.Start(ctx)
defer reg.Stop()
// Setup HTTP handlers.
web := handler.NewWebHandler(store)
api := handler.NewAPIHandler(store)
mux := http.NewServeMux()
// Web pages.
mux.HandleFunc("GET /", web.Dashboard)
mux.HandleFunc("GET /models", web.Models)
mux.HandleFunc("GET /training", web.Training)
mux.HandleFunc("GET /dataset", web.Dataset)
mux.HandleFunc("GET /golden-set", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dataset", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /runs", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/training", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /agents", web.Agents)
mux.HandleFunc("GET /services", web.Services)
// SSE for live updates.
mux.HandleFunc("GET /events", web.Events)
// JSON API.
mux.HandleFunc("GET /api/status", api.Status)
mux.HandleFunc("GET /api/models", api.Models)
mux.HandleFunc("GET /api/training", api.Training)
mux.HandleFunc("GET /api/dataset", api.GoldenSet)
mux.HandleFunc("GET /api/golden-set", api.GoldenSet)
mux.HandleFunc("GET /api/runs", api.Runs)
mux.HandleFunc("GET /api/agents", api.Agents)
mux.HandleFunc("GET /api/services", api.Services)
mux.HandleFunc("GET /health", api.Health)
srv := &http.Server{
Addr: cfg.Addr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
logger.Info("shutting down")
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
srv.Shutdown(shutCtx)
}()
logger.Info("lab dashboard starting", "addr", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}

View file

@ -1,47 +0,0 @@
// Package monitor provides security monitoring commands.
//
// Commands:
// - monitor: Aggregate security findings from GitHub Security Tab, workflow artifacts, and PR comments
//
// Data sources (all free tier):
// - Code scanning: Semgrep, Trivy, Gitleaks, OSV-Scanner, Checkov, CodeQL
// - Dependabot: Dependency vulnerability alerts
// - Secret scanning: Exposed secrets/credentials
package monitor
import (
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
func init() {
cli.RegisterCommands(AddMonitorCommands)
}
// Style aliases from shared package
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
warningStyle = cli.WarningStyle
dimStyle = cli.DimStyle
)
// AddMonitorCommands registers the 'monitor' command.
func AddMonitorCommands(root *cli.Command) {
monitorCmd := &cli.Command{
Use: "monitor",
Short: i18n.T("cmd.monitor.short"),
Long: i18n.T("cmd.monitor.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runMonitor()
},
}
// Flags
monitorCmd.Flags().StringVarP(&monitorRepo, "repo", "r", "", i18n.T("cmd.monitor.flag.repo"))
monitorCmd.Flags().StringSliceVarP(&monitorSeverity, "severity", "s", []string{}, i18n.T("cmd.monitor.flag.severity"))
monitorCmd.Flags().BoolVar(&monitorJSON, "json", false, i18n.T("cmd.monitor.flag.json"))
monitorCmd.Flags().BoolVar(&monitorAll, "all", false, i18n.T("cmd.monitor.flag.all"))
root.AddCommand(monitorCmd)
}

View file

@ -1,590 +0,0 @@
// cmd_monitor.go implements the 'monitor' command for aggregating security findings.
//
// Usage:
// core monitor # Monitor current repo
// core monitor --repo X # Monitor specific repo
// core monitor --all # Monitor all repos in registry
// core monitor --severity high # Filter by severity
// core monitor --json # Output as JSON
package monitor
import (
"encoding/json"
"fmt"
"os/exec"
"sort"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/log"
"forge.lthn.ai/core/go/pkg/repos"
)
// Command flags
var (
monitorRepo string
monitorSeverity []string
monitorJSON bool
monitorAll bool
)
// Finding represents a security finding from any source
type Finding struct {
Source string `json:"source"` // semgrep, trivy, dependabot, secret-scanning, etc.
Severity string `json:"severity"` // critical, high, medium, low
Rule string `json:"rule"` // Rule ID or CVE
File string `json:"file"` // Affected file path
Line int `json:"line"` // Line number (0 if N/A)
Message string `json:"message"` // Description
URL string `json:"url"` // Link to finding
State string `json:"state"` // open, dismissed, fixed
RepoName string `json:"repo"` // Repository name
CreatedAt string `json:"created_at"` // When found
Labels []string `json:"suggested_labels,omitempty"`
}
// CodeScanningAlert represents a GitHub code scanning alert
type CodeScanningAlert struct {
Number int `json:"number"`
State string `json:"state"` // open, dismissed, fixed
Rule struct {
ID string `json:"id"`
Severity string `json:"severity"`
Description string `json:"description"`
} `json:"rule"`
Tool struct {
Name string `json:"name"`
} `json:"tool"`
MostRecentInstance struct {
Location struct {
Path string `json:"path"`
StartLine int `json:"start_line"`
} `json:"location"`
Message struct {
Text string `json:"text"`
} `json:"message"`
} `json:"most_recent_instance"`
HTMLURL string `json:"html_url"`
CreatedAt string `json:"created_at"`
}
// DependabotAlert represents a GitHub Dependabot alert
type DependabotAlert struct {
Number int `json:"number"`
State string `json:"state"` // open, dismissed, fixed
SecurityVulnerability struct {
Severity string `json:"severity"`
Package struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
} `json:"package"`
} `json:"security_vulnerability"`
SecurityAdvisory struct {
CVEID string `json:"cve_id"`
Summary string `json:"summary"`
Description string `json:"description"`
} `json:"security_advisory"`
Dependency struct {
ManifestPath string `json:"manifest_path"`
} `json:"dependency"`
HTMLURL string `json:"html_url"`
CreatedAt string `json:"created_at"`
}
// SecretScanningAlert represents a GitHub secret scanning alert
type SecretScanningAlert struct {
Number int `json:"number"`
State string `json:"state"` // open, resolved
SecretType string `json:"secret_type"`
Secret string `json:"secret"` // Partial, redacted
HTMLURL string `json:"html_url"`
LocationType string `json:"location_type"`
CreatedAt string `json:"created_at"`
}
func runMonitor() error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return log.E("monitor", i18n.T("error.gh_not_found"), err)
}
// Determine repos to scan
repoList, err := resolveRepos()
if err != nil {
return err
}
if len(repoList) == 0 {
return log.E("monitor", i18n.T("cmd.monitor.error.no_repos"), nil)
}
// Collect all findings and errors
var allFindings []Finding
var fetchErrors []string
for _, repo := range repoList {
if !monitorJSON {
cli.Print("\033[2K\r%s %s...", dimStyle.Render(i18n.T("cmd.monitor.scanning")), repo)
}
findings, errs := fetchRepoFindings(repo)
allFindings = append(allFindings, findings...)
fetchErrors = append(fetchErrors, errs...)
}
// Filter by severity if specified
if len(monitorSeverity) > 0 {
allFindings = filterBySeverity(allFindings, monitorSeverity)
}
// Sort by severity (critical first)
sortBySeverity(allFindings)
// Output
if monitorJSON {
return outputJSON(allFindings)
}
cli.Print("\033[2K\r") // Clear scanning line
// Show any fetch errors as warnings
if len(fetchErrors) > 0 {
for _, e := range fetchErrors {
cli.Print("%s %s\n", warningStyle.Render("!"), e)
}
cli.Blank()
}
return outputTable(allFindings)
}
// resolveRepos determines which repos to scan
func resolveRepos() ([]string, error) {
if monitorRepo != "" {
// Specific repo - if fully qualified (org/repo), use as-is
if strings.Contains(monitorRepo, "/") {
return []string{monitorRepo}, nil
}
// Otherwise, try to detect org from git remote, fallback to host-uk
// Note: Users outside host-uk org should use fully qualified names
org := detectOrgFromGit()
if org == "" {
org = "host-uk"
}
return []string{org + "/" + monitorRepo}, nil
}
if monitorAll {
// All repos from registry
registry, err := repos.FindRegistry(io.Local)
if err != nil {
return nil, log.E("monitor", "failed to find registry", err)
}
loaded, err := repos.LoadRegistry(io.Local, registry)
if err != nil {
return nil, log.E("monitor", "failed to load registry", err)
}
var repoList []string
for _, r := range loaded.Repos {
repoList = append(repoList, loaded.Org+"/"+r.Name)
}
return repoList, nil
}
// Default to current repo
repo, err := detectRepoFromGit()
if err != nil {
return nil, err
}
return []string{repo}, nil
}
// fetchRepoFindings fetches all security findings for a repo
// Returns findings and any errors encountered (errors don't stop other fetches)
func fetchRepoFindings(repoFullName string) ([]Finding, []string) {
var findings []Finding
var errs []string
repoName := strings.Split(repoFullName, "/")[1]
// Fetch code scanning alerts
codeFindings, err := fetchCodeScanningAlerts(repoFullName)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: code-scanning: %s", repoName, err))
}
findings = append(findings, codeFindings...)
// Fetch Dependabot alerts
depFindings, err := fetchDependabotAlerts(repoFullName)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: dependabot: %s", repoName, err))
}
findings = append(findings, depFindings...)
// Fetch secret scanning alerts
secretFindings, err := fetchSecretScanningAlerts(repoFullName)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: secret-scanning: %s", repoName, err))
}
findings = append(findings, secretFindings...)
return findings, errs
}
// fetchCodeScanningAlerts fetches code scanning alerts
func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) {
args := []string{
"api",
fmt.Sprintf("repos/%s/code-scanning/alerts", repoFullName),
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
// Check for expected "not enabled" responses vs actual errors
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
// These are expected conditions, not errors
if strings.Contains(stderr, "Advanced Security must be enabled") ||
strings.Contains(stderr, "no analysis found") ||
strings.Contains(stderr, "Not Found") {
return nil, nil
}
}
return nil, log.E("monitor.fetchCodeScanning", "API request failed", err)
}
var alerts []CodeScanningAlert
if err := json.Unmarshal(output, &alerts); err != nil {
return nil, log.E("monitor.fetchCodeScanning", "failed to parse response", err)
}
repoName := strings.Split(repoFullName, "/")[1]
var findings []Finding
for _, alert := range alerts {
if alert.State != "open" {
continue
}
f := Finding{
Source: alert.Tool.Name,
Severity: normalizeSeverity(alert.Rule.Severity),
Rule: alert.Rule.ID,
File: alert.MostRecentInstance.Location.Path,
Line: alert.MostRecentInstance.Location.StartLine,
Message: alert.MostRecentInstance.Message.Text,
URL: alert.HTMLURL,
State: alert.State,
RepoName: repoName,
CreatedAt: alert.CreatedAt,
Labels: []string{"type:security"},
}
if f.Message == "" {
f.Message = alert.Rule.Description
}
findings = append(findings, f)
}
return findings, nil
}
// fetchDependabotAlerts fetches Dependabot alerts
func fetchDependabotAlerts(repoFullName string) ([]Finding, error) {
args := []string{
"api",
fmt.Sprintf("repos/%s/dependabot/alerts", repoFullName),
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
// Dependabot not enabled is expected
if strings.Contains(stderr, "Dependabot alerts are not enabled") ||
strings.Contains(stderr, "Not Found") {
return nil, nil
}
}
return nil, log.E("monitor.fetchDependabot", "API request failed", err)
}
var alerts []DependabotAlert
if err := json.Unmarshal(output, &alerts); err != nil {
return nil, log.E("monitor.fetchDependabot", "failed to parse response", err)
}
repoName := strings.Split(repoFullName, "/")[1]
var findings []Finding
for _, alert := range alerts {
if alert.State != "open" {
continue
}
f := Finding{
Source: "dependabot",
Severity: normalizeSeverity(alert.SecurityVulnerability.Severity),
Rule: alert.SecurityAdvisory.CVEID,
File: alert.Dependency.ManifestPath,
Line: 0,
Message: fmt.Sprintf("%s: %s", alert.SecurityVulnerability.Package.Name, alert.SecurityAdvisory.Summary),
URL: alert.HTMLURL,
State: alert.State,
RepoName: repoName,
CreatedAt: alert.CreatedAt,
Labels: []string{"type:security", "dependencies"},
}
findings = append(findings, f)
}
return findings, nil
}
// fetchSecretScanningAlerts fetches secret scanning alerts
func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) {
args := []string{
"api",
fmt.Sprintf("repos/%s/secret-scanning/alerts", repoFullName),
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
// Secret scanning not enabled is expected
if strings.Contains(stderr, "Secret scanning is disabled") ||
strings.Contains(stderr, "Not Found") {
return nil, nil
}
}
return nil, log.E("monitor.fetchSecretScanning", "API request failed", err)
}
var alerts []SecretScanningAlert
if err := json.Unmarshal(output, &alerts); err != nil {
return nil, log.E("monitor.fetchSecretScanning", "failed to parse response", err)
}
repoName := strings.Split(repoFullName, "/")[1]
var findings []Finding
for _, alert := range alerts {
if alert.State != "open" {
continue
}
f := Finding{
Source: "secret-scanning",
Severity: "critical", // Secrets are always critical
Rule: alert.SecretType,
File: alert.LocationType,
Line: 0,
Message: fmt.Sprintf("Exposed %s detected", alert.SecretType),
URL: alert.HTMLURL,
State: alert.State,
RepoName: repoName,
CreatedAt: alert.CreatedAt,
Labels: []string{"type:security", "secrets"},
}
findings = append(findings, f)
}
return findings, nil
}
// normalizeSeverity normalizes severity strings to standard values
func normalizeSeverity(s string) string {
s = strings.ToLower(s)
switch s {
case "critical", "crit":
return "critical"
case "high", "error":
return "high"
case "medium", "moderate", "warning":
return "medium"
case "low", "info", "note":
return "low"
default:
return "medium"
}
}
// filterBySeverity filters findings by severity
func filterBySeverity(findings []Finding, severities []string) []Finding {
sevSet := make(map[string]bool)
for _, s := range severities {
sevSet[strings.ToLower(s)] = true
}
var filtered []Finding
for _, f := range findings {
if sevSet[f.Severity] {
filtered = append(filtered, f)
}
}
return filtered
}
// sortBySeverity sorts findings by severity (critical first)
func sortBySeverity(findings []Finding) {
severityOrder := map[string]int{
"critical": 0,
"high": 1,
"medium": 2,
"low": 3,
}
sort.Slice(findings, func(i, j int) bool {
oi := severityOrder[findings[i].Severity]
oj := severityOrder[findings[j].Severity]
if oi != oj {
return oi < oj
}
return findings[i].RepoName < findings[j].RepoName
})
}
// outputJSON outputs findings as JSON
func outputJSON(findings []Finding) error {
data, err := json.MarshalIndent(findings, "", " ")
if err != nil {
return log.E("monitor", "failed to marshal findings", err)
}
cli.Print("%s\n", string(data))
return nil
}
// outputTable outputs findings as a formatted table
func outputTable(findings []Finding) error {
if len(findings) == 0 {
cli.Print("%s\n", successStyle.Render(i18n.T("cmd.monitor.no_findings")))
return nil
}
// Count by severity
counts := make(map[string]int)
for _, f := range findings {
counts[f.Severity]++
}
// Header summary
var parts []string
if counts["critical"] > 0 {
parts = append(parts, errorStyle.Render(fmt.Sprintf("%d critical", counts["critical"])))
}
if counts["high"] > 0 {
parts = append(parts, errorStyle.Render(fmt.Sprintf("%d high", counts["high"])))
}
if counts["medium"] > 0 {
parts = append(parts, warningStyle.Render(fmt.Sprintf("%d medium", counts["medium"])))
}
if counts["low"] > 0 {
parts = append(parts, dimStyle.Render(fmt.Sprintf("%d low", counts["low"])))
}
cli.Print("%s: %s\n", i18n.T("cmd.monitor.found"), strings.Join(parts, ", "))
cli.Blank()
// Group by repo
byRepo := make(map[string][]Finding)
for _, f := range findings {
byRepo[f.RepoName] = append(byRepo[f.RepoName], f)
}
// Sort repos for consistent output
repoNames := make([]string, 0, len(byRepo))
for repo := range byRepo {
repoNames = append(repoNames, repo)
}
sort.Strings(repoNames)
// Print by repo
for _, repo := range repoNames {
repoFindings := byRepo[repo]
cli.Print("%s\n", cli.BoldStyle.Render(repo))
for _, f := range repoFindings {
sevStyle := dimStyle
switch f.Severity {
case "critical", "high":
sevStyle = errorStyle
case "medium":
sevStyle = warningStyle
}
// Format: [severity] source: message (file:line)
location := ""
if f.File != "" {
location = f.File
if f.Line > 0 {
location = fmt.Sprintf("%s:%d", f.File, f.Line)
}
}
cli.Print(" %s %s: %s",
sevStyle.Render(fmt.Sprintf("[%s]", f.Severity)),
dimStyle.Render(f.Source),
truncate(f.Message, 60))
if location != "" {
cli.Print(" %s", dimStyle.Render("("+location+")"))
}
cli.Blank()
}
cli.Blank()
}
return nil
}
// truncate truncates a string to max runes (Unicode-safe)
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-3]) + "..."
}
// detectRepoFromGit detects the repo from git remote
func detectRepoFromGit() (string, error) {
cmd := exec.Command("git", "remote", "get-url", "origin")
output, err := cmd.Output()
if err != nil {
return "", log.E("monitor", i18n.T("cmd.monitor.error.not_git_repo"), err)
}
url := strings.TrimSpace(string(output))
return parseGitHubRepo(url)
}
// detectOrgFromGit tries to detect the org from git remote
func detectOrgFromGit() string {
repo, err := detectRepoFromGit()
if err != nil {
return ""
}
parts := strings.Split(repo, "/")
if len(parts) >= 1 {
return parts[0]
}
return ""
}
// parseGitHubRepo extracts org/repo from a git URL
func parseGitHubRepo(url string) (string, error) {
// Handle SSH URLs: git@github.com:org/repo.git
if strings.HasPrefix(url, "git@github.com:") {
path := strings.TrimPrefix(url, "git@github.com:")
path = strings.TrimSuffix(path, ".git")
return path, nil
}
// Handle HTTPS URLs: https://github.com/org/repo.git
if strings.Contains(url, "github.com/") {
parts := strings.Split(url, "github.com/")
if len(parts) >= 2 {
path := strings.TrimSuffix(parts[1], ".git")
return path, nil
}
}
return "", fmt.Errorf("could not parse GitHub repo from URL: %s", url)
}

View file

@ -1,353 +0,0 @@
// cmd_docblock.go implements docblock/docstring coverage checking for Go code.
//
// Usage:
//
// core qa docblock # Check current directory
// core qa docblock ./pkg/... # Check specific packages
// core qa docblock --threshold=80 # Require 80% coverage
package qa
import (
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Docblock command flags
var (
docblockThreshold float64
docblockVerbose bool
docblockJSON bool
)
// addDocblockCommand adds the 'docblock' command to qa.
func addDocblockCommand(parent *cli.Command) {
docblockCmd := &cli.Command{
Use: "docblock [packages...]",
Short: i18n.T("cmd.qa.docblock.short"),
Long: i18n.T("cmd.qa.docblock.long"),
RunE: func(cmd *cli.Command, args []string) error {
paths := args
if len(paths) == 0 {
paths = []string{"./..."}
}
return RunDocblockCheck(paths, docblockThreshold, docblockVerbose, docblockJSON)
},
}
docblockCmd.Flags().Float64Var(&docblockThreshold, "threshold", 80, i18n.T("cmd.qa.docblock.flag.threshold"))
docblockCmd.Flags().BoolVarP(&docblockVerbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
docblockCmd.Flags().BoolVar(&docblockJSON, "json", false, i18n.T("common.flag.json"))
parent.AddCommand(docblockCmd)
}
// DocblockResult holds the result of a docblock coverage check.
type DocblockResult struct {
Coverage float64 `json:"coverage"`
Threshold float64 `json:"threshold"`
Total int `json:"total"`
Documented int `json:"documented"`
Missing []MissingDocblock `json:"missing,omitempty"`
Passed bool `json:"passed"`
}
// MissingDocblock represents an exported symbol without documentation.
type MissingDocblock struct {
File string `json:"file"`
Line int `json:"line"`
Name string `json:"name"`
Kind string `json:"kind"` // func, type, const, var
Reason string `json:"reason,omitempty"`
}
// RunDocblockCheck checks docblock coverage for the given packages.
func RunDocblockCheck(paths []string, threshold float64, verbose, jsonOutput bool) error {
result, err := CheckDocblockCoverage(paths)
if err != nil {
return err
}
result.Threshold = threshold
result.Passed = result.Coverage >= threshold
if jsonOutput {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
if !result.Passed {
return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold)
}
return nil
}
// Sort missing by file then line
sort.Slice(result.Missing, func(i, j int) bool {
if result.Missing[i].File != result.Missing[j].File {
return result.Missing[i].File < result.Missing[j].File
}
return result.Missing[i].Line < result.Missing[j].Line
})
// Print result
if verbose && len(result.Missing) > 0 {
cli.Print("%s\n\n", i18n.T("cmd.qa.docblock.missing_docs"))
for _, m := range result.Missing {
cli.Print(" %s:%d: %s %s\n",
dimStyle.Render(m.File),
m.Line,
dimStyle.Render(m.Kind),
m.Name,
)
}
cli.Blank()
}
// Summary
coverageStr := fmt.Sprintf("%.1f%%", result.Coverage)
thresholdStr := fmt.Sprintf("%.1f%%", threshold)
if result.Passed {
cli.Print("%s %s %s/%s (%s >= %s)\n",
successStyle.Render(i18n.T("common.label.success")),
i18n.T("cmd.qa.docblock.coverage"),
fmt.Sprintf("%d", result.Documented),
fmt.Sprintf("%d", result.Total),
successStyle.Render(coverageStr),
thresholdStr,
)
return nil
}
cli.Print("%s %s %s/%s (%s < %s)\n",
errorStyle.Render(i18n.T("common.label.error")),
i18n.T("cmd.qa.docblock.coverage"),
fmt.Sprintf("%d", result.Documented),
fmt.Sprintf("%d", result.Total),
errorStyle.Render(coverageStr),
thresholdStr,
)
// Always show compact file:line list when failing (token-efficient for AI agents)
if len(result.Missing) > 0 {
cli.Blank()
for _, m := range result.Missing {
cli.Print("%s:%d\n", m.File, m.Line)
}
}
return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold)
}
// CheckDocblockCoverage analyzes Go packages for docblock coverage.
func CheckDocblockCoverage(patterns []string) (*DocblockResult, error) {
result := &DocblockResult{}
// Expand patterns to actual directories
dirs, err := expandPatterns(patterns)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
for _, dir := range dirs {
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, parser.ParseComments)
if err != nil {
// Log parse errors but continue to check other directories
cli.Warnf("failed to parse %s: %v", dir, err)
continue
}
for _, pkg := range pkgs {
for filename, file := range pkg.Files {
checkFile(fset, filename, file, result)
}
}
}
if result.Total > 0 {
result.Coverage = float64(result.Documented) / float64(result.Total) * 100
}
return result, nil
}
// expandPatterns expands Go package patterns like ./... to actual directories.
func expandPatterns(patterns []string) ([]string, error) {
var dirs []string
seen := make(map[string]bool)
for _, pattern := range patterns {
if strings.HasSuffix(pattern, "/...") {
// Recursive pattern
base := strings.TrimSuffix(pattern, "/...")
if base == "." {
base = "."
}
err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip errors
}
if !info.IsDir() {
return nil
}
// Skip vendor, testdata, and hidden directories (but not "." itself)
name := info.Name()
if name == "vendor" || name == "testdata" || (strings.HasPrefix(name, ".") && name != ".") {
return filepath.SkipDir
}
// Check if directory has Go files
if hasGoFiles(path) && !seen[path] {
dirs = append(dirs, path)
seen[path] = true
}
return nil
})
if err != nil {
return nil, err
}
} else {
// Single directory
path := pattern
if !seen[path] && hasGoFiles(path) {
dirs = append(dirs, path)
seen[path] = true
}
}
}
return dirs, nil
}
// hasGoFiles checks if a directory contains Go files.
func hasGoFiles(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") {
return true
}
}
return false
}
// checkFile analyzes a single file for docblock coverage.
func checkFile(fset *token.FileSet, filename string, file *ast.File, result *DocblockResult) {
// Make filename relative if possible
if cwd, err := os.Getwd(); err == nil {
if rel, err := filepath.Rel(cwd, filename); err == nil {
filename = rel
}
}
for _, decl := range file.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
// Skip unexported functions
if !ast.IsExported(d.Name.Name) {
continue
}
// Skip methods on unexported types
if d.Recv != nil && len(d.Recv.List) > 0 {
if recvType := getReceiverTypeName(d.Recv.List[0].Type); recvType != "" && !ast.IsExported(recvType) {
continue
}
}
result.Total++
if d.Doc != nil && len(d.Doc.List) > 0 {
result.Documented++
} else {
pos := fset.Position(d.Pos())
result.Missing = append(result.Missing, MissingDocblock{
File: filename,
Line: pos.Line,
Name: d.Name.Name,
Kind: "func",
})
}
case *ast.GenDecl:
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.TypeSpec:
if !ast.IsExported(s.Name.Name) {
continue
}
result.Total++
// Type can have doc on GenDecl or TypeSpec
if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) {
result.Documented++
} else {
pos := fset.Position(s.Pos())
result.Missing = append(result.Missing, MissingDocblock{
File: filename,
Line: pos.Line,
Name: s.Name.Name,
Kind: "type",
})
}
case *ast.ValueSpec:
// Check exported consts and vars
for _, name := range s.Names {
if !ast.IsExported(name.Name) {
continue
}
result.Total++
// Value can have doc on GenDecl or ValueSpec
if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) {
result.Documented++
} else {
pos := fset.Position(name.Pos())
result.Missing = append(result.Missing, MissingDocblock{
File: filename,
Line: pos.Line,
Name: name.Name,
Kind: kindFromToken(d.Tok),
})
}
}
}
}
}
}
}
// getReceiverTypeName extracts the type name from a method receiver.
func getReceiverTypeName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return getReceiverTypeName(t.X)
}
return ""
}
// kindFromToken returns a string representation of the token kind.
func kindFromToken(tok token.Token) string {
switch tok {
case token.CONST:
return "const"
case token.VAR:
return "var"
default:
return "value"
}
}

View file

@ -1,289 +0,0 @@
// cmd_health.go implements the 'qa health' command for aggregate CI health.
//
// Usage:
// core qa health # Show CI health summary
// core qa health --problems # Show only repos with problems
package qa
import (
"encoding/json"
"os/exec"
"sort"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/log"
"forge.lthn.ai/core/go/pkg/repos"
)
// Health command flags
var (
healthProblems bool
healthRegistry string
)
// HealthWorkflowRun represents a GitHub Actions workflow run
type HealthWorkflowRun struct {
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Name string `json:"name"`
HeadSha string `json:"headSha"`
UpdatedAt string `json:"updatedAt"`
URL string `json:"url"`
}
// RepoHealth represents the CI health of a single repo
type RepoHealth struct {
Name string
Status string // "passing", "failing", "pending", "no_ci", "disabled"
Message string
URL string
FailingSince string
}
// addHealthCommand adds the 'health' subcommand to qa.
func addHealthCommand(parent *cli.Command) {
healthCmd := &cli.Command{
Use: "health",
Short: i18n.T("cmd.qa.health.short"),
Long: i18n.T("cmd.qa.health.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runHealth()
},
}
healthCmd.Flags().BoolVarP(&healthProblems, "problems", "p", false, i18n.T("cmd.qa.health.flag.problems"))
healthCmd.Flags().StringVar(&healthRegistry, "registry", "", i18n.T("common.flag.registry"))
parent.AddCommand(healthCmd)
}
func runHealth() error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return log.E("qa.health", i18n.T("error.gh_not_found"), nil)
}
// Load registry
var reg *repos.Registry
var err error
if healthRegistry != "" {
reg, err = repos.LoadRegistry(io.Local, healthRegistry)
} else {
registryPath, findErr := repos.FindRegistry(io.Local)
if findErr != nil {
return log.E("qa.health", i18n.T("error.registry_not_found"), nil)
}
reg, err = repos.LoadRegistry(io.Local, registryPath)
}
if err != nil {
return log.E("qa.health", "failed to load registry", err)
}
// Fetch CI status from all repos
var healthResults []RepoHealth
repoList := reg.List()
for i, repo := range repoList {
cli.Print("\033[2K\r%s %d/%d %s",
dimStyle.Render(i18n.T("cmd.qa.issues.fetching")),
i+1, len(repoList), repo.Name)
health := fetchRepoHealth(reg.Org, repo.Name)
healthResults = append(healthResults, health)
}
cli.Print("\033[2K\r") // Clear progress
// Sort: problems first, then passing
sort.Slice(healthResults, func(i, j int) bool {
return healthPriority(healthResults[i].Status) < healthPriority(healthResults[j].Status)
})
// Filter if --problems flag
if healthProblems {
var problems []RepoHealth
for _, h := range healthResults {
if h.Status != "passing" {
problems = append(problems, h)
}
}
healthResults = problems
}
// Calculate summary
passing := 0
for _, h := range healthResults {
if h.Status == "passing" {
passing++
}
}
total := len(repoList)
percentage := 0
if total > 0 {
percentage = (passing * 100) / total
}
// Print summary
cli.Print("%s: %d/%d repos healthy (%d%%)\n\n",
i18n.T("cmd.qa.health.summary"),
passing, total, percentage)
if len(healthResults) == 0 {
cli.Text(i18n.T("cmd.qa.health.all_healthy"))
return nil
}
// Group by status
grouped := make(map[string][]RepoHealth)
for _, h := range healthResults {
grouped[h.Status] = append(grouped[h.Status], h)
}
// Print problems first
printHealthGroup("failing", grouped["failing"], errorStyle)
printHealthGroup("pending", grouped["pending"], warningStyle)
printHealthGroup("no_ci", grouped["no_ci"], dimStyle)
printHealthGroup("disabled", grouped["disabled"], dimStyle)
if !healthProblems {
printHealthGroup("passing", grouped["passing"], successStyle)
}
return nil
}
func fetchRepoHealth(org, repoName string) RepoHealth {
repoFullName := cli.Sprintf("%s/%s", org, repoName)
args := []string{
"run", "list",
"--repo", repoFullName,
"--limit", "1",
"--json", "status,conclusion,name,headSha,updatedAt,url",
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
// Check if it's a 404 (no workflows)
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
if strings.Contains(stderr, "no workflows") || strings.Contains(stderr, "not found") {
return RepoHealth{
Name: repoName,
Status: "no_ci",
Message: i18n.T("cmd.qa.health.no_ci_configured"),
}
}
}
return RepoHealth{
Name: repoName,
Status: "no_ci",
Message: i18n.T("cmd.qa.health.fetch_error"),
}
}
var runs []HealthWorkflowRun
if err := json.Unmarshal(output, &runs); err != nil {
return RepoHealth{
Name: repoName,
Status: "no_ci",
Message: i18n.T("cmd.qa.health.parse_error"),
}
}
if len(runs) == 0 {
return RepoHealth{
Name: repoName,
Status: "no_ci",
Message: i18n.T("cmd.qa.health.no_ci_configured"),
}
}
run := runs[0]
health := RepoHealth{
Name: repoName,
URL: run.URL,
}
switch run.Status {
case "completed":
switch run.Conclusion {
case "success":
health.Status = "passing"
health.Message = i18n.T("cmd.qa.health.passing")
case "failure":
health.Status = "failing"
health.Message = i18n.T("cmd.qa.health.tests_failing")
case "cancelled":
health.Status = "pending"
health.Message = i18n.T("cmd.qa.health.cancelled")
case "skipped":
health.Status = "passing"
health.Message = i18n.T("cmd.qa.health.skipped")
default:
health.Status = "failing"
health.Message = run.Conclusion
}
case "in_progress", "queued", "waiting":
health.Status = "pending"
health.Message = i18n.T("cmd.qa.health.running")
default:
health.Status = "no_ci"
health.Message = run.Status
}
return health
}
func healthPriority(status string) int {
switch status {
case "failing":
return 0
case "pending":
return 1
case "no_ci":
return 2
case "disabled":
return 3
case "passing":
return 4
default:
return 5
}
}
func printHealthGroup(status string, repos []RepoHealth, style *cli.AnsiStyle) {
if len(repos) == 0 {
return
}
var label string
switch status {
case "failing":
label = i18n.T("cmd.qa.health.count_failing")
case "pending":
label = i18n.T("cmd.qa.health.count_pending")
case "no_ci":
label = i18n.T("cmd.qa.health.count_no_ci")
case "disabled":
label = i18n.T("cmd.qa.health.count_disabled")
case "passing":
label = i18n.T("cmd.qa.health.count_passing")
}
cli.Print("%s (%d):\n", style.Render(label), len(repos))
for _, repo := range repos {
cli.Print(" %s %s\n",
cli.RepoStyle.Render(repo.Name),
dimStyle.Render(repo.Message))
if repo.URL != "" && status == "failing" {
cli.Print(" -> %s\n", dimStyle.Render(repo.URL))
}
}
cli.Blank()
}

View file

@ -1,401 +0,0 @@
// cmd_issues.go implements the 'qa issues' command for intelligent issue triage.
//
// Usage:
// core qa issues # Show prioritised, actionable issues
// core qa issues --mine # Show issues assigned to you
// core qa issues --triage # Show issues needing triage (no labels/assignee)
// core qa issues --blocked # Show blocked issues
package qa
import (
"encoding/json"
"os/exec"
"sort"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/log"
"forge.lthn.ai/core/go/pkg/repos"
)
// Issue command flags
var (
issuesMine bool
issuesTriage bool
issuesBlocked bool
issuesRegistry string
issuesLimit int
)
// Issue represents a GitHub issue with triage metadata
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Assignees struct {
Nodes []struct {
Login string `json:"login"`
} `json:"nodes"`
} `json:"assignees"`
Labels struct {
Nodes []struct {
Name string `json:"name"`
} `json:"nodes"`
} `json:"labels"`
Comments struct {
TotalCount int `json:"totalCount"`
Nodes []struct {
Author struct {
Login string `json:"login"`
} `json:"author"`
CreatedAt time.Time `json:"createdAt"`
} `json:"nodes"`
} `json:"comments"`
URL string `json:"url"`
// Computed fields
RepoName string
Priority int // Lower = higher priority
Category string // "needs_response", "ready", "blocked", "triage"
ActionHint string
}
// addIssuesCommand adds the 'issues' subcommand to qa.
func addIssuesCommand(parent *cli.Command) {
issuesCmd := &cli.Command{
Use: "issues",
Short: i18n.T("cmd.qa.issues.short"),
Long: i18n.T("cmd.qa.issues.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runQAIssues()
},
}
issuesCmd.Flags().BoolVarP(&issuesMine, "mine", "m", false, i18n.T("cmd.qa.issues.flag.mine"))
issuesCmd.Flags().BoolVarP(&issuesTriage, "triage", "t", false, i18n.T("cmd.qa.issues.flag.triage"))
issuesCmd.Flags().BoolVarP(&issuesBlocked, "blocked", "b", false, i18n.T("cmd.qa.issues.flag.blocked"))
issuesCmd.Flags().StringVar(&issuesRegistry, "registry", "", i18n.T("common.flag.registry"))
issuesCmd.Flags().IntVarP(&issuesLimit, "limit", "l", 50, i18n.T("cmd.qa.issues.flag.limit"))
parent.AddCommand(issuesCmd)
}
func runQAIssues() error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return log.E("qa.issues", i18n.T("error.gh_not_found"), nil)
}
// Load registry
var reg *repos.Registry
var err error
if issuesRegistry != "" {
reg, err = repos.LoadRegistry(io.Local, issuesRegistry)
} else {
registryPath, findErr := repos.FindRegistry(io.Local)
if findErr != nil {
return log.E("qa.issues", i18n.T("error.registry_not_found"), nil)
}
reg, err = repos.LoadRegistry(io.Local, registryPath)
}
if err != nil {
return log.E("qa.issues", "failed to load registry", err)
}
// Fetch issues from all repos
var allIssues []Issue
repoList := reg.List()
for i, repo := range repoList {
cli.Print("\033[2K\r%s %d/%d %s",
dimStyle.Render(i18n.T("cmd.qa.issues.fetching")),
i+1, len(repoList), repo.Name)
issues, err := fetchQAIssues(reg.Org, repo.Name, issuesLimit)
if err != nil {
continue // Skip repos with errors
}
allIssues = append(allIssues, issues...)
}
cli.Print("\033[2K\r") // Clear progress
if len(allIssues) == 0 {
cli.Text(i18n.T("cmd.qa.issues.no_issues"))
return nil
}
// Categorise and prioritise issues
categorised := categoriseIssues(allIssues)
// Filter based on flags
if issuesMine {
categorised = filterMine(categorised)
}
if issuesTriage {
categorised = filterCategory(categorised, "triage")
}
if issuesBlocked {
categorised = filterCategory(categorised, "blocked")
}
// Print categorised issues
printCategorisedIssues(categorised)
return nil
}
func fetchQAIssues(org, repoName string, limit int) ([]Issue, error) {
repoFullName := cli.Sprintf("%s/%s", org, repoName)
args := []string{
"issue", "list",
"--repo", repoFullName,
"--state", "open",
"--limit", cli.Sprintf("%d", limit),
"--json", "number,title,state,body,createdAt,updatedAt,author,assignees,labels,comments,url",
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
return nil, err
}
var issues []Issue
if err := json.Unmarshal(output, &issues); err != nil {
return nil, err
}
// Tag with repo name
for i := range issues {
issues[i].RepoName = repoName
}
return issues, nil
}
func categoriseIssues(issues []Issue) map[string][]Issue {
result := map[string][]Issue{
"needs_response": {},
"ready": {},
"blocked": {},
"triage": {},
}
currentUser := getCurrentUser()
for i := range issues {
issue := &issues[i]
categoriseIssue(issue, currentUser)
result[issue.Category] = append(result[issue.Category], *issue)
}
// Sort each category by priority
for cat := range result {
sort.Slice(result[cat], func(i, j int) bool {
return result[cat][i].Priority < result[cat][j].Priority
})
}
return result
}
func categoriseIssue(issue *Issue, currentUser string) {
labels := getLabels(issue)
// Check if blocked
for _, l := range labels {
if strings.HasPrefix(l, "blocked") || l == "waiting" {
issue.Category = "blocked"
issue.Priority = 30
issue.ActionHint = i18n.T("cmd.qa.issues.hint.blocked")
return
}
}
// Check if needs triage (no labels, no assignee)
if len(issue.Labels.Nodes) == 0 && len(issue.Assignees.Nodes) == 0 {
issue.Category = "triage"
issue.Priority = 20
issue.ActionHint = i18n.T("cmd.qa.issues.hint.triage")
return
}
// Check if needs response (recent comment from someone else)
if issue.Comments.TotalCount > 0 && len(issue.Comments.Nodes) > 0 {
lastComment := issue.Comments.Nodes[len(issue.Comments.Nodes)-1]
// If last comment is not from current user and is recent
if lastComment.Author.Login != currentUser {
age := time.Since(lastComment.CreatedAt)
if age < 48*time.Hour {
issue.Category = "needs_response"
issue.Priority = 10
issue.ActionHint = cli.Sprintf("@%s %s", lastComment.Author.Login, i18n.T("cmd.qa.issues.hint.needs_response"))
return
}
}
}
// Default: ready to work
issue.Category = "ready"
issue.Priority = calculatePriority(issue, labels)
issue.ActionHint = ""
}
func calculatePriority(issue *Issue, labels []string) int {
priority := 50
// Priority labels
for _, l := range labels {
switch {
case strings.Contains(l, "critical") || strings.Contains(l, "urgent"):
priority = 1
case strings.Contains(l, "high"):
priority = 10
case strings.Contains(l, "medium"):
priority = 30
case strings.Contains(l, "low"):
priority = 70
case l == "good-first-issue" || l == "good first issue":
priority = min(priority, 15) // Boost good first issues
case l == "help-wanted" || l == "help wanted":
priority = min(priority, 20)
case l == "agent:ready" || l == "agentic":
priority = min(priority, 5) // AI-ready issues are high priority
}
}
return priority
}
func getLabels(issue *Issue) []string {
var labels []string
for _, l := range issue.Labels.Nodes {
labels = append(labels, strings.ToLower(l.Name))
}
return labels
}
func getCurrentUser() string {
cmd := exec.Command("gh", "api", "user", "--jq", ".login")
output, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}
func filterMine(categorised map[string][]Issue) map[string][]Issue {
currentUser := getCurrentUser()
result := make(map[string][]Issue)
for cat, issues := range categorised {
var filtered []Issue
for _, issue := range issues {
for _, a := range issue.Assignees.Nodes {
if a.Login == currentUser {
filtered = append(filtered, issue)
break
}
}
}
if len(filtered) > 0 {
result[cat] = filtered
}
}
return result
}
func filterCategory(categorised map[string][]Issue, category string) map[string][]Issue {
if issues, ok := categorised[category]; ok && len(issues) > 0 {
return map[string][]Issue{category: issues}
}
return map[string][]Issue{}
}
func printCategorisedIssues(categorised map[string][]Issue) {
// Print in order: needs_response, ready, blocked, triage
categories := []struct {
key string
title string
style *cli.AnsiStyle
}{
{"needs_response", i18n.T("cmd.qa.issues.category.needs_response"), warningStyle},
{"ready", i18n.T("cmd.qa.issues.category.ready"), successStyle},
{"blocked", i18n.T("cmd.qa.issues.category.blocked"), errorStyle},
{"triage", i18n.T("cmd.qa.issues.category.triage"), dimStyle},
}
first := true
for _, cat := range categories {
issues := categorised[cat.key]
if len(issues) == 0 {
continue
}
if !first {
cli.Blank()
}
first = false
cli.Print("%s (%d):\n", cat.style.Render(cat.title), len(issues))
for _, issue := range issues {
printTriagedIssue(issue)
}
}
if first {
cli.Text(i18n.T("cmd.qa.issues.no_issues"))
}
}
func printTriagedIssue(issue Issue) {
// #42 [core-bio] Fix avatar upload
num := cli.TitleStyle.Render(cli.Sprintf("#%d", issue.Number))
repo := dimStyle.Render(cli.Sprintf("[%s]", issue.RepoName))
title := cli.ValueStyle.Render(truncate(issue.Title, 50))
cli.Print(" %s %s %s", num, repo, title)
// Add labels if priority-related
var importantLabels []string
for _, l := range issue.Labels.Nodes {
name := strings.ToLower(l.Name)
if strings.Contains(name, "priority") || strings.Contains(name, "critical") ||
name == "good-first-issue" || name == "agent:ready" || name == "agentic" {
importantLabels = append(importantLabels, l.Name)
}
}
if len(importantLabels) > 0 {
cli.Print(" %s", warningStyle.Render("["+strings.Join(importantLabels, ", ")+"]"))
}
// Add age
age := cli.FormatAge(issue.UpdatedAt)
cli.Print(" %s\n", dimStyle.Render(age))
// Add action hint if present
if issue.ActionHint != "" {
cli.Print(" %s %s\n", dimStyle.Render("->"), issue.ActionHint)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -1,45 +0,0 @@
// Package qa provides quality assurance workflow commands.
//
// Unlike `core dev` which is about doing work (commit, push, pull),
// `core qa` is about verifying work (CI status, reviews, issues).
//
// Commands:
// - watch: Monitor GitHub Actions after a push, report actionable data
// - review: PR review status with actionable next steps
// - health: Aggregate CI health across all repos
// - issues: Intelligent issue triage
package qa
import (
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
func init() {
cli.RegisterCommands(AddQACommands)
}
// Style aliases from shared package
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
warningStyle = cli.WarningStyle
dimStyle = cli.DimStyle
)
// AddQACommands registers the 'qa' command and all subcommands.
func AddQACommands(root *cli.Command) {
qaCmd := &cli.Command{
Use: "qa",
Short: i18n.T("cmd.qa.short"),
Long: i18n.T("cmd.qa.long"),
}
root.AddCommand(qaCmd)
// Subcommands
addWatchCommand(qaCmd)
addReviewCommand(qaCmd)
addHealthCommand(qaCmd)
addIssuesCommand(qaCmd)
addDocblockCommand(qaCmd)
}

View file

@ -1,322 +0,0 @@
// cmd_review.go implements the 'qa review' command for PR review status.
//
// Usage:
// core qa review # Show all PRs needing attention
// core qa review --mine # Show status of your open PRs
// core qa review --requested # Show PRs you need to review
package qa
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/log"
)
// Review command flags
var (
reviewMine bool
reviewRequested bool
reviewRepo string
)
// PullRequest represents a GitHub pull request
type PullRequest struct {
Number int `json:"number"`
Title string `json:"title"`
Author Author `json:"author"`
State string `json:"state"`
IsDraft bool `json:"isDraft"`
Mergeable string `json:"mergeable"`
ReviewDecision string `json:"reviewDecision"`
URL string `json:"url"`
HeadRefName string `json:"headRefName"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
ChangedFiles int `json:"changedFiles"`
StatusChecks *StatusCheckRollup `json:"statusCheckRollup"`
ReviewRequests ReviewRequests `json:"reviewRequests"`
Reviews []Review `json:"reviews"`
}
// Author represents a GitHub user
type Author struct {
Login string `json:"login"`
}
// StatusCheckRollup contains CI check status
type StatusCheckRollup struct {
Contexts []StatusContext `json:"contexts"`
}
// StatusContext represents a single check
type StatusContext struct {
State string `json:"state"`
Conclusion string `json:"conclusion"`
Name string `json:"name"`
}
// ReviewRequests contains pending review requests
type ReviewRequests struct {
Nodes []ReviewRequest `json:"nodes"`
}
// ReviewRequest represents a review request
type ReviewRequest struct {
RequestedReviewer Author `json:"requestedReviewer"`
}
// Review represents a PR review
type Review struct {
Author Author `json:"author"`
State string `json:"state"`
}
// addReviewCommand adds the 'review' subcommand to the qa command.
func addReviewCommand(parent *cli.Command) {
reviewCmd := &cli.Command{
Use: "review",
Short: i18n.T("cmd.qa.review.short"),
Long: i18n.T("cmd.qa.review.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runReview()
},
}
reviewCmd.Flags().BoolVarP(&reviewMine, "mine", "m", false, i18n.T("cmd.qa.review.flag.mine"))
reviewCmd.Flags().BoolVarP(&reviewRequested, "requested", "r", false, i18n.T("cmd.qa.review.flag.requested"))
reviewCmd.Flags().StringVar(&reviewRepo, "repo", "", i18n.T("cmd.qa.review.flag.repo"))
parent.AddCommand(reviewCmd)
}
func runReview() error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return log.E("qa.review", i18n.T("error.gh_not_found"), nil)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Determine repo
repoFullName := reviewRepo
if repoFullName == "" {
var err error
repoFullName, err = detectRepoFromGit()
if err != nil {
return log.E("qa.review", i18n.T("cmd.qa.review.error.no_repo"), nil)
}
}
// Default: show both mine and requested if neither flag is set
showMine := reviewMine || (!reviewMine && !reviewRequested)
showRequested := reviewRequested || (!reviewMine && !reviewRequested)
if showMine {
if err := showMyPRs(ctx, repoFullName); err != nil {
return err
}
}
if showRequested {
if showMine {
cli.Blank()
}
if err := showRequestedReviews(ctx, repoFullName); err != nil {
return err
}
}
return nil
}
// showMyPRs shows the user's open PRs with status
func showMyPRs(ctx context.Context, repo string) error {
prs, err := fetchPRs(ctx, repo, "author:@me")
if err != nil {
return log.E("qa.review", "failed to fetch your PRs", err)
}
if len(prs) == 0 {
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_prs")))
return nil
}
cli.Print("%s (%d):\n", i18n.T("cmd.qa.review.your_prs"), len(prs))
for _, pr := range prs {
printPRStatus(pr)
}
return nil
}
// showRequestedReviews shows PRs where user's review is requested
func showRequestedReviews(ctx context.Context, repo string) error {
prs, err := fetchPRs(ctx, repo, "review-requested:@me")
if err != nil {
return log.E("qa.review", "failed to fetch review requests", err)
}
if len(prs) == 0 {
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_reviews")))
return nil
}
cli.Print("%s (%d):\n", i18n.T("cmd.qa.review.review_requested"), len(prs))
for _, pr := range prs {
printPRForReview(pr)
}
return nil
}
// fetchPRs fetches PRs matching the search query
func fetchPRs(ctx context.Context, repo, search string) ([]PullRequest, error) {
args := []string{
"pr", "list",
"--state", "open",
"--search", search,
"--json", "number,title,author,state,isDraft,mergeable,reviewDecision,url,headRefName,createdAt,updatedAt,additions,deletions,changedFiles,statusCheckRollup,reviewRequests,reviews",
}
if repo != "" {
args = append(args, "--repo", repo)
}
cmd := exec.CommandContext(ctx, "gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("%s", strings.TrimSpace(string(exitErr.Stderr)))
}
return nil, err
}
var prs []PullRequest
if err := json.Unmarshal(output, &prs); err != nil {
return nil, err
}
return prs, nil
}
// printPRStatus prints a PR with its merge status
func printPRStatus(pr PullRequest) {
// Determine status icon and color
status, style, action := analyzePRStatus(pr)
cli.Print(" %s #%d %s\n",
style.Render(status),
pr.Number,
truncate(pr.Title, 50))
if action != "" {
cli.Print(" %s %s\n", dimStyle.Render("->"), action)
}
}
// printPRForReview prints a PR that needs review
func printPRForReview(pr PullRequest) {
// Show PR info with stats
stats := fmt.Sprintf("+%d/-%d, %d files",
pr.Additions, pr.Deletions, pr.ChangedFiles)
cli.Print(" %s #%d %s\n",
warningStyle.Render("◯"),
pr.Number,
truncate(pr.Title, 50))
cli.Print(" %s @%s, %s\n",
dimStyle.Render("->"),
pr.Author.Login,
stats)
cli.Print(" %s gh pr checkout %d\n",
dimStyle.Render("->"),
pr.Number)
}
// analyzePRStatus determines the status, style, and action for a PR
func analyzePRStatus(pr PullRequest) (status string, style *cli.AnsiStyle, action string) {
// Check if draft
if pr.IsDraft {
return "◯", dimStyle, "Draft - convert to ready when done"
}
// Check CI status
ciPassed := true
ciFailed := false
ciPending := false
var failedCheck string
if pr.StatusChecks != nil {
for _, check := range pr.StatusChecks.Contexts {
switch check.Conclusion {
case "FAILURE", "failure":
ciFailed = true
ciPassed = false
if failedCheck == "" {
failedCheck = check.Name
}
case "PENDING", "pending", "":
if check.State == "PENDING" || check.State == "" {
ciPending = true
ciPassed = false
}
}
}
}
// Check review status
approved := pr.ReviewDecision == "APPROVED"
changesRequested := pr.ReviewDecision == "CHANGES_REQUESTED"
// Check mergeable status
hasConflicts := pr.Mergeable == "CONFLICTING"
// Determine overall status
if hasConflicts {
return "✗", errorStyle, "Needs rebase - has merge conflicts"
}
if ciFailed {
return "✗", errorStyle, fmt.Sprintf("CI failed: %s", failedCheck)
}
if changesRequested {
return "✗", warningStyle, "Changes requested - address review feedback"
}
if ciPending {
return "◯", warningStyle, "CI running..."
}
if !approved && pr.ReviewDecision != "" {
return "◯", warningStyle, "Awaiting review"
}
if approved && ciPassed {
return "✓", successStyle, "Ready to merge"
}
return "◯", dimStyle, ""
}
// truncate shortens a string to max length (rune-safe for UTF-8)
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-3]) + "..."
}

View file

@ -1,444 +0,0 @@
// cmd_watch.go implements the 'qa watch' command for monitoring GitHub Actions.
//
// Usage:
// core qa watch # Watch current repo's latest push
// core qa watch --repo X # Watch specific repo
// core qa watch --commit SHA # Watch specific commit
// core qa watch --timeout 5m # Custom timeout (default: 10m)
package qa
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/log"
)
// Watch command flags
var (
watchRepo string
watchCommit string
watchTimeout time.Duration
)
// WorkflowRun represents a GitHub Actions workflow run
type WorkflowRun struct {
ID int64 `json:"databaseId"`
Name string `json:"name"`
DisplayTitle string `json:"displayTitle"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
HeadSha string `json:"headSha"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// WorkflowJob represents a job within a workflow run
type WorkflowJob struct {
ID int64 `json:"databaseId"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
URL string `json:"url"`
}
// JobStep represents a step within a job
type JobStep struct {
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Number int `json:"number"`
}
// addWatchCommand adds the 'watch' subcommand to the qa command.
func addWatchCommand(parent *cli.Command) {
watchCmd := &cli.Command{
Use: "watch",
Short: i18n.T("cmd.qa.watch.short"),
Long: i18n.T("cmd.qa.watch.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runWatch()
},
}
watchCmd.Flags().StringVarP(&watchRepo, "repo", "r", "", i18n.T("cmd.qa.watch.flag.repo"))
watchCmd.Flags().StringVarP(&watchCommit, "commit", "c", "", i18n.T("cmd.qa.watch.flag.commit"))
watchCmd.Flags().DurationVarP(&watchTimeout, "timeout", "t", 10*time.Minute, i18n.T("cmd.qa.watch.flag.timeout"))
parent.AddCommand(watchCmd)
}
func runWatch() error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return log.E("qa.watch", i18n.T("error.gh_not_found"), nil)
}
// Determine repo
repoFullName, err := resolveRepo(watchRepo)
if err != nil {
return err
}
// Determine commit
commitSha, err := resolveCommit(watchCommit)
if err != nil {
return err
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("repo")), repoFullName)
// Safe prefix for display - handle short SHAs gracefully
shaPrefix := commitSha
if len(commitSha) > 8 {
shaPrefix = commitSha[:8]
}
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.qa.watch.commit")), shaPrefix)
cli.Blank()
// Create context with timeout for all gh commands
ctx, cancel := context.WithTimeout(context.Background(), watchTimeout)
defer cancel()
// Poll for workflow runs
pollInterval := 3 * time.Second
var lastStatus string
for {
// Check if context deadline exceeded
if ctx.Err() != nil {
cli.Blank()
return log.E("qa.watch", i18n.T("cmd.qa.watch.timeout", map[string]interface{}{"Duration": watchTimeout}), nil)
}
runs, err := fetchWorkflowRunsForCommit(ctx, repoFullName, commitSha)
if err != nil {
return log.Wrap(err, "qa.watch", "failed to fetch workflow runs")
}
if len(runs) == 0 {
// No workflows triggered yet, keep waiting
cli.Print("\033[2K\r%s", dimStyle.Render(i18n.T("cmd.qa.watch.waiting_for_workflows")))
time.Sleep(pollInterval)
continue
}
// Check status of all runs
allComplete := true
var pending, success, failed int
for _, run := range runs {
switch run.Status {
case "completed":
if run.Conclusion == "success" {
success++
} else {
// Count all non-success conclusions as failed
// (failure, cancelled, timed_out, action_required, stale, etc.)
failed++
}
default:
allComplete = false
pending++
}
}
// Build status line
status := fmt.Sprintf("%d workflow(s): ", len(runs))
if pending > 0 {
status += warningStyle.Render(fmt.Sprintf("%d running", pending))
if success > 0 || failed > 0 {
status += ", "
}
}
if success > 0 {
status += successStyle.Render(fmt.Sprintf("%d passed", success))
if failed > 0 {
status += ", "
}
}
if failed > 0 {
status += errorStyle.Render(fmt.Sprintf("%d failed", failed))
}
// Only print if status changed
if status != lastStatus {
cli.Print("\033[2K\r%s", status)
lastStatus = status
}
if allComplete {
cli.Blank()
cli.Blank()
return printResults(ctx, repoFullName, runs)
}
time.Sleep(pollInterval)
}
}
// resolveRepo determines the repo to watch
func resolveRepo(specified string) (string, error) {
if specified != "" {
// If it contains /, assume it's already full name
if strings.Contains(specified, "/") {
return specified, nil
}
// Try to get org from current directory
org := detectOrgFromGit()
if org != "" {
return org + "/" + specified, nil
}
return "", log.E("qa.watch", i18n.T("cmd.qa.watch.error.repo_format"), nil)
}
// Detect from current directory
return detectRepoFromGit()
}
// resolveCommit determines the commit to watch
func resolveCommit(specified string) (string, error) {
if specified != "" {
return specified, nil
}
// Get HEAD commit
cmd := exec.Command("git", "rev-parse", "HEAD")
output, err := cmd.Output()
if err != nil {
return "", log.Wrap(err, "qa.watch", "failed to get HEAD commit")
}
return strings.TrimSpace(string(output)), nil
}
// detectRepoFromGit detects the repo from git remote
func detectRepoFromGit() (string, error) {
cmd := exec.Command("git", "remote", "get-url", "origin")
output, err := cmd.Output()
if err != nil {
return "", log.E("qa.watch", i18n.T("cmd.qa.watch.error.not_git_repo"), nil)
}
url := strings.TrimSpace(string(output))
return parseGitHubRepo(url)
}
// detectOrgFromGit tries to detect the org from git remote
func detectOrgFromGit() string {
repo, err := detectRepoFromGit()
if err != nil {
return ""
}
parts := strings.Split(repo, "/")
if len(parts) >= 1 {
return parts[0]
}
return ""
}
// parseGitHubRepo extracts org/repo from a git URL
func parseGitHubRepo(url string) (string, error) {
// Handle SSH URLs: git@github.com:org/repo.git
if strings.HasPrefix(url, "git@github.com:") {
path := strings.TrimPrefix(url, "git@github.com:")
path = strings.TrimSuffix(path, ".git")
return path, nil
}
// Handle HTTPS URLs: https://github.com/org/repo.git
if strings.Contains(url, "github.com/") {
parts := strings.Split(url, "github.com/")
if len(parts) >= 2 {
path := strings.TrimSuffix(parts[1], ".git")
return path, nil
}
}
return "", fmt.Errorf("could not parse GitHub repo from URL: %s", url)
}
// fetchWorkflowRunsForCommit fetches workflow runs for a specific commit
func fetchWorkflowRunsForCommit(ctx context.Context, repoFullName, commitSha string) ([]WorkflowRun, error) {
args := []string{
"run", "list",
"--repo", repoFullName,
"--commit", commitSha,
"--json", "databaseId,name,displayTitle,status,conclusion,headSha,url,createdAt,updatedAt",
}
cmd := exec.CommandContext(ctx, "gh", args...)
output, err := cmd.Output()
if err != nil {
// Check if context was cancelled/deadline exceeded
if ctx.Err() != nil {
return nil, ctx.Err()
}
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, cli.Err("%s", strings.TrimSpace(string(exitErr.Stderr)))
}
return nil, err
}
var runs []WorkflowRun
if err := json.Unmarshal(output, &runs); err != nil {
return nil, err
}
return runs, nil
}
// printResults prints the final results with actionable information
func printResults(ctx context.Context, repoFullName string, runs []WorkflowRun) error {
var failures []WorkflowRun
var successes []WorkflowRun
for _, run := range runs {
if run.Conclusion == "success" {
successes = append(successes, run)
} else {
// Treat all non-success as failures (failure, cancelled, timed_out, etc.)
failures = append(failures, run)
}
}
// Print successes briefly
for _, run := range successes {
cli.Print("%s %s\n", successStyle.Render(cli.Glyph(":check:")), run.Name)
}
// Print failures with details
for _, run := range failures {
cli.Print("%s %s\n", errorStyle.Render(cli.Glyph(":cross:")), run.Name)
// Fetch failed job details
failedJob, failedStep, errorLine := fetchFailureDetails(ctx, repoFullName, run.ID)
if failedJob != "" {
cli.Print(" %s Job: %s", dimStyle.Render("->"), failedJob)
if failedStep != "" {
cli.Print(" (step: %s)", failedStep)
}
cli.Blank()
}
if errorLine != "" {
cli.Print(" %s Error: %s\n", dimStyle.Render("->"), errorLine)
}
cli.Print(" %s %s\n", dimStyle.Render("->"), run.URL)
}
// Exit with error if any failures
if len(failures) > 0 {
cli.Blank()
return cli.Err("%s", i18n.T("cmd.qa.watch.workflows_failed", map[string]interface{}{"Count": len(failures)}))
}
cli.Blank()
cli.Print("%s\n", successStyle.Render(i18n.T("cmd.qa.watch.all_passed")))
return nil
}
// fetchFailureDetails fetches details about why a workflow failed
func fetchFailureDetails(ctx context.Context, repoFullName string, runID int64) (jobName, stepName, errorLine string) {
// Fetch jobs for this run
args := []string{
"run", "view", fmt.Sprintf("%d", runID),
"--repo", repoFullName,
"--json", "jobs",
}
cmd := exec.CommandContext(ctx, "gh", args...)
output, err := cmd.Output()
if err != nil {
return "", "", ""
}
var result struct {
Jobs []struct {
Name string `json:"name"`
Conclusion string `json:"conclusion"`
Steps []struct {
Name string `json:"name"`
Conclusion string `json:"conclusion"`
Number int `json:"number"`
} `json:"steps"`
} `json:"jobs"`
}
if err := json.Unmarshal(output, &result); err != nil {
return "", "", ""
}
// Find the failed job and step
for _, job := range result.Jobs {
if job.Conclusion == "failure" {
jobName = job.Name
for _, step := range job.Steps {
if step.Conclusion == "failure" {
stepName = fmt.Sprintf("%d: %s", step.Number, step.Name)
break
}
}
break
}
}
// Try to get the error line from logs (if available)
errorLine = fetchErrorFromLogs(ctx, repoFullName, runID)
return jobName, stepName, errorLine
}
// fetchErrorFromLogs attempts to extract the first error line from workflow logs
func fetchErrorFromLogs(ctx context.Context, repoFullName string, runID int64) string {
// Use gh run view --log-failed to get failed step logs
args := []string{
"run", "view", fmt.Sprintf("%d", runID),
"--repo", repoFullName,
"--log-failed",
}
cmd := exec.CommandContext(ctx, "gh", args...)
output, err := cmd.Output()
if err != nil {
return ""
}
// Parse output to find the first meaningful error line
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Skip common metadata/progress lines
lower := strings.ToLower(line)
if strings.HasPrefix(lower, "##[") { // GitHub Actions command markers
continue
}
if strings.HasPrefix(line, "Run ") || strings.HasPrefix(line, "Running ") {
continue
}
// Look for error indicators
if strings.Contains(lower, "error") ||
strings.Contains(lower, "failed") ||
strings.Contains(lower, "fatal") ||
strings.Contains(lower, "panic") ||
strings.Contains(line, ": ") { // Likely a file:line or key: value format
// Truncate long lines
if len(line) > 120 {
line = line[:117] + "..."
}
return line
}
}
return ""
}

View file

@ -1,176 +0,0 @@
// cmd_bootstrap.go implements bootstrap mode for new workspaces.
//
// Bootstrap mode is activated when no repos.yaml exists in the current
// directory or any parent. It clones core-devops first, then uses its
// repos.yaml to present the package wizard.
package setup
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"forge.lthn.ai/core/cli/cmd/workspace"
"forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// runSetupOrchestrator decides between registry mode and bootstrap mode.
func runSetupOrchestrator(registryPath, only string, dryRun, all bool, projectName string, runBuild bool) error {
ctx := context.Background()
// Try to find an existing registry
var foundRegistry string
var err error
if registryPath != "" {
foundRegistry = registryPath
} else {
foundRegistry, err = repos.FindRegistry(coreio.Local)
}
// If registry exists, use registry mode
if err == nil && foundRegistry != "" {
return runRegistrySetup(ctx, foundRegistry, only, dryRun, all, runBuild)
}
// No registry found - enter bootstrap mode
return runBootstrap(ctx, only, dryRun, all, projectName, runBuild)
}
// runBootstrap handles the case where no repos.yaml exists.
func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectName string, runBuild bool) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
fmt.Printf("%s %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.bootstrap_mode"))
var targetDir string
// Check if current directory is empty
empty, err := isDirEmpty(cwd)
if err != nil {
return fmt.Errorf("failed to check directory: %w", err)
}
if empty {
// Clone into current directory
targetDir = cwd
fmt.Printf("%s %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.cloning_current_dir"))
} else {
// Directory has content - check if it's a git repo root
isRepo := isGitRepoRoot(cwd)
if isRepo && isTerminal() && !all {
// Offer choice: setup working directory or create package
choice, err := promptSetupChoice()
if err != nil {
return fmt.Errorf("failed to get choice: %w", err)
}
if choice == "setup" {
// Setup this working directory with .core/ config
return runRepoSetup(cwd, dryRun)
}
// Otherwise continue to "create package" flow
}
// Create package flow - need a project name
if projectName == "" {
if !isTerminal() || all {
projectName = defaultOrg
} else {
projectName, err = promptProjectName(defaultOrg)
if err != nil {
return fmt.Errorf("failed to get project name: %w", err)
}
}
}
targetDir = filepath.Join(cwd, projectName)
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.creating_project_dir"), projectName)
if !dryRun {
if err := coreio.Local.EnsureDir(targetDir); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
}
// Clone core-devops first
devopsPath := filepath.Join(targetDir, devopsRepo)
if !coreio.Local.Exists(filepath.Join(devopsPath, ".git")) {
fmt.Printf("%s %s %s...\n", dimStyle.Render(">>"), i18n.T("common.status.cloning"), devopsRepo)
if !dryRun {
if err := gitClone(ctx, defaultOrg, devopsRepo, devopsPath); err != nil {
return fmt.Errorf("failed to clone %s: %w", devopsRepo, err)
}
fmt.Printf("%s %s %s\n", successStyle.Render(">>"), devopsRepo, i18n.T("cmd.setup.cloned"))
} else {
fmt.Printf(" %s %s/%s to %s\n", i18n.T("cmd.setup.would_clone"), defaultOrg, devopsRepo, devopsPath)
}
} else {
fmt.Printf("%s %s %s\n", dimStyle.Render(">>"), devopsRepo, i18n.T("cmd.setup.already_exists"))
}
// Load the repos.yaml from core-devops
registryPath := filepath.Join(devopsPath, devopsReposYaml)
if dryRun {
fmt.Printf("\n%s %s %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.would_load_registry"), registryPath)
return nil
}
reg, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return fmt.Errorf("failed to load registry from %s: %w", devopsRepo, err)
}
// Override base path to target directory
reg.BasePath = targetDir
// Check workspace config for default_only if no filter specified
if only == "" {
if wsConfig, err := workspace.LoadConfig(devopsPath); err == nil && wsConfig != nil && len(wsConfig.DefaultOnly) > 0 {
only = strings.Join(wsConfig.DefaultOnly, ",")
}
}
// Now run the regular setup with the loaded registry
return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild)
}
// isGitRepoRoot returns true if the directory is a git repository root.
// Handles both regular repos (.git is a directory) and worktrees (.git is a file).
func isGitRepoRoot(path string) bool {
return coreio.Local.Exists(filepath.Join(path, ".git"))
}
// isDirEmpty returns true if the directory is empty or contains only hidden files.
func isDirEmpty(path string) (bool, error) {
entries, err := coreio.Local.List(path)
if err != nil {
return false, err
}
for _, e := range entries {
name := e.Name()
// Ignore common hidden/metadata files
if name == ".DS_Store" || name == ".git" || name == ".gitignore" {
continue
}
// Any other non-hidden file means directory is not empty
if len(name) > 0 && name[0] != '.' {
return false, nil
}
}
return true, nil
}

View file

@ -1,300 +0,0 @@
package setup
import (
"fmt"
"os"
"path/filepath"
"runtime"
"forge.lthn.ai/core/go/pkg/cli"
coreio "forge.lthn.ai/core/go/pkg/io"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
// CIConfig holds CI setup configuration from .core/ci.yaml
type CIConfig struct {
// Homebrew tap (e.g., "host-uk/tap")
Tap string `yaml:"tap"`
// Formula name (defaults to "core")
Formula string `yaml:"formula"`
// Scoop bucket URL
ScoopBucket string `yaml:"scoop_bucket"`
// Chocolatey package name
ChocolateyPkg string `yaml:"chocolatey_pkg"`
// GitHub repository for direct downloads
Repository string `yaml:"repository"`
// Default version to install
DefaultVersion string `yaml:"default_version"`
}
// DefaultCIConfig returns the default CI configuration.
func DefaultCIConfig() *CIConfig {
return &CIConfig{
Tap: "host-uk/tap",
Formula: "core",
ScoopBucket: "https://https://forge.lthn.ai/core/scoop-bucket.git",
ChocolateyPkg: "core-cli",
Repository: "host-uk/core",
DefaultVersion: "dev",
}
}
// LoadCIConfig loads CI configuration from .core/ci.yaml
func LoadCIConfig() *CIConfig {
cfg := DefaultCIConfig()
// Try to find .core/ci.yaml in current directory or parents
dir, err := os.Getwd()
if err != nil {
return cfg
}
for {
configPath := filepath.Join(dir, ".core", "ci.yaml")
data, err := coreio.Local.Read(configPath)
if err == nil {
if err := yaml.Unmarshal([]byte(data), cfg); err == nil {
return cfg
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return cfg
}
// CI setup command flags
var (
ciShell string
ciVersion string
)
func init() {
ciCmd := &cobra.Command{
Use: "ci",
Short: "Output CI installation commands for core CLI",
Long: `Output installation commands for the core CLI in CI environments.
Generates shell commands to install the core CLI using the appropriate
package manager for each platform:
macOS/Linux: Homebrew (brew install host-uk/tap/core)
Windows: Scoop or Chocolatey, or direct download
Configuration can be customized via .core/ci.yaml:
tap: host-uk/tap # Homebrew tap
formula: core # Homebrew formula name
scoop_bucket: https://... # Scoop bucket URL
chocolatey_pkg: core-cli # Chocolatey package name
repository: host-uk/core # GitHub repo for direct downloads
default_version: dev # Default version to install
Examples:
# Output installation commands for current platform
core setup ci
# Output for specific shell (bash, powershell, yaml)
core setup ci --shell=bash
core setup ci --shell=powershell
core setup ci --shell=yaml
# Install specific version
core setup ci --version=v1.0.0
# Use in GitHub Actions (pipe to shell)
eval "$(core setup ci --shell=bash)"`,
RunE: runSetupCI,
}
ciCmd.Flags().StringVar(&ciShell, "shell", "", "Output format: bash, powershell, yaml (auto-detected if not specified)")
ciCmd.Flags().StringVar(&ciVersion, "version", "", "Version to install (tag name or 'dev' for latest dev build)")
setupCmd.AddCommand(ciCmd)
}
func runSetupCI(cmd *cobra.Command, args []string) error {
cfg := LoadCIConfig()
// Use flag version or config default
version := ciVersion
if version == "" {
version = cfg.DefaultVersion
}
// Auto-detect shell if not specified
shell := ciShell
if shell == "" {
if runtime.GOOS == "windows" {
shell = "powershell"
} else {
shell = "bash"
}
}
switch shell {
case "bash", "sh":
return outputBashInstall(cfg, version)
case "powershell", "pwsh", "ps1":
return outputPowershellInstall(cfg, version)
case "yaml", "yml", "gha", "github":
return outputGitHubActionsYAML(cfg, version)
default:
return cli.Err("unsupported shell: %s (use bash, powershell, or yaml)", shell)
}
}
func outputBashInstall(cfg *CIConfig, version string) error {
script := fmt.Sprintf(`#!/bin/bash
set -e
VERSION="%s"
REPO="%s"
TAP="%s"
FORMULA="%s"
# Detect OS and architecture
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH="amd64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
# Try Homebrew first on macOS/Linux
if command -v brew &>/dev/null; then
echo "Installing via Homebrew..."
brew tap "$TAP" 2>/dev/null || true
if [ "$VERSION" = "dev" ]; then
brew install "${TAP}/${FORMULA}" --HEAD 2>/dev/null || brew upgrade "${TAP}/${FORMULA}" --fetch-HEAD 2>/dev/null || brew install "${TAP}/${FORMULA}"
else
brew install "${TAP}/${FORMULA}"
fi
%s --version
exit 0
fi
# Fall back to direct download
echo "Installing %s CLI ${VERSION} for ${OS}/${ARCH}..."
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/%s-${OS}-${ARCH}"
# Download binary
curl -fsSL "$DOWNLOAD_URL" -o /tmp/%s
chmod +x /tmp/%s
# Install to /usr/local/bin (requires sudo on most systems)
if [ -w /usr/local/bin ]; then
mv /tmp/%s /usr/local/bin/%s
else
sudo mv /tmp/%s /usr/local/bin/%s
fi
echo "Installed:"
%s --version
`, version, cfg.Repository, cfg.Tap, cfg.Formula,
cfg.Formula, cfg.Formula, cfg.Formula,
cfg.Formula, cfg.Formula, cfg.Formula, cfg.Formula, cfg.Formula, cfg.Formula, cfg.Formula)
fmt.Print(script)
return nil
}
func outputPowershellInstall(cfg *CIConfig, version string) error {
script := fmt.Sprintf(`# PowerShell installation script for %s CLI
$ErrorActionPreference = "Stop"
$Version = "%s"
$Repo = "%s"
$ScoopBucket = "%s"
$ChocoPkg = "%s"
$BinaryName = "%s"
$Arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
# Try Scoop first
if (Get-Command scoop -ErrorAction SilentlyContinue) {
Write-Host "Installing via Scoop..."
scoop bucket add host-uk $ScoopBucket 2>$null
scoop install "host-uk/$BinaryName"
& $BinaryName --version
exit 0
}
# Try Chocolatey
if (Get-Command choco -ErrorAction SilentlyContinue) {
Write-Host "Installing via Chocolatey..."
choco install $ChocoPkg -y
& $BinaryName --version
exit 0
}
# Fall back to direct download
Write-Host "Installing $BinaryName CLI $Version for windows/$Arch..."
$DownloadUrl = "https://github.com/$Repo/releases/download/$Version/$BinaryName-windows-$Arch.exe"
$InstallDir = "$env:LOCALAPPDATA\Programs\$BinaryName"
$BinaryPath = "$InstallDir\$BinaryName.exe"
# Create install directory
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
# Download binary
Invoke-WebRequest -Uri $DownloadUrl -OutFile $BinaryPath
# Add to PATH if not already there
$CurrentPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($CurrentPath -notlike "*$InstallDir*") {
[Environment]::SetEnvironmentVariable("Path", "$CurrentPath;$InstallDir", "User")
$env:Path = "$env:Path;$InstallDir"
}
Write-Host "Installed:"
& $BinaryPath --version
`, cfg.Formula, version, cfg.Repository, cfg.ScoopBucket, cfg.ChocolateyPkg, cfg.Formula)
fmt.Print(script)
return nil
}
func outputGitHubActionsYAML(cfg *CIConfig, version string) error {
yaml := fmt.Sprintf(`# GitHub Actions steps to install %s CLI
# Add these to your workflow file
# Option 1: Direct download (fastest, no extra dependencies)
- name: Install %s CLI
shell: bash
run: |
VERSION="%s"
REPO="%s"
BINARY="%s"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH="amd64" ;;
arm64|aarch64) ARCH="arm64" ;;
esac
curl -fsSL "https://github.com/${REPO}/releases/download/${VERSION}/${BINARY}-${OS}-${ARCH}" -o "${BINARY}"
chmod +x "${BINARY}"
sudo mv "${BINARY}" /usr/local/bin/
%s --version
# Option 2: Homebrew (better for caching, includes dependencies)
- name: Install %s CLI (Homebrew)
run: |
brew tap %s
brew install %s/%s
%s --version
`, cfg.Formula, cfg.Formula, version, cfg.Repository, cfg.Formula, cfg.Formula,
cfg.Formula, cfg.Tap, cfg.Tap, cfg.Formula, cfg.Formula)
fmt.Print(yaml)
return nil
}

View file

@ -1,38 +0,0 @@
// Package setup provides workspace bootstrap and package cloning commands.
//
// Two modes of operation:
//
// REGISTRY MODE (repos.yaml exists):
// - Clones all repositories defined in repos.yaml into packages/
// - Skips repos that already exist
// - Supports filtering by type with --only
//
// BOOTSTRAP MODE (no repos.yaml):
// - Clones core-devops to set up the workspace foundation
// - Presents an interactive wizard to select packages (unless --all)
// - Clones selected packages
//
// Flags:
// - --registry: Path to repos.yaml (auto-detected if not specified)
// - --only: Filter by repo type (foundation, module, product)
// - --dry-run: Preview what would be cloned
// - --all: Skip wizard, clone all packages (non-interactive)
// - --name: Project directory name for bootstrap mode
// - --build: Run build after cloning
//
// Uses gh CLI with HTTPS when authenticated, falls back to SSH.
package setup
import (
"forge.lthn.ai/core/go/pkg/cli"
"github.com/spf13/cobra"
)
func init() {
cli.RegisterCommands(AddSetupCommands)
}
// AddSetupCommands registers the 'setup' command and all subcommands.
func AddSetupCommands(root *cobra.Command) {
AddSetupCommand(root)
}

View file

@ -1,230 +0,0 @@
// cmd_github.go implements the 'setup github' command for configuring
// GitHub repositories with organization standards.
//
// Usage:
// core setup github [flags]
//
// Flags:
// -r, --repo string Specific repo to setup
// -a, --all Setup all repos in registry
// -l, --labels Only sync labels
// -w, --webhooks Only sync webhooks
// -p, --protection Only sync branch protection
// -s, --security Only sync security settings
// -c, --check Dry-run: show what would change
// --config string Path to github.yaml config
// --verbose Show detailed output
package setup
import (
"errors"
"os/exec"
"path/filepath"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
"github.com/spf13/cobra"
)
// GitHub command flags
var (
ghRepo string
ghAll bool
ghLabels bool
ghWebhooks bool
ghProtection bool
ghSecurity bool
ghCheck bool
ghConfigPath string
ghVerbose bool
)
// addGitHubCommand adds the 'github' subcommand to the setup command.
func addGitHubCommand(parent *cobra.Command) {
ghCmd := &cobra.Command{
Use: "github",
Aliases: []string{"gh"},
Short: i18n.T("cmd.setup.github.short"),
Long: i18n.T("cmd.setup.github.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return runGitHubSetup()
},
}
ghCmd.Flags().StringVarP(&ghRepo, "repo", "r", "", i18n.T("cmd.setup.github.flag.repo"))
ghCmd.Flags().BoolVarP(&ghAll, "all", "a", false, i18n.T("cmd.setup.github.flag.all"))
ghCmd.Flags().BoolVarP(&ghLabels, "labels", "l", false, i18n.T("cmd.setup.github.flag.labels"))
ghCmd.Flags().BoolVarP(&ghWebhooks, "webhooks", "w", false, i18n.T("cmd.setup.github.flag.webhooks"))
ghCmd.Flags().BoolVarP(&ghProtection, "protection", "p", false, i18n.T("cmd.setup.github.flag.protection"))
ghCmd.Flags().BoolVarP(&ghSecurity, "security", "s", false, i18n.T("cmd.setup.github.flag.security"))
ghCmd.Flags().BoolVarP(&ghCheck, "check", "c", false, i18n.T("cmd.setup.github.flag.check"))
ghCmd.Flags().StringVar(&ghConfigPath, "config", "", i18n.T("cmd.setup.github.flag.config"))
ghCmd.Flags().BoolVarP(&ghVerbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
parent.AddCommand(ghCmd)
}
func runGitHubSetup() error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return errors.New(i18n.T("error.gh_not_found"))
}
// Check gh is authenticated
if !cli.GhAuthenticated() {
return errors.New(i18n.T("cmd.setup.github.error.not_authenticated"))
}
// Find registry
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Wrap(err, i18n.T("error.registry_not_found"))
}
reg, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return cli.Wrap(err, "failed to load registry")
}
registryDir := filepath.Dir(registryPath)
// Find GitHub config
configPath, err := FindGitHubConfig(registryDir, ghConfigPath)
if err != nil {
return cli.Wrap(err, i18n.T("cmd.setup.github.error.config_not_found"))
}
config, err := LoadGitHubConfig(configPath)
if err != nil {
return cli.Wrap(err, "failed to load GitHub config")
}
if err := config.Validate(); err != nil {
return cli.Wrap(err, "invalid GitHub config")
}
// Print header
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("registry")), registryPath)
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("config")), configPath)
if ghCheck {
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.setup.github.dry_run_mode")))
}
// Determine which repos to process
var reposToProcess []*repos.Repo
// Reject conflicting flags
if ghRepo != "" && ghAll {
return errors.New(i18n.T("cmd.setup.github.error.conflicting_flags"))
}
if ghRepo != "" {
// Single repo mode
repo, ok := reg.Get(ghRepo)
if !ok {
return errors.New(i18n.T("error.repo_not_found", map[string]interface{}{"Name": ghRepo}))
}
reposToProcess = []*repos.Repo{repo}
} else if ghAll {
// All repos mode
reposToProcess = reg.List()
} else {
// No repos specified
cli.Print("\n%s\n", i18n.T("cmd.setup.github.no_repos_specified"))
cli.Print(" %s\n", i18n.T("cmd.setup.github.usage_hint"))
return nil
}
// Determine which operations to run
runAll := !ghLabels && !ghWebhooks && !ghProtection && !ghSecurity
runLabels := runAll || ghLabels
runWebhooks := runAll || ghWebhooks
runProtection := runAll || ghProtection
runSecurity := runAll || ghSecurity
// Process each repo
aggregate := NewAggregate()
for i, repo := range reposToProcess {
repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name)
// Show progress
cli.Print("\033[2K\r%s %d/%d %s",
dimStyle.Render(i18n.T("common.progress.checking")),
i+1, len(reposToProcess), repo.Name)
changes := NewChangeSet(repo.Name)
// Sync labels
if runLabels {
labelChanges, err := SyncLabels(repoFullName, config, ghCheck)
if err != nil {
cli.Print("\033[2K\r")
cli.Print("%s %s: %s\n", errorStyle.Render(cli.Glyph(":cross:")), repo.Name, err)
aggregate.Add(changes) // Preserve partial results
continue
}
changes.Changes = append(changes.Changes, labelChanges.Changes...)
}
// Sync webhooks
if runWebhooks {
webhookChanges, err := SyncWebhooks(repoFullName, config, ghCheck)
if err != nil {
cli.Print("\033[2K\r")
cli.Print("%s %s: %s\n", errorStyle.Render(cli.Glyph(":cross:")), repo.Name, err)
aggregate.Add(changes) // Preserve partial results
continue
}
changes.Changes = append(changes.Changes, webhookChanges.Changes...)
}
// Sync branch protection
if runProtection {
protectionChanges, err := SyncBranchProtection(repoFullName, config, ghCheck)
if err != nil {
cli.Print("\033[2K\r")
cli.Print("%s %s: %s\n", errorStyle.Render(cli.Glyph(":cross:")), repo.Name, err)
aggregate.Add(changes) // Preserve partial results
continue
}
changes.Changes = append(changes.Changes, protectionChanges.Changes...)
}
// Sync security settings
if runSecurity {
securityChanges, err := SyncSecuritySettings(repoFullName, config, ghCheck)
if err != nil {
cli.Print("\033[2K\r")
cli.Print("%s %s: %s\n", errorStyle.Render(cli.Glyph(":cross:")), repo.Name, err)
aggregate.Add(changes) // Preserve partial results
continue
}
changes.Changes = append(changes.Changes, securityChanges.Changes...)
}
aggregate.Add(changes)
}
// Clear progress line
cli.Print("\033[2K\r")
// Print results
for _, cs := range aggregate.Sets {
cs.Print(ghVerbose || ghCheck)
}
// Print summary
aggregate.PrintSummary()
// Suggest permission fix if needed
if ghCheck {
cli.Print("\n%s\n", i18n.T("cmd.setup.github.run_without_check"))
}
return nil
}

View file

@ -1,264 +0,0 @@
// cmd_registry.go implements registry mode for cloning packages.
//
// Registry mode is activated when a repos.yaml exists. It reads the registry
// and clones all (or selected) packages into the configured packages directory.
package setup
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/cli/cmd/workspace"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// runRegistrySetup loads a registry from path and runs setup.
func runRegistrySetup(ctx context.Context, registryPath, only string, dryRun, all, runBuild bool) error {
reg, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return fmt.Errorf("failed to load registry: %w", err)
}
// Check workspace config for default_only if no filter specified
if only == "" {
registryDir := filepath.Dir(registryPath)
if wsConfig, err := workspace.LoadConfig(registryDir); err == nil && wsConfig != nil && len(wsConfig.DefaultOnly) > 0 {
only = strings.Join(wsConfig.DefaultOnly, ",")
}
}
return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild)
}
// runRegistrySetupWithReg runs setup with an already-loaded registry.
func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryPath, only string, dryRun, all, runBuild bool) error {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("registry")), registryPath)
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.setup.org_label")), reg.Org)
registryDir := filepath.Dir(registryPath)
// Determine base path for cloning
basePath := reg.BasePath
if basePath == "" {
// Load workspace config to see if packages_dir is set (ignore errors, fall back to default)
wsConfig, _ := workspace.LoadConfig(registryDir)
if wsConfig != nil && wsConfig.PackagesDir != "" {
basePath = wsConfig.PackagesDir
} else {
basePath = "./packages"
}
}
// Expand ~
if strings.HasPrefix(basePath, "~/") {
home, _ := os.UserHomeDir()
basePath = filepath.Join(home, basePath[2:])
}
// Resolve relative to registry location
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(registryDir, basePath)
}
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), basePath)
// Parse type filter
var typeFilter []string
if only != "" {
for _, t := range strings.Split(only, ",") {
typeFilter = append(typeFilter, strings.TrimSpace(t))
}
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("filter")), only)
}
// Ensure base path exists
if !dryRun {
if err := coreio.Local.EnsureDir(basePath); err != nil {
return fmt.Errorf("failed to create packages directory: %w", err)
}
}
// Get all available repos
allRepos := reg.List()
// Determine which repos to clone
var toClone []*repos.Repo
var skipped, exists int
// Use wizard in interactive mode, unless --all specified
useWizard := isTerminal() && !all && !dryRun
if useWizard {
selected, err := runPackageWizard(reg, typeFilter)
if err != nil {
return fmt.Errorf("wizard error: %w", err)
}
// Build set of selected repos
selectedSet := make(map[string]bool)
for _, name := range selected {
selectedSet[name] = true
}
// Filter repos based on selection
for _, repo := range allRepos {
if !selectedSet[repo.Name] {
skipped++
continue
}
// Check if already exists
repoPath := filepath.Join(basePath, repo.Name)
// Check .git dir existence via Exists
if coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
exists++
continue
}
toClone = append(toClone, repo)
}
} else {
// Non-interactive: filter by type
typeFilterSet := make(map[string]bool)
for _, t := range typeFilter {
typeFilterSet[t] = true
}
for _, repo := range allRepos {
// Skip if type filter doesn't match (when filter is specified)
if len(typeFilterSet) > 0 && !typeFilterSet[repo.Type] {
skipped++
continue
}
// Skip if clone: false
if repo.Clone != nil && !*repo.Clone {
skipped++
continue
}
// Check if already exists
repoPath := filepath.Join(basePath, repo.Name)
if coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
exists++
continue
}
toClone = append(toClone, repo)
}
}
// Summary
fmt.Println()
fmt.Printf("%s, %s, %s\n",
i18n.T("cmd.setup.to_clone", map[string]interface{}{"Count": len(toClone)}),
i18n.T("cmd.setup.exist", map[string]interface{}{"Count": exists}),
i18n.T("common.count.skipped", map[string]interface{}{"Count": skipped}))
if len(toClone) == 0 {
fmt.Printf("\n%s\n", i18n.T("cmd.setup.nothing_to_clone"))
return nil
}
if dryRun {
fmt.Printf("\n%s\n", i18n.T("cmd.setup.would_clone_list"))
for _, repo := range toClone {
fmt.Printf(" %s (%s)\n", repoNameStyle.Render(repo.Name), repo.Type)
}
return nil
}
// Confirm in interactive mode
if useWizard {
confirmed, err := confirmClone(len(toClone), basePath)
if err != nil {
return err
}
if !confirmed {
fmt.Println(i18n.T("cmd.setup.cancelled"))
return nil
}
}
// Clone repos
fmt.Println()
var succeeded, failed int
for _, repo := range toClone {
fmt.Printf(" %s %s... ", dimStyle.Render(i18n.T("common.status.cloning")), repo.Name)
repoPath := filepath.Join(basePath, repo.Name)
err := gitClone(ctx, reg.Org, repo.Name, repoPath)
if err != nil {
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
failed++
} else {
fmt.Printf("%s\n", successStyle.Render(i18n.T("cmd.setup.done")))
succeeded++
}
}
// Summary
fmt.Println()
fmt.Printf("%s %s", successStyle.Render(i18n.Label("done")), i18n.T("cmd.setup.cloned_count", map[string]interface{}{"Count": succeeded}))
if failed > 0 {
fmt.Printf(", %s", errorStyle.Render(i18n.T("i18n.count.failed", failed)))
}
if exists > 0 {
fmt.Printf(", %s", i18n.T("cmd.setup.already_exist_count", map[string]interface{}{"Count": exists}))
}
fmt.Println()
// Run build if requested
if runBuild && succeeded > 0 {
fmt.Println()
fmt.Printf("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("run", "build"))
buildCmd := exec.Command("core", "build")
buildCmd.Dir = basePath
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.run", "build"), err)
}
}
return nil
}
// gitClone clones a repository using gh CLI or git.
func gitClone(ctx context.Context, org, repo, path string) error {
// Try gh clone first with HTTPS (works without SSH keys)
if cli.GhAuthenticated() {
// Use HTTPS URL directly to bypass git_protocol config
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
errStr := strings.TrimSpace(string(output))
// Only fall through to SSH if it's an auth error
if !strings.Contains(errStr, "Permission denied") &&
!strings.Contains(errStr, "could not read") {
return fmt.Errorf("%s", errStr)
}
}
// Fallback to git clone via SSH
url := fmt.Sprintf("git@github.com:%s/%s.git", org, repo)
cmd := exec.CommandContext(ctx, "git", "clone", url, path)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
}
return nil
}

View file

@ -1,289 +0,0 @@
// cmd_repo.go implements repository setup with .core/ configuration.
//
// When running setup in an existing git repository, this generates
// build.yaml, release.yaml, and test.yaml configurations based on
// detected project type.
package setup
import (
"fmt"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io"
)
// runRepoSetup sets up the current repository with .core/ configuration.
func runRepoSetup(repoPath string, dryRun bool) error {
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.repo.setting_up"), repoPath)
// Detect project type
projectType := detectProjectType(repoPath)
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.repo.detected_type"), projectType)
// Create .core directory
coreDir := filepath.Join(repoPath, ".core")
if !dryRun {
if err := coreio.Local.EnsureDir(coreDir); err != nil {
return fmt.Errorf("failed to create .core directory: %w", err)
}
}
// Generate configs based on project type
name := filepath.Base(repoPath)
configs := map[string]string{
"build.yaml": generateBuildConfig(repoPath, projectType),
"release.yaml": generateReleaseConfig(name, projectType),
"test.yaml": generateTestConfig(projectType),
}
if dryRun {
fmt.Printf("\n%s %s:\n", dimStyle.Render(">>"), i18n.T("cmd.setup.repo.would_create"))
for filename, content := range configs {
fmt.Printf("\n %s:\n", filepath.Join(coreDir, filename))
// Indent content for display
for _, line := range strings.Split(content, "\n") {
fmt.Printf(" %s\n", line)
}
}
return nil
}
for filename, content := range configs {
configPath := filepath.Join(coreDir, filename)
if err := coreio.Local.Write(configPath, content); err != nil {
return fmt.Errorf("failed to write %s: %w", filename, err)
}
fmt.Printf("%s %s %s\n", successStyle.Render(">>"), i18n.T("cmd.setup.repo.created"), configPath)
}
return nil
}
// detectProjectType identifies the project type from files present.
func detectProjectType(path string) string {
// Check in priority order
if coreio.Local.IsFile(filepath.Join(path, "wails.json")) {
return "wails"
}
if coreio.Local.IsFile(filepath.Join(path, "go.mod")) {
return "go"
}
if coreio.Local.IsFile(filepath.Join(path, "composer.json")) {
return "php"
}
if coreio.Local.IsFile(filepath.Join(path, "package.json")) {
return "node"
}
return "unknown"
}
// generateBuildConfig creates a build.yaml configuration based on project type.
func generateBuildConfig(path, projectType string) string {
name := filepath.Base(path)
switch projectType {
case "go", "wails":
return fmt.Sprintf(`version: 1
project:
name: %s
description: Go application
main: ./cmd/%s
binary: %s
build:
cgo: false
flags:
- -trimpath
ldflags:
- -s
- -w
targets:
- os: linux
arch: amd64
- os: linux
arch: arm64
- os: darwin
arch: amd64
- os: darwin
arch: arm64
- os: windows
arch: amd64
`, name, name, name)
case "php":
return fmt.Sprintf(`version: 1
project:
name: %s
description: PHP application
type: php
build:
dockerfile: Dockerfile
image: %s
`, name, name)
case "node":
return fmt.Sprintf(`version: 1
project:
name: %s
description: Node.js application
type: node
build:
script: npm run build
output: dist
`, name)
default:
return fmt.Sprintf(`version: 1
project:
name: %s
description: Application
`, name)
}
}
// generateReleaseConfig creates a release.yaml configuration.
func generateReleaseConfig(name, projectType string) string {
// Try to detect GitHub repo from git remote
repo := detectGitHubRepo()
if repo == "" {
repo = "owner/" + name
}
base := fmt.Sprintf(`version: 1
project:
name: %s
repository: %s
`, name, repo)
switch projectType {
case "go", "wails":
return base + `
changelog:
include:
- feat
- fix
- perf
- refactor
exclude:
- chore
- docs
- style
- test
publishers:
- type: github
draft: false
prerelease: false
`
case "php":
return base + `
changelog:
include:
- feat
- fix
- perf
publishers:
- type: github
draft: false
`
default:
return base + `
changelog:
include:
- feat
- fix
publishers:
- type: github
`
}
}
// generateTestConfig creates a test.yaml configuration.
func generateTestConfig(projectType string) string {
switch projectType {
case "go", "wails":
return `version: 1
commands:
- name: unit
run: go test ./...
- name: coverage
run: go test -coverprofile=coverage.out ./...
- name: race
run: go test -race ./...
env:
CGO_ENABLED: "0"
`
case "php":
return `version: 1
commands:
- name: unit
run: vendor/bin/pest --parallel
- name: types
run: vendor/bin/phpstan analyse
- name: lint
run: vendor/bin/pint --test
env:
APP_ENV: testing
DB_CONNECTION: sqlite
`
case "node":
return `version: 1
commands:
- name: unit
run: npm test
- name: lint
run: npm run lint
- name: typecheck
run: npm run typecheck
env:
NODE_ENV: test
`
default:
return `version: 1
commands:
- name: test
run: echo "No tests configured"
`
}
}
// detectGitHubRepo tries to extract owner/repo from git remote.
func detectGitHubRepo() string {
cmd := exec.Command("git", "remote", "get-url", "origin")
output, err := cmd.Output()
if err != nil {
return ""
}
url := strings.TrimSpace(string(output))
// Handle SSH format: git@github.com:owner/repo.git
if strings.HasPrefix(url, "git@github.com:") {
repo := strings.TrimPrefix(url, "git@github.com:")
repo = strings.TrimSuffix(repo, ".git")
return repo
}
// Handle HTTPS format: https://github.com/owner/repo.git
if strings.Contains(url, "github.com/") {
parts := strings.Split(url, "github.com/")
if len(parts) == 2 {
repo := strings.TrimSuffix(parts[1], ".git")
return repo
}
}
return ""
}

View file

@ -1,59 +0,0 @@
// Package setup provides workspace setup and bootstrap commands.
package setup
import (
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"github.com/spf13/cobra"
)
// Style aliases from shared package
var (
repoNameStyle = cli.RepoStyle
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
warningStyle = cli.WarningStyle
dimStyle = cli.DimStyle
)
// Default organization and devops repo for bootstrap
const (
defaultOrg = "host-uk"
devopsRepo = "core-devops"
devopsReposYaml = "repos.yaml"
)
// Setup command flags
var (
registryPath string
only string
dryRun bool
all bool
name string
build bool
)
var setupCmd = &cobra.Command{
Use: "setup",
Short: i18n.T("cmd.setup.short"),
Long: i18n.T("cmd.setup.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return runSetupOrchestrator(registryPath, only, dryRun, all, name, build)
},
}
func initSetupFlags() {
setupCmd.Flags().StringVar(&registryPath, "registry", "", i18n.T("cmd.setup.flag.registry"))
setupCmd.Flags().StringVar(&only, "only", "", i18n.T("cmd.setup.flag.only"))
setupCmd.Flags().BoolVar(&dryRun, "dry-run", false, i18n.T("cmd.setup.flag.dry_run"))
setupCmd.Flags().BoolVar(&all, "all", false, i18n.T("cmd.setup.flag.all"))
setupCmd.Flags().StringVar(&name, "name", "", i18n.T("cmd.setup.flag.name"))
setupCmd.Flags().BoolVar(&build, "build", false, i18n.T("cmd.setup.flag.build"))
}
// AddSetupCommand adds the 'setup' command to the given parent command.
func AddSetupCommand(root *cobra.Command) {
initSetupFlags()
addGitHubCommand(setupCmd)
root.AddCommand(setupCmd)
}

View file

@ -1,93 +0,0 @@
// cmd_wizard.go implements the interactive package selection wizard.
package setup
import (
"fmt"
"os"
"sort"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/repos"
"golang.org/x/term"
)
// isTerminal returns true if stdin is a terminal.
func isTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
// promptSetupChoice asks the user whether to setup the working directory or create a package.
func promptSetupChoice() (string, error) {
fmt.Println(cli.TitleStyle.Render(i18n.T("cmd.setup.wizard.git_repo_title")))
fmt.Println(i18n.T("cmd.setup.wizard.what_to_do"))
choice, err := cli.Select("Choose action", []string{"setup", "package"})
if err != nil {
return "", err
}
return choice, nil
}
// promptProjectName asks the user for a project directory name.
func promptProjectName(defaultName string) (string, error) {
fmt.Println(cli.TitleStyle.Render(i18n.T("cmd.setup.wizard.project_name_title")))
return cli.Prompt(i18n.T("cmd.setup.wizard.project_name_desc"), defaultName)
}
// runPackageWizard presents an interactive multi-select UI for package selection.
func runPackageWizard(reg *repos.Registry, preselectedTypes []string) ([]string, error) {
allRepos := reg.List()
// Build options
var options []string
// Sort by name
sort.Slice(allRepos, func(i, j int) bool {
return allRepos[i].Name < allRepos[j].Name
})
for _, repo := range allRepos {
if repo.Clone != nil && !*repo.Clone {
continue
}
// Format: name (type)
label := fmt.Sprintf("%s (%s)", repo.Name, repo.Type)
options = append(options, label)
}
fmt.Println(cli.TitleStyle.Render(i18n.T("cmd.setup.wizard.package_selection")))
fmt.Println(i18n.T("cmd.setup.wizard.selection_hint"))
selectedLabels, err := cli.MultiSelect(i18n.T("cmd.setup.wizard.select_packages"), options)
if err != nil {
return nil, err
}
// Extract names from labels
var selected []string
for _, label := range selectedLabels {
// Basic parsing assuming "name (type)" format
// Find last space
var name string
// Since we constructed it, we know it ends with (type)
// but repo name might have spaces? Repos usually don't.
// Let's iterate repos to find match
for _, repo := range allRepos {
if label == fmt.Sprintf("%s (%s)", repo.Name, repo.Type) {
name = repo.Name
break
}
}
if name != "" {
selected = append(selected, name)
}
}
return selected, nil
}
// confirmClone asks for confirmation before cloning.
func confirmClone(count int, target string) (bool, error) {
confirmed := cli.Confirm(i18n.T("cmd.setup.wizard.confirm_clone", map[string]interface{}{"Count": count, "Target": target}))
return confirmed, nil
}

View file

@ -1,204 +0,0 @@
// github_config.go defines configuration types for GitHub repository setup.
//
// Configuration is loaded from .core/github.yaml and supports environment
// variable expansion using ${VAR} or ${VAR:-default} syntax.
package setup
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
coreio "forge.lthn.ai/core/go/pkg/io"
"gopkg.in/yaml.v3"
)
// GitHubConfig represents the full GitHub setup configuration.
type GitHubConfig struct {
Version int `yaml:"version"`
Labels []LabelConfig `yaml:"labels"`
Webhooks map[string]WebhookConfig `yaml:"webhooks"`
BranchProtection []BranchProtectionConfig `yaml:"branch_protection"`
Security SecurityConfig `yaml:"security"`
}
// LabelConfig defines a GitHub issue/PR label.
type LabelConfig struct {
Name string `yaml:"name"`
Color string `yaml:"color"`
Description string `yaml:"description"`
}
// WebhookConfig defines a GitHub webhook configuration.
type WebhookConfig struct {
URL string `yaml:"url"` // Webhook URL (supports ${ENV_VAR})
ContentType string `yaml:"content_type"` // json or form (default: json)
Secret string `yaml:"secret"` // Optional secret (supports ${ENV_VAR})
Events []string `yaml:"events"` // Events to trigger on
Active *bool `yaml:"active"` // Whether webhook is active (default: true)
}
// BranchProtectionConfig defines branch protection rules.
type BranchProtectionConfig struct {
Branch string `yaml:"branch"`
RequiredReviews int `yaml:"required_reviews"`
DismissStale bool `yaml:"dismiss_stale"`
RequireCodeOwnerReviews bool `yaml:"require_code_owner_reviews"`
RequiredStatusChecks []string `yaml:"required_status_checks"`
RequireLinearHistory bool `yaml:"require_linear_history"`
AllowForcePushes bool `yaml:"allow_force_pushes"`
AllowDeletions bool `yaml:"allow_deletions"`
EnforceAdmins bool `yaml:"enforce_admins"`
RequireConversationResolution bool `yaml:"require_conversation_resolution"`
}
// SecurityConfig defines repository security settings.
type SecurityConfig struct {
DependabotAlerts bool `yaml:"dependabot_alerts"`
DependabotSecurityUpdates bool `yaml:"dependabot_security_updates"`
SecretScanning bool `yaml:"secret_scanning"`
SecretScanningPushProtection bool `yaml:"push_protection"`
}
// LoadGitHubConfig reads and parses a GitHub configuration file.
func LoadGitHubConfig(path string) (*GitHubConfig, error) {
data, err := coreio.Local.Read(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Expand environment variables before parsing
expanded := expandEnvVars(data)
var config GitHubConfig
if err := yaml.Unmarshal([]byte(expanded), &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// Set defaults
for i := range config.Webhooks {
wh := config.Webhooks[i]
if wh.ContentType == "" {
wh.ContentType = "json"
}
if wh.Active == nil {
active := true
wh.Active = &active
}
config.Webhooks[i] = wh
}
return &config, nil
}
// envVarPattern matches ${VAR} or ${VAR:-default} patterns.
var envVarPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}`)
// expandEnvVars expands environment variables in the input string.
// Supports ${VAR} and ${VAR:-default} syntax.
func expandEnvVars(input string) string {
return envVarPattern.ReplaceAllStringFunc(input, func(match string) string {
// Parse the match
submatch := envVarPattern.FindStringSubmatch(match)
if len(submatch) < 2 {
return match
}
varName := submatch[1]
defaultValue := ""
if len(submatch) >= 3 {
defaultValue = submatch[2]
}
// Look up the environment variable
if value, ok := os.LookupEnv(varName); ok {
return value
}
return defaultValue
})
}
// FindGitHubConfig searches for github.yaml in common locations.
// Search order:
// 1. Specified path (if non-empty)
// 2. .core/github.yaml (relative to registry)
// 3. github.yaml (relative to registry)
func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
if specifiedPath != "" {
if coreio.Local.IsFile(specifiedPath) {
return specifiedPath, nil
}
return "", fmt.Errorf("config file not found: %s", specifiedPath)
}
// Search in common locations (using filepath.Join for OS-portable paths)
candidates := []string{
filepath.Join(registryDir, ".core", "github.yaml"),
filepath.Join(registryDir, "github.yaml"),
}
for _, path := range candidates {
if coreio.Local.IsFile(path) {
return path, nil
}
}
return "", fmt.Errorf("github.yaml not found in %s/.core/ or %s/", registryDir, registryDir)
}
// Validate checks the configuration for errors.
func (c *GitHubConfig) Validate() error {
if c.Version != 1 {
return fmt.Errorf("unsupported config version: %d (expected 1)", c.Version)
}
// Validate labels
for i, label := range c.Labels {
if label.Name == "" {
return fmt.Errorf("label %d: name is required", i+1)
}
if label.Color == "" {
return fmt.Errorf("label %q: color is required", label.Name)
}
// Validate color format (hex without #)
if !isValidHexColor(label.Color) {
return fmt.Errorf("label %q: invalid color %q (expected 6-digit hex without #)", label.Name, label.Color)
}
}
// Validate webhooks (skip those with empty URLs - allows optional webhooks via env vars)
for name, wh := range c.Webhooks {
if wh.URL == "" {
// Empty URL is allowed - webhook will be skipped during sync
continue
}
if len(wh.Events) == 0 {
return fmt.Errorf("webhook %q: at least one event is required", name)
}
}
// Validate branch protection
for i, bp := range c.BranchProtection {
if bp.Branch == "" {
return fmt.Errorf("branch_protection %d: branch is required", i+1)
}
}
return nil
}
// isValidHexColor checks if a string is a valid 6-digit hex color (without #).
func isValidHexColor(color string) bool {
if len(color) != 6 {
return false
}
for _, c := range strings.ToLower(color) {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}

View file

@ -1,288 +0,0 @@
// github_diff.go provides change tracking for dry-run output.
package setup
import (
"fmt"
"sort"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// ChangeType indicates the type of change being made.
type ChangeType string
// Change type constants for GitHub configuration diffs.
const (
// ChangeCreate indicates a new resource to be created.
ChangeCreate ChangeType = "create"
// ChangeUpdate indicates an existing resource to be updated.
ChangeUpdate ChangeType = "update"
// ChangeDelete indicates a resource to be deleted.
ChangeDelete ChangeType = "delete"
// ChangeSkip indicates a resource that requires no changes.
ChangeSkip ChangeType = "skip"
)
// ChangeCategory groups changes by type.
type ChangeCategory string
// Change category constants for grouping GitHub configuration changes.
const (
// CategoryLabel indicates label-related changes.
CategoryLabel ChangeCategory = "label"
// CategoryWebhook indicates webhook-related changes.
CategoryWebhook ChangeCategory = "webhook"
// CategoryProtection indicates branch protection changes.
CategoryProtection ChangeCategory = "protection"
// CategorySecurity indicates security settings changes.
CategorySecurity ChangeCategory = "security"
)
// Change represents a single change to be made.
type Change struct {
Category ChangeCategory
Type ChangeType
Name string
Description string
Details map[string]string // Key-value details about the change
}
// ChangeSet tracks all changes for a repository.
type ChangeSet struct {
Repo string
Changes []Change
}
// NewChangeSet creates a new change set for a repository.
func NewChangeSet(repo string) *ChangeSet {
return &ChangeSet{
Repo: repo,
Changes: make([]Change, 0),
}
}
// Add adds a change to the set.
func (cs *ChangeSet) Add(category ChangeCategory, changeType ChangeType, name, description string) {
cs.Changes = append(cs.Changes, Change{
Category: category,
Type: changeType,
Name: name,
Description: description,
Details: make(map[string]string),
})
}
// AddWithDetails adds a change with additional details.
func (cs *ChangeSet) AddWithDetails(category ChangeCategory, changeType ChangeType, name, description string, details map[string]string) {
cs.Changes = append(cs.Changes, Change{
Category: category,
Type: changeType,
Name: name,
Description: description,
Details: details,
})
}
// HasChanges returns true if there are any non-skip changes.
func (cs *ChangeSet) HasChanges() bool {
for _, c := range cs.Changes {
if c.Type != ChangeSkip {
return true
}
}
return false
}
// Count returns the number of changes by type.
func (cs *ChangeSet) Count() (creates, updates, deletes, skips int) {
for _, c := range cs.Changes {
switch c.Type {
case ChangeCreate:
creates++
case ChangeUpdate:
updates++
case ChangeDelete:
deletes++
case ChangeSkip:
skips++
}
}
return
}
// CountByCategory returns changes grouped by category.
func (cs *ChangeSet) CountByCategory() map[ChangeCategory]int {
counts := make(map[ChangeCategory]int)
for _, c := range cs.Changes {
if c.Type != ChangeSkip {
counts[c.Category]++
}
}
return counts
}
// Print outputs the change set to the console.
func (cs *ChangeSet) Print(verbose bool) {
creates, updates, deletes, skips := cs.Count()
// Print header
fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.Label("repo")), repoNameStyle.Render(cs.Repo))
if !cs.HasChanges() {
fmt.Printf(" %s\n", dimStyle.Render(i18n.T("cmd.setup.github.no_changes")))
return
}
// Print summary
var parts []string
if creates > 0 {
parts = append(parts, successStyle.Render(fmt.Sprintf("+%d", creates)))
}
if updates > 0 {
parts = append(parts, warningStyle.Render(fmt.Sprintf("~%d", updates)))
}
if deletes > 0 {
parts = append(parts, errorStyle.Render(fmt.Sprintf("-%d", deletes)))
}
if skips > 0 && verbose {
parts = append(parts, dimStyle.Render(fmt.Sprintf("=%d", skips)))
}
fmt.Printf(" %s\n", strings.Join(parts, " "))
// Print details if verbose
if verbose {
cs.printByCategory(CategoryLabel, "Labels")
cs.printByCategory(CategoryWebhook, "Webhooks")
cs.printByCategory(CategoryProtection, "Branch protection")
cs.printByCategory(CategorySecurity, "Security")
}
}
func (cs *ChangeSet) printByCategory(category ChangeCategory, title string) {
var categoryChanges []Change
for _, c := range cs.Changes {
if c.Category == category && c.Type != ChangeSkip {
categoryChanges = append(categoryChanges, c)
}
}
if len(categoryChanges) == 0 {
return
}
fmt.Printf("\n %s:\n", dimStyle.Render(title))
for _, c := range categoryChanges {
icon := getChangeIcon(c.Type)
style := getChangeStyle(c.Type)
fmt.Printf(" %s %s", style.Render(icon), c.Name)
if c.Description != "" {
fmt.Printf(" %s", dimStyle.Render(c.Description))
}
fmt.Println()
// Print details (sorted for deterministic output)
keys := make([]string, 0, len(c.Details))
for k := range c.Details {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf(" %s: %s\n", dimStyle.Render(k), c.Details[k])
}
}
}
func getChangeIcon(t ChangeType) string {
switch t {
case ChangeCreate:
return "+"
case ChangeUpdate:
return "~"
case ChangeDelete:
return "-"
default:
return "="
}
}
func getChangeStyle(t ChangeType) *cli.AnsiStyle {
switch t {
case ChangeCreate:
return successStyle
case ChangeUpdate:
return warningStyle
case ChangeDelete:
return errorStyle
default:
return dimStyle
}
}
// Aggregate combines multiple change sets into a summary.
type Aggregate struct {
Sets []*ChangeSet
}
// NewAggregate creates a new aggregate.
func NewAggregate() *Aggregate {
return &Aggregate{
Sets: make([]*ChangeSet, 0),
}
}
// Add adds a change set to the aggregate.
func (a *Aggregate) Add(cs *ChangeSet) {
a.Sets = append(a.Sets, cs)
}
// TotalChanges returns the total number of changes across all sets.
func (a *Aggregate) TotalChanges() (creates, updates, deletes, skips int) {
for _, cs := range a.Sets {
c, u, d, s := cs.Count()
creates += c
updates += u
deletes += d
skips += s
}
return
}
// ReposWithChanges returns the number of repos that have changes.
func (a *Aggregate) ReposWithChanges() int {
count := 0
for _, cs := range a.Sets {
if cs.HasChanges() {
count++
}
}
return count
}
// PrintSummary outputs the aggregate summary.
func (a *Aggregate) PrintSummary() {
creates, updates, deletes, _ := a.TotalChanges()
reposWithChanges := a.ReposWithChanges()
fmt.Println()
fmt.Printf("%s\n", dimStyle.Render(i18n.Label("summary")))
fmt.Printf(" %s: %d\n", i18n.T("cmd.setup.github.repos_checked"), len(a.Sets))
if reposWithChanges == 0 {
fmt.Printf(" %s\n", dimStyle.Render(i18n.T("cmd.setup.github.all_up_to_date")))
return
}
fmt.Printf(" %s: %d\n", i18n.T("cmd.setup.github.repos_with_changes"), reposWithChanges)
if creates > 0 {
fmt.Printf(" %s: %s\n", i18n.T("cmd.setup.github.to_create"), successStyle.Render(fmt.Sprintf("%d", creates)))
}
if updates > 0 {
fmt.Printf(" %s: %s\n", i18n.T("cmd.setup.github.to_update"), warningStyle.Render(fmt.Sprintf("%d", updates)))
}
if deletes > 0 {
fmt.Printf(" %s: %s\n", i18n.T("cmd.setup.github.to_delete"), errorStyle.Render(fmt.Sprintf("%d", deletes)))
}
}

View file

@ -1,152 +0,0 @@
// github_labels.go implements GitHub label synchronization.
//
// Uses the gh CLI for label operations:
// - gh label list --repo {repo} --json name,color,description
// - gh label create --repo {repo} {name} --color {color} --description {desc}
// - gh label edit --repo {repo} {name} --color {color} --description {desc}
package setup
import (
"encoding/json"
"os/exec"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
)
// GitHubLabel represents a label as returned by the GitHub API.
type GitHubLabel struct {
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
}
// ListLabels fetches all labels for a repository.
func ListLabels(repoFullName string) ([]GitHubLabel, error) {
args := []string{
"label", "list",
"--repo", repoFullName,
"--json", "name,color,description",
"--limit", "200",
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, cli.Err("%s", strings.TrimSpace(string(exitErr.Stderr)))
}
return nil, err
}
var labels []GitHubLabel
if err := json.Unmarshal(output, &labels); err != nil {
return nil, err
}
return labels, nil
}
// CreateLabel creates a new label in a repository.
func CreateLabel(repoFullName string, label LabelConfig) error {
args := []string{
"label", "create",
"--repo", repoFullName,
label.Name,
"--color", label.Color,
}
if label.Description != "" {
args = append(args, "--description", label.Description)
}
cmd := exec.Command("gh", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", strings.TrimSpace(string(output)))
}
return nil
}
// EditLabel updates an existing label in a repository.
func EditLabel(repoFullName string, label LabelConfig) error {
args := []string{
"label", "edit",
"--repo", repoFullName,
label.Name,
"--color", label.Color,
}
if label.Description != "" {
args = append(args, "--description", label.Description)
}
cmd := exec.Command("gh", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", strings.TrimSpace(string(output)))
}
return nil
}
// SyncLabels synchronizes labels for a repository.
// Returns a ChangeSet describing what was changed (or would be changed in dry-run mode).
func SyncLabels(repoFullName string, config *GitHubConfig, dryRun bool) (*ChangeSet, error) {
changes := NewChangeSet(repoFullName)
// Get existing labels
existing, err := ListLabels(repoFullName)
if err != nil {
return nil, cli.Wrap(err, "failed to list labels")
}
// Build lookup map
existingMap := make(map[string]GitHubLabel)
for _, label := range existing {
existingMap[strings.ToLower(label.Name)] = label
}
// Process each configured label
for _, wantLabel := range config.Labels {
key := strings.ToLower(wantLabel.Name)
existing, exists := existingMap[key]
if !exists {
// Create new label
changes.Add(CategoryLabel, ChangeCreate, wantLabel.Name, wantLabel.Description)
if !dryRun {
if err := CreateLabel(repoFullName, wantLabel); err != nil {
return changes, cli.Wrap(err, "failed to create label "+wantLabel.Name)
}
}
continue
}
// Check if update is needed
needsUpdate := false
details := make(map[string]string)
if !strings.EqualFold(existing.Color, wantLabel.Color) {
needsUpdate = true
details["color"] = existing.Color + " -> " + wantLabel.Color
}
if existing.Description != wantLabel.Description {
needsUpdate = true
details["description"] = "updated"
}
if needsUpdate {
changes.AddWithDetails(CategoryLabel, ChangeUpdate, wantLabel.Name, "", details)
if !dryRun {
if err := EditLabel(repoFullName, wantLabel); err != nil {
return changes, cli.Wrap(err, "failed to update label "+wantLabel.Name)
}
}
} else {
changes.Add(CategoryLabel, ChangeSkip, wantLabel.Name, "up to date")
}
}
return changes, nil
}

View file

@ -1,299 +0,0 @@
// github_protection.go implements GitHub branch protection synchronization.
//
// Uses the gh api command for branch protection operations:
// - gh api repos/{owner}/{repo}/branches/{branch}/protection --method GET
// - gh api repos/{owner}/{repo}/branches/{branch}/protection --method PUT
package setup
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
)
// GitHubBranchProtection represents branch protection rules from the GitHub API.
type GitHubBranchProtection struct {
RequiredStatusChecks *RequiredStatusChecks `json:"required_status_checks"`
RequiredPullRequestReviews *RequiredPullRequestReviews `json:"required_pull_request_reviews"`
EnforceAdmins *EnforceAdmins `json:"enforce_admins"`
RequiredLinearHistory *RequiredLinearHistory `json:"required_linear_history"`
AllowForcePushes *AllowForcePushes `json:"allow_force_pushes"`
AllowDeletions *AllowDeletions `json:"allow_deletions"`
RequiredConversationResolution *RequiredConversationResolution `json:"required_conversation_resolution"`
}
// RequiredStatusChecks defines required CI checks.
type RequiredStatusChecks struct {
Strict bool `json:"strict"`
Contexts []string `json:"contexts"`
}
// RequiredPullRequestReviews defines review requirements.
type RequiredPullRequestReviews struct {
DismissStaleReviews bool `json:"dismiss_stale_reviews"`
RequireCodeOwnerReviews bool `json:"require_code_owner_reviews"`
RequiredApprovingReviewCount int `json:"required_approving_review_count"`
}
// EnforceAdmins indicates if admins are subject to rules.
type EnforceAdmins struct {
Enabled bool `json:"enabled"`
}
// RequiredLinearHistory indicates if linear history is required.
type RequiredLinearHistory struct {
Enabled bool `json:"enabled"`
}
// AllowForcePushes indicates if force pushes are allowed.
type AllowForcePushes struct {
Enabled bool `json:"enabled"`
}
// AllowDeletions indicates if branch deletion is allowed.
type AllowDeletions struct {
Enabled bool `json:"enabled"`
}
// RequiredConversationResolution indicates if conversation resolution is required.
type RequiredConversationResolution struct {
Enabled bool `json:"enabled"`
}
// GetBranchProtection fetches branch protection rules for a branch.
func GetBranchProtection(repoFullName, branch string) (*GitHubBranchProtection, error) {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid repo format: %s", repoFullName)
}
endpoint := fmt.Sprintf("repos/%s/%s/branches/%s/protection", parts[0], parts[1], branch)
cmd := exec.Command("gh", "api", endpoint)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := strings.TrimSpace(string(exitErr.Stderr))
// Branch protection not enabled returns 404
if strings.Contains(stderr, "404") || strings.Contains(stderr, "Branch not protected") {
return nil, nil // No protection set
}
if strings.Contains(stderr, "403") {
return nil, cli.Err("insufficient permissions to manage branch protection (requires admin)")
}
return nil, cli.Err("%s", stderr)
}
return nil, err
}
var protection GitHubBranchProtection
if err := json.Unmarshal(output, &protection); err != nil {
return nil, err
}
return &protection, nil
}
// SetBranchProtection sets branch protection rules for a branch.
func SetBranchProtection(repoFullName, branch string, config BranchProtectionConfig) error {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid repo format: %s", repoFullName)
}
// Build the protection payload
payload := map[string]interface{}{
"enforce_admins": config.EnforceAdmins,
"required_linear_history": config.RequireLinearHistory,
"allow_force_pushes": config.AllowForcePushes,
"allow_deletions": config.AllowDeletions,
"required_conversation_resolution": config.RequireConversationResolution,
}
// Required pull request reviews
if config.RequiredReviews > 0 {
payload["required_pull_request_reviews"] = map[string]interface{}{
"dismiss_stale_reviews": config.DismissStale,
"require_code_owner_reviews": config.RequireCodeOwnerReviews,
"required_approving_review_count": config.RequiredReviews,
}
} else {
payload["required_pull_request_reviews"] = nil
}
// Required status checks
if len(config.RequiredStatusChecks) > 0 {
payload["required_status_checks"] = map[string]interface{}{
"strict": true,
"contexts": config.RequiredStatusChecks,
}
} else {
payload["required_status_checks"] = nil
}
// Restrictions (required but can be empty for non-org repos)
payload["restrictions"] = nil
payloadJSON, err := json.Marshal(payload)
if err != nil {
return err
}
endpoint := fmt.Sprintf("repos/%s/%s/branches/%s/protection", parts[0], parts[1], branch)
cmd := exec.Command("gh", "api", endpoint, "--method", "PUT", "--input", "-")
cmd.Stdin = strings.NewReader(string(payloadJSON))
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", strings.TrimSpace(string(output)))
}
return nil
}
// SyncBranchProtection synchronizes branch protection for a repository.
func SyncBranchProtection(repoFullName string, config *GitHubConfig, dryRun bool) (*ChangeSet, error) {
changes := NewChangeSet(repoFullName)
// Skip if no branch protection configured
if len(config.BranchProtection) == 0 {
return changes, nil
}
// Process each configured branch
for _, wantProtection := range config.BranchProtection {
branch := wantProtection.Branch
// Get existing protection
existing, err := GetBranchProtection(repoFullName, branch)
if err != nil {
// If permission denied, note it but don't fail
if strings.Contains(err.Error(), "insufficient permissions") {
changes.Add(CategoryProtection, ChangeSkip, branch, "insufficient permissions")
continue
}
return nil, cli.Wrap(err, "failed to get protection for "+branch)
}
// Check if protection needs to be created or updated
if existing == nil {
// Create new protection
changes.Add(CategoryProtection, ChangeCreate, branch, describeProtection(wantProtection))
if !dryRun {
if err := SetBranchProtection(repoFullName, branch, wantProtection); err != nil {
return changes, cli.Wrap(err, "failed to set protection for "+branch)
}
}
continue
}
// Compare and check if update is needed
needsUpdate := false
details := make(map[string]string)
// Check required reviews
existingReviews := 0
existingDismissStale := false
existingCodeOwner := false
if existing.RequiredPullRequestReviews != nil {
existingReviews = existing.RequiredPullRequestReviews.RequiredApprovingReviewCount
existingDismissStale = existing.RequiredPullRequestReviews.DismissStaleReviews
existingCodeOwner = existing.RequiredPullRequestReviews.RequireCodeOwnerReviews
}
if existingReviews != wantProtection.RequiredReviews {
needsUpdate = true
details["required_reviews"] = fmt.Sprintf("%d -> %d", existingReviews, wantProtection.RequiredReviews)
}
if existingDismissStale != wantProtection.DismissStale {
needsUpdate = true
details["dismiss_stale"] = fmt.Sprintf("%v -> %v", existingDismissStale, wantProtection.DismissStale)
}
if existingCodeOwner != wantProtection.RequireCodeOwnerReviews {
needsUpdate = true
details["code_owner_reviews"] = fmt.Sprintf("%v -> %v", existingCodeOwner, wantProtection.RequireCodeOwnerReviews)
}
// Check enforce admins
existingEnforceAdmins := false
if existing.EnforceAdmins != nil {
existingEnforceAdmins = existing.EnforceAdmins.Enabled
}
if existingEnforceAdmins != wantProtection.EnforceAdmins {
needsUpdate = true
details["enforce_admins"] = fmt.Sprintf("%v -> %v", existingEnforceAdmins, wantProtection.EnforceAdmins)
}
// Check linear history
existingLinear := false
if existing.RequiredLinearHistory != nil {
existingLinear = existing.RequiredLinearHistory.Enabled
}
if existingLinear != wantProtection.RequireLinearHistory {
needsUpdate = true
details["linear_history"] = fmt.Sprintf("%v -> %v", existingLinear, wantProtection.RequireLinearHistory)
}
// Check force pushes
existingForcePush := false
if existing.AllowForcePushes != nil {
existingForcePush = existing.AllowForcePushes.Enabled
}
if existingForcePush != wantProtection.AllowForcePushes {
needsUpdate = true
details["allow_force_pushes"] = fmt.Sprintf("%v -> %v", existingForcePush, wantProtection.AllowForcePushes)
}
// Check deletions
existingDeletions := false
if existing.AllowDeletions != nil {
existingDeletions = existing.AllowDeletions.Enabled
}
if existingDeletions != wantProtection.AllowDeletions {
needsUpdate = true
details["allow_deletions"] = fmt.Sprintf("%v -> %v", existingDeletions, wantProtection.AllowDeletions)
}
// Check required status checks
var existingStatusChecks []string
if existing.RequiredStatusChecks != nil {
existingStatusChecks = existing.RequiredStatusChecks.Contexts
}
if !stringSliceEqual(existingStatusChecks, wantProtection.RequiredStatusChecks) {
needsUpdate = true
details["status_checks"] = fmt.Sprintf("%v -> %v", existingStatusChecks, wantProtection.RequiredStatusChecks)
}
if needsUpdate {
changes.AddWithDetails(CategoryProtection, ChangeUpdate, branch, "", details)
if !dryRun {
if err := SetBranchProtection(repoFullName, branch, wantProtection); err != nil {
return changes, cli.Wrap(err, "failed to update protection for "+branch)
}
}
} else {
changes.Add(CategoryProtection, ChangeSkip, branch, "up to date")
}
}
return changes, nil
}
// describeProtection returns a human-readable description of protection rules.
func describeProtection(p BranchProtectionConfig) string {
var parts []string
if p.RequiredReviews > 0 {
parts = append(parts, fmt.Sprintf("%d review(s)", p.RequiredReviews))
}
if p.DismissStale {
parts = append(parts, "dismiss stale")
}
if p.EnforceAdmins {
parts = append(parts, "enforce admins")
}
if len(parts) == 0 {
return "basic protection"
}
return strings.Join(parts, ", ")
}

View file

@ -1,281 +0,0 @@
// github_security.go implements GitHub security settings synchronization.
//
// Uses the gh api command for security settings:
// - gh api repos/{owner}/{repo}/vulnerability-alerts --method GET (check if enabled)
// - gh api repos/{owner}/{repo}/vulnerability-alerts --method PUT (enable)
// - gh api repos/{owner}/{repo}/automated-security-fixes --method PUT (enable dependabot updates)
// - gh api repos/{owner}/{repo} --method PATCH (security_and_analysis settings)
package setup
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
)
// GitHubSecurityStatus represents the security settings status of a repository.
type GitHubSecurityStatus struct {
DependabotAlerts bool
DependabotSecurityUpdates bool
SecretScanning bool
SecretScanningPushProtection bool
}
// GitHubRepoResponse contains security-related fields from repo API.
type GitHubRepoResponse struct {
SecurityAndAnalysis *SecurityAndAnalysis `json:"security_and_analysis"`
}
// SecurityAndAnalysis contains security feature settings.
type SecurityAndAnalysis struct {
SecretScanning *SecurityFeature `json:"secret_scanning"`
SecretScanningPushProtection *SecurityFeature `json:"secret_scanning_push_protection"`
DependabotSecurityUpdates *SecurityFeature `json:"dependabot_security_updates"`
}
// SecurityFeature represents a single security feature status.
type SecurityFeature struct {
Status string `json:"status"` // "enabled" or "disabled"
}
// GetSecuritySettings fetches current security settings for a repository.
func GetSecuritySettings(repoFullName string) (*GitHubSecurityStatus, error) {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid repo format: %s", repoFullName)
}
status := &GitHubSecurityStatus{}
// Check Dependabot alerts (vulnerability alerts)
endpoint := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", parts[0], parts[1])
cmd := exec.Command("gh", "api", endpoint, "--method", "GET")
_, err := cmd.Output()
if err == nil {
status.DependabotAlerts = true
} else if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
// 404 means alerts are disabled, 204 means enabled
if strings.Contains(stderr, "403") {
return nil, cli.Err("insufficient permissions to check security settings")
}
// Other errors (like 404) mean alerts are disabled
status.DependabotAlerts = false
}
// Get repo security_and_analysis settings
endpoint = fmt.Sprintf("repos/%s/%s", parts[0], parts[1])
cmd = exec.Command("gh", "api", endpoint)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, cli.Err("%s", strings.TrimSpace(string(exitErr.Stderr)))
}
return nil, err
}
var repo GitHubRepoResponse
if err := json.Unmarshal(output, &repo); err != nil {
return nil, err
}
if repo.SecurityAndAnalysis != nil {
if repo.SecurityAndAnalysis.SecretScanning != nil {
status.SecretScanning = repo.SecurityAndAnalysis.SecretScanning.Status == "enabled"
}
if repo.SecurityAndAnalysis.SecretScanningPushProtection != nil {
status.SecretScanningPushProtection = repo.SecurityAndAnalysis.SecretScanningPushProtection.Status == "enabled"
}
if repo.SecurityAndAnalysis.DependabotSecurityUpdates != nil {
status.DependabotSecurityUpdates = repo.SecurityAndAnalysis.DependabotSecurityUpdates.Status == "enabled"
}
}
return status, nil
}
// EnableDependabotAlerts enables Dependabot vulnerability alerts.
func EnableDependabotAlerts(repoFullName string) error {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid repo format: %s", repoFullName)
}
endpoint := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", parts[0], parts[1])
cmd := exec.Command("gh", "api", endpoint, "--method", "PUT")
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", strings.TrimSpace(string(output)))
}
return nil
}
// EnableDependabotSecurityUpdates enables automated Dependabot security updates.
func EnableDependabotSecurityUpdates(repoFullName string) error {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid repo format: %s", repoFullName)
}
endpoint := fmt.Sprintf("repos/%s/%s/automated-security-fixes", parts[0], parts[1])
cmd := exec.Command("gh", "api", endpoint, "--method", "PUT")
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", strings.TrimSpace(string(output)))
}
return nil
}
// DisableDependabotSecurityUpdates disables automated Dependabot security updates.
func DisableDependabotSecurityUpdates(repoFullName string) error {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid repo format: %s", repoFullName)
}
endpoint := fmt.Sprintf("repos/%s/%s/automated-security-fixes", parts[0], parts[1])
cmd := exec.Command("gh", "api", endpoint, "--method", "DELETE")
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", strings.TrimSpace(string(output)))
}
return nil
}
// UpdateSecurityAndAnalysis updates security_and_analysis settings.
func UpdateSecurityAndAnalysis(repoFullName string, secretScanning, pushProtection bool) error {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid repo format: %s", repoFullName)
}
// Build the payload
payload := map[string]interface{}{
"security_and_analysis": map[string]interface{}{
"secret_scanning": map[string]string{
"status": boolToStatus(secretScanning),
},
"secret_scanning_push_protection": map[string]string{
"status": boolToStatus(pushProtection),
},
},
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return err
}
endpoint := fmt.Sprintf("repos/%s/%s", parts[0], parts[1])
cmd := exec.Command("gh", "api", endpoint, "--method", "PATCH", "--input", "-")
cmd.Stdin = strings.NewReader(string(payloadJSON))
output, err := cmd.CombinedOutput()
if err != nil {
errStr := strings.TrimSpace(string(output))
// Some repos (private without GHAS) don't support these features
if strings.Contains(errStr, "secret scanning") || strings.Contains(errStr, "not available") {
return nil // Silently skip unsupported features
}
return cli.Err("%s", errStr)
}
return nil
}
func boolToStatus(b bool) string {
if b {
return "enabled"
}
return "disabled"
}
// SyncSecuritySettings synchronizes security settings for a repository.
func SyncSecuritySettings(repoFullName string, config *GitHubConfig, dryRun bool) (*ChangeSet, error) {
changes := NewChangeSet(repoFullName)
// Get current settings
existing, err := GetSecuritySettings(repoFullName)
if err != nil {
// If permission denied, note it but don't fail
if strings.Contains(err.Error(), "insufficient permissions") {
changes.Add(CategorySecurity, ChangeSkip, "all", "insufficient permissions")
return changes, nil
}
return nil, cli.Wrap(err, "failed to get security settings")
}
wantConfig := config.Security
// Check Dependabot alerts
if wantConfig.DependabotAlerts && !existing.DependabotAlerts {
changes.Add(CategorySecurity, ChangeCreate, "dependabot_alerts", "enable")
if !dryRun {
if err := EnableDependabotAlerts(repoFullName); err != nil {
return changes, cli.Wrap(err, "failed to enable dependabot alerts")
}
}
} else if !wantConfig.DependabotAlerts && existing.DependabotAlerts {
changes.Add(CategorySecurity, ChangeSkip, "dependabot_alerts", "cannot disable via API")
} else {
changes.Add(CategorySecurity, ChangeSkip, "dependabot_alerts", "up to date")
}
// Check Dependabot security updates
if wantConfig.DependabotSecurityUpdates && !existing.DependabotSecurityUpdates {
changes.Add(CategorySecurity, ChangeCreate, "dependabot_security_updates", "enable")
if !dryRun {
if err := EnableDependabotSecurityUpdates(repoFullName); err != nil {
// This might fail if alerts aren't enabled first
return changes, cli.Wrap(err, "failed to enable dependabot security updates")
}
}
} else if !wantConfig.DependabotSecurityUpdates && existing.DependabotSecurityUpdates {
changes.Add(CategorySecurity, ChangeDelete, "dependabot_security_updates", "disable")
if !dryRun {
if err := DisableDependabotSecurityUpdates(repoFullName); err != nil {
return changes, cli.Wrap(err, "failed to disable dependabot security updates")
}
}
} else {
changes.Add(CategorySecurity, ChangeSkip, "dependabot_security_updates", "up to date")
}
// Check secret scanning and push protection
needsSecurityUpdate := false
if wantConfig.SecretScanning != existing.SecretScanning {
needsSecurityUpdate = true
if wantConfig.SecretScanning {
changes.Add(CategorySecurity, ChangeCreate, "secret_scanning", "enable")
} else {
changes.Add(CategorySecurity, ChangeDelete, "secret_scanning", "disable")
}
} else {
changes.Add(CategorySecurity, ChangeSkip, "secret_scanning", "up to date")
}
if wantConfig.SecretScanningPushProtection != existing.SecretScanningPushProtection {
needsSecurityUpdate = true
if wantConfig.SecretScanningPushProtection {
changes.Add(CategorySecurity, ChangeCreate, "push_protection", "enable")
} else {
changes.Add(CategorySecurity, ChangeDelete, "push_protection", "disable")
}
} else {
changes.Add(CategorySecurity, ChangeSkip, "push_protection", "up to date")
}
// Apply security_and_analysis changes
if needsSecurityUpdate && !dryRun {
if err := UpdateSecurityAndAnalysis(repoFullName, wantConfig.SecretScanning, wantConfig.SecretScanningPushProtection); err != nil {
// Don't fail on unsupported features
if !strings.Contains(err.Error(), "not available") {
return changes, cli.Wrap(err, "failed to update security settings")
}
}
}
return changes, nil
}

View file

@ -1,263 +0,0 @@
// github_webhooks.go implements GitHub webhook synchronization.
//
// Uses the gh api command for webhook operations:
// - gh api repos/{owner}/{repo}/hooks --method GET
// - gh api repos/{owner}/{repo}/hooks --method POST
package setup
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
)
// GitHubWebhook represents a webhook as returned by the GitHub API.
type GitHubWebhook struct {
ID int `json:"id"`
Name string `json:"name"`
Active bool `json:"active"`
Events []string `json:"events"`
Config GitHubWebhookConfig `json:"config"`
}
// GitHubWebhookConfig contains webhook configuration details.
type GitHubWebhookConfig struct {
URL string `json:"url"`
ContentType string `json:"content_type"`
InsecureSSL string `json:"insecure_ssl"`
}
// ListWebhooks fetches all webhooks for a repository.
func ListWebhooks(repoFullName string) ([]GitHubWebhook, error) {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid repo format: %s", repoFullName)
}
endpoint := fmt.Sprintf("repos/%s/%s/hooks", parts[0], parts[1])
cmd := exec.Command("gh", "api", endpoint)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := strings.TrimSpace(string(exitErr.Stderr))
// Check for permission error
if strings.Contains(stderr, "Must have admin rights") || strings.Contains(stderr, "403") {
return nil, cli.Err("insufficient permissions to manage webhooks (requires admin)")
}
return nil, cli.Err("%s", stderr)
}
return nil, err
}
var hooks []GitHubWebhook
if err := json.Unmarshal(output, &hooks); err != nil {
return nil, err
}
return hooks, nil
}
// CreateWebhook creates a new webhook in a repository.
func CreateWebhook(repoFullName string, name string, config WebhookConfig) error {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid repo format: %s", repoFullName)
}
// Build the webhook payload
payload := map[string]interface{}{
"name": "web",
"active": true,
"events": config.Events,
"config": map[string]interface{}{
"url": config.URL,
"content_type": config.ContentType,
"insecure_ssl": "0",
},
}
if config.Active != nil {
payload["active"] = *config.Active
}
if config.Secret != "" {
configMap := payload["config"].(map[string]interface{})
configMap["secret"] = config.Secret
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return err
}
endpoint := fmt.Sprintf("repos/%s/%s/hooks", parts[0], parts[1])
cmd := exec.Command("gh", "api", endpoint, "--method", "POST", "--input", "-")
cmd.Stdin = strings.NewReader(string(payloadJSON))
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", strings.TrimSpace(string(output)))
}
return nil
}
// UpdateWebhook updates an existing webhook.
func UpdateWebhook(repoFullName string, hookID int, config WebhookConfig) error {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid repo format: %s", repoFullName)
}
payload := map[string]interface{}{
"active": true,
"events": config.Events,
"config": map[string]interface{}{
"url": config.URL,
"content_type": config.ContentType,
"insecure_ssl": "0",
},
}
if config.Active != nil {
payload["active"] = *config.Active
}
if config.Secret != "" {
configMap := payload["config"].(map[string]interface{})
configMap["secret"] = config.Secret
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return err
}
endpoint := fmt.Sprintf("repos/%s/%s/hooks/%d", parts[0], parts[1], hookID)
cmd := exec.Command("gh", "api", endpoint, "--method", "PATCH", "--input", "-")
cmd.Stdin = strings.NewReader(string(payloadJSON))
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", strings.TrimSpace(string(output)))
}
return nil
}
// SyncWebhooks synchronizes webhooks for a repository.
// Webhooks are matched by URL - if a webhook with the same URL exists, it's updated.
// Otherwise, a new webhook is created.
func SyncWebhooks(repoFullName string, config *GitHubConfig, dryRun bool) (*ChangeSet, error) {
changes := NewChangeSet(repoFullName)
// Skip if no webhooks configured
if len(config.Webhooks) == 0 {
return changes, nil
}
// Get existing webhooks
existing, err := ListWebhooks(repoFullName)
if err != nil {
// If permission denied, note it but don't fail entirely
if strings.Contains(err.Error(), "insufficient permissions") {
changes.Add(CategoryWebhook, ChangeSkip, "all", "insufficient permissions")
return changes, nil
}
return nil, cli.Wrap(err, "failed to list webhooks")
}
// Build lookup map by URL
existingByURL := make(map[string]GitHubWebhook)
for _, hook := range existing {
existingByURL[hook.Config.URL] = hook
}
// Process each configured webhook
for name, wantHook := range config.Webhooks {
// Skip webhooks with empty URLs (env var not set)
if wantHook.URL == "" {
changes.Add(CategoryWebhook, ChangeSkip, name, "URL not configured")
continue
}
existingHook, exists := existingByURL[wantHook.URL]
if !exists {
// Create new webhook
changes.Add(CategoryWebhook, ChangeCreate, name, wantHook.URL)
if !dryRun {
if err := CreateWebhook(repoFullName, name, wantHook); err != nil {
return changes, cli.Wrap(err, "failed to create webhook "+name)
}
}
continue
}
// Check if update is needed
needsUpdate := false
details := make(map[string]string)
// Check events
if !stringSliceEqual(existingHook.Events, wantHook.Events) {
needsUpdate = true
details["events"] = fmt.Sprintf("%v -> %v", existingHook.Events, wantHook.Events)
}
// Check content type
if existingHook.Config.ContentType != wantHook.ContentType {
needsUpdate = true
details["content_type"] = fmt.Sprintf("%s -> %s", existingHook.Config.ContentType, wantHook.ContentType)
}
// Check active state
wantActive := true
if wantHook.Active != nil {
wantActive = *wantHook.Active
}
if existingHook.Active != wantActive {
needsUpdate = true
details["active"] = fmt.Sprintf("%v -> %v", existingHook.Active, wantActive)
}
if needsUpdate {
changes.AddWithDetails(CategoryWebhook, ChangeUpdate, name, "", details)
if !dryRun {
if err := UpdateWebhook(repoFullName, existingHook.ID, wantHook); err != nil {
return changes, cli.Wrap(err, "failed to update webhook "+name)
}
}
} else {
changes.Add(CategoryWebhook, ChangeSkip, name, "up to date")
}
}
return changes, nil
}
// stringSliceEqual compares two string slices for equality (order-independent).
// Uses frequency counting to properly handle duplicates.
func stringSliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
// Count frequencies in slice a
counts := make(map[string]int)
for _, s := range a {
counts[s]++
}
// Decrement for each element in slice b
for _, s := range b {
counts[s]--
if counts[s] < 0 {
return false
}
}
// All counts should be zero if slices are equal
for _, count := range counts {
if count != 0 {
return false
}
}
return true
}

View file

@ -1,7 +0,0 @@
package workspace
import "forge.lthn.ai/core/go/pkg/cli"
func init() {
cli.RegisterCommands(AddWorkspaceCommands)
}

View file

@ -1,289 +0,0 @@
// cmd_agent.go manages persistent agent context within task workspaces.
//
// Each agent gets a directory at:
//
// .core/workspace/p{epic}/i{issue}/agents/{provider}/{agent-name}/
//
// This directory persists across invocations, allowing agents to build
// understanding over time — QA agents accumulate findings, reviewers
// track patterns, implementors record decisions.
//
// Layout:
//
// agents/
// ├── claude-opus/implementor/
// │ ├── memory.md # Persistent notes, decisions, context
// │ └── artifacts/ # Generated artifacts (reports, diffs, etc.)
// ├── claude-opus/qa/
// │ ├── memory.md
// │ └── artifacts/
// └── gemini/reviewer/
// └── memory.md
package workspace
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
coreio "forge.lthn.ai/core/go/pkg/io"
"github.com/spf13/cobra"
)
var (
agentProvider string
agentName string
)
func addAgentCommands(parent *cobra.Command) {
agentCmd := &cobra.Command{
Use: "agent",
Short: "Manage persistent agent context within task workspaces",
}
initCmd := &cobra.Command{
Use: "init <provider/agent-name>",
Short: "Initialize an agent's context directory in the task workspace",
Long: `Creates agents/{provider}/{agent-name}/ with memory.md and artifacts/
directory. The agent can read/write memory.md across invocations to
build understanding over time.`,
Args: cobra.ExactArgs(1),
RunE: runAgentInit,
}
initCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
initCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
_ = initCmd.MarkFlagRequired("epic")
_ = initCmd.MarkFlagRequired("issue")
agentListCmd := &cobra.Command{
Use: "list",
Short: "List agents in a task workspace",
RunE: runAgentList,
}
agentListCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
agentListCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
_ = agentListCmd.MarkFlagRequired("epic")
_ = agentListCmd.MarkFlagRequired("issue")
pathCmd := &cobra.Command{
Use: "path <provider/agent-name>",
Short: "Print the agent's context directory path",
Args: cobra.ExactArgs(1),
RunE: runAgentPath,
}
pathCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
pathCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
_ = pathCmd.MarkFlagRequired("epic")
_ = pathCmd.MarkFlagRequired("issue")
agentCmd.AddCommand(initCmd, agentListCmd, pathCmd)
parent.AddCommand(agentCmd)
}
// agentContextPath returns the path for an agent's context directory.
func agentContextPath(wsPath, provider, name string) string {
return filepath.Join(wsPath, "agents", provider, name)
}
// parseAgentID splits "provider/agent-name" into parts.
func parseAgentID(id string) (provider, name string, err error) {
parts := strings.SplitN(id, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("agent ID must be provider/agent-name (e.g. claude-opus/qa)")
}
return parts[0], parts[1], nil
}
// AgentManifest tracks agent metadata for a task workspace.
type AgentManifest struct {
Provider string `json:"provider"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
LastSeen time.Time `json:"last_seen"`
}
func runAgentInit(cmd *cobra.Command, args []string) error {
provider, name, err := parseAgentID(args[0])
if err != nil {
return err
}
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
if !coreio.Local.IsDir(wsPath) {
return cli.Err("task workspace does not exist: p%d/i%d — create it first with `core workspace task create`", taskEpic, taskIssue)
}
agentDir := agentContextPath(wsPath, provider, name)
if coreio.Local.IsDir(agentDir) {
// Update last_seen
updateAgentManifest(agentDir, provider, name)
cli.Print("Agent %s/%s already initialized at p%d/i%d\n",
cli.ValueStyle.Render(provider), cli.ValueStyle.Render(name), taskEpic, taskIssue)
cli.Print("Path: %s\n", cli.DimStyle.Render(agentDir))
return nil
}
// Create directory structure
if err := coreio.Local.EnsureDir(agentDir); err != nil {
return fmt.Errorf("failed to create agent directory: %w", err)
}
if err := coreio.Local.EnsureDir(filepath.Join(agentDir, "artifacts")); err != nil {
return fmt.Errorf("failed to create artifacts directory: %w", err)
}
// Create initial memory.md
memoryContent := fmt.Sprintf(`# %s/%s Issue #%d (EPIC #%d)
## Context
- **Task workspace:** p%d/i%d
- **Initialized:** %s
## Notes
<!-- Add observations, decisions, and findings below -->
`, provider, name, taskIssue, taskEpic, taskEpic, taskIssue, time.Now().Format(time.RFC3339))
if err := coreio.Local.Write(filepath.Join(agentDir, "memory.md"), memoryContent); err != nil {
return fmt.Errorf("failed to create memory.md: %w", err)
}
// Write manifest
updateAgentManifest(agentDir, provider, name)
cli.Print("%s Agent %s/%s initialized at p%d/i%d\n",
cli.SuccessStyle.Render("Done:"),
cli.ValueStyle.Render(provider), cli.ValueStyle.Render(name),
taskEpic, taskIssue)
cli.Print("Memory: %s\n", cli.DimStyle.Render(filepath.Join(agentDir, "memory.md")))
return nil
}
func runAgentList(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
agentsDir := filepath.Join(wsPath, "agents")
if !coreio.Local.IsDir(agentsDir) {
cli.Println("No agents in this workspace.")
return nil
}
providers, err := coreio.Local.List(agentsDir)
if err != nil {
return fmt.Errorf("failed to list agents: %w", err)
}
found := false
for _, providerEntry := range providers {
if !providerEntry.IsDir() {
continue
}
providerDir := filepath.Join(agentsDir, providerEntry.Name())
agents, err := coreio.Local.List(providerDir)
if err != nil {
continue
}
for _, agentEntry := range agents {
if !agentEntry.IsDir() {
continue
}
found = true
agentDir := filepath.Join(providerDir, agentEntry.Name())
// Read manifest for last_seen
lastSeen := ""
manifestPath := filepath.Join(agentDir, "manifest.json")
if data, err := coreio.Local.Read(manifestPath); err == nil {
var m AgentManifest
if json.Unmarshal([]byte(data), &m) == nil {
lastSeen = m.LastSeen.Format("2006-01-02 15:04")
}
}
// Check if memory has content beyond the template
memorySize := ""
if content, err := coreio.Local.Read(filepath.Join(agentDir, "memory.md")); err == nil {
lines := len(strings.Split(content, "\n"))
memorySize = fmt.Sprintf("%d lines", lines)
}
cli.Print(" %s/%s %s",
cli.ValueStyle.Render(providerEntry.Name()),
cli.ValueStyle.Render(agentEntry.Name()),
cli.DimStyle.Render(memorySize))
if lastSeen != "" {
cli.Print(" last: %s", cli.DimStyle.Render(lastSeen))
}
cli.Print("\n")
}
}
if !found {
cli.Println("No agents in this workspace.")
}
return nil
}
func runAgentPath(cmd *cobra.Command, args []string) error {
provider, name, err := parseAgentID(args[0])
if err != nil {
return err
}
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
agentDir := agentContextPath(wsPath, provider, name)
if !coreio.Local.IsDir(agentDir) {
return cli.Err("agent %s/%s not initialized — run `core workspace agent init %s/%s`", provider, name, provider, name)
}
// Print just the path (useful for scripting: cd $(core workspace agent path ...))
cli.Text(agentDir)
return nil
}
func updateAgentManifest(agentDir, provider, name string) {
now := time.Now()
manifest := AgentManifest{
Provider: provider,
Name: name,
CreatedAt: now,
LastSeen: now,
}
// Try to preserve created_at from existing manifest
manifestPath := filepath.Join(agentDir, "manifest.json")
if data, err := coreio.Local.Read(manifestPath); err == nil {
var existing AgentManifest
if json.Unmarshal([]byte(data), &existing) == nil {
manifest.CreatedAt = existing.CreatedAt
}
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return
}
_ = coreio.Local.Write(manifestPath, string(data))
}

View file

@ -1,79 +0,0 @@
package workspace
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseAgentID_Good(t *testing.T) {
provider, name, err := parseAgentID("claude-opus/qa")
require.NoError(t, err)
assert.Equal(t, "claude-opus", provider)
assert.Equal(t, "qa", name)
}
func TestParseAgentID_Bad(t *testing.T) {
tests := []string{
"noslash",
"/missing-provider",
"missing-name/",
"",
}
for _, id := range tests {
_, _, err := parseAgentID(id)
assert.Error(t, err, "expected error for: %q", id)
}
}
func TestAgentContextPath(t *testing.T) {
path := agentContextPath("/ws/p101/i343", "claude-opus", "qa")
assert.Equal(t, "/ws/p101/i343/agents/claude-opus/qa", path)
}
func TestUpdateAgentManifest_Good(t *testing.T) {
tmp := t.TempDir()
agentDir := filepath.Join(tmp, "agents", "test-provider", "test-agent")
require.NoError(t, os.MkdirAll(agentDir, 0755))
updateAgentManifest(agentDir, "test-provider", "test-agent")
data, err := os.ReadFile(filepath.Join(agentDir, "manifest.json"))
require.NoError(t, err)
var m AgentManifest
require.NoError(t, json.Unmarshal(data, &m))
assert.Equal(t, "test-provider", m.Provider)
assert.Equal(t, "test-agent", m.Name)
assert.False(t, m.CreatedAt.IsZero())
assert.False(t, m.LastSeen.IsZero())
}
func TestUpdateAgentManifest_PreservesCreatedAt(t *testing.T) {
tmp := t.TempDir()
agentDir := filepath.Join(tmp, "agents", "p", "a")
require.NoError(t, os.MkdirAll(agentDir, 0755))
// First call sets created_at
updateAgentManifest(agentDir, "p", "a")
data, err := os.ReadFile(filepath.Join(agentDir, "manifest.json"))
require.NoError(t, err)
var first AgentManifest
require.NoError(t, json.Unmarshal(data, &first))
// Second call should preserve created_at
updateAgentManifest(agentDir, "p", "a")
data, err = os.ReadFile(filepath.Join(agentDir, "manifest.json"))
require.NoError(t, err)
var second AgentManifest
require.NoError(t, json.Unmarshal(data, &second))
assert.Equal(t, first.CreatedAt, second.CreatedAt)
assert.True(t, second.LastSeen.After(first.CreatedAt) || second.LastSeen.Equal(first.CreatedAt))
}

View file

@ -1,466 +0,0 @@
// cmd_task.go implements task workspace isolation using git worktrees.
//
// Each task gets an isolated workspace at .core/workspace/p{epic}/i{issue}/
// containing git worktrees of required repos. This prevents agents from
// writing to the implementor's working tree.
//
// Safety checks enforce that workspaces cannot be removed if they contain
// uncommitted changes or unpushed branches.
package workspace
import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strconv"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
"github.com/spf13/cobra"
)
var (
taskEpic int
taskIssue int
taskRepos []string
taskForce bool
taskBranch string
)
func addTaskCommands(parent *cobra.Command) {
taskCmd := &cobra.Command{
Use: "task",
Short: "Manage isolated task workspaces for agents",
}
createCmd := &cobra.Command{
Use: "create",
Short: "Create an isolated task workspace with git worktrees",
Long: `Creates a workspace at .core/workspace/p{epic}/i{issue}/ with git
worktrees for each specified repo. Each worktree gets a fresh branch
(issue/{id} by default) so agents work in isolation.`,
RunE: runTaskCreate,
}
createCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
createCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
createCmd.Flags().StringSliceVar(&taskRepos, "repo", nil, "Repos to include (default: all from registry)")
createCmd.Flags().StringVar(&taskBranch, "branch", "", "Branch name (default: issue/{issue})")
_ = createCmd.MarkFlagRequired("epic")
_ = createCmd.MarkFlagRequired("issue")
removeCmd := &cobra.Command{
Use: "remove",
Short: "Remove a task workspace (with safety checks)",
Long: `Removes a task workspace after checking for uncommitted changes and
unpushed branches. Use --force to skip safety checks.`,
RunE: runTaskRemove,
}
removeCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
removeCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
removeCmd.Flags().BoolVar(&taskForce, "force", false, "Skip safety checks")
_ = removeCmd.MarkFlagRequired("epic")
_ = removeCmd.MarkFlagRequired("issue")
listCmd := &cobra.Command{
Use: "list",
Short: "List all task workspaces",
RunE: runTaskList,
}
statusCmd := &cobra.Command{
Use: "status",
Short: "Show status of a task workspace",
RunE: runTaskStatus,
}
statusCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
statusCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
_ = statusCmd.MarkFlagRequired("epic")
_ = statusCmd.MarkFlagRequired("issue")
addAgentCommands(taskCmd)
taskCmd.AddCommand(createCmd, removeCmd, listCmd, statusCmd)
parent.AddCommand(taskCmd)
}
// taskWorkspacePath returns the path for a task workspace.
func taskWorkspacePath(root string, epic, issue int) string {
return filepath.Join(root, ".core", "workspace", fmt.Sprintf("p%d", epic), fmt.Sprintf("i%d", issue))
}
func runTaskCreate(cmd *cobra.Command, args []string) error {
ctx := context.Background()
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace — run from workspace root or a package directory")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
if coreio.Local.IsDir(wsPath) {
return cli.Err("task workspace already exists: %s", wsPath)
}
branch := taskBranch
if branch == "" {
branch = fmt.Sprintf("issue/%d", taskIssue)
}
// Determine repos to include
repoNames := taskRepos
if len(repoNames) == 0 {
repoNames, err = registryRepoNames(root)
if err != nil {
return fmt.Errorf("failed to load registry: %w", err)
}
}
if len(repoNames) == 0 {
return cli.Err("no repos specified and no registry found")
}
// Resolve package paths
config, _ := LoadConfig(root)
pkgDir := "./packages"
if config != nil && config.PackagesDir != "" {
pkgDir = config.PackagesDir
}
if !filepath.IsAbs(pkgDir) {
pkgDir = filepath.Join(root, pkgDir)
}
if err := coreio.Local.EnsureDir(wsPath); err != nil {
return fmt.Errorf("failed to create workspace directory: %w", err)
}
cli.Print("Creating task workspace: %s\n", cli.ValueStyle.Render(fmt.Sprintf("p%d/i%d", taskEpic, taskIssue)))
cli.Print("Branch: %s\n", cli.ValueStyle.Render(branch))
cli.Print("Path: %s\n\n", cli.DimStyle.Render(wsPath))
var created, skipped int
for _, repoName := range repoNames {
repoPath := filepath.Join(pkgDir, repoName)
if !coreio.Local.IsDir(filepath.Join(repoPath, ".git")) {
cli.Print(" %s %s (not cloned, skipping)\n", cli.DimStyle.Render("·"), repoName)
skipped++
continue
}
worktreePath := filepath.Join(wsPath, repoName)
cli.Print(" %s %s... ", cli.DimStyle.Render("·"), repoName)
if err := createWorktree(ctx, repoPath, worktreePath, branch); err != nil {
cli.Print("%s\n", cli.ErrorStyle.Render("x "+err.Error()))
skipped++
continue
}
cli.Print("%s\n", cli.SuccessStyle.Render("ok"))
created++
}
cli.Print("\n%s %d worktrees created", cli.SuccessStyle.Render("Done:"), created)
if skipped > 0 {
cli.Print(", %d skipped", skipped)
}
cli.Print("\n")
return nil
}
func runTaskRemove(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
if !coreio.Local.IsDir(wsPath) {
return cli.Err("task workspace does not exist: p%d/i%d", taskEpic, taskIssue)
}
if !taskForce {
dirty, reasons := checkWorkspaceSafety(wsPath)
if dirty {
cli.Print("%s Cannot remove workspace p%d/i%d:\n", cli.ErrorStyle.Render("Blocked:"), taskEpic, taskIssue)
for _, r := range reasons {
cli.Print(" %s %s\n", cli.ErrorStyle.Render("·"), r)
}
cli.Print("\nUse --force to override or resolve the issues first.\n")
return errors.New("workspace has unresolved changes")
}
}
// Remove worktrees first (so git knows they're gone)
entries, err := coreio.Local.List(wsPath)
if err != nil {
return fmt.Errorf("failed to list workspace: %w", err)
}
config, _ := LoadConfig(root)
pkgDir := "./packages"
if config != nil && config.PackagesDir != "" {
pkgDir = config.PackagesDir
}
if !filepath.IsAbs(pkgDir) {
pkgDir = filepath.Join(root, pkgDir)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
worktreePath := filepath.Join(wsPath, entry.Name())
repoPath := filepath.Join(pkgDir, entry.Name())
// Remove worktree from git
if coreio.Local.IsDir(filepath.Join(repoPath, ".git")) {
removeWorktree(repoPath, worktreePath)
}
}
// Remove the workspace directory
if err := coreio.Local.DeleteAll(wsPath); err != nil {
return fmt.Errorf("failed to remove workspace directory: %w", err)
}
// Clean up empty parent (p{epic}/) if it's now empty
epicDir := filepath.Dir(wsPath)
if entries, err := coreio.Local.List(epicDir); err == nil && len(entries) == 0 {
coreio.Local.DeleteAll(epicDir)
}
cli.Print("%s Removed workspace p%d/i%d\n", cli.SuccessStyle.Render("Done:"), taskEpic, taskIssue)
return nil
}
func runTaskList(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsRoot := filepath.Join(root, ".core", "workspace")
if !coreio.Local.IsDir(wsRoot) {
cli.Println("No task workspaces found.")
return nil
}
epics, err := coreio.Local.List(wsRoot)
if err != nil {
return fmt.Errorf("failed to list workspaces: %w", err)
}
found := false
for _, epicEntry := range epics {
if !epicEntry.IsDir() || !strings.HasPrefix(epicEntry.Name(), "p") {
continue
}
epicDir := filepath.Join(wsRoot, epicEntry.Name())
issues, err := coreio.Local.List(epicDir)
if err != nil {
continue
}
for _, issueEntry := range issues {
if !issueEntry.IsDir() || !strings.HasPrefix(issueEntry.Name(), "i") {
continue
}
found = true
wsPath := filepath.Join(epicDir, issueEntry.Name())
// Count worktrees
entries, _ := coreio.Local.List(wsPath)
dirCount := 0
for _, e := range entries {
if e.IsDir() {
dirCount++
}
}
// Check safety
dirty, _ := checkWorkspaceSafety(wsPath)
status := cli.SuccessStyle.Render("clean")
if dirty {
status = cli.ErrorStyle.Render("dirty")
}
cli.Print(" %s/%s %d repos %s\n",
epicEntry.Name(), issueEntry.Name(),
dirCount, status)
}
}
if !found {
cli.Println("No task workspaces found.")
}
return nil
}
func runTaskStatus(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
if !coreio.Local.IsDir(wsPath) {
return cli.Err("task workspace does not exist: p%d/i%d", taskEpic, taskIssue)
}
cli.Print("Workspace: %s\n", cli.ValueStyle.Render(fmt.Sprintf("p%d/i%d", taskEpic, taskIssue)))
cli.Print("Path: %s\n\n", cli.DimStyle.Render(wsPath))
entries, err := coreio.Local.List(wsPath)
if err != nil {
return fmt.Errorf("failed to list workspace: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
worktreePath := filepath.Join(wsPath, entry.Name())
// Get branch
branch := gitOutput(worktreePath, "rev-parse", "--abbrev-ref", "HEAD")
branch = strings.TrimSpace(branch)
// Get status
status := gitOutput(worktreePath, "status", "--porcelain")
statusLabel := cli.SuccessStyle.Render("clean")
if strings.TrimSpace(status) != "" {
lines := len(strings.Split(strings.TrimSpace(status), "\n"))
statusLabel = cli.ErrorStyle.Render(fmt.Sprintf("%d changes", lines))
}
// Get unpushed
unpushed := gitOutput(worktreePath, "log", "--oneline", "@{u}..HEAD")
unpushedLabel := ""
if trimmed := strings.TrimSpace(unpushed); trimmed != "" {
count := len(strings.Split(trimmed, "\n"))
unpushedLabel = cli.WarningStyle.Render(fmt.Sprintf(" %d unpushed", count))
}
cli.Print(" %s %s %s%s\n",
cli.RepoStyle.Render(entry.Name()),
cli.DimStyle.Render(branch),
statusLabel,
unpushedLabel)
}
return nil
}
// createWorktree adds a git worktree at worktreePath for the given branch.
func createWorktree(ctx context.Context, repoPath, worktreePath, branch string) error {
// Check if branch exists on remote first
cmd := exec.CommandContext(ctx, "git", "worktree", "add", "-b", branch, worktreePath)
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
errStr := strings.TrimSpace(string(output))
// If branch already exists, try without -b
if strings.Contains(errStr, "already exists") {
cmd = exec.CommandContext(ctx, "git", "worktree", "add", worktreePath, branch)
cmd.Dir = repoPath
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
}
return nil
}
return fmt.Errorf("%s", errStr)
}
return nil
}
// removeWorktree removes a git worktree.
func removeWorktree(repoPath, worktreePath string) {
cmd := exec.Command("git", "worktree", "remove", worktreePath)
cmd.Dir = repoPath
_ = cmd.Run()
// Prune stale worktrees
cmd = exec.Command("git", "worktree", "prune")
cmd.Dir = repoPath
_ = cmd.Run()
}
// checkWorkspaceSafety checks all worktrees in a workspace for uncommitted/unpushed changes.
func checkWorkspaceSafety(wsPath string) (dirty bool, reasons []string) {
entries, err := coreio.Local.List(wsPath)
if err != nil {
return false, nil
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
worktreePath := filepath.Join(wsPath, entry.Name())
// Check for uncommitted changes
status := gitOutput(worktreePath, "status", "--porcelain")
if strings.TrimSpace(status) != "" {
dirty = true
reasons = append(reasons, fmt.Sprintf("%s: has uncommitted changes", entry.Name()))
}
// Check for unpushed commits
unpushed := gitOutput(worktreePath, "log", "--oneline", "@{u}..HEAD")
if strings.TrimSpace(unpushed) != "" {
dirty = true
count := len(strings.Split(strings.TrimSpace(unpushed), "\n"))
reasons = append(reasons, fmt.Sprintf("%s: %d unpushed commits", entry.Name(), count))
}
}
return dirty, reasons
}
// gitOutput runs a git command and returns stdout.
func gitOutput(dir string, args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, _ := cmd.Output()
return string(out)
}
// registryRepoNames returns repo names from the workspace registry.
func registryRepoNames(root string) ([]string, error) {
// Try to find repos.yaml
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return nil, err
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return nil, err
}
var names []string
for _, repo := range reg.List() {
// Only include cloneable repos
if repo.Clone != nil && !*repo.Clone {
continue
}
// Skip meta repos
if repo.Type == "meta" {
continue
}
names = append(names, repo.Name)
}
return names, nil
}
// epicBranchName returns the branch name for an EPIC.
func epicBranchName(epicID int) string {
return "epic/" + strconv.Itoa(epicID)
}

View file

@ -1,109 +0,0 @@
package workspace
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestRepo(t *testing.T, dir, name string) string {
t.Helper()
repoPath := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(repoPath, 0755))
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
{"git", "commit", "--allow-empty", "-m", "initial"},
}
for _, c := range cmds {
cmd := exec.Command(c[0], c[1:]...)
cmd.Dir = repoPath
out, err := cmd.CombinedOutput()
require.NoError(t, err, "cmd %v failed: %s", c, string(out))
}
return repoPath
}
func TestTaskWorkspacePath(t *testing.T) {
path := taskWorkspacePath("/home/user/Code/host-uk", 101, 343)
assert.Equal(t, "/home/user/Code/host-uk/.core/workspace/p101/i343", path)
}
func TestCreateWorktree_Good(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "test-repo")
worktreePath := filepath.Join(tmp, "workspace", "test-repo")
err := createWorktree(t.Context(), repoPath, worktreePath, "issue/123")
require.NoError(t, err)
// Verify worktree exists
assert.DirExists(t, worktreePath)
assert.FileExists(t, filepath.Join(worktreePath, ".git"))
// Verify branch
branch := gitOutput(worktreePath, "rev-parse", "--abbrev-ref", "HEAD")
assert.Equal(t, "issue/123", trimNL(branch))
}
func TestCreateWorktree_BranchExists(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "test-repo")
// Create branch first
cmd := exec.Command("git", "branch", "issue/456")
cmd.Dir = repoPath
require.NoError(t, cmd.Run())
worktreePath := filepath.Join(tmp, "workspace", "test-repo")
err := createWorktree(t.Context(), repoPath, worktreePath, "issue/456")
require.NoError(t, err)
assert.DirExists(t, worktreePath)
}
func TestCheckWorkspaceSafety_Clean(t *testing.T) {
tmp := t.TempDir()
wsPath := filepath.Join(tmp, "workspace")
require.NoError(t, os.MkdirAll(wsPath, 0755))
repoPath := setupTestRepo(t, tmp, "origin-repo")
worktreePath := filepath.Join(wsPath, "origin-repo")
require.NoError(t, createWorktree(t.Context(), repoPath, worktreePath, "test-branch"))
dirty, reasons := checkWorkspaceSafety(wsPath)
assert.False(t, dirty)
assert.Empty(t, reasons)
}
func TestCheckWorkspaceSafety_Dirty(t *testing.T) {
tmp := t.TempDir()
wsPath := filepath.Join(tmp, "workspace")
require.NoError(t, os.MkdirAll(wsPath, 0755))
repoPath := setupTestRepo(t, tmp, "origin-repo")
worktreePath := filepath.Join(wsPath, "origin-repo")
require.NoError(t, createWorktree(t.Context(), repoPath, worktreePath, "test-branch"))
// Create uncommitted file
require.NoError(t, os.WriteFile(filepath.Join(worktreePath, "dirty.txt"), []byte("dirty"), 0644))
dirty, reasons := checkWorkspaceSafety(wsPath)
assert.True(t, dirty)
assert.Contains(t, reasons[0], "uncommitted changes")
}
func TestEpicBranchName(t *testing.T) {
assert.Equal(t, "epic/101", epicBranchName(101))
assert.Equal(t, "epic/42", epicBranchName(42))
}
func trimNL(s string) string {
return s[:len(s)-1]
}

View file

@ -1,90 +0,0 @@
package workspace
import (
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"github.com/spf13/cobra"
)
// AddWorkspaceCommands registers workspace management commands.
func AddWorkspaceCommands(root *cobra.Command) {
wsCmd := &cobra.Command{
Use: "workspace",
Short: "Manage workspace configuration",
RunE: runWorkspaceInfo,
}
wsCmd.AddCommand(&cobra.Command{
Use: "active [package]",
Short: "Show or set the active package",
RunE: runWorkspaceActive,
})
addTaskCommands(wsCmd)
root.AddCommand(wsCmd)
}
func runWorkspaceInfo(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
config, err := LoadConfig(root)
if err != nil {
return err
}
if config == nil {
return cli.Err("workspace config not found")
}
cli.Print("Active: %s\n", cli.ValueStyle.Render(config.Active))
cli.Print("Packages: %s\n", cli.DimStyle.Render(config.PackagesDir))
if len(config.DefaultOnly) > 0 {
cli.Print("Types: %s\n", cli.DimStyle.Render(strings.Join(config.DefaultOnly, ", ")))
}
return nil
}
func runWorkspaceActive(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
config, err := LoadConfig(root)
if err != nil {
return err
}
if config == nil {
config = DefaultConfig()
}
// If no args, show active
if len(args) == 0 {
if config.Active == "" {
cli.Println("No active package set")
return nil
}
cli.Text(config.Active)
return nil
}
// Set active
target := args[0]
if target == config.Active {
cli.Print("Active package is already %s\n", cli.ValueStyle.Render(target))
return nil
}
config.Active = target
if err := SaveConfig(root, config); err != nil {
return err
}
cli.Print("Active package set to %s\n", cli.SuccessStyle.Render(target))
return nil
}

View file

@ -1,103 +0,0 @@
package workspace
import (
"fmt"
"os"
"path/filepath"
coreio "forge.lthn.ai/core/go/pkg/io"
"gopkg.in/yaml.v3"
)
// WorkspaceConfig holds workspace-level configuration from .core/workspace.yaml.
type WorkspaceConfig struct {
Version int `yaml:"version"`
Active string `yaml:"active"` // Active package name
DefaultOnly []string `yaml:"default_only"` // Default types for setup
PackagesDir string `yaml:"packages_dir"` // Where packages are cloned
}
// DefaultConfig returns a config with default values.
func DefaultConfig() *WorkspaceConfig {
return &WorkspaceConfig{
Version: 1,
PackagesDir: "./packages",
}
}
// LoadConfig tries to load workspace.yaml from the given directory's .core subfolder.
// Returns nil if no config file exists (caller should check for nil).
func LoadConfig(dir string) (*WorkspaceConfig, error) {
path := filepath.Join(dir, ".core", "workspace.yaml")
data, err := coreio.Local.Read(path)
if err != nil {
// If using Local.Read, it returns error on not found.
// We can check if file exists first or handle specific error if exposed.
// Simplest is to check existence first or assume IsNotExist.
// Since we don't have easy IsNotExist check on coreio error returned yet (uses wrapped error),
// let's check IsFile first.
if !coreio.Local.IsFile(path) {
// Try parent directory
parent := filepath.Dir(dir)
if parent != dir {
return LoadConfig(parent)
}
// No workspace.yaml found anywhere - return nil to indicate no config
return nil, nil
}
return nil, fmt.Errorf("failed to read workspace config: %w", err)
}
config := DefaultConfig()
if err := yaml.Unmarshal([]byte(data), config); err != nil {
return nil, fmt.Errorf("failed to parse workspace config: %w", err)
}
if config.Version != 1 {
return nil, fmt.Errorf("unsupported workspace config version: %d", config.Version)
}
return config, nil
}
// SaveConfig saves the configuration to the given directory's .core/workspace.yaml.
func SaveConfig(dir string, config *WorkspaceConfig) error {
coreDir := filepath.Join(dir, ".core")
if err := coreio.Local.EnsureDir(coreDir); err != nil {
return fmt.Errorf("failed to create .core directory: %w", err)
}
path := filepath.Join(coreDir, "workspace.yaml")
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("failed to marshal workspace config: %w", err)
}
if err := coreio.Local.Write(path, string(data)); err != nil {
return fmt.Errorf("failed to write workspace config: %w", err)
}
return nil
}
// FindWorkspaceRoot searches for the root directory containing .core/workspace.yaml.
func FindWorkspaceRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if coreio.Local.IsFile(filepath.Join(dir, ".core", "workspace.yaml")) {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("not in a workspace")
}

22
main.go
View file

@ -3,23 +3,25 @@ package main
import (
"forge.lthn.ai/core/go/pkg/cli"
// Commands via self-registration (local to CLI)
// Commands — local to CLI
_ "forge.lthn.ai/core/cli/cmd/config"
_ "forge.lthn.ai/core/cli/cmd/dev"
_ "forge.lthn.ai/core/cli/cmd/docs"
_ "forge.lthn.ai/core/cli/cmd/doctor"
_ "forge.lthn.ai/core/cli/cmd/gitcmd"
_ "forge.lthn.ai/core/cli/cmd/go"
_ "forge.lthn.ai/core/cli/cmd/help"
_ "forge.lthn.ai/core/cli/cmd/lab"
_ "forge.lthn.ai/core/cli/cmd/module"
_ "forge.lthn.ai/core/cli/cmd/monitor"
_ "forge.lthn.ai/core/cli/cmd/pkgcmd"
_ "forge.lthn.ai/core/cli/cmd/plugin"
_ "forge.lthn.ai/core/cli/cmd/qa"
_ "forge.lthn.ai/core/cli/cmd/session"
_ "forge.lthn.ai/core/cli/cmd/setup"
_ "forge.lthn.ai/core/cli/cmd/workspace"
// Commands — from ecosystem repos
_ "forge.lthn.ai/core/go/cmd/gocmd"
_ "forge.lthn.ai/core/go-agentic/cmd/workspace"
_ "forge.lthn.ai/core/go-ai/cmd/lab"
_ "forge.lthn.ai/core/go-devops/cmd/dev"
_ "forge.lthn.ai/core/go-devops/cmd/docs"
_ "forge.lthn.ai/core/go-devops/cmd/gitcmd"
_ "forge.lthn.ai/core/go-devops/cmd/monitor"
_ "forge.lthn.ai/core/go-devops/cmd/qa"
_ "forge.lthn.ai/core/go-devops/cmd/setup"
)
func main() {