Merge IO batch

This commit is contained in:
Snider 2026-02-02 01:47:54 +00:00
commit 413c7b823a
32 changed files with 485 additions and 280 deletions

BIN
core-test Executable file

Binary file not shown.

2
go.mod
View file

@ -5,7 +5,6 @@ go 1.25.5
require (
github.com/Snider/Borg v0.1.0
github.com/getkin/kin-openapi v0.133.0
github.com/host-uk/core-gui v0.0.0-20260131214111-6e2460834a87
github.com/leaanthony/debme v1.2.1
github.com/leaanthony/gosod v1.0.4
github.com/minio/selfupdate v0.6.0
@ -58,6 +57,7 @@ require (
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/wI2L/jsondiff v0.7.0 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect

2
go.sum
View file

@ -59,8 +59,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/host-uk/core-gui v0.0.0-20260131214111-6e2460834a87 h1:gCdRVNxL1GpKhiYhtqJ60xm2ML3zU/UbYR9lHzlAWb8=
github.com/host-uk/core-gui v0.0.0-20260131214111-6e2460834a87/go.mod h1:yOBnW4of0/82O6GSxFl2Pxepq9yTlJg2pLVwaU9cWHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=

View file

@ -77,8 +77,8 @@ func runApply() error {
// Validate script exists
if applyScript != "" {
if !io.Local.Exists(applyScript) {
return errors.E("dev.apply", "script not found: "+applyScript, nil)
if !io.Local.IsFile(applyScript) {
return errors.E("dev.apply", "script not found: "+applyScript, nil) // Error mismatch? IsFile returns bool
}
}

View file

@ -8,6 +8,7 @@ import (
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
)
// Commit command flags
@ -139,8 +140,8 @@ func runCommit(registryPath string, all bool) error {
// isGitRepo checks if a directory is a git repository.
func isGitRepo(path string) bool {
gitDir := path + "/.git"
info, err := os.Stat(gitDir)
return err == nil && info.IsDir()
_, err := coreio.Local.List(gitDir)
return err == nil
}
// runCommitSingleRepo handles commit for a single repo (current directory).

View file

@ -9,7 +9,6 @@ package dev
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
@ -19,6 +18,7 @@ import (
"github.com/host-uk/core/pkg/errors"
"github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
)
@ -63,7 +63,24 @@ func runFileSync(source string) error {
}
// Validate source exists
sourceInfo, err := os.Stat(source)
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 errors.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err)
}
@ -113,7 +130,9 @@ func runFileSync(source string) error {
continue
}
} else {
if err := copyFile(source, destPath); err != nil {
// Ensure dir exists
coreio.Local.EnsureDir(filepath.Dir(destPath))
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
@ -287,47 +306,14 @@ func gitCommandQuiet(ctx context.Context, dir string, args ...string) (string, e
return string(output), nil
}
// copyFile copies a single file
func copyFile(src, dst string) error {
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
srcInfo, err := srcFile.Stat()
if err != nil {
return err
}
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
// copyDir recursively copies a directory
func copyDir(src, dst string) error {
srcInfo, err := os.Stat(src)
entries, err := coreio.Local.List(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
if err := coreio.Local.EnsureDir(dst); err != nil {
return err
}
@ -340,7 +326,7 @@ func copyDir(src, dst string) error {
return err
}
} else {
if err := copyFile(srcPath, dstPath); err != nil {
if err := coreio.Copy(coreio.Local, srcPath, coreio.Local, dstPath); err != nil {
return err
}
}

View file

@ -2,19 +2,40 @@ package dev
import (
"bytes"
"context"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"text/template"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/cli" // Added
"github.com/host-uk/core/pkg/i18n" // Added
coreio "github.com/host-uk/core/pkg/io"
// Added
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// syncInternalToPublic handles the synchronization of internal packages to public-facing directories.
// This function is a placeholder for future implementation.
func syncInternalToPublic(ctx context.Context, publicDir string) error {
// 1. Clean public/internal
// 2. Copy relevant files from internal/ to public/internal/
// Usually just shared logic, not private stuff.
// For now, let's assume we copy specific safe packages
// Logic to be refined.
// Example migration of os calls:
// internalDirs, err := os.ReadDir(pkgDir) -> coreio.Local.List(pkgDir)
// os.Stat -> coreio.Local.IsFile (returns bool) or List for existence check
// os.MkdirAll -> coreio.Local.EnsureDir
// os.WriteFile -> coreio.Local.Write
return nil
}
// addSyncCommand adds the 'sync' command to the given parent command.
func addSyncCommand(parent *cli.Command) {
syncCmd := &cli.Command{
@ -40,7 +61,7 @@ type symbolInfo struct {
func runSync() error {
pkgDir := "pkg"
internalDirs, err := os.ReadDir(pkgDir)
internalDirs, err := coreio.Local.List(pkgDir)
if err != nil {
return cli.Wrap(err, "failed to read pkg directory")
}
@ -55,7 +76,7 @@ func runSync() error {
publicDir := serviceName
publicFile := filepath.Join(publicDir, serviceName+".go")
if _, err := os.Stat(internalFile); os.IsNotExist(err) {
if !coreio.Local.IsFile(internalFile) {
continue
}
@ -73,8 +94,16 @@ func runSync() error {
}
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()
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
// ParseFile can take content as string (src argument).
node, err := parser.ParseFile(fset, path, content, parser.ParseComments)
if err != nil {
return nil, err
}
@ -134,7 +163,7 @@ type {{.InterfaceName}} = core.{{.InterfaceName}}
`
func generatePublicAPIFile(dir, path, serviceName string, symbols []symbolInfo) error {
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
if err := coreio.Local.EnsureDir(dir); err != nil {
return err
}
@ -161,5 +190,5 @@ func generatePublicAPIFile(dir, path, serviceName string, symbols []symbolInfo)
return err
}
return os.WriteFile(path, buf.Bytes(), 0644)
return coreio.Local.Write(path, buf.String())
}

View file

@ -298,7 +298,7 @@ func findTemplateWorkflow(registryDir, workflowFile string) string {
}
for _, candidate := range candidates {
if io.Local.Exists(candidate) {
if io.Local.IsFile(candidate) {
return candidate
}
}

View file

@ -94,28 +94,29 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
// Check for README.md
readme := filepath.Join(repo.Path, "README.md")
if io.Local.Exists(readme) {
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.Exists(claudeMd) {
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.Exists(changelog) {
if io.Local.IsFile(changelog) {
info.Changelog = changelog
info.HasDocs = true
}
// Recursively scan docs/ directory for .md files
docsDir := filepath.Join(repo.Path, "docs")
if io.Local.IsDir(docsDir) {
// 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
@ -138,7 +139,3 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
return info
}
func copyFile(src, dst string) error {
return io.Copy(io.Local, src, io.Local, dst)
}

View file

@ -127,7 +127,7 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
repoOutDir := filepath.Join(outputDir, outName)
// Clear existing directory
_ = io.Local.DeleteAll(repoOutDir)
io.Local.Delete(repoOutDir) // Recursive delete
if err := io.Local.EnsureDir(repoOutDir); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
@ -139,8 +139,10 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
for _, f := range info.DocsFiles {
src := filepath.Join(docsDir, f)
dst := filepath.Join(repoOutDir, f)
_ = io.Local.EnsureDir(filepath.Dir(dst))
if err := copyFile(src, dst); err != nil {
// Ensure parent dir
io.Local.EnsureDir(filepath.Dir(dst))
if err := io.Copy(io.Local, src, io.Local, dst); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
}
}

66
internal/cmd/help/cmd.go Normal file
View file

@ -0,0 +1,66 @@
package help
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/help"
)
func init() {
cli.RegisterCommands(AddHelpCommands)
}
func AddHelpCommands(root *cli.Command) {
var searchFlag string
helpCmd := &cli.Command{
Use: "help [topic]",
Short: "Display help documentation",
Run: func(cmd *cli.Command, args []string) {
catalog := help.DefaultCatalog()
if searchFlag != "" {
results := catalog.Search(searchFlag)
if len(results) == 0 {
fmt.Println("No topics found.")
return
}
fmt.Println("Search Results:")
for _, res := range results {
fmt.Printf(" %s - %s\n", res.Topic.ID, res.Topic.Title)
}
return
}
if len(args) == 0 {
topics := catalog.List()
fmt.Println("Available Help Topics:")
for _, t := range topics {
fmt.Printf(" %s - %s\n", t.ID, t.Title)
}
return
}
topic, err := catalog.Get(args[0])
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
renderTopic(topic)
},
}
helpCmd.Flags().StringVarP(&searchFlag, "search", "s", "", "Search help topics")
root.AddCommand(helpCmd)
}
func renderTopic(t *help.Topic) {
// Simple ANSI rendering for now
// Use explicit ANSI codes or just print
fmt.Printf("\n\033[1;34m%s\033[0m\n", t.Title) // Blue bold title
fmt.Println("----------------------------------------")
fmt.Println(t.Content)
fmt.Println()
}

View file

@ -9,6 +9,7 @@ import (
"strings"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
"github.com/spf13/cobra"
)
@ -73,12 +74,12 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
repoPath := filepath.Join(targetDir, repoName)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
return nil
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(targetDir); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.create", "directory"), err)
}
@ -123,18 +124,17 @@ func addToRegistryFile(org, repoName string) error {
return nil
}
f, err := os.OpenFile(regPath, os.O_APPEND|os.O_WRONLY, 0644)
content, err := coreio.Local.Read(regPath)
if err != nil {
return err
}
defer f.Close()
repoType := detectRepoType(repoName)
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
repoName, repoType)
_, err = f.WriteString(entry)
return err
content += entry
return coreio.Local.Write(regPath, content)
}
func detectRepoType(name string) string {

View file

@ -3,12 +3,12 @@ package pkgcmd
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/i18n"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
"github.com/spf13/cobra"
)
@ -58,7 +58,7 @@ func runPkgList() error {
for _, r := range allRepos {
repoPath := filepath.Join(basePath, r.Name)
exists := false
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
exists = true
installed++
} else {
@ -147,7 +147,7 @@ func runPkgUpdate(packages []string, all bool) error {
for _, name := range toUpdate {
repoPath := filepath.Join(basePath, name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil {
fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
skipped++
continue
@ -219,7 +219,7 @@ func runPkgOutdated() error {
for _, r := range reg.List() {
repoPath := filepath.Join(basePath, r.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil {
notInstalled++
continue
}

View file

@ -2,9 +2,10 @@ package sdk
import (
"fmt"
"os"
"path/filepath"
"strings"
coreio "github.com/host-uk/core/pkg/io"
)
// commonSpecPaths are checked in order when no spec is configured.
@ -25,7 +26,7 @@ func (s *SDK) DetectSpec() (string, error) {
// 1. Check configured path
if s.config.Spec != "" {
specPath := filepath.Join(s.projectDir, s.config.Spec)
if _, err := os.Stat(specPath); err == nil {
if coreio.Local.IsFile(specPath) {
return specPath, nil
}
return "", fmt.Errorf("sdk.DetectSpec: configured spec not found: %s", s.config.Spec)
@ -34,7 +35,7 @@ func (s *SDK) DetectSpec() (string, error) {
// 2. Check common paths
for _, p := range commonSpecPaths {
specPath := filepath.Join(s.projectDir, p)
if _, err := os.Stat(specPath); err == nil {
if coreio.Local.IsFile(specPath) {
return specPath, nil
}
}
@ -51,12 +52,12 @@ func (s *SDK) DetectSpec() (string, error) {
// detectScramble checks for Laravel Scramble and exports the spec.
func (s *SDK) detectScramble() (string, error) {
composerPath := filepath.Join(s.projectDir, "composer.json")
if _, err := os.Stat(composerPath); err != nil {
if !coreio.Local.IsFile(composerPath) {
return "", fmt.Errorf("no composer.json")
}
// Check for scramble in composer.json
data, err := os.ReadFile(composerPath)
data, err := coreio.Local.Read(composerPath)
if err != nil {
return "", err
}
@ -71,8 +72,7 @@ func (s *SDK) detectScramble() (string, error) {
}
// containsScramble checks if composer.json includes scramble.
func containsScramble(data []byte) bool {
content := string(data)
func containsScramble(content string) bool {
return strings.Contains(content, "dedoc/scramble") ||
strings.Contains(content, "\"scramble\"")
}

View file

@ -62,7 +62,7 @@ func TestContainsScramble(t *testing.T) {
}
for _, tt := range tests {
assert.Equal(t, tt.expected, containsScramble([]byte(tt.data)))
assert.Equal(t, tt.expected, containsScramble(tt.data))
}
}

View file

@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
)
// GoGenerator generates Go SDKs from OpenAPI specs.
@ -34,7 +36,7 @@ func (g *GoGenerator) Install() string {
// Generate creates SDK from OpenAPI spec.
func (g *GoGenerator) Generate(ctx context.Context, opts Options) error {
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil {
return fmt.Errorf("go.Generate: failed to create output dir: %w", err)
}
@ -61,7 +63,7 @@ func (g *GoGenerator) generateNative(ctx context.Context, opts Options) error {
}
goMod := fmt.Sprintf("module %s\n\ngo 1.21\n", opts.PackageName)
return os.WriteFile(filepath.Join(opts.OutputDir, "go.mod"), []byte(goMod), 0644)
return coreio.Local.Write(filepath.Join(opts.OutputDir, "go.mod"), goMod)
}
func (g *GoGenerator) generateDocker(ctx context.Context, opts Options) error {

View file

@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
)
// PHPGenerator generates PHP SDKs from OpenAPI specs.
@ -38,7 +40,7 @@ func (g *PHPGenerator) Generate(ctx context.Context, opts Options) error {
return fmt.Errorf("php.Generate: Docker is required but not available")
}
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil {
return fmt.Errorf("php.Generate: failed to create output dir: %w", err)
}

View file

@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
)
// PythonGenerator generates Python SDKs from OpenAPI specs.
@ -34,7 +36,7 @@ func (g *PythonGenerator) Install() string {
// Generate creates SDK from OpenAPI spec.
func (g *PythonGenerator) Generate(ctx context.Context, opts Options) error {
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil {
return fmt.Errorf("python.Generate: failed to create output dir: %w", err)
}

View file

@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
)
// TypeScriptGenerator generates TypeScript SDKs from OpenAPI specs.
@ -38,7 +40,7 @@ func (g *TypeScriptGenerator) Install() string {
// Generate creates SDK from OpenAPI spec.
func (g *TypeScriptGenerator) Generate(ctx context.Context, opts Options) error {
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil {
return fmt.Errorf("typescript.Generate: failed to create output dir: %w", err)
}

View file

@ -15,7 +15,7 @@ import (
"github.com/host-uk/core/internal/cmd/workspace"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
)
@ -97,7 +97,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.creating_project_dir"), projectName)
if !dryRun {
if err := io.Local.EnsureDir(targetDir); err != nil {
if err := coreio.Local.EnsureDir(targetDir); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
@ -105,7 +105,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
// Clone core-devops first
devopsPath := filepath.Join(targetDir, devopsRepo)
if !io.Local.IsDir(filepath.Join(devopsPath, ".git")) {
if _, err := coreio.Local.List(filepath.Join(devopsPath, ".git")); err != nil {
fmt.Printf("%s %s %s...\n", dimStyle.Render(">>"), i18n.T("common.status.cloning"), devopsRepo)
if !dryRun {
@ -149,12 +149,13 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
// isGitRepoRoot returns true if the directory is a git repository root.
func isGitRepoRoot(path string) bool {
return io.Local.Exists(filepath.Join(path, ".git"))
_, err := coreio.Local.List(filepath.Join(path, ".git"))
return err == nil
}
// isDirEmpty returns true if the directory is empty or contains only hidden files.
func isDirEmpty(path string) (bool, error) {
entries, err := io.Local.List(path)
entries, err := coreio.Local.List(path)
if err != nil {
return false, err
}

View file

@ -7,7 +7,7 @@ import (
"runtime"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/io"
coreio "github.com/host-uk/core/pkg/io"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
@ -52,7 +52,7 @@ func LoadCIConfig() *CIConfig {
for {
configPath := filepath.Join(dir, ".core", "ci.yaml")
data, err := io.Local.Read(configPath)
data, err := coreio.Local.Read(configPath)
if err == nil {
if err := yaml.Unmarshal([]byte(data), cfg); err == nil {
return cfg

View file

@ -16,7 +16,7 @@ import (
"github.com/host-uk/core/internal/cmd/workspace"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
coreio "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/repos"
)
@ -81,7 +81,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
// Ensure base path exists
if !dryRun {
if err := io.Local.EnsureDir(basePath); err != nil {
if err := coreio.Local.EnsureDir(basePath); err != nil {
return fmt.Errorf("failed to create packages directory: %w", err)
}
}
@ -117,7 +117,8 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
// Check if already exists
repoPath := filepath.Join(basePath, repo.Name)
if io.Local.Exists(filepath.Join(repoPath, ".git")) {
// Check .git dir existence via List
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
exists++
continue
}
@ -146,7 +147,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
// Check if already exists
repoPath := filepath.Join(basePath, repo.Name)
if io.Local.Exists(filepath.Join(repoPath, ".git")) {
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
exists++
continue
}

View file

@ -13,7 +13,7 @@ import (
"strings"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
coreio "github.com/host-uk/core/pkg/io"
)
// runRepoSetup sets up the current repository with .core/ configuration.
@ -27,7 +27,7 @@ func runRepoSetup(repoPath string, dryRun bool) error {
// Create .core directory
coreDir := filepath.Join(repoPath, ".core")
if !dryRun {
if err := io.Local.EnsureDir(coreDir); err != nil {
if err := coreio.Local.EnsureDir(coreDir); err != nil {
return fmt.Errorf("failed to create .core directory: %w", err)
}
}
@ -54,7 +54,7 @@ func runRepoSetup(repoPath string, dryRun bool) error {
for filename, content := range configs {
configPath := filepath.Join(coreDir, filename)
if err := io.Local.Write(configPath, content); err != nil {
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)
@ -66,16 +66,16 @@ func runRepoSetup(repoPath string, dryRun bool) error {
// detectProjectType identifies the project type from files present.
func detectProjectType(path string) string {
// Check in priority order
if io.Local.IsFile(filepath.Join(path, "wails.json")) {
if coreio.Local.IsFile(filepath.Join(path, "wails.json")) {
return "wails"
}
if io.Local.IsFile(filepath.Join(path, "go.mod")) {
if coreio.Local.IsFile(filepath.Join(path, "go.mod")) {
return "go"
}
if io.Local.IsFile(filepath.Join(path, "composer.json")) {
if coreio.Local.IsFile(filepath.Join(path, "composer.json")) {
return "php"
}
if io.Local.IsFile(filepath.Join(path, "package.json")) {
if coreio.Local.IsFile(filepath.Join(path, "package.json")) {
return "node"
}
return "unknown"

View file

@ -12,7 +12,7 @@ import (
"regexp"
"strings"
"github.com/host-uk/core/pkg/io"
coreio "github.com/host-uk/core/pkg/io"
"gopkg.in/yaml.v3"
)
@ -65,7 +65,7 @@ type SecurityConfig struct {
// LoadGitHubConfig reads and parses a GitHub configuration file.
func LoadGitHubConfig(path string) (*GitHubConfig, error) {
data, err := io.Local.Read(path)
data, err := coreio.Local.Read(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
@ -128,7 +128,7 @@ func expandEnvVars(input string) string {
// 3. github.yaml (relative to registry)
func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
if specifiedPath != "" {
if io.Local.Exists(specifiedPath) {
if coreio.Local.IsFile(specifiedPath) {
return specifiedPath, nil
}
return "", fmt.Errorf("config file not found: %s", specifiedPath)
@ -141,7 +141,7 @@ func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
}
for _, path := range candidates {
if io.Local.Exists(path) {
if coreio.Local.IsFile(path) {
return path, nil
}
}

View file

@ -11,6 +11,9 @@ import (
"golang.org/x/mod/semver"
)
// PkgVersion is set via ldflags
var PkgVersion = "dev"
// Version holds the current version of the application.
// It is set at build time via ldflags or fallback to the version in package.json.
var Version = PkgVersion

View file

@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
coreio "github.com/host-uk/core/pkg/io"
"gopkg.in/yaml.v3"
)
@ -28,9 +29,14 @@ func DefaultConfig() *WorkspaceConfig {
// 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 := os.ReadFile(path)
data, err := coreio.Local.Read(path)
if err != nil {
if os.IsNotExist(err) {
// 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 {
@ -43,7 +49,7 @@ func LoadConfig(dir string) (*WorkspaceConfig, error) {
}
config := DefaultConfig()
if err := yaml.Unmarshal(data, config); err != nil {
if err := yaml.Unmarshal([]byte(data), config); err != nil {
return nil, fmt.Errorf("failed to parse workspace config: %w", err)
}
@ -57,7 +63,7 @@ func LoadConfig(dir string) (*WorkspaceConfig, error) {
// 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 := os.MkdirAll(coreDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(coreDir); err != nil {
return fmt.Errorf("failed to create .core directory: %w", err)
}
@ -67,7 +73,7 @@ func SaveConfig(dir string, config *WorkspaceConfig) error {
return fmt.Errorf("failed to marshal workspace config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
if err := coreio.Local.Write(path, string(data)); err != nil {
return fmt.Errorf("failed to write workspace config: %w", err)
}
@ -82,7 +88,7 @@ func FindWorkspaceRoot() (string, error) {
}
for {
if _, err := os.Stat(filepath.Join(dir, ".core", "workspace.yaml")); err == nil {
if coreio.Local.IsFile(filepath.Join(dir, ".core", "workspace.yaml")) {
return dir, nil
}

View file

@ -41,5 +41,6 @@ import (
_ "github.com/host-uk/core/internal/cmd/updater"
_ "github.com/host-uk/core/internal/cmd/vm"
_ "github.com/host-uk/core/internal/cmd/workspace"
_ "github.com/host-uk/core/internal/cmd/help"
_ "github.com/host-uk/core/pkg/build/buildcmd"
)

1
issues.json Normal file

File diff suppressed because one or more lines are too long

87
pkg/help/catalog.go Normal file
View file

@ -0,0 +1,87 @@
package help
import (
"fmt"
)
// Catalog manages help topics.
type Catalog struct {
topics map[string]*Topic
index *searchIndex
}
// DefaultCatalog returns a catalog with built-in topics.
func DefaultCatalog() *Catalog {
c := &Catalog{
topics: make(map[string]*Topic),
index: newSearchIndex(),
}
// Add default topics
c.Add(&Topic{
ID: "getting-started",
Title: "Getting Started",
Content: `# Getting Started
Welcome to Core! This CLI tool helps you manage development workflows.
## Common Commands
- core dev: Development workflows
- core setup: Setup repository
- core doctor: Check environment health
- core test: Run tests
## Next Steps
Run 'core help <topic>' to learn more about a specific topic.
`,
})
c.Add(&Topic{
ID: "config",
Title: "Configuration",
Content: `# Configuration
Core is configured via environment variables and config files.
## Environment Variables
- CORE_DEBUG: Enable debug logging
- GITHUB_TOKEN: GitHub API token
## Config Files
Config is stored in ~/.core/config.yaml
`,
})
return c
}
// Add adds a topic to the catalog.
func (c *Catalog) Add(t *Topic) {
c.topics[t.ID] = t
c.index.Add(t)
}
// List returns all topics.
func (c *Catalog) List() []*Topic {
var list []*Topic
for _, t := range c.topics {
list = append(list, t)
}
return list
}
// Search searches for topics.
func (c *Catalog) Search(query string) []*SearchResult {
return c.index.Search(query)
}
// Get returns a topic by ID.
func (c *Catalog) Get(id string) (*Topic, error) {
t, ok := c.topics[id]
if !ok {
return nil, fmt.Errorf("topic not found: %s", id)
}
return t, nil
}

View file

@ -82,61 +82,61 @@ func New(opts ...Option) (*Service, error) {
}
}
s.registerTools()
s.registerTools(s.server)
return s, nil
}
// registerTools adds file operation tools to the MCP server.
func (s *Service) registerTools() {
func (s *Service) registerTools(server *mcp.Server) {
// File operations
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_read",
Description: "Read the contents of a file",
}, s.readFile)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_write",
Description: "Write content to a file",
}, s.writeFile)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_delete",
Description: "Delete a file or empty directory",
}, s.deleteFile)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_rename",
Description: "Rename or move a file",
}, s.renameFile)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_exists",
Description: "Check if a file or directory exists",
}, s.fileExists)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "file_edit",
Description: "Edit a file by replacing old_string with new_string. Use replace_all=true to replace all occurrences.",
}, s.editDiff)
// Directory operations
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "dir_list",
Description: "List contents of a directory",
}, s.listDirectory)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "dir_create",
Description: "Create a new directory",
}, s.createDirectory)
// Language detection
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "lang_detect",
Description: "Detect the programming language of a file",
}, s.detectLanguage)
mcp.AddTool(s.server, &mcp.Tool{
mcp.AddTool(server, &mcp.Tool{
Name: "lang_list",
Description: "Get list of supported programming languages",
}, s.getSupportedLanguages)
@ -298,13 +298,7 @@ func (s *Service) writeFile(ctx context.Context, req *mcp.CallToolRequest, input
}
func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, input ListDirectoryInput) (*mcp.CallToolResult, ListDirectoryOutput, error) {
// For directory listing, we need to use the underlying filesystem
// The Medium interface doesn't have a list method, so we validate and use os.ReadDir
path, err := s.resolvePath(input.Path)
if err != nil {
return nil, ListDirectoryOutput{}, err
}
entries, err := os.ReadDir(path)
entries, err := s.medium.List(input.Path)
if err != nil {
return nil, ListDirectoryOutput{}, fmt.Errorf("failed to list directory: %w", err)
}
@ -316,8 +310,11 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i
size = info.Size()
}
result = append(result, DirectoryEntry{
Name: e.Name(),
Path: filepath.Join(input.Path, e.Name()),
Name: e.Name(),
Path: filepath.Join(input.Path, e.Name()), // Note: This might be relative path, client might expect absolute?
// Issue 103 says "Replace ... with local.Medium sandboxing".
// Previous code returned `filepath.Join(input.Path, e.Name())`.
// If input.Path is relative, this preserves it.
IsDir: e.IsDir(),
Size: size,
})
@ -333,28 +330,14 @@ func (s *Service) createDirectory(ctx context.Context, req *mcp.CallToolRequest,
}
func (s *Service) deleteFile(ctx context.Context, req *mcp.CallToolRequest, input DeleteFileInput) (*mcp.CallToolResult, DeleteFileOutput, error) {
// Medium interface doesn't have delete, use resolved path with os.Remove
path, err := s.resolvePath(input.Path)
if err != nil {
return nil, DeleteFileOutput{}, err
}
if err := os.Remove(path); err != nil {
if err := s.medium.Delete(input.Path); err != nil {
return nil, DeleteFileOutput{}, fmt.Errorf("failed to delete file: %w", err)
}
return nil, DeleteFileOutput{Success: true, Path: input.Path}, nil
}
func (s *Service) renameFile(ctx context.Context, req *mcp.CallToolRequest, input RenameFileInput) (*mcp.CallToolResult, RenameFileOutput, error) {
// Medium interface doesn't have rename, use resolved paths with os.Rename
oldPath, err := s.resolvePath(input.OldPath)
if err != nil {
return nil, RenameFileOutput{}, err
}
newPath, err := s.resolvePath(input.NewPath)
if err != nil {
return nil, RenameFileOutput{}, err
}
if err := os.Rename(oldPath, newPath); err != nil {
if err := s.medium.Rename(input.OldPath, input.NewPath); err != nil {
return nil, RenameFileOutput{}, fmt.Errorf("failed to rename file: %w", err)
}
return nil, RenameFileOutput{Success: true, OldPath: input.OldPath, NewPath: input.NewPath}, nil
@ -365,19 +348,17 @@ func (s *Service) fileExists(ctx context.Context, req *mcp.CallToolRequest, inpu
if exists {
return nil, FileExistsOutput{Exists: true, IsDir: false, Path: input.Path}, nil
}
// Check if it's a directory
path, err := s.resolvePath(input.Path)
if err != nil {
return nil, FileExistsOutput{}, err
}
info, err := os.Stat(path)
if os.IsNotExist(err) {
return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil
}
if err != nil {
return nil, FileExistsOutput{}, fmt.Errorf("failed to check file: %w", err)
}
return nil, FileExistsOutput{Exists: true, IsDir: info.IsDir(), Path: input.Path}, nil
// Check if it's a directory by attempting to list it
// List might fail if it's a file too (but we checked IsFile) or if doesn't exist.
_, err := s.medium.List(input.Path)
isDir := err == nil
// If List failed, it might mean it doesn't exist OR it's a special file or permissions.
// Assuming if List works, it's a directory.
// Refinement: If it doesn't exist, List returns error.
return nil, FileExistsOutput{Exists: isDir, IsDir: isDir, Path: input.Path}, nil
}
func (s *Service) detectLanguage(ctx context.Context, req *mcp.CallToolRequest, input DetectLanguageInput) (*mcp.CallToolResult, DetectLanguageOutput, error) {
@ -443,73 +424,6 @@ func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input
}, nil
}
// resolvePath converts a relative path to absolute using the workspace root.
// For operations not covered by Medium interface, this provides the full path.
// Returns an error if the path is outside the workspace root.
func (s *Service) resolvePath(path string) (string, error) {
if s.workspaceRoot == "" {
// Unrestricted mode
if filepath.IsAbs(path) {
return filepath.Clean(path), nil
}
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
return abs, nil
}
var absPath string
if filepath.IsAbs(path) {
absPath = filepath.Clean(path)
} else {
absPath = filepath.Join(s.workspaceRoot, path)
}
// Resolve symlinks for security
resolvedRoot, err := filepath.EvalSymlinks(s.workspaceRoot)
if err != nil {
return "", fmt.Errorf("failed to resolve workspace root: %w", err)
}
// Build boundary-aware prefix
rootWithSep := resolvedRoot
if !strings.HasSuffix(rootWithSep, string(filepath.Separator)) {
rootWithSep += string(filepath.Separator)
}
// Check if path exists to resolve symlinks
if _, err := os.Lstat(absPath); err == nil {
resolvedPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
if resolvedPath != resolvedRoot && !strings.HasPrefix(resolvedPath, rootWithSep) {
return "", fmt.Errorf("path outside workspace: %s", path)
}
return resolvedPath, nil
}
// Path doesn't exist - verify parent directory
parentDir := filepath.Dir(absPath)
if _, err := os.Lstat(parentDir); err == nil {
resolvedParent, err := filepath.EvalSymlinks(parentDir)
if err != nil {
return "", fmt.Errorf("failed to resolve parent: %w", err)
}
if resolvedParent != resolvedRoot && !strings.HasPrefix(resolvedParent, rootWithSep) {
return "", fmt.Errorf("path outside workspace: %s", path)
}
}
// Verify the cleaned path is within workspace
if absPath != s.workspaceRoot && !strings.HasPrefix(absPath, rootWithSep) {
return "", fmt.Errorf("path outside workspace: %s", path)
}
return absPath, nil
}
// detectLanguageFromPath maps file extensions to language IDs.
func detectLanguageFromPath(path string) string {
ext := filepath.Ext(path)
@ -566,8 +480,14 @@ func detectLanguageFromPath(path string) string {
}
}
// Run starts the MCP server on stdio.
// Run starts the MCP server.
// If MCP_ADDR is set, it starts a TCP server.
// Otherwise, it starts a Stdio server.
func (s *Service) Run(ctx context.Context) error {
addr := os.Getenv("MCP_ADDR")
if addr != "" {
return s.ServeTCP(ctx, addr)
}
return s.server.Run(ctx, &mcp.StdioTransport{})
}

View file

@ -129,46 +129,7 @@ func TestMedium_Good_IsFile(t *testing.T) {
}
}
func TestResolvePath_Good(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
// Write a test file so resolve can work
_ = s.medium.Write("test.txt", "content")
// Relative path should resolve to workspace
resolved, err := s.resolvePath("test.txt")
if err != nil {
t.Fatalf("Failed to resolve path: %v", err)
}
// The resolved path may be the symlink-resolved version
if !filepath.IsAbs(resolved) {
t.Errorf("Expected absolute path, got %s", resolved)
}
}
func TestResolvePath_Good_NoWorkspace(t *testing.T) {
s, err := New(WithWorkspaceRoot(""))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
// With no workspace, relative paths resolve to cwd
cwd, _ := os.Getwd()
resolved, err := s.resolvePath("test.txt")
if err != nil {
t.Fatalf("Failed to resolve path: %v", err)
}
expected := filepath.Join(cwd, "test.txt")
if resolved != expected {
t.Errorf("Expected %s, got %s", expected, resolved)
}
}
func TestResolvePath_Bad_Traversal(t *testing.T) {
func TestSandboxing_Bad_Traversal(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
@ -176,19 +137,25 @@ func TestResolvePath_Bad_Traversal(t *testing.T) {
}
// Path traversal should fail
_, err = s.resolvePath("../secret.txt")
_, err = s.medium.Read("../secret.txt")
if err == nil {
t.Error("Expected error for path traversal")
}
// Absolute path outside workspace should fail
_, err = s.resolvePath("/etc/passwd")
// Note: local.Medium rejects all absolute paths if they are not inside root.
// But Read takes relative path usually. If absolute, it cleans it.
// If we pass "/etc/passwd", local.Medium path clean might reject it or treat it relative?
// local.Medium.path() implementation:
// if filepath.IsAbs(cleanPath) { return "", errors.New("path traversal attempt detected") }
// So yes, it rejects absolute paths passed to Read.
_, err = s.medium.Read("/etc/passwd")
if err == nil {
t.Error("Expected error for absolute path outside workspace")
t.Error("Expected error for absolute path")
}
}
func TestResolvePath_Bad_SymlinkTraversal(t *testing.T) {
func TestSandboxing_Bad_SymlinkTraversal(t *testing.T) {
tmpDir := t.TempDir()
outsideDir := t.TempDir()
@ -210,7 +177,7 @@ func TestResolvePath_Bad_SymlinkTraversal(t *testing.T) {
}
// Symlink traversal should be blocked
_, err = s.resolvePath("evil-link")
_, err = s.medium.Read("evil-link")
if err == nil {
t.Error("Expected error for symlink pointing outside workspace")
}

131
pkg/mcp/transport_tcp.go Normal file
View file

@ -0,0 +1,131 @@
package mcp
import (
"bufio"
"context"
"fmt"
"net"
"os"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// TCPTransport manages a TCP listener for MCP.
type TCPTransport struct {
addr string
listener net.Listener
}
// NewTCPTransport creates a new TCP transport listener.
// It listens on the provided address (e.g. "localhost:9100").
func NewTCPTransport(addr string) (*TCPTransport, error) {
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
return &TCPTransport{addr: addr, listener: listener}, nil
}
// ServeTCP starts a TCP server for the MCP service.
// It accepts connections and spawns a new MCP server session for each connection.
func (s *Service) ServeTCP(ctx context.Context, addr string) error {
t, err := NewTCPTransport(addr)
if err != nil {
return err
}
defer t.listener.Close()
if addr == "" {
addr = t.listener.Addr().String()
}
fmt.Fprintf(os.Stderr, "MCP TCP server listening on %s\n", addr)
for {
conn, err := t.listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
fmt.Fprintf(os.Stderr, "Accept error: %v\n", err)
continue
}
}
go s.handleConnection(ctx, conn)
}
}
func (s *Service) handleConnection(ctx context.Context, conn net.Conn) {
// Note: We don't defer conn.Close() here because it's closed by the Server/Transport
// Create new server instance for this connection
impl := &mcp.Implementation{
Name: "core-cli",
Version: "0.1.0",
}
server := mcp.NewServer(impl, nil)
s.registerTools(server)
// Create transport for this connection
transport := &connTransport{conn: conn}
// Run server (blocks until connection closed)
// Server.Run calls Connect, then Read loop.
if err := server.Run(ctx, transport); err != nil {
fmt.Fprintf(os.Stderr, "Connection error: %v\n", err)
}
}
// connTransport adapts net.Conn to mcp.Transport
type connTransport struct {
conn net.Conn
}
func (t *connTransport) Connect(ctx context.Context) (mcp.Connection, error) {
return &connConnection{
conn: t.conn,
scanner: bufio.NewScanner(t.conn),
}, nil
}
// connConnection implements mcp.Connection
type connConnection struct {
conn net.Conn
scanner *bufio.Scanner
}
func (c *connConnection) Read(ctx context.Context) (jsonrpc.Message, error) {
// Blocks until line is read
if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil {
return nil, err
}
// EOF
// Return error to signal closure, as per Scanner contract?
// SDK usually expects error on close.
return nil, fmt.Errorf("EOF")
}
line := c.scanner.Bytes()
return jsonrpc.DecodeMessage(line)
}
func (c *connConnection) Write(ctx context.Context, msg jsonrpc.Message) error {
data, err := jsonrpc.EncodeMessage(msg)
if err != nil {
return err
}
// Append newline for line-delimited JSON
data = append(data, '\n')
_, err = c.conn.Write(data)
return err
}
func (c *connConnection) Close() error {
return c.conn.Close()
}
func (c *connConnection) SessionID() string {
return "tcp-session" // Unique ID might be better, but optional
}