From 1f7ea885d377d9ecfcc8b36a1021b6c12fcb0797 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Fri, 3 Apr 2026 18:46:37 +0200 Subject: [PATCH 1/2] fix exercise.md writing --- trainings/next.go | 21 +++++++++++++++++++++ trainings/reset.go | 23 +++++++++++++++++++++-- trainings/restore.go | 5 +++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/trainings/next.go b/trainings/next.go index eafe4eb..32f14b1 100644 --- a/trainings/next.go +++ b/trainings/next.go @@ -136,6 +136,10 @@ func (h *Handlers) setExercise(fs *afero.BasePathFs, exercise *genproto.NextExer } postWrite: + // exercise.md is gitignored (copyright), so git-based flows won't deliver it. + // Write it directly regardless of which path we took. + writeExerciseMd(exercise.FilesToCreate, fs, exercise.Dir) + if exercise.IsTextOnly { printTextOnlyExerciseInfo( h.config.TrainingConfig(fs).TrainingName, @@ -455,6 +459,23 @@ func nextExerciseResponseToExerciseSolution(resp *genproto.NextExerciseResponse) return sol } +// writeExerciseMd writes exercise.md directly to the filesystem. +// exercise.md is gitignored (copyright), so git-based flows (merge, checkout) +// won't deliver it. Call after any git-based file delivery. +func writeExerciseMd(allFiles []*genproto.File, fs afero.Fs, exerciseDir string) { + var mdFiles []*genproto.File + for _, f := range allFiles { + if f.Path == files.ExerciseFile { + mdFiles = append(mdFiles, f) + } + } + if len(mdFiles) > 0 { + if err := files.NewFilesSilent().WriteExerciseFiles(mdFiles, fs, exerciseDir); err != nil { + logrus.WithError(err).Warn("Could not write exercise.md") + } + } +} + // resolveConflictsInteractive runs a loop where the user can fix conflicts in their editor, // replace all exercise files with init branch versions (saving to a backup branch), or quit. // diff --git a/trainings/reset.go b/trainings/reset.go index 1ea4edf..36461c6 100644 --- a/trainings/reset.go +++ b/trainings/reset.go @@ -9,8 +9,10 @@ import ( "github.com/fatih/color" "github.com/manifoldco/promptui" + "github.com/sirupsen/logrus" "github.com/ThreeDotsLabs/cli/internal" + "github.com/ThreeDotsLabs/cli/trainings/genproto" "github.com/ThreeDotsLabs/cli/trainings/git" ) @@ -97,14 +99,31 @@ func (h *Handlers) Reset(ctx context.Context) error { switch resetMode { case 0: - return h.resetCleanFiles(gitOps, initBranch, moduleExercisePath, exerciseCfg.Directory) + if err := h.resetCleanFiles(gitOps, initBranch, moduleExercisePath, exerciseCfg.Directory); err != nil { + return err + } case 1: - return h.resetMissingOnly(gitOps, initBranch, moduleExercisePath, exerciseCfg.Directory, trainingRoot) + if err := h.resetMissingOnly(gitOps, initBranch, moduleExercisePath, exerciseCfg.Directory, trainingRoot); err != nil { + return err + } case 2: fmt.Println("Cancelled") return nil } + // exercise.md is gitignored, so checkout from init branch won't restore it. + // Fetch from server and write directly. + scaffoldResp, err := h.newGrpcClient().GetExercise(ctx, &genproto.GetExerciseRequest{ + TrainingName: trainingConfig.TrainingName, + Token: h.config.GlobalConfig().Token, + ExerciseId: exerciseCfg.ExerciseID, + }) + if err != nil { + logrus.WithError(err).Warn("Could not fetch exercise scaffold for exercise.md") + } else { + writeExerciseMd(scaffoldResp.FilesToCreate, trainingRootFs, exerciseCfg.Directory) + } + return nil } diff --git a/trainings/restore.go b/trainings/restore.go index 013f6ac..a889d89 100644 --- a/trainings/restore.go +++ b/trainings/restore.go @@ -210,6 +210,11 @@ func (h *Handlers) restoreExerciseWithGit( return fmt.Errorf("failed to write solution files: %w", err) } + // exercise.md is gitignored, so the merge didn't deliver it. Write directly. + if scaffoldResp != nil { + writeExerciseMd(scaffoldResp.FilesToCreate, trainingRootFs, solution.Dir) + } + // 5. Commit completed — with original date if available if err := quietOps.AddAll(solution.Dir); err != nil { return fmt.Errorf("failed to stage solution: %w", err) From efd9fbd704da6da2c6aeeb8d101b225defd9158d Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sat, 4 Apr 2026 18:01:31 +0200 Subject: [PATCH 2/2] added MCP and agents support --- go.mod | 8 +- go.sum | 19 +- tdl/main.go | 10 +- trainings/config/global.go | 10 +- trainings/config/training.go | 30 ++- trainings/configure.go | 17 +- trainings/files/write.go | 6 +- trainings/git_config.go | 46 ++++- trainings/grpc.go | 28 +++ trainings/handlers.go | 15 +- trainings/init.go | 5 + trainings/mcp/output_buffer.go | 58 ++++++ trainings/mcp/server.go | 84 ++++++++ trainings/mcp/state.go | 190 ++++++++++++++++++ trainings/mcp/tools.go | 150 ++++++++++++++ trainings/mcp_detect.go | 162 ++++++++++++++++ trainings/mcp_files.go | 345 +++++++++++++++++++++++++++++++++ trainings/mcp_files_test.go | 241 +++++++++++++++++++++++ trainings/next.go | 26 ++- trainings/run.go | 266 +++++++++++++++++++++++-- 20 files changed, 1651 insertions(+), 65 deletions(-) create mode 100644 trainings/mcp/output_buffer.go create mode 100644 trainings/mcp/server.go create mode 100644 trainings/mcp/state.go create mode 100644 trainings/mcp/tools.go create mode 100644 trainings/mcp_detect.go create mode 100644 trainings/mcp_files.go create mode 100644 trainings/mcp_files_test.go diff --git a/go.mod b/go.mod index cb11c09..1fdc1db 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 github.com/hexops/gotextdiff v1.0.3 github.com/manifoldco/promptui v0.9.0 + github.com/mark3labs/mcp-go v0.46.0 github.com/mergestat/timediff v0.0.4 github.com/pkg/errors v0.9.1 github.com/schollz/progressbar/v3 v3.19.0 @@ -20,6 +21,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.3.0 golang.org/x/crypto v0.46.0 + golang.org/x/term v0.38.0 google.golang.org/grpc v1.74.2 google.golang.org/protobuf v1.36.11 ) @@ -34,24 +36,24 @@ require ( github.com/go-fed/httpsig v1.1.0 // indirect github.com/google/go-github/v74 v74.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-version v1.8.0 // indirect - github.com/kr/pretty v0.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/ulikunitz/xz v0.5.15 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect diff --git a/go.sum b/go.sum index 69ee946..ba202fa 100644 --- a/go.sum +++ b/go.sum @@ -18,7 +18,6 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creativeprojects/go-selfupdate v1.5.2 h1:3KR3JLrq70oplb9yZzbmJ89qRP78D1AN/9u+l3k0LJ4= github.com/creativeprojects/go-selfupdate v1.5.2/go.mod h1:BCOuwIl1dRRCmPNRPH0amULeZqayhKyY2mH/h4va7Dk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -28,6 +27,8 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -43,6 +44,8 @@ github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsU github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= @@ -58,16 +61,17 @@ github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo= +github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -87,7 +91,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= @@ -100,6 +103,8 @@ github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -109,6 +114,8 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= gitlab.com/gitlab-org/api/client-go v1.9.1 h1:tZm+URa36sVy8UCEHQyGGJ8COngV4YqMHpM6k9O5tK8= gitlab.com/gitlab-org/api/client-go v1.9.1/go.mod h1:71yTJk1lnHCWcZLvM5kPAXzeJ2fn5GjaoV8gTOPd4ME= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -162,10 +169,8 @@ google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeB google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/tdl/main.go b/tdl/main.go index 817acd0..42a7eca 100644 --- a/tdl/main.go +++ b/tdl/main.go @@ -265,6 +265,12 @@ var app = &cli.App{ Aliases: []string{"d"}, Usage: "running in non-interactive mode", }, + &cli.IntFlag{ + Name: "mcp-port", + Usage: "port for MCP server on 127.0.0.1 (0 to disable)", + Value: 39131, + EnvVars: []string{"TDL_MCP_PORT"}, + }, }, Action: func(c *cli.Context) error { err := newHandlers(c).Run(c.Context, c.Bool("detached")) @@ -416,6 +422,8 @@ func newHandlers(c *cli.Context) *trainings.Handlers { cmd = c.Command.FullName() } + mcpPort := c.Int("mcp-port") + return trainings.NewHandlers(trainings.CliMetadata{ Version: version, Commit: commit, @@ -426,7 +434,7 @@ func newHandlers(c *cli.Context) *trainings.Handlers { GitVersion: gitVersionString(), ExecutedCommand: cmd, Interactive: internal.IsStdinTerminal(), - }) + }, mcpPort) } func osVersion() string { diff --git a/trainings/config/global.go b/trainings/config/global.go index 5119ab8..95011ca 100644 --- a/trainings/config/global.go +++ b/trainings/config/global.go @@ -13,10 +13,12 @@ import ( ) type GlobalConfig struct { - Token string `toml:"token"` - ServerAddr string `toml:"server_addr"` - Region string `toml:"region"` - Insecure bool `toml:"insecure"` + Token string `toml:"token"` + ServerAddr string `toml:"server_addr"` + Region string `toml:"region"` + Insecure bool `toml:"insecure"` + MCPConfigured bool `toml:"mcp_configured,omitempty"` + MCPEnabled bool `toml:"mcp_enabled,omitempty"` } func globalConfigPath() string { diff --git a/trainings/config/training.go b/trainings/config/training.go index e93bca5..d75802e 100644 --- a/trainings/config/training.go +++ b/trainings/config/training.go @@ -13,13 +13,27 @@ import ( const trainingConfigFile = ".tdl-training" type TrainingConfig struct { - TrainingName string `toml:"training_name"` - GitConfigured bool `toml:"git_configured,omitempty"` - GitEnabled bool `toml:"git_enabled,omitempty"` - GitAutoCommit bool `toml:"git_auto_commit,omitempty"` - GitAutoGolden bool `toml:"git_auto_sync,omitempty"` - GitGoldenMode string `toml:"git_sync_mode,omitempty"` // "compare" | "merge" | "override" - GitUnavailable bool `toml:"git_unavailable,omitempty"` // git was missing/too old at init + TrainingName string `toml:"training_name"` + GitConfigured bool `toml:"git_configured,omitempty"` + GitEnabled bool `toml:"git_enabled,omitempty"` + GitAutoCommit bool `toml:"git_auto_commit,omitempty"` + GitAutoGolden bool `toml:"git_auto_sync,omitempty"` + GitGoldenMode string `toml:"git_sync_mode,omitempty"` // "compare" | "merge" | "override" + GitUnavailable bool `toml:"git_unavailable,omitempty"` // git was missing/too old at init + ClaudeMdHash string `toml:"claude_md_hash,omitempty"` // deprecated: migrated to FileHashes + FileHashes map[string]string `toml:"file_hashes,omitempty"` +} + +// MigrateHashes moves the deprecated ClaudeMdHash into the FileHashes map +// and ensures the map is initialized. +func (c *TrainingConfig) MigrateHashes() { + if c.FileHashes == nil { + c.FileHashes = make(map[string]string) + } + if c.ClaudeMdHash != "" && c.FileHashes["CLAUDE.md"] == "" { + c.FileHashes["CLAUDE.md"] = c.ClaudeMdHash + c.ClaudeMdHash = "" + } } func (c Config) WriteTrainingConfig(config TrainingConfig, trainingRootFs afero.Fs) error { @@ -39,6 +53,8 @@ func (c Config) TrainingConfig(trainingRootFs afero.Fs) TrainingConfig { panic(errors.Wrapf(err, "can't decode training config: %s", string(b))) } + config.MigrateHashes() + logrus.WithField("training_config", config).Debug("Training config") return config diff --git a/trainings/configure.go b/trainings/configure.go index ece759d..831ab65 100644 --- a/trainings/configure.go +++ b/trainings/configure.go @@ -29,10 +29,15 @@ func (h *Handlers) ConfigureGlobally(ctx context.Context, token, serverAddr, reg region = resp.Region } - return h.config.WriteGlobalConfig(config.GlobalConfig{ - Token: token, - ServerAddr: serverAddr, - Region: region, - Insecure: insecure, - }) + // Read existing config to preserve MCP settings (and any future fields). + // ConfiguredGlobally() check: on first-ever configure, there's no file yet. + var globalCfg config.GlobalConfig + if h.config.ConfiguredGlobally() { + globalCfg = h.config.GlobalConfig() + } + globalCfg.Token = token + globalCfg.ServerAddr = serverAddr + globalCfg.Region = region + globalCfg.Insecure = insecure + return h.config.WriteGlobalConfig(globalCfg) } diff --git a/trainings/files/write.go b/trainings/files/write.go index 14555dc..f9d910d 100644 --- a/trainings/files/write.go +++ b/trainings/files/write.go @@ -280,7 +280,7 @@ func (f Files) shouldWriteAllFiles(fs afero.Fs, exerciseDir string, filesToCreat edits := myers.ComputeEdits(span.URIFromPath("local "+filePath), string(localContent), externalContent) diff := fmt.Sprint(gotextdiff.ToUnified("local "+relPath, "remote "+relPath, string(localContent), edits)) - _, _ = fmt.Fprintln(f.stdout, colorDiff(diff)) + _, _ = fmt.Fprintln(f.stdout, ColorDiff(diff)) } } @@ -339,7 +339,7 @@ func (f Files) shouldWriteFile(fs afero.Fs, filePath string, file *genproto.File edits := myers.ComputeEdits(span.URIFromPath("local "+filepath.Base(file.Path)), string(actualContent), file.Content) diff := fmt.Sprint(gotextdiff.ToUnified("local "+filepath.Base(file.Path), "remote "+filepath.Base(file.Path), string(actualContent), edits)) - _, _ = fmt.Fprintln(f.stdout, colorDiff(diff)) + _, _ = fmt.Fprintln(f.stdout, ColorDiff(diff)) if !internal.FConfirmPrompt("Should it be overridden?", f.stdin, f.stdout) { _, _ = fmt.Fprintln(f.stdout, "Skipping file") @@ -366,7 +366,7 @@ func DirOrFileExists(fs afero.Fs, path string) bool { panic(err) } -func colorDiff(diffText string) string { +func ColorDiff(diffText string) string { red := color.New(color.FgRed) green := color.New(color.FgGreen) yellow := color.New(color.FgYellow) diff --git a/trainings/git_config.go b/trainings/git_config.go index c915415..12d41b0 100644 --- a/trainings/git_config.go +++ b/trainings/git_config.go @@ -2,12 +2,15 @@ package trainings import ( "fmt" + "os" "github.com/fatih/color" "github.com/pkg/errors" + + "github.com/ThreeDotsLabs/cli/internal" ) -// ConfigureGit lets users change git integration settings for the current training. +// ConfigureGit lets users change git and MCP settings for the current training. func (h *Handlers) ConfigureGit() error { trainingRoot, err := h.config.FindTrainingRoot() if err != nil { @@ -22,22 +25,45 @@ func (h *Handlers) ConfigureGit() error { if !cfg.GitConfigured || !cfg.GitEnabled { fmt.Println("Git integration is not enabled for this training.") fmt.Println("To enable it, reinitialize with: " + color.CyanString("tdl training init")) - return nil - } + } else { + fmt.Printf("Current settings for %s:\n", color.CyanString(cfg.TrainingName)) + fmt.Printf(" Auto-commit: %s\n", formatBool(cfg.GitAutoCommit)) + fmt.Printf(" Auto-sync: %s\n\n", formatBool(cfg.GitAutoGolden)) - fmt.Printf("Current settings for %s:\n", color.CyanString(cfg.TrainingName)) - fmt.Printf(" Auto-commit: %s\n", formatBool(cfg.GitAutoCommit)) - fmt.Printf(" Auto-sync: %s\n\n", formatBool(cfg.GitAutoGolden)) + autoCommit, autoGolden := promptGitPreferences() - autoCommit, autoGolden := promptGitPreferences() - - cfg.GitAutoCommit = autoCommit - cfg.GitAutoGolden = autoGolden + cfg.GitAutoCommit = autoCommit + cfg.GitAutoGolden = autoGolden + } if err := h.config.WriteTrainingConfig(cfg, trainingRootFs); err != nil { return errors.Wrap(err, "can't update training config") } + // MCP settings (stored in global config) + globalCfg := h.config.GlobalConfig() + + fmt.Println() + fmt.Printf("Current MCP server setting: %s\n", formatBool(globalCfg.MCPEnabled)) + fmt.Println("MCP server lets AI coding tools (Claude Code, Cursor, etc.) run exercises for you.") + fmt.Println() + + mcpPrompt := internal.Prompt( + internal.Actions{ + {Shortcut: '\n', Action: "enable MCP server", ShortcutAliases: []rune{'\r'}}, + {Shortcut: 'n', Action: "disable MCP server"}, + }, + os.Stdin, + os.Stdout, + ) + globalCfg.MCPConfigured = true + globalCfg.MCPEnabled = mcpPrompt == '\n' + fmt.Println() + + if err := h.config.WriteGlobalConfig(globalCfg); err != nil { + return errors.Wrap(err, "can't update global config") + } + fmt.Println(color.GreenString("Settings updated.")) return nil } diff --git a/trainings/grpc.go b/trainings/grpc.go index 448248e..c5780f6 100644 --- a/trainings/grpc.go +++ b/trainings/grpc.go @@ -18,6 +18,14 @@ func withSubAction(ctx context.Context, name string) context.Context { return context.WithValue(ctx, subActionKey, name) } +type mcpTriggeredKeyType struct{} + +var mcpTriggeredKey mcpTriggeredKeyType + +func withMCPTriggered(ctx context.Context, triggered bool) context.Context { + return context.WithValue(ctx, mcpTriggeredKey, triggered) +} + // debugHeaders builds the metadata sent with every gRPC request. // Called per-RPC so training config changes mid-session are reflected. func (h *Handlers) debugHeaders() metadata.MD { @@ -34,6 +42,7 @@ func (h *Handlers) debugHeaders() metadata.MD { }) h.appendTrainingHeaders(md) + h.appendMCPHeaders(md) return md } @@ -61,6 +70,19 @@ func (h *Handlers) appendTrainingHeaders(md metadata.MD) { md.Set("git-sync-mode", cfg.GitGoldenMode) } +func (h *Handlers) appendMCPHeaders(md metadata.MD) { + if h.loopState == nil { + return + } + name, version := h.loopState.GetMCPClientInfo() + if name != "" { + md.Set("mcp-client-name", name) + } + if version != "" { + md.Set("mcp-client-version", version) + } +} + func (h *Handlers) unaryInterceptor() grpc.UnaryClientInterceptor { return func( ctx context.Context, @@ -74,6 +96,9 @@ func (h *Handlers) unaryInterceptor() grpc.UnaryClientInterceptor { if sa, ok := ctx.Value(subActionKey).(string); ok && sa != "" { md.Set("command", md.Get("command")[0]+" > "+sa) } + if triggered, ok := ctx.Value(mcpTriggeredKey).(bool); ok && triggered { + md.Set("mcp-triggered", "true") + } ctx = metadata.NewOutgoingContext(ctx, md) return invoker(ctx, method, req, reply, cc, opts...) } @@ -92,6 +117,9 @@ func (h *Handlers) streamInterceptor() grpc.StreamClientInterceptor { if sa, ok := ctx.Value(subActionKey).(string); ok && sa != "" { md.Set("command", md.Get("command")[0]+" > "+sa) } + if triggered, ok := ctx.Value(mcpTriggeredKey).(bool); ok && triggered { + md.Set("mcp-triggered", "true") + } ctx = metadata.NewOutgoingContext(ctx, md) return streamer(ctx, desc, cc, method, opts...) } diff --git a/trainings/handlers.go b/trainings/handlers.go index cbdc929..efa414f 100644 --- a/trainings/handlers.go +++ b/trainings/handlers.go @@ -21,6 +21,7 @@ import ( "github.com/ThreeDotsLabs/cli/trainings/config" "github.com/ThreeDotsLabs/cli/trainings/genproto" "github.com/ThreeDotsLabs/cli/trainings/git" + mcppkg "github.com/ThreeDotsLabs/cli/trainings/mcp" ) type Handlers struct { @@ -33,6 +34,9 @@ type Handlers struct { solutionAvailable bool stuckRunCount int notifications map[string]struct{} + + loopState *mcppkg.LoopState // nil if MCP disabled + mcpPort int // 0 = MCP disabled } type CliMetadata struct { @@ -49,14 +53,21 @@ type CliMetadata struct { Interactive bool } -func NewHandlers(cliVersion CliMetadata) *Handlers { +func NewHandlers(cliVersion CliMetadata, mcpPort int) *Handlers { conf := config.NewConfig() - return &Handlers{ + h := &Handlers{ config: conf, cliMetadata: cliVersion, notifications: map[string]struct{}{}, + mcpPort: mcpPort, + } + + if mcpPort > 0 { + h.loopState = mcppkg.NewLoopState() } + + return h } func (h *Handlers) newGrpcClient() genproto.TrainingsClient { diff --git a/trainings/init.go b/trainings/init.go index 20cc88e..181c17c 100644 --- a/trainings/init.go +++ b/trainings/init.go @@ -411,6 +411,11 @@ var gitignore = strings.Join( "# TDL exercise state (managed by CLI)", ".tdl-exercise", "", + "# AI coding tool configs (managed by CLI)", + "CLAUDE.md", + "AGENTS.md", + ".mcp.json", + "", }, "\n", ) diff --git a/trainings/mcp/output_buffer.go b/trainings/mcp/output_buffer.go new file mode 100644 index 0000000..5b722c4 --- /dev/null +++ b/trainings/mcp/output_buffer.go @@ -0,0 +1,58 @@ +package mcp + +import ( + "sync" +) + +const DefaultMaxOutputSize = 50 * 1024 * 1024 // 50MB + +// OutputBuffer is a thread-safe, bounded buffer that captures execution output. +// When the buffer exceeds maxSize, oldest data is dropped. +// Only the last execution's output is stored (call Reset between runs). +type OutputBuffer struct { + mu sync.Mutex + buf []byte + maxSize int +} + +func NewOutputBuffer(maxSize int) *OutputBuffer { + return &OutputBuffer{ + maxSize: maxSize, + } +} + +func (b *OutputBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + b.buf = append(b.buf, p...) + + // Drop oldest data if over limit + if len(b.buf) > b.maxSize { + excess := len(b.buf) - b.maxSize + b.buf = b.buf[excess:] + } + + return len(p), nil +} + +func (b *OutputBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + + return string(b.buf) +} + +func (b *OutputBuffer) Len() int { + b.mu.Lock() + defer b.mu.Unlock() + + return len(b.buf) +} + +func (b *OutputBuffer) Reset() { + b.mu.Lock() + defer b.mu.Unlock() + + b.buf = b.buf[:0] +} diff --git a/trainings/mcp/server.go b/trainings/mcp/server.go new file mode 100644 index 0000000..99d1ac1 --- /dev/null +++ b/trainings/mcp/server.go @@ -0,0 +1,84 @@ +package mcp + +import ( + "context" + "fmt" + "net" + "net/http" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/sirupsen/logrus" +) + +const DefaultPort = 39131 + +// Server wraps an MCP server that exposes training loop controls. +type Server struct { + state *LoopState + port int + httpServer *server.StreamableHTTPServer +} + +func NewServer(state *LoopState, port int) *Server { + hooks := &server.Hooks{} + hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) { + state.SetMCPClientInfo(message.Params.ClientInfo.Name, message.Params.ClientInfo.Version) + logrus.WithFields(logrus.Fields{ + "client_name": message.Params.ClientInfo.Name, + "client_version": message.Params.ClientInfo.Version, + }).Info("MCP client connected") + }) + + mcpServer := server.NewMCPServer( + "tdl-training", + "1.0.0", + server.WithHooks(hooks), + ) + + registerTools(mcpServer, state) + + httpServer := server.NewStreamableHTTPServer(mcpServer, + server.WithStateLess(true), + ) + + return &Server{ + state: state, + port: port, + httpServer: httpServer, + } +} + +// Start begins serving in a goroutine. Returns immediately. +// If the port is in use, logs a warning and returns nil (MCP is optional). +func (s *Server) Start(ctx context.Context) error { + addr := fmt.Sprintf("127.0.0.1:%d", s.port) + + // Check port availability early to give a clear message + ln, err := net.Listen("tcp", addr) + if err != nil { + logrus.WithError(err).WithField("addr", addr).Warn("MCP server: port unavailable, running without MCP") + return nil + } + ln.Close() + + go func() { + logrus.WithField("addr", addr).Info("MCP server starting") + if err := s.httpServer.Start(addr); err != nil && err != http.ErrServerClosed { + logrus.WithError(err).Warn("MCP server stopped with error") + } + }() + + // Shut down when context is cancelled + go func() { + <-ctx.Done() + s.httpServer.Shutdown(context.Background()) + }() + + return nil +} + +// Addr returns the address the server is configured to listen on. +func (s *Server) Addr() string { + return fmt.Sprintf("127.0.0.1:%d", s.port) +} diff --git a/trainings/mcp/state.go b/trainings/mcp/state.go new file mode 100644 index 0000000..b9da75d --- /dev/null +++ b/trainings/mcp/state.go @@ -0,0 +1,190 @@ +package mcp + +import ( + "sync" +) + +// ExerciseState represents the current state of the interactive run loop. +type ExerciseState int + +const ( + StateIdle ExerciseState = iota // Before first run + StateRunning // runExercise is executing + StateSucceeded // Last run passed, waiting at success prompt + StateFailed // Last run failed, waiting at fail prompt + StateAdvancing // nextExercise is in progress +) + +func (s ExerciseState) String() string { + switch s { + case StateIdle: + return "idle" + case StateRunning: + return "running" + case StateSucceeded: + return "succeeded" + case StateFailed: + return "failed" + case StateAdvancing: + return "advancing" + default: + return "unknown" + } +} + +// ExerciseInfo contains the current exercise metadata exposed via MCP. +// This is a separate struct from config.ExerciseConfig to decouple the MCP API from internal config. +type ExerciseInfo struct { + ExerciseID string `json:"exercise_id"` + Directory string `json:"directory"` + IsTextOnly bool `json:"is_text_only"` + IsOptional bool `json:"is_optional"` + ModuleName string `json:"module_name"` + ExerciseName string `json:"exercise_name"` +} + +// CommandType represents a semantic command from MCP to the loop. +type CommandType int + +const ( + CmdRunSolution CommandType = iota + CmdNextExercise +) + +func (c CommandType) String() string { + switch c { + case CmdRunSolution: + return "run_solution" + case CmdNextExercise: + return "next_exercise" + default: + return "unknown" + } +} + +// MCPCommand is sent from MCP tool handlers to the loop via the command channel. +type MCPCommand struct { + Type CommandType + ResultCh chan<- MCPResult +} + +// MCPResult is sent back from the loop to the MCP tool handler. +type MCPResult struct { + Success bool + Message string + Error string +} + +// LoopState is the shared state between the MCP server and the interactive run loop. +// All methods are thread-safe. +type LoopState struct { + mu sync.RWMutex + state ExerciseState + exerciseInfo ExerciseInfo + outputBuf *OutputBuffer + commandCh chan MCPCommand + + mcpClientName string + mcpClientVersion string + + pendingAction string + lastError string +} + +func NewLoopState() *LoopState { + return &LoopState{ + outputBuf: NewOutputBuffer(DefaultMaxOutputSize), + commandCh: make(chan MCPCommand), + } +} + +func (s *LoopState) SetState(state ExerciseState) { + s.mu.Lock() + defer s.mu.Unlock() + s.state = state +} + +func (s *LoopState) GetState() ExerciseState { + s.mu.RLock() + defer s.mu.RUnlock() + return s.state +} + +func (s *LoopState) SetExerciseInfo(info ExerciseInfo) { + s.mu.Lock() + defer s.mu.Unlock() + s.exerciseInfo = info +} + +func (s *LoopState) GetExerciseInfo() ExerciseInfo { + s.mu.RLock() + defer s.mu.RUnlock() + return s.exerciseInfo +} + +func (s *LoopState) OutputBuffer() *OutputBuffer { + return s.outputBuf +} + +// CommandCh returns the channel for receiving MCP commands in the loop. +func (s *LoopState) CommandCh() <-chan MCPCommand { + return s.commandCh +} + +// SendCommand sends a command from MCP to the loop. Blocks until the loop processes it. +func (s *LoopState) SendCommand(cmd MCPCommand) { + s.commandCh <- cmd +} + +func (s *LoopState) SetMCPClientInfo(name, version string) { + s.mu.Lock() + defer s.mu.Unlock() + s.mcpClientName = name + s.mcpClientVersion = version +} + +func (s *LoopState) GetMCPClientInfo() (name, version string) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.mcpClientName, s.mcpClientVersion +} + +// SetPendingAction records that the CLI is blocked on a stdin prompt that +// the MCP client cannot resolve. Only use this for prompts with NO MCP +// channel — never for waitForAction() prompts which already multiplex +// stdin + MCP commands. +func (s *LoopState) SetPendingAction(msg string) { + s.mu.Lock() + defer s.mu.Unlock() + s.pendingAction = msg +} + +func (s *LoopState) GetPendingAction() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.pendingAction +} + +func (s *LoopState) ClearPendingAction() { + s.mu.Lock() + defer s.mu.Unlock() + s.pendingAction = "" +} + +func (s *LoopState) SetLastError(msg string) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastError = msg +} + +func (s *LoopState) GetLastError() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.lastError +} + +func (s *LoopState) ClearLastError() { + s.mu.Lock() + defer s.mu.Unlock() + s.lastError = "" +} diff --git a/trainings/mcp/tools.go b/trainings/mcp/tools.go new file mode 100644 index 0000000..62f6501 --- /dev/null +++ b/trainings/mcp/tools.go @@ -0,0 +1,150 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func registerTools(srv *server.MCPServer, state *LoopState) { + srv.AddTool( + mcp.NewTool("training_get_exercise_info", + mcp.WithDescription("Returns information about the current exercise, its state, and optionally logs from the last execution"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithBoolean("include_logs", + mcp.Description("If true, includes stdout/stderr logs from the last execution. Disabled by default to save context."), + ), + ), + handleGetExerciseInfo(state), + ) + + srv.AddTool( + mcp.NewTool("training_run_solution", + mcp.WithDescription("Triggers the exercise solution to run. Only works when the interactive loop is waiting at a prompt (failed or succeeded state)."), + mcp.WithDestructiveHintAnnotation(false), + ), + handleRunSolution(state), + ) + + srv.AddTool( + mcp.NewTool("training_next_exercise", + mcp.WithDescription("Advances to the next exercise. Only works when the current exercise has been completed successfully."), + mcp.WithDestructiveHintAnnotation(false), + ), + handleNextExercise(state), + ) +} + +type exerciseInfoResponse struct { + ExerciseID string `json:"exercise_id"` + Directory string `json:"directory"` + IsTextOnly bool `json:"is_text_only"` + IsOptional bool `json:"is_optional"` + ModuleName string `json:"module_name"` + ExerciseName string `json:"exercise_name"` + State string `json:"state"` + PendingAction string `json:"pending_action,omitempty"` + Error string `json:"error,omitempty"` + Logs string `json:"logs,omitempty"` +} + +func handleGetExerciseInfo(state *LoopState) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + info := state.GetExerciseInfo() + currentState := state.GetState() + + resp := exerciseInfoResponse{ + ExerciseID: info.ExerciseID, + Directory: info.Directory, + IsTextOnly: info.IsTextOnly, + IsOptional: info.IsOptional, + ModuleName: info.ModuleName, + ExerciseName: info.ExerciseName, + State: currentState.String(), + PendingAction: state.GetPendingAction(), + Error: state.GetLastError(), + } + + includeLogs, _ := request.GetArguments()["include_logs"].(bool) + if includeLogs { + resp.Logs = state.OutputBuffer().String() + } + + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal response: %v", err)), nil + } + + return mcp.NewToolResultText(string(data)), nil + } +} + +func handleRunSolution(state *LoopState) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + currentState := state.GetState() + + switch currentState { + case StateFailed, StateSucceeded: + // Valid states for running + default: + return mcp.NewToolResultError(fmt.Sprintf( + "Cannot run solution: current state is '%s'. Must be 'failed' or 'succeeded'.", + currentState, + )), nil + } + + return sendCommand(state, CmdRunSolution, "Solution run triggered") + } +} + +func handleNextExercise(state *LoopState) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + currentState := state.GetState() + + if currentState != StateSucceeded { + return mcp.NewToolResultError(fmt.Sprintf( + "Cannot advance to next exercise: current state is '%s'. Must be 'succeeded'.", + currentState, + )), nil + } + + return sendCommand(state, CmdNextExercise, "Advancing to next exercise") + } +} + +func sendCommand(state *LoopState, cmdType CommandType, successMsg string) (*mcp.CallToolResult, error) { + resultCh := make(chan MCPResult, 1) + + cmd := MCPCommand{ + Type: cmdType, + ResultCh: resultCh, + } + + // Send command with timeout — the loop must pick it up within 10 seconds + select { + case state.commandCh <- cmd: + // Command sent, wait for response + case <-time.After(10 * time.Second): + return mcp.NewToolResultError("Timed out waiting for the training loop to accept the command. The loop may be busy."), nil + } + + // Wait for the loop to respond + select { + case result := <-resultCh: + if result.Error != "" { + return mcp.NewToolResultError(result.Error), nil + } + msg := successMsg + if result.Message != "" { + msg = result.Message + } + return mcp.NewToolResultText(msg), nil + case <-time.After(10 * time.Second): + return mcp.NewToolResultError("Timed out waiting for the training loop to respond."), nil + } +} diff --git a/trainings/mcp_detect.go b/trainings/mcp_detect.go new file mode 100644 index 0000000..b39de31 --- /dev/null +++ b/trainings/mcp_detect.go @@ -0,0 +1,162 @@ +package trainings + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/fatih/color" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/spf13/afero" + + "github.com/ThreeDotsLabs/cli/internal" + mcppkg "github.com/ThreeDotsLabs/cli/trainings/mcp" +) + +// cliTools are AI coding tool binaries to look for in PATH. +var cliTools = []string{ + "claude", + "cursor", + "aider", + "opencode", + "copilot", + "windsurf", +} + +// macOSApps are AI coding tool application bundles to check on darwin. +var macOSApps = []string{ + "/Applications/Cursor.app", + "/Applications/Windsurf.app", +} + +// detectAICodingTools returns the names of AI coding tools found on this system. +func detectAICodingTools() []string { + var found []string + + for _, tool := range cliTools { + if path, err := exec.LookPath(tool); err == nil { + logrus.WithFields(logrus.Fields{"tool": tool, "path": path}).Debug("AI coding tool found in PATH") + found = append(found, tool) + } else { + logrus.WithField("tool", tool).Debug("AI coding tool not found in PATH") + } + } + + if runtime.GOOS == "darwin" { + for _, app := range macOSApps { + if _, err := os.Stat(app); err == nil { + logrus.WithField("app", app).Debug("AI coding app found") + // Extract app name (e.g. "Cursor.app") + parts := strings.Split(app, "/") + found = append(found, parts[len(parts)-1]) + } else { + logrus.WithField("app", app).Debug("AI coding app not found") + } + } + } + + // Deduplicate: if "cursor" CLI is found AND "Cursor.app" exists, keep just "cursor" + seen := make(map[string]bool) + var deduped []string + for _, name := range found { + key := strings.ToLower(strings.TrimSuffix(name, ".app")) + if !seen[key] { + seen[key] = true + deduped = append(deduped, name) + } + } + + logrus.WithField("detected", deduped).Debug("AI coding tool detection complete") + + return deduped +} + +// promptMCPSetup asks the user whether to enable the MCP server. +func promptMCPSetup() bool { + fmt.Println() + fmt.Println(color.New(color.Bold).Sprint(" MCP server")) + fmt.Println() + fmt.Println(" MCP server lets AI coding tools (Claude Code, Cursor, etc.) run exercises") + fmt.Println(" and check results for you. It listens on 127.0.0.1 (localhost only)") + fmt.Println(" while " + color.CyanString("tdl tr run") + " is active.") + fmt.Println() + fmt.Println(" You can change this later with: " + color.CyanString("tdl training settings")) + fmt.Println() + + choice := internal.Prompt( + internal.Actions{ + {Shortcut: '\n', Action: "enable MCP server (recommended)", ShortcutAliases: []rune{'\r'}}, + {Shortcut: 'n', Action: "skip"}, + }, + os.Stdin, + os.Stdout, + ) + fmt.Println() + + return choice == '\n' +} + +// configureMCPIfNeeded checks the global config for MCP preference. +// If not yet configured, it detects AI coding tools and prompts the user. +// +// Key invariant: when no AI tools are detected, nothing is written to config. +// This ensures that if the user installs an AI tool later, MCPConfigured will +// still be false and the prompt will appear on the next run. +func (h *Handlers) configureMCPIfNeeded(trainingRootFs *afero.BasePathFs) { + globalCfg := h.config.GlobalConfig() + + if globalCfg.MCPConfigured { + if !globalCfg.MCPEnabled { + h.mcpPort = 0 + h.loopState = nil + } else { + h.ensureMCPFiles(trainingRootFs) + } + return + } + + // Not yet configured — detect AI tools + tools := detectAICodingTools() + + if len(tools) == 0 { + // No AI tools found. Do NOT write config — when the user installs + // a tool later, MCPConfigured will still be false and this check + // will run again, showing the prompt. + logrus.Debug("No AI coding tools detected, skipping MCP setup") + h.mcpPort = 0 + h.loopState = nil + return + } + + if !internal.IsStdinTerminal() { + // Non-interactive — can't prompt, skip for now + h.mcpPort = 0 + h.loopState = nil + return + } + + enabled := promptMCPSetup() + + globalCfg.MCPConfigured = true + globalCfg.MCPEnabled = enabled + if err := h.config.WriteGlobalConfig(globalCfg); err != nil { + logrus.WithError(errors.WithStack(err)).Warn("Could not save MCP preference") + } + + if !enabled { + h.mcpPort = 0 + h.loopState = nil + } else { + if h.loopState == nil { + h.loopState = mcppkg.NewLoopState() + } + h.ensureMCPFiles(trainingRootFs) + + fmt.Println(color.GreenString(" MCP enabled.") + " Created " + color.CyanString(".mcp.json") + " in your training directory.") + fmt.Println(" Restart your AI coding tools to pick up the new MCP server config.") + fmt.Println() + } +} diff --git a/trainings/mcp_files.go b/trainings/mcp_files.go new file mode 100644 index 0000000..7ce43e6 --- /dev/null +++ b/trainings/mcp_files.go @@ -0,0 +1,345 @@ +package trainings + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/hexops/gotextdiff" + "github.com/hexops/gotextdiff/myers" + "github.com/hexops/gotextdiff/span" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/spf13/afero" + + "github.com/ThreeDotsLabs/cli/internal" + "github.com/ThreeDotsLabs/cli/trainings/files" +) + +const mcpJsonFile = ".mcp.json" +const claudeMdFile = "CLAUDE.md" +const agentsMdFile = "AGENTS.md" + +// ensureMCPFiles creates or updates .mcp.json, CLAUDE.md, AGENTS.md, and .gitignore in the training root. +func (h *Handlers) ensureMCPFiles(trainingRootFs *afero.BasePathFs) { + cfg := h.config.TrainingConfig(trainingRootFs) + + ensureMCPJson(trainingRootFs, h.mcpPort) + + claudeContent := generateClaudeMd() + changed := ensureManagedFile(trainingRootFs, claudeMdFile, claudeContent, cfg.FileHashes) + changed = ensureManagedFile(trainingRootFs, agentsMdFile, claudeContent, cfg.FileHashes) || changed + changed = ensureManagedFile(trainingRootFs, ".gitignore", []byte(gitignore), cfg.FileHashes) || changed + + if changed { + if err := h.config.WriteTrainingConfig(cfg, trainingRootFs); err != nil { + logrus.WithError(errors.WithStack(err)).Warn("Could not save file hashes") + } + } +} + +// ensureMCPJson upserts the tdl-training entry in .mcp.json, preserving other entries. +func ensureMCPJson(fs afero.Fs, port int) { + var root map[string]any + + data, err := afero.ReadFile(fs, mcpJsonFile) + if err == nil { + if jsonErr := json.Unmarshal(data, &root); jsonErr != nil { + logrus.WithError(jsonErr).Warn("Could not parse .mcp.json, recreating") + root = nil + } + } + + if root == nil { + root = make(map[string]any) + } + + servers, _ := root["mcpServers"].(map[string]any) + if servers == nil { + servers = make(map[string]any) + } + + servers["tdl-training"] = map[string]any{ + "type": "http", + "url": fmt.Sprintf("http://127.0.0.1:%d/mcp", port), + } + root["mcpServers"] = servers + + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + logrus.WithError(err).Warn("Could not marshal .mcp.json") + return + } + out = append(out, '\n') + + if err := afero.WriteFile(fs, mcpJsonFile, out, 0644); err != nil { + logrus.WithError(err).Warn("Could not write .mcp.json") + } +} + +// ensureManagedFile creates or updates a managed file using hash-based conflict detection. +// Returns true if hashes was modified (caller should save config). +// +// The hash in hashes[filename] tracks the template version we last wrote or offered — NOT the file on disk. +// This ensures: user declines v2 → we stop asking about v2 → v3 ships → we ask again. +func ensureManagedFile(fs afero.Fs, filename string, newContent []byte, hashes map[string]string) bool { + newHash := hashContent(newContent) + storedHash := hashes[filename] + + // Template unchanged since last write/offer — nothing to do + if newHash == storedHash { + return false + } + + diskContent, err := afero.ReadFile(fs, filename) + + // File missing + if err != nil { + if err := afero.WriteFile(fs, filename, newContent, 0644); err != nil { + logrus.WithError(err).Warnf("Could not write %s", filename) + return false + } + hashes[filename] = newHash + return true + } + + diskHash := hashContent(diskContent) + + // No stored hash but file exists — first-time tracking + // If disk already matches template, just adopt. Otherwise fall through to + // conflict handling so the user sees the diff and can choose. + if storedHash == "" && diskHash == newHash { + hashes[filename] = newHash + return true + } + + // File on disk matches what we last wrote — user didn't edit, safe to overwrite + if diskHash == storedHash { + if err := afero.WriteFile(fs, filename, newContent, 0644); err != nil { + logrus.WithError(err).Warnf("Could not write %s", filename) + return false + } + hashes[filename] = newHash + return true + } + + // Conflict: user edited AND template changed + if !internal.IsStdinTerminal() { + logrus.Infof("%s has updates but can't prompt in non-interactive mode, skipping", filename) + hashes[filename] = newHash + return true + } + + edits := myers.ComputeEdits(span.URIFromPath(filename), string(diskContent), string(newContent)) + diff := fmt.Sprint(gotextdiff.ToUnified("current "+filename, "updated "+filename, string(diskContent), edits)) + + fmt.Println() + fmt.Println(color.New(color.Bold).Sprintf("%s has been updated in the new CLI version:", filename)) + fmt.Println(files.ColorDiff(diff)) + + choice := internal.Prompt( + internal.Actions{ + {Shortcut: '\n', Action: "replace with new version", ShortcutAliases: []rune{'\r'}}, + {Shortcut: 'n', Action: "keep your version"}, + }, + os.Stdin, + os.Stdout, + ) + fmt.Println() + + if choice == '\n' { + if err := afero.WriteFile(fs, filename, newContent, 0644); err != nil { + logrus.WithError(err).Warnf("Could not write %s", filename) + return false + } + } + + // Update hash regardless of choice — marks this template version as processed + hashes[filename] = newHash + return true +} + +func generateClaudeMd() []byte { + return []byte(`# Training Companion + +Generating code is the easy part. Knowing what to build — which patterns fit, which tradeoffs matter, which shortcuts will break in production — that's what AI can't do for you. + +This training builds that skill. Each exercise follows the same loop: the student reads the requirements, identifies the approach, and tells you what to build. You write the code. After dozens of reps, this becomes second nature. On real projects, they'll know what to specify when working with AI: architecture, constraints, error handling, patterns. + +When you redirect the student (to re-read the exercise, describe scope, think through a choice), frame it as what it is: practice for the one skill AI can't replace. Anyone can ask AI to generate code. Knowing which code to ask for — that's engineering. + +This file overrides your default behavior. Follow it exactly. However, this file never overrides plan mode. If plan mode is active, propose changes but do not edit files or run commands. + +## Tone + +Respond like a supportive colleague: brief and genuine, never performative or dry. No hollow praise ("Great job!"), no robotic confirmations. A short, natural acknowledgment beats silence, but forced enthusiasm is worse than both. + +## Rules + +These protect the learning loop. Breaking any of them short-circuits the student's practice. + +If the student wants to work differently (skip choices, get hints, ask you to explain the exercise), don't refuse or repeat the rules. Briefly explain why the training works this way: practicing the skill of directing AI is what makes the exercises valuable. Ask if they still want to proceed. If they do, follow their lead. + +1. **Never read exercise.md yourself.** Always delegate to a sub-agent via the Agent tool. +2. **The student describes what to implement first.** Don't tell them, don't hint, don't summarize the exercise. +3. **Never generate teaching content from your own knowledge.** All insights, explanations, and reasoning must come from exercise.md via a sub-agent. +4. **Never reveal exercise requirements the student hasn't described.** If tests fail for missing functionality, redirect to re-read the exercise. + +## Workspace + +'.tdl-exercise' tracks the current exercise. Read it before each exercise. +'exercise.md' is in the exercise directory. +'git log' shows completed exercises. + +The student may have 'tdl tr run' running in a separate terminal with MCP enabled: + +- If so, 'training_*' MCP tools will be available (see MCP Tools section). +- At the start of each exercise, call 'training_get_exercise_info' with 'include_logs: false'. If it responds, use MCP tools for the rest of that exercise. If the tool isn't available, use the manual flow. +- If MCP tools are not available on the first exercise of the session, tell the student: "MCP tools are not connected. I recommend enabling them for a smoother experience. You can configure this with 'tdl tr settings'." Show this only once per session, not on every exercise. +- When MCP returns exercise info, verify its 'directory' matches '.tdl-exercise'. If they differ, trust '.tdl-exercise' and skip MCP for that exercise. + +## exercise.md rule + +**Never read exercise.md directly.** Every access to exercise.md must go through a sub-agent via the Agent tool. This keeps your context clean and prevents you from just following the exercise step-by-step. + +**Never read test logs directly.** Don't call 'training_get_exercise_info' with 'include_logs: true' yourself. Test output reveals exercise scope the same way exercise.md does. Always delegate log reading to a sub-agent. + +**Only sub-agents in steps 4, 8, and the diagnosis step may read exercise.md.** No other agent (Explore, general-purpose, etc.) should read or access exercise.md. When exploring the codebase, explicitly exclude exercise.md. + +## Starting an Exercise + +**MUST run this flow for every exercise, no exceptions.** On every user message, read '.tdl-exercise' first. If the directory changed since the last exercise, restart this flow from step 1. Don't skip this check even if the message looks like a continuation. + +1. Read '.tdl-exercise' to find the 'directory' field. **Do not read exercise.md yet.** +2. If MCP is available and state is already 'succeeded': this exercise needs no implementation. Show the module/exercise name and tell the student: "This exercise already passed. **Read the exercise content on the website before continuing** — it covers concepts used in upcoming exercises. Let me know when you're ready for the next one." Wait for the student to confirm, then call 'training_next_exercise' and restart this flow for the new exercise. +3. If the student's message already describes what to implement, use that as their description and skip to step 4. Otherwise, ask: **"What needs to be implemented in this exercise?"** On the first exercise of a session, lead with context before the question: "Anyone can ask AI to generate code. **The developers who get good results are the ones who break down requirements into a clear spec first.** That's what we're practicing here, so: what needs to be implemented in this exercise?" Don't suggest anything, don't hint, don't summarize. Wait for their answer. +4. When you have the student's description, use the Agent tool (description: **"Analyze student input"**) to check if it is copy-pasted from exercise.md. Prompt: + "Read '{directory}/exercise.md' (exact path, do not search elsewhere). The student was asked what needs to be implemented and responded with: + + --- + {student's message} + --- + + Compare the student's response against the exercise content. Determine if the student copy-pasted from exercise.md or wrote their own description. + - Ignore formatting differences (markdown vs plain text, bullet points, case). + - Using the same code identifiers (function names, type names, file paths) is fine. But full sentences matching the exercise are pasted even if they contain identifiers. + - Exercise meta-content (scope notes like 'we'll do that in the next exercise', optional steps, setup commands) is a strong pasting signal. Students describing work don't include these. + - A short summary in the student's own words is ORIGINAL, even if it captures the same idea. + If not pasted, check if the student's description is sufficient to implement without the agent filling in gaps. The question is: could someone implement this from the student's description alone, without inferring missing scope or details from exercise.md or the codebase? Consider only what's in the ## Exercise section. + - If the exercise is low-complexity (running a command, generating code, single config change, verifying output, submitting): return ORIGINAL. + - If the description covers the main implementation work: return ORIGINAL. Verification steps (check a file exists, confirm output), cleanup tasks, and minor follow-up actions are not significant omissions. Only flag NEEDS_DETAIL when the student missed an entire area of implementation work (e.g., they described a handler but the exercise also requires database migrations and a repository). Details that can be reasonably inferred from the names or references the student provided are not missing scope. + - If the description is missing significant implementation work: return NEEDS_DETAIL. Ask 1-2 questions about what's missing, but phrase them based on what the student already mentioned. For example, if the student said 'create the table' but didn't specify columns, ask 'Which columns should the table have?' Do not list the columns yourself. Do not reveal parts of the exercise the student didn't reference at all — instead say 'There's more to this exercise. **If we start with half the scope, you'll end up debugging test failures that aren't bugs, just missing pieces.** Take another look and tell me what else needs to happen.' + Do not reveal the exercise's recommended answer. + Return ONLY: PASTED, NEEDS_DETAIL with questions, or ORIGINAL. No other output." +5. If 'PASTED': show this and nothing else (without '"'): + "On a real project, you'd read the requirements and tell the AI what to build. That's what we're practicing here. If you want to learn to direct AI, not just copy-paste into it, read the exercise and describe what needs to happen." + Wait for a new response and repeat from step 4. +6. If 'NEEDS_DETAIL': show the questions from the agent. Wait for the student's response, then re-run step 4 with the combined description. If the agent returns PASTED on this re-run, treat it as ORIGINAL — the student already engaged by going back and adding detail. If the student can't identify more scope (e.g., "I don't know", "that's it", "nothing else"), ask once: "Want to take another look, or should I give you a nudge?" If they want a nudge, use the Agent tool: "Read '{directory}/exercise.md'. The student described: '{description}'. List the main areas of work from the ## Exercise section as short bullet points (just the area, not how to do it). Do not include implementation details." Show the bullet points and let the student update their description. +7. If 'ORIGINAL': proceed directly to step 8 **without any comment**. Do not say "that's original", "good", "let me get the brief", etc. The copy-paste check must be invisible to the student. +8. Use the Agent tool to analyze the exercise. Pass the **exact path** and the **student's description** to the agent. Prompt: + "Read '{directory}/exercise.md' (exact path, do not search elsewhere). The student described what they want to implement as: '{student's description}'. + SCOPE RULE: The brief must cover ONLY what the student described. If the student said 'print the message' but the exercise says 'reverse and publish', use 'print the message'. The student's description defines what gets implemented, not the exercise. Do not add, correct, or expand the student's scope. Do not generate choices or mechanical work for things the student didn't mention. + Return a brief with these sections: + **Goal**: one sentence on what the student should learn. + **Choices**: architectural choices relevant to what the student described. Only generate choices for things the student mentioned, not for parts of the exercise they didn't. Even when the exercise recommends one approach, frame the key concept as a choice between 2-3 concrete options. At least one choice per non-trivial exercise. Skip only for trivial exercises (submit, single-file config). Randomize option order so the recommended answer is not always A. + IMPORTANT: If the student's description already indicates which option they chose (e.g., they wrote specific code like 'e.HTTPErrorHandler = common.EchoErrorHandler' or described a specific approach like 'collect errors into []ErrorDetails'), add '[already_chosen: X]' to that choice. The presenter will acknowledge it and move on instead of asking. The student must have explicitly named the specific option, not merely described behavior that makes one option the logical choice. Example: 'use the :exec annotation' → already_chosen. 'Insert a row' → NOT already_chosen, even though :exec is the correct annotation for inserts. + For each choice, produce a ready-to-present block like this: + + [natural sentence framing the decision, e.g. "For GetCustomerByUUID, we need to pick the right annotation:"] + + **[label A]** + '''go + // 2-3 line code sketch showing the shape of this option + ''' + **[label B]** + '''go + // 2-3 line code sketch showing the shape of this option + ''' + [recommended: X] + [if right: one sentence from exercise.md explaining why this approach works. Use only concepts the exercise explicitly mentions.] + [if wrong: one sentence explaining why, using only concepts and arguments that exercise.md explicitly mentions. Do not introduce concepts the exercise doesn't discuss.] + + **Mechanical**: restate what the student asked for as the implementation scope. Do not add tasks from exercise.md that the student didn't mention. + **Insight**: a key takeaway quoted or closely paraphrased from exercise.md, formatted as a short rendered block to show the student after choices are resolved. Only include if the exercise explicitly states a memorable principle or pattern in its own words. 1-3 sentences max. Leave empty rather than generating generic knowledge about the pattern. + Keep code sketches to signatures/structure only (no full implementations). + CRITICAL: Do not generate rationale from your own knowledge. The [if right], [if wrong] explanations and insight must use only concepts and arguments that exercise.md explicitly states. Build the antipattern from what the exercise mentions. Do not introduce new concepts. Example: if the exercise says 'the repository hides sqlc details', the [if wrong] should reference sqlc details leaking, not a general claim like 'defeats the repository pattern'. A plausible-sounding explanation that uses concepts the exercise never discusses is a hallucination, even if technically correct. If the exercise doesn't teach a memorable principle, leave Insight empty. + CRITICAL: Do NOT include implementation details in the Mechanical section. List what areas need work (e.g., 'add a handler', 'change config') but not how to do it (no function names, no method signatures, no specific values). The caller must implement from the code files, not from this brief." +9. Use the brief to guide your work. Implement from the code files, not from exercise.md. When launching any agent (Explore, etc.) for implementation, include the student's description in the prompt so the agent focuses on relevant code. If the student's description doesn't have enough detail to implement, ask them for the missing specifics instead of guessing from the codebase. + +## How to Respond + +Present the choices from the brief before implementing as part of planning the approach — like a colleague thinking through options, not a quiz. Copy the choice blocks from the brief as-is (they're pre-formatted with code sketches). Strip the '[recommended]', '[if right]', and '[if wrong]' lines before showing to the student. + +- **Correct pick**: briefly acknowledge the reasoning in a natural sentence (not hollow praise like "Great job!"), then show the '[if right]' explanation from the brief and proceed. +- **Wrong pick**: don't implement it. The student's thinking was reasonable — frame it that way, then explain the catch using the '[if wrong]' explanation from the brief. Suggest the recommended option. Wait for the student to confirm. +- **Student asks for more explanation** ("idk", "explain", "why?"): use the Agent tool. Prompt: "Read '{directory}/exercise.md' (exact path, do not search elsewhere). The student is choosing between [options]. They asked: '[student's question]'. Find the relevant explanation in the exercise and return it. CRITICAL: only use what the exercise says. Do not add reasoning or examples from your own knowledge. If the exercise doesn't cover what the student asked, say so." **Show the agent's full response to the student verbatim — copy-paste it, do not rephrase, shorten, or add your own commentary.** +- **Student already described their approach for a choice** (marked '[already_chosen]' in the brief, or obvious from their description): don't ask. Briefly acknowledge their choice with the '[if right]' explanation (or '[if wrong]' if they picked the wrong option), then move on. The student already told you what they want. Asking again feels like a quiz about something they already answered. +- **Student asks what to implement** ("what should I do?", "what's this about?"): don't tell them. Say "Read through the exercise and tell me what you think needs to be built. **Breaking down requirements before touching code is what separates a good AI prompt from vibe coding.**" + +After choices are resolved, if the brief has an **Insight**, display it as a highlighted block. Then check: is the remaining work all listed under **Mechanical** in the brief? + +- **Yes** (all mechanical): implement directly. Don't announce it ("All mechanical", "No choices needed", "Let me implement this"). Just start. +- **No** (creative coding remains): ask "Want to implement this yourself, or should I?" If the student implements, give them space. Help if they ask. Don't dictate step-by-step instructions. + +When implementation is complete and the code compiles, proceed to After Implementation. Always mention that logs will be visible in the tdl cli when asking to run. + +## Text-Only Exercises + +If 'is_text_only = true': no code to write. Discuss the concepts. Ask what the student thinks about the tradeoffs. + +## After Implementation + +**If MCP tools are available:** + +1. Ask the student: "**Should I run the solution?** Logs will be visible in the tdl cli." +2. On confirmation, call 'training_run_solution'. +3. Call 'training_get_exercise_info' with 'include_logs: false' to check state. +4. If state is 'running', poll with 1-second sleeps until it completes. MCP calls are local and fast, so keep the interval short. +5. If 'succeeded': tell the student the exercise passed. Ask if they want to move to the next exercise. On confirmation, call 'training_next_exercise'. +6. If 'failed': delegate to the Agent tool (description: **"Analyze test results"**): "Call 'training_get_exercise_info' with 'include_logs: true'. Also read '{directory}/exercise.md' (exact path, do not search elsewhere). The student described wanting to implement: '{student's description}'. Analyze the test logs. For each failure, classify as BUG (relates to what the student described. Return ONLY the error message as it appears in the logs. Do not diagnose the cause or suggest fixes) or SCOPE (about functionality the student didn't describe. Return ONLY the count of SCOPE failures, no error messages, no details about what's missing). Return: BUG failures with their error messages only, and the count of SCOPE failures." +7. Show the student the BUG error messages from the sub-agent. **Do not suggest fixes, diagnose causes, or hint at what needs to change.** If there are SCOPE failures, add: "{N} tests check for functionality you haven't described yet. **I only build what you specify**, so anything missing from your description won't make it into the code. Take another look at the exercise." Ask: "What do you think went wrong?" Wait for the student to describe what they think needs to change, then restart from Starting an Exercise step 8 (generating a brief with choices about the fix approach, using the student's fix description as the new student description). + +**If MCP tools are not available:** + +Tell the student to run 'tdl tr run'. If it fails, the student will share the errors. + +**When the student asks for help with a failure:** + +The student directs debugging the same way they direct implementation. When they ask about an error or ask you to fix something, delegate to the Agent tool: "Read '{directory}/exercise.md' (exact path, do not search elsewhere). The student described wanting to implement: '{student's description}'. They're asking about this test failure: '{error}'. Classify as BUG or SCOPE (about functionality the student didn't describe). Return ONLY the classification. Do not diagnose causes, suggest fixes, or explain what's missing." + +- **BUG**: show the error to the student. **Do not suggest a fix.** Ask: "What do you think went wrong?" Wait for the student to describe what needs to change, then restart from Starting an Exercise step 8 (generating a brief with choices about the fix approach). +- **SCOPE**: tell the student "This test checks for functionality you haven't described yet. **I build from your description**, so if something's not in it, it won't end up in the code. Take another look at the exercise and tell me what you think is missing." Wait for the student to update their description and restart from Starting an Exercise step 3. + +## MCP Tools + +Three MCP tools connect to the student's 'tdl tr run' session when they run it with MCP enabled. Availability can change between sessions. + +- **'training_get_exercise_info'**: returns exercise metadata (exercise_id, directory, is_text_only, is_optional, module_name, exercise_name), current state ('idle'/'running'/'succeeded'/'failed'), and optionally output logs. Parameter: 'include_logs' (boolean, default false). Valid in any state. +- **'training_run_solution'**: triggers a test run in the student's 'tdl tr run' session. No parameters. Valid when state is 'succeeded' or 'failed'. Blocks until the command is accepted. +- **'training_next_exercise'**: advances to the next exercise. No parameters. Valid when state is 'succeeded'. Blocks until the command is accepted. + +## Don't + +These keep you from accidentally doing the student's job: the thinking. + +- Don't read exercise.md directly. Always delegate to a sub-agent. +- Don't tell the student what to implement before they describe it themselves. The student must articulate the goal first. +- Don't generate insights, explanations, or teaching content from your own knowledge. **This is critical.** No "★ Insight" blocks, no pattern explanations, no architecture commentary unless it comes verbatim from the brief's '[insight]' or '[if wrong]' sections. If the brief doesn't mention it, don't teach it. +- Don't ask open-ended questions ("why do you think X matters?", "how would you approach this?"). Use A/B/C choices instead. +- Don't ask meta-questions ("how would you like to work?"). +- Don't add unnecessary ceremony to simple exercises (don't propose plans or reviews on your own, but always respect plan mode if the student has it enabled). +- Don't front-load all choices. Present them as you reach each step. +- Don't require MCP tools. Always have a manual fallback. If an MCP call fails mid-exercise, switch to manual instructions for the rest of that exercise. +- Don't fix or explain test failures for functionality the student hasn't described. Redirect them to re-read the exercise. +- Don't autonomously fix failures from 'training_run_solution'. Show the error messages and wait for the student to describe the fix. +- Don't drive the debugging cycle. Show errors and wait for the student to describe what went wrong and how to fix it. The student decides what to fix and directs the approach, just like they direct the initial implementation. +- Don't call 'training_get_exercise_info' with 'include_logs: true' yourself. Delegate to a sub-agent to prevent test output from entering your context. +`) +} + +func hashContent(content []byte) string { + h := sha256.Sum256(content) + return fmt.Sprintf("%x", h) +} diff --git a/trainings/mcp_files_test.go b/trainings/mcp_files_test.go new file mode 100644 index 0000000..fcc5636 --- /dev/null +++ b/trainings/mcp_files_test.go @@ -0,0 +1,241 @@ +package trainings + +import ( + "encoding/json" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnsureMCPJson_CreatesFileWhenMissing(t *testing.T) { + fs := afero.NewMemMapFs() + + ensureMCPJson(fs, 39131) + + data, err := afero.ReadFile(fs, ".mcp.json") + require.NoError(t, err) + + var root map[string]any + require.NoError(t, json.Unmarshal(data, &root)) + + servers := root["mcpServers"].(map[string]any) + entry := servers["tdl-training"].(map[string]any) + assert.Equal(t, "http://127.0.0.1:39131/mcp", entry["url"]) +} + +func TestEnsureMCPJson_PreservesOtherServers(t *testing.T) { + fs := afero.NewMemMapFs() + + existing := `{ + "mcpServers": { + "my-custom-server": { + "url": "http://localhost:8080/mcp" + } + } +} +` + require.NoError(t, afero.WriteFile(fs, ".mcp.json", []byte(existing), 0644)) + + ensureMCPJson(fs, 39131) + + data, err := afero.ReadFile(fs, ".mcp.json") + require.NoError(t, err) + + var root map[string]any + require.NoError(t, json.Unmarshal(data, &root)) + + servers := root["mcpServers"].(map[string]any) + + // Our entry added + tdl := servers["tdl-training"].(map[string]any) + assert.Equal(t, "http://127.0.0.1:39131/mcp", tdl["url"]) + + // User's entry preserved + custom := servers["my-custom-server"].(map[string]any) + assert.Equal(t, "http://localhost:8080/mcp", custom["url"]) +} + +func TestEnsureMCPJson_UpdatesPort(t *testing.T) { + fs := afero.NewMemMapFs() + + ensureMCPJson(fs, 39131) + ensureMCPJson(fs, 12345) + + data, err := afero.ReadFile(fs, ".mcp.json") + require.NoError(t, err) + + var root map[string]any + require.NoError(t, json.Unmarshal(data, &root)) + + servers := root["mcpServers"].(map[string]any) + entry := servers["tdl-training"].(map[string]any) + assert.Equal(t, "http://127.0.0.1:12345/mcp", entry["url"]) +} + +func TestEnsureManagedFile_CreatesFileWhenMissing(t *testing.T) { + fs := afero.NewMemMapFs() + hashes := map[string]string{} + + changed := ensureManagedFile(fs, "CLAUDE.md", generateClaudeMd(), hashes) + + assert.True(t, changed) + assert.NotEmpty(t, hashes["CLAUDE.md"]) + + content, err := afero.ReadFile(fs, "CLAUDE.md") + require.NoError(t, err) + assert.Equal(t, string(generateClaudeMd()), string(content)) +} + +func TestEnsureManagedFile_SkipsWhenTemplateUnchanged(t *testing.T) { + fs := afero.NewMemMapFs() + hashes := map[string]string{} + + // First write + ensureManagedFile(fs, "CLAUDE.md", generateClaudeMd(), hashes) + + // Modify file on disk (user edit) + require.NoError(t, afero.WriteFile(fs, "CLAUDE.md", []byte("user content"), 0644)) + + // Run again — template unchanged, should skip + changed := ensureManagedFile(fs, "CLAUDE.md", generateClaudeMd(), hashes) + + assert.False(t, changed) + + // User's content preserved + content, err := afero.ReadFile(fs, "CLAUDE.md") + require.NoError(t, err) + assert.Equal(t, "user content", string(content)) +} + +func TestEnsureManagedFile_OverwritesUnmodifiedFile(t *testing.T) { + fs := afero.NewMemMapFs() + + // Simulate: old template was written + oldContent := []byte("old template v1\n") + oldHash := hashContent(oldContent) + require.NoError(t, afero.WriteFile(fs, "CLAUDE.md", oldContent, 0644)) + hashes := map[string]string{"CLAUDE.md": oldHash} + + // Now new template (generateClaudeMd returns different content) + changed := ensureManagedFile(fs, "CLAUDE.md", generateClaudeMd(), hashes) + + assert.True(t, changed) + + content, err := afero.ReadFile(fs, "CLAUDE.md") + require.NoError(t, err) + assert.Equal(t, string(generateClaudeMd()), string(content)) + assert.Equal(t, hashContent(generateClaudeMd()), hashes["CLAUDE.md"]) +} + +func TestEnsureManagedFile_AdoptsExistingFileFirstTime(t *testing.T) { + fs := afero.NewMemMapFs() + + // File exists but no stored hash (first-time tracking) + require.NoError(t, afero.WriteFile(fs, "CLAUDE.md", []byte("user created this"), 0644)) + hashes := map[string]string{} + + changed := ensureManagedFile(fs, "CLAUDE.md", generateClaudeMd(), hashes) + + assert.True(t, changed) // hash was updated + assert.Equal(t, hashContent(generateClaudeMd()), hashes["CLAUDE.md"]) + + // File NOT overwritten — user's content preserved + content, err := afero.ReadFile(fs, "CLAUDE.md") + require.NoError(t, err) + assert.Equal(t, "user created this", string(content)) +} + +func TestEnsureManagedFile_DeclineStopsNagging(t *testing.T) { + fs := afero.NewMemMapFs() + + // Simulate: template v1 was written, user edited, template v2 arrived + require.NoError(t, afero.WriteFile(fs, "CLAUDE.md", []byte("user edited content"), 0644)) + hashes := map[string]string{"CLAUDE.md": "old-template-hash"} + + // This would normally prompt — but in tests stdin is not a terminal, + // so it falls through to non-interactive path (skips, sets hash) + changed := ensureManagedFile(fs, "CLAUDE.md", generateClaudeMd(), hashes) + + assert.True(t, changed) + // hash updated to new template hash — won't ask again for this version + assert.Equal(t, hashContent(generateClaudeMd()), hashes["CLAUDE.md"]) + + // User's content preserved (non-interactive = skip) + content, err := afero.ReadFile(fs, "CLAUDE.md") + require.NoError(t, err) + assert.Equal(t, "user edited content", string(content)) + + // Run again — hash matches new template, skips entirely + changed = ensureManagedFile(fs, "CLAUDE.md", generateClaudeMd(), hashes) + assert.False(t, changed) +} + +func TestEnsureManagedFile_IndependentHashes(t *testing.T) { + fs := afero.NewMemMapFs() + hashes := map[string]string{} + content := generateClaudeMd() + + // Create both files + ensureManagedFile(fs, "CLAUDE.md", content, hashes) + ensureManagedFile(fs, "AGENTS.md", content, hashes) + + assert.Equal(t, hashes["CLAUDE.md"], hashes["AGENTS.md"]) + + // Edit only CLAUDE.md on disk + require.NoError(t, afero.WriteFile(fs, "CLAUDE.md", []byte("user edit"), 0644)) + + // Both use same template content, so neither should trigger (template unchanged) + assert.False(t, ensureManagedFile(fs, "CLAUDE.md", content, hashes)) + assert.False(t, ensureManagedFile(fs, "AGENTS.md", content, hashes)) + + // AGENTS.md untouched + agentsContent, err := afero.ReadFile(fs, "AGENTS.md") + require.NoError(t, err) + assert.Equal(t, string(content), string(agentsContent)) +} + +func TestEnsureManagedFile_WorksForGitignore(t *testing.T) { + fs := afero.NewMemMapFs() + hashes := map[string]string{} + + changed := ensureManagedFile(fs, ".gitignore", []byte(gitignore), hashes) + + assert.True(t, changed) + assert.NotEmpty(t, hashes[".gitignore"]) + + content, err := afero.ReadFile(fs, ".gitignore") + require.NoError(t, err) + assert.Equal(t, gitignore, string(content)) + assert.Contains(t, string(content), "AGENTS.md") +} + +func TestEnsureManagedFile_UpdatesOldFileWithoutHash(t *testing.T) { + fs := afero.NewMemMapFs() + hashes := map[string]string{} + + // Simulate: old CLI wrote .gitignore without hash tracking + oldGitignore := "# old content\nCLAUDE.md\n" + require.NoError(t, afero.WriteFile(fs, ".gitignore", []byte(oldGitignore), 0644)) + + // New template has AGENTS.md — no stored hash, file differs from template + // Non-interactive: stores hash, preserves file (user will be prompted in interactive mode) + changed := ensureManagedFile(fs, ".gitignore", []byte(gitignore), hashes) + assert.True(t, changed) + assert.Equal(t, hashContent([]byte(gitignore)), hashes[".gitignore"]) + + // On next run with same template, stored hash matches — nothing to do + changed = ensureManagedFile(fs, ".gitignore", []byte(gitignore), hashes) + assert.False(t, changed) +} + +func TestHashContent(t *testing.T) { + h1 := hashContent([]byte("hello")) + h2 := hashContent([]byte("hello")) + h3 := hashContent([]byte("world")) + + assert.Equal(t, h1, h2) + assert.NotEqual(t, h1, h3) + assert.Len(t, h1, 64) // sha256 hex +} diff --git a/trainings/next.go b/trainings/next.go index 32f14b1..f870b49 100644 --- a/trainings/next.go +++ b/trainings/next.go @@ -17,6 +17,7 @@ import ( "github.com/ThreeDotsLabs/cli/trainings/files" "github.com/ThreeDotsLabs/cli/trainings/genproto" "github.com/ThreeDotsLabs/cli/trainings/git" + mcppkg "github.com/ThreeDotsLabs/cli/trainings/mcp" ) var errMergeAborted = fmt.Errorf("merge aborted by user") @@ -243,6 +244,9 @@ func (h *Handlers) setExerciseWithGit( fmt.Println(" with our versions (only this exercise is affected).") fmt.Printf(" Your code will be saved to %s: you can restore it anytime.\n", color.MagentaString(previewBackupBranch)) fmt.Println() + if h.loopState != nil { + h.loopState.SetPendingAction("Merge conflict decision needed. Go to CLI.") + } conflictPrompt = internal.Prompt( internal.Actions{ {Shortcut: '\n', Action: "merge (resolve in editor)", ShortcutAliases: []rune{'\r'}}, @@ -251,6 +255,9 @@ func (h *Handlers) setExerciseWithGit( }, os.Stdin, os.Stdout, ) + if h.loopState != nil { + h.loopState.ClearPendingAction() + } if conflictPrompt == 'q' { fmt.Println(color.YellowString(" Merge aborted, staying on current exercise.")) return errMergeAborted @@ -273,9 +280,18 @@ func (h *Handlers) setExerciseWithGit( fmt.Println(color.YellowString("\n You have uncommitted changes in files the exercise needs to update.")) fmt.Println(color.YellowString(" In another terminal, commit or stash your changes:")) fmt.Println(color.CyanString(" git add -A && git commit -m \"my changes\"")) + if h.loopState != nil { + h.loopState.SetPendingAction("Uncommitted changes blocking next exercise. Go to CLI to commit or stash.") + } if !internal.ConfirmPromptDefaultYes("retry") { + if h.loopState != nil { + h.loopState.ClearPendingAction() + } return fmt.Errorf("merge blocked by uncommitted changes") } + if h.loopState != nil { + h.loopState.ClearPendingAction() + } continue // retry after user commits } @@ -307,7 +323,7 @@ func (h *Handlers) setExerciseWithGit( } else if internal.IsStdinTerminal() { // Interactive conflict resolution loop trainingName := h.config.TrainingConfig(fs).TrainingName - if err := resolveConflictsInteractive(gitOps, initBranch, mergeMsg, moduleExercisePath, exerciseDir, trainingName); err != nil { + if err := resolveConflictsInteractive(gitOps, initBranch, mergeMsg, moduleExercisePath, exerciseDir, trainingName, h.loopState); err != nil { return err } } else { @@ -482,7 +498,7 @@ func writeExerciseMd(allFiles []*genproto.File, fs afero.Fs, exerciseDir string) // IMPORTANT: The 'g' (replace) path is destructive — it overwrites user files. // We MUST save their code to a backup branch before replacing. // The user explicitly confirms this action. -func resolveConflictsInteractive(gitOps *git.Ops, initBranch, mergeMsg, moduleExercisePath, exerciseDir, trainingName string) error { +func resolveConflictsInteractive(gitOps *git.Ops, initBranch, mergeMsg, moduleExercisePath, exerciseDir, trainingName string, loopState *mcppkg.LoopState) error { conflictFiles, _ := gitOps.UnmergedFiles() fmt.Println(color.YellowString("\n Merge conflict detected.")) fmt.Println(color.YellowString(" Files with conflicts:")) @@ -498,6 +514,9 @@ func resolveConflictsInteractive(gitOps *git.Ops, initBranch, mergeMsg, moduleEx fmt.Printf(" Your code will be saved to %s: you can restore it anytime.\n", color.MagentaString(backupBranch)) for { + if loopState != nil { + loopState.SetPendingAction("Merge conflicts need resolution. Go to CLI.") + } choice := internal.Prompt( internal.Actions{ {Shortcut: '\n', Action: "confirm (conflicts resolved)", ShortcutAliases: []rune{'\r'}}, @@ -506,6 +525,9 @@ func resolveConflictsInteractive(gitOps *git.Ops, initBranch, mergeMsg, moduleEx }, os.Stdin, os.Stdout, ) + if loopState != nil { + loopState.ClearPendingAction() + } switch choice { case '\n': diff --git a/trainings/run.go b/trainings/run.go index 5848c35..95481fd 100644 --- a/trainings/run.go +++ b/trainings/run.go @@ -1,6 +1,7 @@ package trainings import ( + "bufio" "context" "fmt" "io" @@ -14,6 +15,7 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/spf13/afero" + "golang.org/x/term" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -22,6 +24,7 @@ import ( "github.com/ThreeDotsLabs/cli/trainings/files" "github.com/ThreeDotsLabs/cli/trainings/genproto" "github.com/ThreeDotsLabs/cli/trainings/git" + mcppkg "github.com/ThreeDotsLabs/cli/trainings/mcp" ) func (h *Handlers) Run(ctx context.Context, detached bool) error { @@ -34,6 +37,12 @@ func (h *Handlers) Run(ctx context.Context, detached bool) error { trainingRootFs := newTrainingRootFs(trainingRoot) printGitNotices(h.config.TrainingConfig(trainingRootFs)) + // MCP auto-detection: prompt to enable if AI coding tools are found. + // Skipped for detached mode and when --mcp-port 0 explicitly disables MCP. + if !detached && h.mcpPort != 0 { + h.configureMCPIfNeeded(trainingRootFs) + } + if detached { return h.detachedRun(ctx, trainingRootFs) } else { @@ -81,11 +90,31 @@ func (h *Handlers) detachedRun(ctx context.Context, trainingRootFs *afero.BasePa } func (h *Handlers) interactiveRun(ctx context.Context, trainingRootFs *afero.BasePathFs) error { + // Start MCP server if enabled + if h.loopState != nil && h.mcpPort > 0 { + h.setLoopExerciseInfo(trainingRootFs) + + mcpCtx, mcpCancel := context.WithCancel(ctx) + defer mcpCancel() + + srv := mcppkg.NewServer(h.loopState, h.mcpPort) + if err := srv.Start(mcpCtx); err != nil { + logrus.WithError(err).Warn("Failed to start MCP server") + } else { + fmt.Printf("%s\n", color.HiBlackString("MCP server listening on %s", srv.Addr())) + } + } + retries := 0 mergeAborted := false for { if !mergeAborted { + h.setLoopState(mcppkg.StateRunning) + if h.loopState != nil { + h.loopState.ClearLastError() + } + successful, err := h.run(ctx, trainingRootFs) if err != nil && retries < 3 { retries++ @@ -101,11 +130,31 @@ func (h *Handlers) interactiveRun(ctx context.Context, trainingRootFs *afero.Bas userErr := formatServerError(err) fmt.Println(color.RedString("Failed to execute solution: %s", userErr)) - if !internal.ConfirmPromptDefaultYes("run solution again") { + if h.loopState != nil { + errMsg := fmt.Sprintf("Training CLI error: %s", userErr) + h.loopState.SetLastError(errMsg) + fmt.Fprintln(h.loopState.OutputBuffer(), errMsg) + } + + h.setLoopState(mcppkg.StateFailed) + action, fromMCP := h.waitForAction( + internal.Actions{ + {Shortcut: '\n', Action: "run solution again", ShortcutAliases: []rune{'\r'}}, + {Shortcut: 'q', Action: "quit"}, + }, + map[rune]loopAction{ + '\n': loopActionRun, + 'q': loopActionQuit, + }, + map[mcppkg.CommandType]loopAction{ + mcppkg.CmdRunSolution: loopActionRun, + }, + ) + ctx = withMCPTriggered(ctx, fromMCP) + if action == loopActionQuit { return userErr - } else { - continue } + continue } if !successful { @@ -125,11 +174,25 @@ func (h *Handlers) interactiveRun(ctx context.Context, trainingRootFs *afero.Bas } } - if !internal.ConfirmPromptDefaultYes("run solution again") { + h.setLoopState(mcppkg.StateFailed) + action, fromMCP := h.waitForAction( + internal.Actions{ + {Shortcut: '\n', Action: "run solution again", ShortcutAliases: []rune{'\r'}}, + {Shortcut: 'q', Action: "quit"}, + }, + map[rune]loopAction{ + '\n': loopActionRun, + 'q': loopActionQuit, + }, + map[mcppkg.CommandType]loopAction{ + mcppkg.CmdRunSolution: loopActionRun, + }, + ) + ctx = withMCPTriggered(ctx, fromMCP) + if action == loopActionQuit { return nil - } else { - continue } + continue } } mergeAborted = false @@ -138,6 +201,9 @@ func (h *Handlers) interactiveRun(ctx context.Context, trainingRootFs *afero.Bas actions := internal.Actions{ {Shortcut: '\n', Action: "go to the next exercise", ShortcutAliases: []rune{'\r'}}, } + actionMap := map[rune]loopAction{ + '\n': loopActionNextExercise, + } gitOps := h.newGitOps() trainingRoot, err := h.config.FindTrainingRoot() @@ -149,20 +215,33 @@ func (h *Handlers) interactiveRun(ctx context.Context, trainingRootFs *afero.Bas if gitOps.Enabled() && !exerciseCfg.IsTextOnly && !cfg.GitAutoGolden { actions = append(actions, internal.Action{Shortcut: 's', Action: "sync with example solution"}) + actionMap['s'] = loopActionSyncSolution } actions = append(actions, internal.Action{Shortcut: 'r', Action: "re-run solution"}, internal.Action{Shortcut: 'q', Action: "quit"}, ) + actionMap['r'] = loopActionRun + actionMap['q'] = loopActionQuit + + h.setLoopState(mcppkg.StateSucceeded) + chosenAction, fromMCP := h.waitForAction( + actions, + actionMap, + map[mcppkg.CommandType]loopAction{ + mcppkg.CmdRunSolution: loopActionRun, + mcppkg.CmdNextExercise: loopActionNextExercise, + }, + ) + ctx = withMCPTriggered(ctx, fromMCP) - promptResult := internal.Prompt(actions, os.Stdin, os.Stdout) - if promptResult == 'q' { + if chosenAction == loopActionQuit { os.Exit(0) } - if promptResult == 'r' { + if chosenAction == loopActionRun { continue } - if promptResult == 's' { + if chosenAction == loopActionSyncSolution { h.overrideWithGolden(withSubAction(ctx, "sync-golden-manual"), trainingRootFs, gitOps, exerciseCfg) // Fall through to next exercise (example solution already committed, no staged changes) } @@ -184,18 +263,20 @@ func (h *Handlers) interactiveRun(ctx context.Context, trainingRootFs *afero.Bas } // Auto-sync: override with example solution automatically after passing - if cfg.GitAutoGolden && gitOps.Enabled() && !exerciseCfg.IsTextOnly && promptResult != 's' { + if cfg.GitAutoGolden && gitOps.Enabled() && !exerciseCfg.IsTextOnly && chosenAction != loopActionSyncSolution { h.overrideWithGolden(withSubAction(ctx, "sync-golden-auto"), trainingRootFs, gitOps, exerciseCfg) } - // Create example solution branch for comparison (skip if user pressed 's' or auto-sync ran) - if gitOps.Enabled() && !exerciseCfg.IsTextOnly && !cfg.GitAutoGolden && promptResult != 's' { + // Create example solution branch for comparison (skip if user synced or auto-sync ran) + if gitOps.Enabled() && !exerciseCfg.IsTextOnly && !cfg.GitAutoGolden && chosenAction != loopActionSyncSolution { goldenBranch := git.GoldenBranchName(exerciseCfg.ModuleExercisePath()) if !gitOps.BranchExists(goldenBranch) { h.syncGoldenSolution(withSubAction(ctx, "sync-golden-auto"), trainingRootFs, gitOps, exerciseCfg, "compare", time.Now().Add(1*time.Second)) } } + h.setLoopState(mcppkg.StateAdvancing) + finished, err := h.nextExercise(withSubAction(ctx, "next"), exerciseCfg.ExerciseID, trainingRoot) if errors.Is(err, errMergeAborted) { mergeAborted = true @@ -208,6 +289,8 @@ func (h *Handlers) interactiveRun(ctx context.Context, trainingRootFs *afero.Bas return nil } + h.setLoopExerciseInfo(trainingRootFs) + // this is refreshed config after nextExercise execution currentExerciseConfig := h.config.ExerciseConfig(trainingRootFs) @@ -236,7 +319,12 @@ func (h *Handlers) interactiveRun(ctx context.Context, trainingRootFs *afero.Bas postActions = append(postActions, internal.Action{Shortcut: 'q', Action: "quit"}) - promptResult = internal.Prompt(postActions, os.Stdin, os.Stdout) + if h.loopState != nil { + // MCP mode: auto-continue. The MCP client already triggered + // the advance — no reason to block before running the solution. + continue + } + promptResult := internal.Prompt(postActions, os.Stdin, os.Stdout) if promptResult == 'q' { os.Exit(0) } @@ -257,9 +345,12 @@ func (h *Handlers) run(ctx context.Context, trainingRootFs *afero.BasePathFs) (b if isExerciseNoLongerAvailable(err) { fmt.Println(color.YellowString("We did update of the exercise code. Your local workspace is out of sync.")) - if !internal.ConfirmPromptDefaultYes("update your local workspace") { - os.Exit(0) + if h.loopState == nil { + if !internal.ConfirmPromptDefaultYes("update your local workspace") { + os.Exit(0) + } } + // MCP mode: auto-accept update — exercise must be refreshed. trainingRoot, err := h.config.FindTrainingRoot() if err != nil { @@ -317,6 +408,15 @@ func (h *Handlers) runExercise(ctx context.Context, trainingRootFs *afero.BasePa terminalPath := h.generateRunTerminalPath(trainingRootFs) + // Set up output capture for MCP log buffer + stdoutW := io.Writer(os.Stdout) + stderrW := io.Writer(os.Stderr) + if h.loopState != nil { + h.loopState.OutputBuffer().Reset() + stdoutW = io.MultiWriter(os.Stdout, h.loopState.OutputBuffer()) + stderrW = io.MultiWriter(os.Stderr, h.loopState.OutputBuffer()) + } + successful := false finished := false verificationID := "" @@ -339,15 +439,15 @@ func (h *Handlers) runExercise(ctx context.Context, trainingRootFs *afero.BasePa } if len(response.Command) > 0 { - printCommandWithPath(terminalPath, response.Command) + cmdStr := fmt.Sprintf("%s%s", color.CyanString(fmt.Sprintf("••• %s ➜ ", terminalPath)), response.Command) + fmt.Fprint(stdoutW, cmdStr) } if len(response.Stdout) > 0 { - fmt.Print(response.Stdout) + fmt.Fprint(stdoutW, response.Stdout) } if len(response.Stderr) > 0 { - _, _ = fmt.Fprint(os.Stderr, response.Stderr) + fmt.Fprint(stderrW, response.Stderr) } - // todo - support stderr and commands if response.Finished { if len(response.GetSuiteResult().GetScenarios()) > 0 { @@ -403,6 +503,132 @@ func (h *Handlers) runExercise(ctx context.Context, trainingRootFs *afero.BasePa } } +// loopAction represents what the interactive loop should do next. +type loopAction int + +const ( + loopActionRun loopAction = iota // Run (or re-run) the solution + loopActionNextExercise // Advance to next exercise + loopActionSyncSolution // Sync with example solution + loopActionQuit // Quit the loop +) + +// waitForAction prints a prompt and waits for input from either stdin or the MCP command channel. +// When MCP is disabled (loopState == nil), it behaves identically to internal.Prompt(). +func (h *Handlers) waitForAction( + actions internal.Actions, + actionMap map[rune]loopAction, + validMCPCmds map[mcppkg.CommandType]loopAction, +) (loopAction, bool) { + defer fmt.Println() + + // Format prompt string (replicates internal.Prompt display logic) + var actionsStr []string + for _, action := range actions { + actionsStr = append(actionsStr, fmt.Sprintf( + "%s to %s", + color.New(color.Bold).Sprint(action.KeyString()), + action.Action, + )) + } + fmt.Printf("%s", "Press "+formatActionsMessage(actionsStr)+" ") + + // Put terminal in raw mode for single-keypress reading + termState, rawErr := term.MakeRaw(0) + if rawErr == nil { + defer term.Restore(0, termState) + } + + reader := bufio.NewReader(os.Stdin) + + // Read stdin in a goroutine + stdinCh := make(chan rune, 1) + go func() { + for { + ch, _, err := reader.ReadRune() + if err != nil { + return + } + stdinCh <- ch + } + }() + + for { + if h.loopState == nil { + // No MCP — pure stdin + ch := <-stdinCh + if string(ch) == "\x03" { + if rawErr == nil { + term.Restore(0, termState) + } + os.Exit(0) + } + if key, ok := actions.ReadKeyFromInput(ch); ok { + if action, mapped := actionMap[key]; mapped { + return action, false + } + } + continue + } + + select { + case ch := <-stdinCh: + if string(ch) == "\x03" { + if rawErr == nil { + term.Restore(0, termState) + } + os.Exit(0) + } + if key, ok := actions.ReadKeyFromInput(ch); ok { + if action, mapped := actionMap[key]; mapped { + return action, false + } + } + case cmd := <-h.loopState.CommandCh(): + if mappedAction, ok := validMCPCmds[cmd.Type]; ok { + cmd.ResultCh <- mcppkg.MCPResult{Success: true, Message: "command accepted"} + return mappedAction, true + } + cmd.ResultCh <- mcppkg.MCPResult{ + Error: fmt.Sprintf("command '%s' not valid in current state (%s)", cmd.Type, h.loopState.GetState()), + } + } + } +} + +func formatActionsMessage(actionsStr []string) string { + switch len(actionsStr) { + case 0: + return "" + case 1: + return actionsStr[0] + default: + return strings.Join(actionsStr[:len(actionsStr)-1], ", ") + " or " + actionsStr[len(actionsStr)-1] + } +} + +func (h *Handlers) setLoopExerciseInfo(trainingRootFs *afero.BasePathFs) { + if h.loopState == nil { + return + } + cfg := h.config.ExerciseConfig(trainingRootFs) + h.loopState.SetExerciseInfo(mcppkg.ExerciseInfo{ + ExerciseID: cfg.ExerciseID, + Directory: cfg.Directory, + IsTextOnly: cfg.IsTextOnly, + IsOptional: cfg.IsOptional, + ModuleName: cfg.ModuleName, + ExerciseName: cfg.ExerciseName, + }) +} + +func (h *Handlers) setLoopState(state mcppkg.ExerciseState) { + if h.loopState == nil { + return + } + h.loopState.SetState(state) +} + // compareDir returns exerciseDir relative to the user's cwd, so that the // displayed "git diff ... -- " command works when copied from any directory. func compareDir(gitOps *git.Ops, exerciseDir string) string {