bots-garden/mini-mepublic Fork
⑂ 335ef35
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Share the plan action dispatch and the Ollama-style probe

runPlanAction replaces the two copies of the plan switch (terminal and
ACP), with a test for every action; probeAPIPS replaces the two copies of
the llmman/ollama probe. Also places the SC2016 directive on the printf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
k33g committed 2026-10-16T09:04:21+02:00 Browse files
335ef35 parent: 33e0ee8
modified 02-build-releases.sh +1 -1
@@ -134,8 +134,8 @@ echo " ✅ SHA256SUMS"
134134 downloadTable() {
135135 printf '| Platform | Download |\n|---|---|\n'
136136 for platform in "${PLATFORMS[@]}"; do
137- # shellcheck disable=SC2016 # the backticks are Markdown code spans, not a command substitution
138137 local goos="${platform%/*}" goarch="${platform#*/}"
138+ # shellcheck disable=SC2016 # the backticks are Markdown code spans, not a command substitution
139139 printf '| %s | `%s` |\n' "${platform}" "$(assetName "${goos}" "${goarch}")"
140140 done
141141 }
@@ -134,8 +134,8 @@ echo " ✅ SHA256SUMS"
134 downloadTable() {134 downloadTable() {
135 printf '| Platform | Download |\n|---|---|\n'135 printf '| Platform | Download |\n|---|---|\n'
136 for platform in "${PLATFORMS[@]}"; do136 for platform in "${PLATFORMS[@]}"; do
137- # shellcheck disable=SC2016 # the backticks are Markdown code spans, not a command substitution
138 local goos="${platform%/*}" goarch="${platform#*/}"137 local goos="${platform%/*}" goarch="${platform#*/}"
138+ # shellcheck disable=SC2016 # the backticks are Markdown code spans, not a command substitution
139 printf '| %s | `%s` |\n' "${platform}" "$(assetName "${goos}" "${goarch}")"139 printf '| %s | `%s` |\n' "${platform}" "$(assetName "${goos}" "${goarch}")"
140 done140 done
141 }141 }
modified internal/engine/apips.go +19 -0
@@ -29,6 +29,25 @@ func (l modelList) has(id string) bool {
2929 return false
3030 }
3131
32+// probeAPIPS is the probe the Ollama-style servers share: reachable when
33+// /v1/models answers, `missing` as the warning when the model is not listed,
34+// and the served window from /api/ps when the model is loaded.
35+func probeAPIPS(b Backend, missing string) Info {
36+ var list modelList
37+ info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)}
38+ if !info.Reachable {
39+ return info
40+ }
41+ if !list.has(b.Model) {
42+ info.Warnings = append(info.Warnings, missing)
43+ return info
44+ }
45+ if w := loadedWindow(b.BaseURL, b.Model); w > 0 {
46+ info.ContextWindow, info.ContextSource = w, "/api/ps"
47+ }
48+ return info
49+}
50+
3251 // loadedWindow reads /api/ps, one level above /v1, and returns the served
3352 // context of `model` — 0 when it is not loaded or the route does not answer.
3453 func loadedWindow(baseURL, model string) int {
@@ -29,6 +29,25 @@ func (l modelList) has(id string) bool {
29 return false29 return false
30 }30 }
31 31
32+// probeAPIPS is the probe the Ollama-style servers share: reachable when
33+// /v1/models answers, `missing` as the warning when the model is not listed,
34+// and the served window from /api/ps when the model is loaded.
35+func probeAPIPS(b Backend, missing string) Info {
36+ var list modelList
37+ info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)}
38+ if !info.Reachable {
39+ return info
40+ }
41+ if !list.has(b.Model) {
42+ info.Warnings = append(info.Warnings, missing)
43+ return info
44+ }
45+ if w := loadedWindow(b.BaseURL, b.Model); w > 0 {
46+ info.ContextWindow, info.ContextSource = w, "/api/ps"
47+ }
48+ return info
49+}
50+
32 // loadedWindow reads /api/ps, one level above /v1, and returns the served51 // loadedWindow reads /api/ps, one level above /v1, and returns the served
33 // context of `model` — 0 when it is not loaded or the route does not answer.52 // context of `model` — 0 when it is not loaded or the route does not answer.
34 func loadedWindow(baseURL, model string) int {53 func loadedWindow(baseURL, model string) int {
modified internal/engine/llmman.go +1 -14
@@ -16,18 +16,5 @@ import (
1616 // pulls it, which can take minutes — worth a warning, because the wait looks
1717 // like a stalled server.
1818 func probeLlmman(_ context.Context, b Backend) Info {
19- var list modelList
20- info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)}
21- if !info.Reachable {
22- return info
23- }
24- if !list.has(b.Model) {
25- info.Warnings = append(info.Warnings,
26- fmt.Sprintf("llmman: model %q is not in the store — the first question will pull it (or: llmman pull %s)", b.Model, b.Model))
27- return info
28- }
29- if w := loadedWindow(b.BaseURL, b.Model); w > 0 {
30- info.ContextWindow, info.ContextSource = w, "/api/ps"
31- }
32- return info
19+ return probeAPIPS(b, fmt.Sprintf("llmman: model %q is not in the store — the first question will pull it (or: llmman pull %s)", b.Model, b.Model))
3320 }
@@ -16,18 +16,5 @@ import (
16 // pulls it, which can take minutes — worth a warning, because the wait looks16 // pulls it, which can take minutes — worth a warning, because the wait looks
17 // like a stalled server.17 // like a stalled server.
18 func probeLlmman(_ context.Context, b Backend) Info {18 func probeLlmman(_ context.Context, b Backend) Info {
19- var list modelList19+ return probeAPIPS(b, fmt.Sprintf("llmman: model %q is not in the store — the first question will pull it (or: llmman pull %s)", b.Model, b.Model))
20- info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)}
21- if !info.Reachable {
22- return info
23- }
24- if !list.has(b.Model) {
25- info.Warnings = append(info.Warnings,
26- fmt.Sprintf("llmman: model %q is not in the store — the first question will pull it (or: llmman pull %s)", b.Model, b.Model))
27- return info
28- }
29- if w := loadedWindow(b.BaseURL, b.Model); w > 0 {
30- info.ContextWindow, info.ContextSource = w, "/api/ps"
31- }
32- return info
33 }20 }
modified internal/engine/ollama.go +1 -14
@@ -10,18 +10,5 @@ import (
1010 // for a loaded qwen3.5:2b). Unlike llmman, Ollama's OpenAI API does NOT pull
1111 // a missing model: the request fails with a 404, so the warning says to pull.
1212 func probeOllama(_ context.Context, b Backend) Info {
13- var list modelList
14- info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)}
15- if !info.Reachable {
16- return info
17- }
18- if !list.has(b.Model) {
19- info.Warnings = append(info.Warnings,
20- fmt.Sprintf("ollama: model %q is not in the server's list — ollama pull %s", b.Model, b.Model))
21- return info
22- }
23- if w := loadedWindow(b.BaseURL, b.Model); w > 0 {
24- info.ContextWindow, info.ContextSource = w, "/api/ps"
25- }
26- return info
13+ return probeAPIPS(b, fmt.Sprintf("ollama: model %q is not in the server's list — ollama pull %s", b.Model, b.Model))
2714 }
@@ -10,18 +10,5 @@ import (
10 // for a loaded qwen3.5:2b). Unlike llmman, Ollama's OpenAI API does NOT pull10 // for a loaded qwen3.5:2b). Unlike llmman, Ollama's OpenAI API does NOT pull
11 // a missing model: the request fails with a 404, so the warning says to pull.11 // a missing model: the request fails with a 404, so the warning says to pull.
12 func probeOllama(_ context.Context, b Backend) Info {12 func probeOllama(_ context.Context, b Backend) Info {
13- var list modelList13+ return probeAPIPS(b, fmt.Sprintf("ollama: model %q is not in the server's list — ollama pull %s", b.Model, b.Model))
14- info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)}
15- if !info.Reachable {
16- return info
17- }
18- if !list.has(b.Model) {
19- info.Warnings = append(info.Warnings,
20- fmt.Sprintf("ollama: model %q is not in the server's list — ollama pull %s", b.Model, b.Model))
21- return info
22- }
23- if w := loadedWindow(b.BaseURL, b.Model); w > 0 {
24- info.ContextWindow, info.ContextSource = w, "/api/ps"
25- }
26- return info
27 }14 }
modified internal/tools/plan.go +2 -183
@@ -443,101 +443,7 @@ Always show the user the current plan status using the status action.`
443443 }
444444
445445 spinner.Start("Running")
446-
447- var result string
448-
449- switch in.Action {
450- case "create":
451- plan, msg, createErr := planStore.CreatePlan(in.Name, in.Tasks)
452- if createErr != nil {
453- result = fmt.Sprintf("[error: %v]", createErr)
454- break
455- }
456- // Return plan info as JSON for model, plus text summary
457- output := PlanOutput{
458- Result: msg,
459- PlanName: plan.Name,
460- Plan: plan,
461- Current: plan.Current,
462- Total: len(plan.Tasks),
463- }
464- outputBytes, _ := json.Marshal(output)
465- result = string(outputBytes)
466-
467- case "get":
468- plan, msg, getErr := planStore.GetPlan(in.Name)
469- if getErr != nil {
470- result = fmt.Sprintf("[error: %v]", getErr)
471- break
472- }
473- output := PlanOutput{
474- Result: msg,
475- Plan: plan,
476- }
477- outputBytes, _ := json.Marshal(output)
478- result = string(outputBytes)
479-
480- case "update":
481- _, msg, updateErr := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message)
482- if updateErr != nil {
483- result = fmt.Sprintf("[error: %v]", updateErr)
484- break
485- }
486- // Get status line
487- statusLine, _, _ := planStore.GetStatus(in.Name)
488- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
489-
490- case "next":
491- _, nextTask, msg, nextErr := planStore.NextTask(in.Name)
492- if nextErr != nil {
493- result = fmt.Sprintf("[error: %v]", nextErr)
494- break
495- }
496- // Get status line
497- statusLine, _, _ := planStore.GetStatus(in.Name)
498- if nextTask != nil {
499- result = fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`,
500- msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\""))
501- } else {
502- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
503- }
504-
505- case "list":
506- plans, msg, listErr := planStore.ListPlans()
507- if listErr != nil {
508- result = fmt.Sprintf("[error: %v]", listErr)
509- break
510- }
511- if len(plans) == 0 {
512- result = `{"result":"` + msg + `","plans":[]}`
513- break
514- }
515- // Build simple list
516- var planNames []string
517- for _, p := range plans {
518- planNames = append(planNames, p.Name)
519- }
520- result = fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(planNames, `", "`))
521-
522- case "delete":
523- msg, delErr := planStore.DeletePlan(in.Name)
524- if delErr != nil {
525- result = fmt.Sprintf("[error: %v]", delErr)
526- break
527- }
528- result = fmt.Sprintf(`{"result":"%s"}`, msg)
529-
530- case "status":
531- statusLine, msg, statusErr := planStore.GetStatus(in.Name)
532- if statusErr != nil {
533- result = fmt.Sprintf("[error: %v]", statusErr)
534- break
535- }
536- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
537-
538- default:
539- result = fmt.Sprintf("[error: unknown action '%s'. Valid actions: create, get, update, next, list, delete, status]", in.Action)
540- }
446+ result := runPlanAction(in, true)
541447
542448 // Record in loop detector
543449 inputJSON, _ := json.Marshal(in)
@@ -580,94 +486,7 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl
580486
581487 s.ToolRunning(ev.ID)
582488
583- // Execute the action
584- var result string
585-
586- switch in.Action {
587- case "create":
588- plan, msg, createErr := planStore.CreatePlan(in.Name, in.Tasks)
589- if createErr != nil {
590- result = fmt.Sprintf("[error: %v]", createErr)
591- } else {
592- output := PlanOutput{
593- Result: msg,
594- PlanName: plan.Name,
595- Current: plan.Current,
596- Total: len(plan.Tasks),
597- }
598- outputBytes, _ := json.Marshal(output)
599- result = string(outputBytes)
600- }
601-
602- case "get":
603- plan, msg, getErr := planStore.GetPlan(in.Name)
604- if getErr != nil {
605- result = fmt.Sprintf("[error: %v]", getErr)
606- } else {
607- output := PlanOutput{
608- Result: msg,
609- Plan: plan,
610- }
611- outputBytes, _ := json.Marshal(output)
612- result = string(outputBytes)
613- }
614-
615- case "update":
616- _, msg, updateErr := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message)
617- if updateErr != nil {
618- result = fmt.Sprintf("[error: %v]", updateErr)
619- } else {
620- statusLine, _, _ := planStore.GetStatus(in.Name)
621- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
622- }
623-
624- case "next":
625- _, nextTask, msg, nextErr := planStore.NextTask(in.Name)
626- if nextErr != nil {
627- result = fmt.Sprintf("[error: %v]", nextErr)
628- } else {
629- statusLine, _, _ := planStore.GetStatus(in.Name)
630- if nextTask != nil {
631- result = fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`,
632- msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\""))
633- } else {
634- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
635- }
636- }
637-
638- case "list":
639- plans, msg, listErr := planStore.ListPlans()
640- if listErr != nil {
641- result = fmt.Sprintf("[error: %v]", listErr)
642- } else if len(plans) == 0 {
643- result = `{"result":"` + msg + `","plans":[]}`
644- } else {
645- var planNames []string
646- for _, p := range plans {
647- planNames = append(planNames, p.Name)
648- }
649- result = fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(planNames, `", "`))
650- }
651-
652- case "delete":
653- msg, delErr := planStore.DeletePlan(in.Name)
654- if delErr != nil {
655- result = fmt.Sprintf("[error: %v]", delErr)
656- } else {
657- result = fmt.Sprintf(`{"result":"%s"}`, msg)
658- }
659-
660- case "status":
661- statusLine, msg, statusErr := planStore.GetStatus(in.Name)
662- if statusErr != nil {
663- result = fmt.Sprintf("[error: %v]", statusErr)
664- } else {
665- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
666- }
667-
668- default:
669- result = fmt.Sprintf("[error: unknown action '%s']", in.Action)
670- }
489+ result := runPlanAction(in, false)
671490
672491 // Check if failed
673492 failed := strings.HasPrefix(result, "[error:")
@@ -443,101 +443,7 @@ Always show the user the current plan status using the status action.`
443 }443 }
444 444
445 spinner.Start("Running")445 spinner.Start("Running")
446-446+ result := runPlanAction(in, true)
447- var result string
448-
449- switch in.Action {
450- case "create":
451- plan, msg, createErr := planStore.CreatePlan(in.Name, in.Tasks)
452- if createErr != nil {
453- result = fmt.Sprintf("[error: %v]", createErr)
454- break
455- }
456- // Return plan info as JSON for model, plus text summary
457- output := PlanOutput{
458- Result: msg,
459- PlanName: plan.Name,
460- Plan: plan,
461- Current: plan.Current,
462- Total: len(plan.Tasks),
463- }
464- outputBytes, _ := json.Marshal(output)
465- result = string(outputBytes)
466-
467- case "get":
468- plan, msg, getErr := planStore.GetPlan(in.Name)
469- if getErr != nil {
470- result = fmt.Sprintf("[error: %v]", getErr)
471- break
472- }
473- output := PlanOutput{
474- Result: msg,
475- Plan: plan,
476- }
477- outputBytes, _ := json.Marshal(output)
478- result = string(outputBytes)
479-
480- case "update":
481- _, msg, updateErr := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message)
482- if updateErr != nil {
483- result = fmt.Sprintf("[error: %v]", updateErr)
484- break
485- }
486- // Get status line
487- statusLine, _, _ := planStore.GetStatus(in.Name)
488- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
489-
490- case "next":
491- _, nextTask, msg, nextErr := planStore.NextTask(in.Name)
492- if nextErr != nil {
493- result = fmt.Sprintf("[error: %v]", nextErr)
494- break
495- }
496- // Get status line
497- statusLine, _, _ := planStore.GetStatus(in.Name)
498- if nextTask != nil {
499- result = fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`,
500- msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\""))
501- } else {
502- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
503- }
504-
505- case "list":
506- plans, msg, listErr := planStore.ListPlans()
507- if listErr != nil {
508- result = fmt.Sprintf("[error: %v]", listErr)
509- break
510- }
511- if len(plans) == 0 {
512- result = `{"result":"` + msg + `","plans":[]}`
513- break
514- }
515- // Build simple list
516- var planNames []string
517- for _, p := range plans {
518- planNames = append(planNames, p.Name)
519- }
520- result = fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(planNames, `", "`))
521-
522- case "delete":
523- msg, delErr := planStore.DeletePlan(in.Name)
524- if delErr != nil {
525- result = fmt.Sprintf("[error: %v]", delErr)
526- break
527- }
528- result = fmt.Sprintf(`{"result":"%s"}`, msg)
529-
530- case "status":
531- statusLine, msg, statusErr := planStore.GetStatus(in.Name)
532- if statusErr != nil {
533- result = fmt.Sprintf("[error: %v]", statusErr)
534- break
535- }
536- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
537-
538- default:
539- result = fmt.Sprintf("[error: unknown action '%s'. Valid actions: create, get, update, next, list, delete, status]", in.Action)
540- }
541 447
542 // Record in loop detector448 // Record in loop detector
543 inputJSON, _ := json.Marshal(in)449 inputJSON, _ := json.Marshal(in)
@@ -580,94 +486,7 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl
580 486
581 s.ToolRunning(ev.ID)487 s.ToolRunning(ev.ID)
582 488
583- // Execute the action489+ result := runPlanAction(in, false)
584- var result string
585-
586- switch in.Action {
587- case "create":
588- plan, msg, createErr := planStore.CreatePlan(in.Name, in.Tasks)
589- if createErr != nil {
590- result = fmt.Sprintf("[error: %v]", createErr)
591- } else {
592- output := PlanOutput{
593- Result: msg,
594- PlanName: plan.Name,
595- Current: plan.Current,
596- Total: len(plan.Tasks),
597- }
598- outputBytes, _ := json.Marshal(output)
599- result = string(outputBytes)
600- }
601-
602- case "get":
603- plan, msg, getErr := planStore.GetPlan(in.Name)
604- if getErr != nil {
605- result = fmt.Sprintf("[error: %v]", getErr)
606- } else {
607- output := PlanOutput{
608- Result: msg,
609- Plan: plan,
610- }
611- outputBytes, _ := json.Marshal(output)
612- result = string(outputBytes)
613- }
614-
615- case "update":
616- _, msg, updateErr := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message)
617- if updateErr != nil {
618- result = fmt.Sprintf("[error: %v]", updateErr)
619- } else {
620- statusLine, _, _ := planStore.GetStatus(in.Name)
621- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
622- }
623-
624- case "next":
625- _, nextTask, msg, nextErr := planStore.NextTask(in.Name)
626- if nextErr != nil {
627- result = fmt.Sprintf("[error: %v]", nextErr)
628- } else {
629- statusLine, _, _ := planStore.GetStatus(in.Name)
630- if nextTask != nil {
631- result = fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`,
632- msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\""))
633- } else {
634- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
635- }
636- }
637-
638- case "list":
639- plans, msg, listErr := planStore.ListPlans()
640- if listErr != nil {
641- result = fmt.Sprintf("[error: %v]", listErr)
642- } else if len(plans) == 0 {
643- result = `{"result":"` + msg + `","plans":[]}`
644- } else {
645- var planNames []string
646- for _, p := range plans {
647- planNames = append(planNames, p.Name)
648- }
649- result = fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(planNames, `", "`))
650- }
651-
652- case "delete":
653- msg, delErr := planStore.DeletePlan(in.Name)
654- if delErr != nil {
655- result = fmt.Sprintf("[error: %v]", delErr)
656- } else {
657- result = fmt.Sprintf(`{"result":"%s"}`, msg)
658- }
659-
660- case "status":
661- statusLine, msg, statusErr := planStore.GetStatus(in.Name)
662- if statusErr != nil {
663- result = fmt.Sprintf("[error: %v]", statusErr)
664- } else {
665- result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
666- }
667-
668- default:
669- result = fmt.Sprintf("[error: unknown action '%s']", in.Action)
670- }
671 490
672 // Check if failed491 // Check if failed
673 failed := strings.HasPrefix(result, "[error:")492 failed := strings.HasPrefix(result, "[error:")
added internal/tools/plan_actions.go +115 -0
new file mode 100644
@@ -0,0 +1,115 @@
1+package tools
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+)
8+
9+// planActions runs one plan action and renders its result for the model.
10+// "create" is not here: it depends on the front end (see runPlanAction).
11+var planActions = map[string]func(planInput) string{
12+ "get": planGet,
13+ "update": planUpdate,
14+ "next": planNext,
15+ "list": planList,
16+ "delete": planDelete,
17+ "status": planStatus,
18+}
19+
20+// runPlanAction is the one dispatch both front ends share. withPlan puts the
21+// whole created plan in the "create" answer — the terminal does, the
22+// event-consuming front ends (ACP) only give its name and position.
23+func runPlanAction(in planInput, withPlan bool) string {
24+ if in.Action == "create" {
25+ return planCreate(in, withPlan)
26+ }
27+ if run, ok := planActions[in.Action]; ok {
28+ return run(in)
29+ }
30+ return fmt.Sprintf("[error: unknown action '%s'. Valid actions: create, get, update, next, list, delete, status]", in.Action)
31+}
32+
33+// planError is the error answer every action gives.
34+func planError(err error) string {
35+ return fmt.Sprintf("[error: %v]", err)
36+}
37+
38+// planJSON renders a PlanOutput.
39+func planJSON(out PlanOutput) string {
40+ b, _ := json.Marshal(out)
41+ return string(b)
42+}
43+
44+func planCreate(in planInput, withPlan bool) string {
45+ plan, msg, err := planStore.CreatePlan(in.Name, in.Tasks)
46+ if err != nil {
47+ return planError(err)
48+ }
49+ out := PlanOutput{Result: msg, PlanName: plan.Name, Current: plan.Current, Total: len(plan.Tasks)}
50+ if withPlan {
51+ out.Plan = plan
52+ }
53+ return planJSON(out)
54+}
55+
56+func planGet(in planInput) string {
57+ plan, msg, err := planStore.GetPlan(in.Name)
58+ if err != nil {
59+ return planError(err)
60+ }
61+ return planJSON(PlanOutput{Result: msg, Plan: plan})
62+}
63+
64+func planUpdate(in planInput) string {
65+ _, msg, err := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message)
66+ if err != nil {
67+ return planError(err)
68+ }
69+ statusLine, _, _ := planStore.GetStatus(in.Name)
70+ return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
71+}
72+
73+func planNext(in planInput) string {
74+ _, nextTask, msg, err := planStore.NextTask(in.Name)
75+ if err != nil {
76+ return planError(err)
77+ }
78+ statusLine, _, _ := planStore.GetStatus(in.Name)
79+ if nextTask == nil {
80+ return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
81+ }
82+ return fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`,
83+ msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\""))
84+}
85+
86+func planList(planInput) string {
87+ plans, msg, err := planStore.ListPlans()
88+ if err != nil {
89+ return planError(err)
90+ }
91+ if len(plans) == 0 {
92+ return `{"result":"` + msg + `","plans":[]}`
93+ }
94+ names := make([]string, 0, len(plans))
95+ for _, p := range plans {
96+ names = append(names, p.Name)
97+ }
98+ return fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(names, `", "`))
99+}
100+
101+func planDelete(in planInput) string {
102+ msg, err := planStore.DeletePlan(in.Name)
103+ if err != nil {
104+ return planError(err)
105+ }
106+ return fmt.Sprintf(`{"result":"%s"}`, msg)
107+}
108+
109+func planStatus(in planInput) string {
110+ statusLine, msg, err := planStore.GetStatus(in.Name)
111+ if err != nil {
112+ return planError(err)
113+ }
114+ return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
115+}
new file mode 100644
@@ -0,0 +1,115 @@
1+package tools
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+)
8+
9+// planActions runs one plan action and renders its result for the model.
10+// "create" is not here: it depends on the front end (see runPlanAction).
11+var planActions = map[string]func(planInput) string{
12+ "get": planGet,
13+ "update": planUpdate,
14+ "next": planNext,
15+ "list": planList,
16+ "delete": planDelete,
17+ "status": planStatus,
18+}
19+
20+// runPlanAction is the one dispatch both front ends share. withPlan puts the
21+// whole created plan in the "create" answer — the terminal does, the
22+// event-consuming front ends (ACP) only give its name and position.
23+func runPlanAction(in planInput, withPlan bool) string {
24+ if in.Action == "create" {
25+ return planCreate(in, withPlan)
26+ }
27+ if run, ok := planActions[in.Action]; ok {
28+ return run(in)
29+ }
30+ return fmt.Sprintf("[error: unknown action '%s'. Valid actions: create, get, update, next, list, delete, status]", in.Action)
31+}
32+
33+// planError is the error answer every action gives.
34+func planError(err error) string {
35+ return fmt.Sprintf("[error: %v]", err)
36+}
37+
38+// planJSON renders a PlanOutput.
39+func planJSON(out PlanOutput) string {
40+ b, _ := json.Marshal(out)
41+ return string(b)
42+}
43+
44+func planCreate(in planInput, withPlan bool) string {
45+ plan, msg, err := planStore.CreatePlan(in.Name, in.Tasks)
46+ if err != nil {
47+ return planError(err)
48+ }
49+ out := PlanOutput{Result: msg, PlanName: plan.Name, Current: plan.Current, Total: len(plan.Tasks)}
50+ if withPlan {
51+ out.Plan = plan
52+ }
53+ return planJSON(out)
54+}
55+
56+func planGet(in planInput) string {
57+ plan, msg, err := planStore.GetPlan(in.Name)
58+ if err != nil {
59+ return planError(err)
60+ }
61+ return planJSON(PlanOutput{Result: msg, Plan: plan})
62+}
63+
64+func planUpdate(in planInput) string {
65+ _, msg, err := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message)
66+ if err != nil {
67+ return planError(err)
68+ }
69+ statusLine, _, _ := planStore.GetStatus(in.Name)
70+ return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
71+}
72+
73+func planNext(in planInput) string {
74+ _, nextTask, msg, err := planStore.NextTask(in.Name)
75+ if err != nil {
76+ return planError(err)
77+ }
78+ statusLine, _, _ := planStore.GetStatus(in.Name)
79+ if nextTask == nil {
80+ return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
81+ }
82+ return fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`,
83+ msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\""))
84+}
85+
86+func planList(planInput) string {
87+ plans, msg, err := planStore.ListPlans()
88+ if err != nil {
89+ return planError(err)
90+ }
91+ if len(plans) == 0 {
92+ return `{"result":"` + msg + `","plans":[]}`
93+ }
94+ names := make([]string, 0, len(plans))
95+ for _, p := range plans {
96+ names = append(names, p.Name)
97+ }
98+ return fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(names, `", "`))
99+}
100+
101+func planDelete(in planInput) string {
102+ msg, err := planStore.DeletePlan(in.Name)
103+ if err != nil {
104+ return planError(err)
105+ }
106+ return fmt.Sprintf(`{"result":"%s"}`, msg)
107+}
108+
109+func planStatus(in planInput) string {
110+ statusLine, msg, err := planStore.GetStatus(in.Name)
111+ if err != nil {
112+ return planError(err)
113+ }
114+ return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
115+}
added internal/tools/plan_actions_test.go +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+package tools
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+)
8+
9+// runPlanAction answers every action the two front ends used to dispatch
10+// separately: the terminal's "create" carries the whole plan, ACP's does not.
11+func TestRunPlanAction(t *testing.T) {
12+ ClearAllPlansForTesting()
13+ tasks := []PlanTaskInput{{Description: `say "hi"`}, {Description: "B"}}
14+
15+ var full, short PlanOutput
16+ if err := json.Unmarshal([]byte(runPlanAction(planInput{Action: "create", Name: "p1", Tasks: tasks}, true)), &full); err != nil || full.Plan == nil {
17+ t.Fatalf("create with plan: %+v, %v", full, err)
18+ }
19+ if err := json.Unmarshal([]byte(runPlanAction(planInput{Action: "create", Name: "p2", Tasks: tasks}, false)), &short); err != nil {
20+ t.Fatalf("create without plan: %v", err)
21+ }
22+ if short.Plan != nil || short.PlanName != "p2" || short.Total != 2 {
23+ t.Errorf("create without plan: %+v", short)
24+ }
25+
26+ for _, tc := range []struct {
27+ in planInput
28+ want string
29+ }{
30+ {planInput{Action: "get", Name: "p1"}, `"plan":`},
31+ {planInput{Action: "update", Name: "p1", TaskID: 1, Status: "in_progress"}, `"status":"[1/2]`},
32+ {planInput{Action: "next", Name: "p1"}, `"nextTask":{"id":1,"description":"say \"hi\""}`},
33+ {planInput{Action: "status", Name: "p1"}, `"status":"[1/2]`},
34+ {planInput{Action: "list"}, `"plans":["`},
35+ {planInput{Action: "delete", Name: "p2"}, `{"result":"`},
36+ {planInput{Action: "get", Name: "p2"}, "[error: "},
37+ {planInput{Action: "explode"}, "[error: unknown action 'explode'. Valid actions:"},
38+ } {
39+ if got := runPlanAction(tc.in, true); !strings.Contains(got, tc.want) {
40+ t.Errorf("%s %s: %s, want it to contain %s", tc.in.Action, tc.in.Name, got, tc.want)
41+ }
42+ }
43+}
new file mode 100644
@@ -0,0 +1,43 @@
1+package tools
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+)
8+
9+// runPlanAction answers every action the two front ends used to dispatch
10+// separately: the terminal's "create" carries the whole plan, ACP's does not.
11+func TestRunPlanAction(t *testing.T) {
12+ ClearAllPlansForTesting()
13+ tasks := []PlanTaskInput{{Description: `say "hi"`}, {Description: "B"}}
14+
15+ var full, short PlanOutput
16+ if err := json.Unmarshal([]byte(runPlanAction(planInput{Action: "create", Name: "p1", Tasks: tasks}, true)), &full); err != nil || full.Plan == nil {
17+ t.Fatalf("create with plan: %+v, %v", full, err)
18+ }
19+ if err := json.Unmarshal([]byte(runPlanAction(planInput{Action: "create", Name: "p2", Tasks: tasks}, false)), &short); err != nil {
20+ t.Fatalf("create without plan: %v", err)
21+ }
22+ if short.Plan != nil || short.PlanName != "p2" || short.Total != 2 {
23+ t.Errorf("create without plan: %+v", short)
24+ }
25+
26+ for _, tc := range []struct {
27+ in planInput
28+ want string
29+ }{
30+ {planInput{Action: "get", Name: "p1"}, `"plan":`},
31+ {planInput{Action: "update", Name: "p1", TaskID: 1, Status: "in_progress"}, `"status":"[1/2]`},
32+ {planInput{Action: "next", Name: "p1"}, `"nextTask":{"id":1,"description":"say \"hi\""}`},
33+ {planInput{Action: "status", Name: "p1"}, `"status":"[1/2]`},
34+ {planInput{Action: "list"}, `"plans":["`},
35+ {planInput{Action: "delete", Name: "p2"}, `{"result":"`},
36+ {planInput{Action: "get", Name: "p2"}, "[error: "},
37+ {planInput{Action: "explode"}, "[error: unknown action 'explode'. Valid actions:"},
38+ } {
39+ if got := runPlanAction(tc.in, true); !strings.Contains(got, tc.want) {
40+ t.Errorf("%s %s: %s, want it to contain %s", tc.in.Action, tc.in.Name, got, tc.want)
41+ }
42+ }
43+}