diff --git a/core-test b/core-test new file mode 100755 index 0000000..65048b8 Binary files /dev/null and b/core-test differ diff --git a/go.mod b/go.mod index 242bca5..04725dc 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f17621d..1402b11 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cmd/dev/cmd_apply.go b/internal/cmd/dev/cmd_apply.go index 7f955f7..21bd1b0 100644 --- a/internal/cmd/dev/cmd_apply.go +++ b/internal/cmd/dev/cmd_apply.go @@ -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 } } diff --git a/internal/cmd/dev/cmd_commit.go b/internal/cmd/dev/cmd_commit.go index 3533298..1bf8c60 100644 --- a/internal/cmd/dev/cmd_commit.go +++ b/internal/cmd/dev/cmd_commit.go @@ -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). diff --git a/internal/cmd/dev/cmd_file_sync.go b/internal/cmd/dev/cmd_file_sync.go index 45ef2c9..4886683 100644 --- a/internal/cmd/dev/cmd_file_sync.go +++ b/internal/cmd/dev/cmd_file_sync.go @@ -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 } } diff --git a/internal/cmd/dev/cmd_sync.go b/internal/cmd/dev/cmd_sync.go index 87a0a96..33670d0 100644 --- a/internal/cmd/dev/cmd_sync.go +++ b/internal/cmd/dev/cmd_sync.go @@ -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()) } diff --git a/internal/cmd/dev/cmd_workflow.go b/internal/cmd/dev/cmd_workflow.go index 4f4ca26..98df508 100644 --- a/internal/cmd/dev/cmd_workflow.go +++ b/internal/cmd/dev/cmd_workflow.go @@ -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 } } diff --git a/internal/cmd/docs/cmd_scan.go b/internal/cmd/docs/cmd_scan.go index 9924407..d88ad27 100644 --- a/internal/cmd/docs/cmd_scan.go +++ b/internal/cmd/docs/cmd_scan.go @@ -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) -} diff --git a/internal/cmd/docs/cmd_sync.go b/internal/cmd/docs/cmd_sync.go index 79bdac0..2cbfb4d 100644 --- a/internal/cmd/docs/cmd_sync.go +++ b/internal/cmd/docs/cmd_sync.go @@ -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) } } diff --git a/internal/cmd/help/cmd.go b/internal/cmd/help/cmd.go new file mode 100644 index 0000000..dcb8073 --- /dev/null +++ b/internal/cmd/help/cmd.go @@ -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() +} diff --git a/internal/cmd/pkgcmd/cmd_install.go b/internal/cmd/pkgcmd/cmd_install.go index 08bf87c..d3d0bf5 100644 --- a/internal/cmd/pkgcmd/cmd_install.go +++ b/internal/cmd/pkgcmd/cmd_install.go @@ -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 { diff --git a/internal/cmd/pkgcmd/cmd_manage.go b/internal/cmd/pkgcmd/cmd_manage.go index d7f1bb9..cabba86 100644 --- a/internal/cmd/pkgcmd/cmd_manage.go +++ b/internal/cmd/pkgcmd/cmd_manage.go @@ -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 } diff --git a/internal/cmd/sdk/detect.go b/internal/cmd/sdk/detect.go index aeb221f..a835ab8 100644 --- a/internal/cmd/sdk/detect.go +++ b/internal/cmd/sdk/detect.go @@ -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\"") } diff --git a/internal/cmd/sdk/detect_test.go b/internal/cmd/sdk/detect_test.go index 4511e08..fef2dbc 100644 --- a/internal/cmd/sdk/detect_test.go +++ b/internal/cmd/sdk/detect_test.go @@ -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)) } } diff --git a/internal/cmd/sdk/generators/go.go b/internal/cmd/sdk/generators/go.go index e2c2bc1..0aff527 100644 --- a/internal/cmd/sdk/generators/go.go +++ b/internal/cmd/sdk/generators/go.go @@ -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 { diff --git a/internal/cmd/sdk/generators/php.go b/internal/cmd/sdk/generators/php.go index 6403af3..ce70191 100644 --- a/internal/cmd/sdk/generators/php.go +++ b/internal/cmd/sdk/generators/php.go @@ -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) } diff --git a/internal/cmd/sdk/generators/python.go b/internal/cmd/sdk/generators/python.go index bd5f91f..a95bcb6 100644 --- a/internal/cmd/sdk/generators/python.go +++ b/internal/cmd/sdk/generators/python.go @@ -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) } diff --git a/internal/cmd/sdk/generators/typescript.go b/internal/cmd/sdk/generators/typescript.go index c88b9b6..843a146 100644 --- a/internal/cmd/sdk/generators/typescript.go +++ b/internal/cmd/sdk/generators/typescript.go @@ -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) } diff --git a/internal/cmd/setup/cmd_bootstrap.go b/internal/cmd/setup/cmd_bootstrap.go index 8f73331..a7d354e 100644 --- a/internal/cmd/setup/cmd_bootstrap.go +++ b/internal/cmd/setup/cmd_bootstrap.go @@ -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 } diff --git a/internal/cmd/setup/cmd_ci.go b/internal/cmd/setup/cmd_ci.go index e19b639..11ca0ea 100644 --- a/internal/cmd/setup/cmd_ci.go +++ b/internal/cmd/setup/cmd_ci.go @@ -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 diff --git a/internal/cmd/setup/cmd_registry.go b/internal/cmd/setup/cmd_registry.go index 6e193d3..896a4e6 100644 --- a/internal/cmd/setup/cmd_registry.go +++ b/internal/cmd/setup/cmd_registry.go @@ -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 } diff --git a/internal/cmd/setup/cmd_repo.go b/internal/cmd/setup/cmd_repo.go index 558f454..c815969 100644 --- a/internal/cmd/setup/cmd_repo.go +++ b/internal/cmd/setup/cmd_repo.go @@ -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" diff --git a/internal/cmd/setup/github_config.go b/internal/cmd/setup/github_config.go index 905e9e1..7c12795 100644 --- a/internal/cmd/setup/github_config.go +++ b/internal/cmd/setup/github_config.go @@ -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 } } diff --git a/internal/cmd/updater/updater.go b/internal/cmd/updater/updater.go index 69929c4..f364fa8 100644 --- a/internal/cmd/updater/updater.go +++ b/internal/cmd/updater/updater.go @@ -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 diff --git a/internal/cmd/workspace/config.go b/internal/cmd/workspace/config.go index fc781b5..2be8e35 100644 --- a/internal/cmd/workspace/config.go +++ b/internal/cmd/workspace/config.go @@ -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 } diff --git a/internal/variants/full.go b/internal/variants/full.go index 0232c70..861ea7b 100644 --- a/internal/variants/full.go +++ b/internal/variants/full.go @@ -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" ) diff --git a/issues.json b/issues.json new file mode 100644 index 0000000..7b21d3d --- /dev/null +++ b/issues.json @@ -0,0 +1 @@ +[{"body":"Parent: #133\n\n**Complexity: Low** - Good for parallel work\n\n## Task\nAdd `core help` command that displays help topics in the terminal.\n\n## Commands\n\n```bash\n# List all help topics\ncore help\n\n# Show specific topic\ncore help getting-started\n\n# Show specific section\ncore help getting-started#installation\n\n# Search help\ncore help --search \"workspace\"\ncore help -s \"config\"\n```\n\n## Implementation\n\n```go\n// internal/cmd/help/cmd.go\n\nvar helpCmd = &cobra.Command{\n Use: \"help [topic]\",\n Short: \"Display help documentation\",\n Run: runHelp,\n}\n\nvar searchFlag string\n\nfunc init() {\n helpCmd.Flags().StringVarP(&searchFlag, \"search\", \"s\", \"\", \"Search help topics\")\n}\n\nfunc runHelp(cmd *cobra.Command, args []string) {\n catalog := help.DefaultCatalog()\n \n if searchFlag != \"\" {\n results := catalog.Search(searchFlag)\n // Display search results\n return\n }\n \n if len(args) == 0 {\n // List all topics\n topics := catalog.List()\n for _, t := range topics {\n fmt.Printf(\" %s - %s\\n\", t.ID, t.Title)\n }\n return\n }\n \n // Show specific topic\n topic, err := catalog.Get(args[0])\n if err != nil {\n cli.Error(\"Topic not found: %s\", args[0])\n return\n }\n \n // Render markdown to terminal\n renderTopic(topic)\n}\n```\n\n## Terminal Rendering\n- Use `github.com/charmbracelet/glamour` for markdown\n- Or simple formatting with ANSI colors\n- Pager support for long content (`less` style)\n\n## Acceptance Criteria\n- [ ] `core help` lists topics\n- [ ] `core help ` shows content\n- [ ] `core help --search` finds topics\n- [ ] Markdown rendered nicely in terminal\n- [ ] Pager for long content","number":136,"title":"feat(help): Add CLI help command"},{"body":"## Overview\n\nMerge `pkg/errors` and `pkg/log` into a unified `pkg/log` package with static functions for both logging and error creation. This simplifies the codebase and provides a consistent API.\n\n## Current State\n\n**pkg/log** (2 files import):\n- `Logger` struct with level-based logging\n- Static: `Debug()`, `Info()`, `Warn()`, `Error()`\n- Structured key-value logging\n\n**pkg/errors** (11 files import):\n- `Error` struct with Op, Msg, Err, Code\n- `E()`, `Wrap()`, `WrapCode()`, `Code()`\n- Standard library wrappers: `Is()`, `As()`, `New()`, `Join()`\n\n## Target API\n\n```go\npackage log\n\n// --- Logging (existing) ---\nlog.Debug(\"message\", \"key\", value)\nlog.Info(\"message\", \"key\", value)\nlog.Warn(\"message\", \"key\", value)\nlog.Error(\"message\", \"key\", value)\n\n// --- Error Creation (merged from pkg/errors) ---\nlog.E(\"op\", \"message\", err) // Create structured error\nlog.Wrap(err, \"op\", \"message\") // Wrap with context\nlog.WrapCode(err, \"CODE\", \"op\", \"msg\") // Wrap with error code\n\n// --- Standard library (re-exported) ---\nlog.Is(err, target) // errors.Is\nlog.As(err, &target) // errors.As\nlog.New(\"message\") // errors.New\nlog.Join(errs...) // errors.Join\n\n// --- Error Helpers ---\nlog.Op(err) // Extract operation\nlog.ErrCode(err) // Extract error code\nlog.Message(err) // Extract message\nlog.Root(err) // Get root cause\n\n// --- Combined Helpers (new) ---\nlog.LogError(err, \"op\", \"msg\") // Log + return wrapped error\nlog.Must(err, \"op\", \"msg\") // Panic if error\n```\n\n## Benefits\n\n1. **Single import** - `log` handles both logging and errors\n2. **Consistent patterns** - Same package for observability\n3. **Simpler mental model** - \"If something goes wrong, use log\"\n4. **Natural pairing** - Errors often logged immediately\n\n## Child Issues\n\n### Phase 1: Extend pkg/log (blocking)\n- [ ] #128 - Add error creation functions to pkg/log\n\n### Phase 2: Migration (sequential)\n- [ ] #129 - Create pkg/errors deprecation alias\n- [ ] #130 - Migrate pkg/errors imports to pkg/log (11 files)\n- [ ] #131 - Remove deprecated pkg/errors package\n\n### Phase 3: Enhancements (optional)\n- [ ] #132 - Add combined log-and-return error helpers\n\n## Parallelization Guide\n\n**Can be done by other models (boring/mechanical):**\n- #129 - Deprecation alias (copy-paste with aliases)\n- #130 - Import migration (find/replace)\n- #131 - Cleanup (delete directory)\n\n**Requires more context:**\n- #128 - API design decisions\n- #132 - Helper design\n\n## Migration Path\n\n1. Extend `pkg/log` with error functions (#128)\n2. Create deprecation alias (#129)\n3. Migrate all imports (#130)\n4. Remove `pkg/errors` (#131)\n\n## Acceptance Criteria\n- [ ] Single `pkg/log` import for logging and errors\n- [ ] Zero imports of `pkg/errors`\n- [ ] All tests pass\n- [ ] Combined helpers available (#132)","number":127,"title":"feat(log): Unify pkg/errors and pkg/log into single logging package"},{"body":"Parent: #118\n\n**Complexity: Low** - Similar to socket but simpler\n\n## Task\nAdd TCP transport for network MCP connections.\n\n## Implementation\n\n```go\n// pkg/mcp/transport_tcp.go\n\ntype TCPTransport struct {\n addr string\n listener net.Listener\n}\n\nfunc NewTCPTransport(addr string) (*TCPTransport, error) {\n listener, err := net.Listen(\"tcp\", addr)\n if err != nil {\n return nil, err\n }\n return &TCPTransport{addr: addr, listener: listener}, nil\n}\n```\n\n## Configuration\n- Default: `127.0.0.1:9100` (localhost only)\n- Configurable via `MCP_ADDR` env var\n- Consider TLS for non-localhost\n\n## Security Considerations\n- Default to localhost binding\n- Warn if binding to 0.0.0.0\n- Future: mTLS support\n\n## Acceptance Criteria\n- [ ] TCP listener with configurable address\n- [ ] Localhost-only by default\n- [ ] Multiple concurrent connections\n- [ ] Graceful shutdown","number":126,"title":"feat(mcp): Add TCP transport"},{"body":"Parent: #101\n\n**Complexity: Medium** - 5 files with config/registry operations\n\n## Task\nMigrate `internal/cmd/setup/*` to use `io.Medium`.\n\n## Files\n- Bootstrap commands\n- Registry management\n- GitHub config files","number":116,"title":"chore(io): Migrate internal/cmd/setup to Medium abstraction"},{"body":"Parent: #101\n\n**Complexity: Medium** - 5 files with code generation\n\n## Task\nMigrate `internal/cmd/sdk/*` to use `io.Medium`.\n\n## Files\n- SDK generation commands\n- Generator implementations","number":115,"title":"chore(io): Migrate internal/cmd/sdk to Medium abstraction"},{"body":"Parent: #101\n\n**Complexity: Low** - 3 files\n\n## Task\nMigrate `internal/cmd/dev/*` to use `io.Medium`.\n\n## Files\n- Dev workflow commands\n- Registry operations","number":114,"title":"chore(io): Migrate internal/cmd/dev to Medium abstraction"},{"body":"Parent: #101\n\n**Complexity: Low** - 2 files\n\n## Task\nMigrate `internal/cmd/docs/*` to use `io.Medium`.\n\n## Files\n- Documentation scanning\n- File sync operations","number":113,"title":"chore(io): Migrate internal/cmd/docs to Medium abstraction"},{"body":"Parent: #101\n\n## Task\nReplace custom path validation in `pkg/mcp/mcp.go` with `local.Medium` sandboxing.\n\n## Current State\nThe MCP server has custom `validatePath()` and `resolvePathWithSymlinks()` functions for path security.\n\n## Target State\nUse `local.New(workspaceRoot)` to create a sandboxed Medium, then use Medium methods for all file operations.\n\n## Changes Required\n1. Add `medium local.Medium` field to `Service` struct\n2. Initialize medium in `New()` with workspace root\n3. Replace all file operation handlers to use medium methods\n4. Remove custom `validatePath()` function (Medium handles this)\n5. Update tests\n\n## Benefits\n- Consistent path security across codebase\n- Symlink handling built into Medium\n- Simpler MCP code\n\n## Blocked By\n- #102 (Medium interface extension)","number":103,"title":"feat(io): Migrate pkg/mcp to use Medium abstraction"},{"body":"Follow-up from #87 (NO_COLOR support implemented in #98).\n\n## Remaining Work\n\n### 1. WCAG Color Contrast Audit\nAudit the color combinations in `pkg/cli/styles.go` for WCAG contrast compliance:\n- Check foreground/background contrast ratios\n- Ensure sufficient contrast on both dark and light terminal backgrounds\n- Document any colors that may have accessibility issues\n\n### 2. Terminal Capability Adaptation\nConsider adapting to terminal capabilities:\n- Detect 16/256/TrueColor support\n- Fallback to simpler colors on limited terminals\n- Potentially use a library that handles this automatically\n\n## Reference\n- Current colors: `pkg/cli/styles.go` (Tailwind palette)\n- ANSI implementation: `pkg/cli/ansi.go`\n- WCAG contrast guidelines: https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html\n\n## Relates to\n- #87 (partial implementation)\n- #86 (Accessibility Audit parent)","number":99,"title":"CLI Output: Color contrast audit and terminal adaptation"},{"body":"Review documentation for accessibility best practices.\n\n**Issue:**\n- Automated scans for alt text on images found no direct Markdown/HTML images, but verification is needed for any generated docs.\n- Ensure heading hierarchy is logical (H1 -> H2 -> H3).\n\n**Recommendation:**\n- Add a CI check for markdown accessibility (e.g., markdownlint with a11y rules).\n- Ensure all future diagrams/images have descriptive alt text.","number":89,"title":"Documentation: Improve Accessibility"},{"body":"The Angular application in pkg/updater/ui/src needs a comprehensive accessibility audit.\n\n**Findings:**\n- Missing aria-labels on buttons.\n- Images potentially missing alt text (grep scan found none, but verification needed on dynamic content).\n- No evidence of high-contrast mode support.\n\n**Recommendation:**\n- Run automated a11y tests (e.g., axe-core).\n- Audit keyboard navigation flow.\n- Ensure all interactive elements have accessible names.","number":88,"title":"Web UI: Audit Angular App Accessibility"}] diff --git a/pkg/help/catalog.go b/pkg/help/catalog.go new file mode 100644 index 0000000..04f2668 --- /dev/null +++ b/pkg/help/catalog.go @@ -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 ' 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 +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 2e4d7b5..9f07dbc 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -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{}) } diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 4d33d7c..9b0c9ee 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -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") } diff --git a/pkg/mcp/transport_tcp.go b/pkg/mcp/transport_tcp.go new file mode 100644 index 0000000..f7b5f1e --- /dev/null +++ b/pkg/mcp/transport_tcp.go @@ -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 +}