refactor: migrate core import to dappco.re/go/core

Replace forge.lthn.ai/core/go/pkg/core with dappco.re/go/core v0.4.7.
Adapt to new API: core.New() returns *Core directly, services registered
via c.Service(), Result replaces (any, bool, error) IPC pattern.
Simplify git/agentic integration by calling package-level functions
directly instead of routing through IPC service handlers.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-21 12:24:45 +00:00
parent e4216a12b0
commit ecb50796b7
7 changed files with 81 additions and 404 deletions

View file

@ -2,10 +2,9 @@ package dev
import (
"context"
"fmt"
agentic "forge.lthn.ai/core/agent/pkg/lifecycle"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/go-scm/git"
"dappco.re/go/core"
)
// WorkBundle contains the Core instance for dev work operations.
@ -16,71 +15,52 @@ type WorkBundle struct {
// 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.
// Includes: dev (orchestration) service.
func NewWorkBundle(opts WorkBundleOptions) (*WorkBundle, error) {
c, err := core.New(
core.WithService(NewService(ServiceOptions{
c := core.New()
svc := &Service{
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{
RegistryPath: opts.RegistryPath,
})),
core.WithService(git.NewService(git.ServiceOptions{})),
core.WithService(agentic.NewService(agentic.ServiceOptions{
AllowEdit: opts.AllowEdit,
})),
core.WithServiceLock(),
)
if err != nil {
return nil, err
}),
}
c.Service("dev", core.Service{
OnStart: func() core.Result {
c.RegisterTask(svc.handleTask)
return core.Result{OK: true}
},
})
c.LockEnable()
c.LockApply()
return &WorkBundle{Core: c}, nil
}
// Start initialises the bundle services.
func (b *WorkBundle) Start(ctx context.Context) error {
return b.Core.ServiceStartup(ctx, nil)
return resultError(b.Core.ServiceStartup(ctx, nil))
}
// Stop shuts down the bundle services.
func (b *WorkBundle) Stop(ctx context.Context) error {
return b.Core.ServiceShutdown(ctx)
return resultError(b.Core.ServiceShutdown(ctx))
}
// StatusBundle contains the Core instance for status-only operations.
type StatusBundle struct {
Core *core.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 := core.New(
core.WithService(NewService(ServiceOptions(opts))),
core.WithService(git.NewService(git.ServiceOptions{})),
// No agentic service - TaskCommit will be unhandled
core.WithServiceLock(),
)
if err != nil {
return nil, err
// resultError extracts an error from a failed core.Result, returning nil on success.
func resultError(r core.Result) error {
if !r.OK {
if err, ok := r.Value.(error); ok {
return err
}
if r.Value != nil {
return fmt.Errorf("service operation failed: %v", r.Value)
}
return fmt.Errorf("service operation failed")
}
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)
return nil
}

View file

@ -117,7 +117,7 @@ func runCommit(registryPath string, all bool) error {
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 {
if err := doCommit(ctx, s.Path, false); err != nil {
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
failed++
} else {
@ -192,7 +192,7 @@ func runCommitSingleRepo(ctx context.Context, repoPath string, all bool) error {
cli.Blank()
// Commit
if err := claudeCommit(ctx, repoPath, repoName, ""); err != nil {
if err := doCommit(ctx, repoPath, false); err != nil {
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
return err
}

View file

@ -206,7 +206,7 @@ func runPushSingleRepo(ctx context.Context, repoPath string, force bool) error {
// 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, "")
err = doCommit(ctx, repoPath, true)
} else {
err = runCommitSingleRepo(ctx, repoPath, false)
}

View file

@ -3,15 +3,12 @@ package dev
import (
"cmp"
"context"
"os"
"os/exec"
"slices"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
agentic "forge.lthn.ai/core/agent/pkg/lifecycle"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-scm/git"
)
// Work command flags
@ -57,42 +54,30 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
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
}()
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return 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
}
}
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{
// Query git status directly
statuses := git.Status(ctx, git.StatusOptions{
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
slices.SortFunc(statuses, func(a, b git.RepoStatus) int {
@ -125,15 +110,7 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
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
}
err := doCommit(ctx, s.Path, false)
if err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
} else {
@ -141,12 +118,11 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
}
}
// Re-QUERY status after commits
result, _, _ = bundle.Core.QUERY(git.QueryStatus{
// Re-query status after commits
statuses = git.Status(ctx, git.StatusOptions{
Paths: paths,
Names: names,
})
statuses = result.([]git.RepoStatus)
// Rebuild ahead repos list
aheadRepos = nil
@ -187,18 +163,11 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
cli.Blank()
// PERFORM push for each repo
// Push each repo directly
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
}
err := git.Push(ctx, s.Path)
if err != nil {
if git.IsNonFastForward(err) {
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, i18n.T("cmd.dev.push.diverged"))
@ -220,8 +189,8 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
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})
// Pull directly
err := git.Pull(ctx, s.Path)
if err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
continue
@ -229,8 +198,8 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
cli.Print(" %s %s...\n", dimStyle.Render("↑"), s.Name)
// PERFORM push
_, _, err = bundle.Core.PERFORM(git.TaskPush{Path: s.Path, Name: s.Name})
// Push directly
err = git.Push(ctx, s.Path)
if err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
continue
@ -318,28 +287,3 @@ func printStatusTable(statuses []git.RepoStatus) {
}
}
// 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,32 +1,14 @@
package dev
import (
"cmp"
"context"
"slices"
"strings"
"os"
"os/exec"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core"
agentic "forge.lthn.ai/core/agent/pkg/lifecycle"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/core"
)
// 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
@ -37,256 +19,24 @@ type Service struct {
*core.ServiceRuntime[ServiceOptions]
}
// NewService creates a dev service factory.
func NewService(opts ServiceOptions) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
ServiceRuntime: core.NewServiceRuntime(c, opts),
}, nil
}
func (s *Service) handleTask(_ *core.Core, _ core.Task) core.Result {
return core.Result{}
}
// OnStartup registers task handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) handleTask(c *core.Core, t core.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
slices.SortFunc(statuses, func(a, b git.RepoStatus) int {
return cmp.Compare(a.Name, b.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)
slices.SortFunc(statuses, func(a, b git.RepoStatus) int {
return cmp.Compare(a.Name, b.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)
}
// doCommit shells out to claude for AI-assisted commit.
func doCommit(ctx context.Context, repoPath string, allowEdit bool) error {
prompt := agentic.Prompt("commit")
tools := "Bash,Read,Glob,Grep"
if allowEdit {
tools = "Bash,Read,Write,Edit,Glob,Grep"
}
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", tools)
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}

3
go.mod
View file

@ -4,9 +4,9 @@ go 1.26.0
require (
code.gitea.io/sdk/gitea v0.23.2
dappco.re/go/core v0.4.7
forge.lthn.ai/core/agent v0.3.3
forge.lthn.ai/core/cli v0.3.7
forge.lthn.ai/core/go v0.3.3
forge.lthn.ai/core/go-container v0.1.7
forge.lthn.ai/core/go-i18n v0.1.7
forge.lthn.ai/core/go-io v0.1.7
@ -22,6 +22,7 @@ require (
require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
forge.lthn.ai/core/config v0.1.8 // indirect
forge.lthn.ai/core/go v0.3.3 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect
forge.lthn.ai/core/go-store v0.1.9 // indirect
github.com/42wim/httpsig v1.2.3 // indirect

2
go.sum
View file

@ -2,6 +2,8 @@ code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
forge.lthn.ai/core/agent v0.3.3 h1:lGpoD5OgvdJ5z+qofw8fBWkDB186QM7I2jjXEbtzSdA=
forge.lthn.ai/core/agent v0.3.3/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=