Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/cli/advisors_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ func TestImportFromLocalCatalog_RendererSpyCalled(t *testing.T) {
seedCatalogAdvisor(t, catalogRoot, "spy-beta-advisor", "Beta", false)

src := AnAdvisorSource().
WithSource("local:" + catalogRoot).
WithSource("local:"+catalogRoot).
WithSelect("spy-alpha-advisor", "spy-beta-advisor").
Build()
manifest := AUserManifest().WithAdvisorSource(src).Build()
Expand Down
16 changes: 8 additions & 8 deletions internal/cli/advisors_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ func (f *fakeAdvisorRenderer) RegenerateAdvisorFiles(
}

// AgentRenderer stubs — minimum needed to satisfy the interface.
func (f *fakeAdvisorRenderer) Name() string { return "fake" }
func (f *fakeAdvisorRenderer) AgentType() string { return "fake" }
func (f *fakeAdvisorRenderer) NeedsCopyMode() bool { return false }
func (f *fakeAdvisorRenderer) Definition() model.AgentDefinition { return model.AgentDefinition{} }
func (f *fakeAdvisorRenderer) Name() string { return "fake" }
func (f *fakeAdvisorRenderer) AgentType() string { return "fake" }
func (f *fakeAdvisorRenderer) NeedsCopyMode() bool { return false }
func (f *fakeAdvisorRenderer) Definition() model.AgentDefinition { return model.AgentDefinition{} }
func (f *fakeAdvisorRenderer) WorkspacePaths() materialize.AgentPaths {
return materialize.AgentPaths{}
}
Expand All @@ -72,10 +72,10 @@ func (f *fakeAdvisorRenderer) Finalize(_ string) error { return nil }
// materialize.AdvisorRenderer — used to verify the silent-skip path.
type nonAdvisorRenderer struct{}

func (n *nonAdvisorRenderer) Name() string { return "non-advisor" }
func (n *nonAdvisorRenderer) AgentType() string { return "non-advisor" }
func (n *nonAdvisorRenderer) NeedsCopyMode() bool { return false }
func (n *nonAdvisorRenderer) Definition() model.AgentDefinition { return model.AgentDefinition{} }
func (n *nonAdvisorRenderer) Name() string { return "non-advisor" }
func (n *nonAdvisorRenderer) AgentType() string { return "non-advisor" }
func (n *nonAdvisorRenderer) NeedsCopyMode() bool { return false }
func (n *nonAdvisorRenderer) Definition() model.AgentDefinition { return model.AgentDefinition{} }
func (n *nonAdvisorRenderer) WorkspacePaths() materialize.AgentPaths {
return materialize.AgentPaths{}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func runInit(cmd *cobra.Command, args []string) error {
Agents: existingAgents,
Sources: existingSources,
WorkflowModels: mergeWorkflowModels(existingManifest.Workflows),
Tools: existingManifest.Tools,
}
}
}
Expand Down Expand Up @@ -274,7 +275,6 @@ func runInit(cmd *cobra.Command, args []string) error {
return nil
}


// printDone writes a styled "completed" step line with a green checkmark.
func printDone(out io.Writer, msg string) {
_, _ = fmt.Fprintln(out, tuistyles.StyleSuccess.Foreground(tuistyles.ColorSuccess).Render(" ✓ ")+tuistyles.StyleSummaryValue.Render(msg))
Expand Down
45 changes: 45 additions & 0 deletions internal/cli/menu.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const (
menuActionConfigureModels menuAction = "configure-models"
menuActionManageAdvisors menuAction = "manage-advisors"
menuActionUpgrade menuAction = "upgrade"
menuActionUpgradeTools menuAction = "upgrade-tools"
menuActionUninstall menuAction = "uninstall"
menuActionQuit menuAction = "quit"
)
Expand Down Expand Up @@ -69,6 +70,7 @@ func buildMenuOptions(hasRouting bool) []huh.Option[menuAction] {
opts = append(opts,
huh.NewOption("Manage SDD advisors", menuActionManageAdvisors),
huh.NewOption("Status", menuActionStatus),
huh.NewOption("Upgrade Tools", menuActionUpgradeTools),
huh.NewOption("Upgrade DevRune", menuActionUpgrade),
huh.NewOption("Uninstall", menuActionUninstall),
huh.NewOption("Quit", menuActionQuit),
Expand Down Expand Up @@ -148,6 +150,12 @@ func RunMenu(cmd *cobra.Command) error {
}
// Loop back to menu.

case menuActionUpgradeTools:
if err := runUpgradeToolsFromMenu(cmd); err != nil {
_ = showMenuMessage(cmd, "Upgrade Tools Failed", err.Error())
}
// Loop back to menu.

case menuActionUpgrade:
// Upgrade: if user confirms, binary is replaced and we exit.
// If user cancels, loop back to menu.
Expand Down Expand Up @@ -360,6 +368,7 @@ func runInitFromMenu(cmd *cobra.Command) error {
Agents: existingAgents,
Sources: existingSources,
WorkflowModels: mergeWorkflowModels(existingManifest.Workflows),
Tools: existingManifest.Tools,
}
catalogSources = existingManifest.Catalogs
}
Expand Down Expand Up @@ -441,3 +450,39 @@ func runInitFromMenu(cmd *cobra.Command) error {

return nil
}

// runUpgradeToolsFromMenu reads devrune.yaml, loads the embedded tool catalog,
// and delegates to steps.RunToolUpgradeStep to run the TUI upgrade flow.
func runUpgradeToolsFromMenu(cmd *cobra.Command) error {
wd := workingDir(cmd)
manifestPath := filepath.Join(wd, "devrune.yaml")

// Verify manifest exists.
if _, err := os.Stat(manifestPath); err != nil {
return showMenuMessage(cmd, "Upgrade Tools", "devrune.yaml not found — run New setup first")
}

// Read and parse manifest.
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
return fmt.Errorf("read manifest: %w", err)
}
manifest, err := parse.ParseManifest(manifestData)
if err != nil {
return fmt.Errorf("parse manifest: %w", err)
}

// Load embedded tool catalog and build lookup map.
builtinTools, err := tui.LoadBuiltinTools()
if err != nil {
return fmt.Errorf("load tool catalog: %w", err)
}
catalogMap := tui.BuiltinToolMap(builtinTools)

// Run the upgrade step (preview → confirm → execute → summary).
if _, err := steps.RunToolUpgradeStep(manifest.Tools, catalogMap); err != nil {
return err
}

return nil
}
4 changes: 2 additions & 2 deletions internal/materialize/linker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ func TestNewLinker(t *testing.T) {
{"hardlink", "hardlink", false},
{"", "symlink", false}, // empty defaults to symlink
{"invalid", "", true},
{"SYMLINK", "", true}, // case-sensitive
{"Copy", "", true}, // case-sensitive
{"SYMLINK", "", true}, // case-sensitive
{"Copy", "", true}, // case-sensitive
}

for _, tt := range tests {
Expand Down
2 changes: 1 addition & 1 deletion internal/materialize/materializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ func (m *Materializer) Install(
allWorkflows = append(allWorkflows, wfManifest)
}

}
}

// Step 8: RenderCatalog removed — root catalog is generated after the per-agent loop (T021).

Expand Down
28 changes: 14 additions & 14 deletions internal/materialize/renderers/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ import (
// Matches the values in agents/codex.yaml.
func codexAgentDef() model.AgentDefinition {
return model.AgentDefinition{
Name: "codex",
Type: "codex",
Workspace: ".codex",
SkillDir: "../.agents/skills",
RulesDir: "rules",
CatalogFile: "AGENTS.md",
Name: "codex",
Type: "codex",
Workspace: ".codex",
SkillDir: "../.agents/skills",
RulesDir: "rules",
CatalogFile: "AGENTS.md",
DefaultRules: "individual",
MCP: &model.MCPConfig{
FilePath: "config.toml",
Expand Down Expand Up @@ -175,15 +175,15 @@ func TestCodexRenderer_TransformFrontmatter(t *testing.T) {
r := renderers.NewCodexRenderer(codexAgentDef())

input := map[string]interface{}{
"name": "my-skill",
"description": "A skill",
"allowed-tools": []string{"Bash"},
"argument-hint": "[topic]",
"name": "my-skill",
"description": "A skill",
"allowed-tools": []string{"Bash"},
"argument-hint": "[topic]",
"disable-model-invocation": false,
"tools-mode": "auto",
"mode": "subagent",
"model": "sonnet",
"temperature": 0.7,
"tools-mode": "auto",
"mode": "subagent",
"model": "sonnet",
"temperature": 0.7,
}

got := renderers.CodexTransformFrontmatter(r, input)
Expand Down
1 change: 0 additions & 1 deletion internal/materialize/renderers/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -1190,4 +1190,3 @@ func transformCopilotToolNames(body string) string {
}
return body
}

8 changes: 4 additions & 4 deletions internal/model/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ const (
// RuleMeta holds metadata parsed from a rule's frontmatter.
// It is populated only for ContentItems with KindRule; nil for skills/prompts/memory.
type RuleMeta struct {
Scope string `yaml:"scope"` // e.g. "architecture", "testing", "tech", "api"
Technology string `yaml:"technology"` // e.g. "java", "any"
AppliesTo string `yaml:"applies_to"` // comma-separated skill names
Scope string `yaml:"scope"` // e.g. "architecture", "testing", "tech", "api"
Technology string `yaml:"technology"` // e.g. "java", "any"
AppliesTo string `yaml:"applies_to"` // comma-separated skill names
Description string `yaml:"description"` // human-readable description
DisplayName string `yaml:"display_name"` // optional display name from frontmatter; empty string falls back to ContentItem.Name
DisplayName string `yaml:"display_name"` // optional display name from frontmatter; empty string falls back to ContentItem.Name
}

// ContentItem describes a single discoverable item within a resolved package.
Expand Down
39 changes: 39 additions & 0 deletions internal/model/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,29 @@ func ReservedAdvisorNames() []string {
return out
}

// ToolRef is one persisted entry under devrune.yaml tools:.
// Name identifies the tool (required, no leading/trailing whitespace).
// Command is the shell command to upgrade the tool; may be empty or whitespace,
// which means the tool is considered "no upgradable" unless a catalog default exists.
type ToolRef struct {
Name string `yaml:"name"`
Command string `yaml:"command,omitempty"`
}

// Validate checks that the ToolRef is internally consistent.
// Rules:
// - Name must be non-empty and must not have leading or trailing whitespace.
// - Command may be empty or whitespace (no error; treated as no-command downstream).
func (t ToolRef) Validate() error {
if t.Name == "" {
return fmt.Errorf("manifest: tool name must not be empty")
}
if strings.TrimSpace(t.Name) != t.Name {
return fmt.Errorf("manifest: tool name %q must not have leading or trailing whitespace", t.Name)
}
return nil
}

// UserManifest represents the user's devrune.yaml file.
// It declares packages, MCP servers, agents, and optional workflows to install.
type UserManifest struct {
Expand All @@ -291,6 +314,10 @@ type UserManifest struct {
// references the primary DevRune package catalog (where DevRune
// packages come from). Advisors holds advisor-only sources.
Advisors []AdvisorSource `yaml:"advisors,omitempty"`
// Tools lists the tools declared in devrune.yaml. Each entry has a name
// and an optional upgrade command. Tools without an effective command are
// displayed as "(no upgradable)" in the Upgrade Tools TUI flow.
Tools []ToolRef `yaml:"tools,omitempty"`
}

// PackageRef is a reference to a package in the user manifest.
Expand Down Expand Up @@ -367,5 +394,17 @@ func (m UserManifest) Validate() error {
}
}

// Validate Tools: no duplicates, each entry has valid Name.
seenTools := make(map[string]bool, len(m.Tools))
for _, tool := range m.Tools {
if err := tool.Validate(); err != nil {
return err
}
if seenTools[tool.Name] {
return fmt.Errorf("manifest: duplicate tool %q", tool.Name)
}
seenTools[tool.Name] = true
}

return nil
}
73 changes: 73 additions & 0 deletions internal/model/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,79 @@ func TestUserManifest_Validate(t *testing.T) {
wantErr: true,
errMsg: "source must not be empty",
},
// ── Tools validation ──────────────────────────────────────────────────
{
name: "valid manifest with tools (engram and crit, both with command)",
manifest: UserManifest{
SchemaVersion: "devrune/v1",
Agents: []AgentRef{{Name: "claude"}},
Tools: []ToolRef{
{Name: "engram", Command: "brew install gentleman-programming/tap/engram"},
{Name: "crit", Command: "brew install crit"},
},
},
wantErr: false,
},
{
name: "empty command is allowed (tool is no upgradable, not an error)",
manifest: UserManifest{
SchemaVersion: "devrune/v1",
Agents: []AgentRef{{Name: "claude"}},
Tools: []ToolRef{
{Name: "custom-local", Command: ""},
},
},
wantErr: false,
},
{
name: "empty tool name fails",
manifest: UserManifest{
SchemaVersion: "devrune/v1",
Agents: []AgentRef{{Name: "claude"}},
Tools: []ToolRef{
{Name: "", Command: "brew install something"},
},
},
wantErr: true,
errMsg: "manifest: tool name must not be empty",
},
{
name: "tool name with leading whitespace fails",
manifest: UserManifest{
SchemaVersion: "devrune/v1",
Agents: []AgentRef{{Name: "claude"}},
Tools: []ToolRef{
{Name: " engram", Command: "brew install gentleman-programming/tap/engram"},
},
},
wantErr: true,
errMsg: "whitespace",
},
{
name: "tool name with trailing whitespace fails",
manifest: UserManifest{
SchemaVersion: "devrune/v1",
Agents: []AgentRef{{Name: "claude"}},
Tools: []ToolRef{
{Name: "engram ", Command: "brew install gentleman-programming/tap/engram"},
},
},
wantErr: true,
errMsg: "whitespace",
},
{
name: "duplicate tool name fails",
manifest: UserManifest{
SchemaVersion: "devrune/v1",
Agents: []AgentRef{{Name: "claude"}},
Tools: []ToolRef{
{Name: "engram", Command: "brew install gentleman-programming/tap/engram"},
{Name: "engram", Command: "go install engram"},
},
},
wantErr: true,
errMsg: `manifest: duplicate tool "engram"`,
},
}

for _, tt := range tests {
Expand Down
4 changes: 2 additions & 2 deletions internal/model/opencode_models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ func TestLoadOpenCodeModels_ValidSource(t *testing.T) {

// Build source with 3 models, 2 tool_call=true, 1 false.
srcData := buildOpenCodeSourceJSON(t, openCodeProvider, map[string]bool{
"gpt-4o": true,
"gpt-4o": true,
"gpt-4o-mini": true,
"ada": false,
"ada": false,
})
writeFile(t, srcPath, srcData)

Expand Down
2 changes: 1 addition & 1 deletion internal/model/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type WorkflowMetadata struct {
Name string `yaml:"name"` // slug identifier, e.g. "sdd"
DisplayName string `yaml:"displayName,omitempty"` // human-readable label for catalogs, e.g. "SDD (Spec-Driven Development)"
Version string `yaml:"version"` // semver, e.g. "1.0.0"
WorkingDir string `yaml:"workingDir,omitempty"` // directory name for workflow files (orchestrator, _shared/); defaults to Name
WorkingDir string `yaml:"workingDir,omitempty"` // directory name for workflow files (orchestrator, _shared/); defaults to Name
}

// EffectiveDisplayName returns DisplayName if set, otherwise falls back to Name.
Expand Down
Loading
Loading