From b5043c92873421405b9874ecc0b7d797db7c8a50 Mon Sep 17 00:00:00 2001 From: nuestraai Date: Thu, 5 Mar 2026 23:14:58 -0600 Subject: [PATCH] feat(cli): add workspace, config-dir, tools, and skills override flags Add --workspace, --config-dir, --tools, and --skills flags to `picoclaw agent` for single-shot invocations. Supports workspace override with bootstrap file injection (AGENTS.md, IDENTITY.md, SOUL.md, USER.md), tool allowlisting, and skills filtering. Also extracts FormatSkillsSummary from SkillsLoader for reuse, adds SetSkillsFilter to ContextBuilder, and wires SkillsFilter from agent config into the context builder. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/internal/agent/command.go | 14 +++- cmd/picoclaw/internal/agent/helpers.go | 92 +++++++++++++++++++++++++- pkg/agent/context.go | 50 +++++++++++++- pkg/agent/instance.go | 6 ++ pkg/skills/loader.go | 10 ++- 5 files changed, 166 insertions(+), 6 deletions(-) diff --git a/cmd/picoclaw/internal/agent/command.go b/cmd/picoclaw/internal/agent/command.go index 47262fc85..8fe683936 100644 --- a/cmd/picoclaw/internal/agent/command.go +++ b/cmd/picoclaw/internal/agent/command.go @@ -10,6 +10,11 @@ func NewAgentCommand() *cobra.Command { sessionKey string model string debug bool + // Workspace and config overrides + workspace string + configDir string + tools string + skills string ) cmd := &cobra.Command{ @@ -17,7 +22,8 @@ func NewAgentCommand() *cobra.Command { Short: "Interact with the agent directly", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return agentCmd(message, sessionKey, model, debug) + return agentCmd(message, sessionKey, model, debug, + workspace, configDir, tools, skills) }, } @@ -26,5 +32,11 @@ func NewAgentCommand() *cobra.Command { cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key") cmd.Flags().StringVarP(&model, "model", "", "", "Model to use") + // Workspace and config overrides + cmd.Flags().StringVar(&workspace, "workspace", "", "Override agent workspace directory") + cmd.Flags().StringVar(&configDir, "config-dir", "", "Directory containing bootstrap files (AGENTS.md, IDENTITY.md, SOUL.md, USER.md) to copy into workspace") + cmd.Flags().StringVar(&tools, "tools", "", "Comma-separated tool allowlist (only these tools enabled)") + cmd.Flags().StringVar(&skills, "skills", "", "Comma-separated skill filter (only these skills loaded)") + return cmd } diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index f754abc65..6bf69be58 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -14,11 +14,13 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) -func agentCmd(message, sessionKey, model string, debug bool) error { +func agentCmd(message, sessionKey, model string, debug bool, + workspace, configDir, toolsFlag, skillsFlag string) error { if sessionKey == "" { sessionKey = "cli:default" } @@ -37,6 +39,30 @@ func agentCmd(message, sessionKey, model string, debug bool) error { cfg.Agents.Defaults.ModelName = model } + // Workspace override + if workspace != "" { + cfg.Agents.Defaults.Workspace = workspace + os.MkdirAll(workspace, 0o755) + } + + // Tool allowlist: disable all tools, then enable only the listed ones + if toolsFlag != "" { + toolList := strings.Split(toolsFlag, ",") + for i := range toolList { + toolList[i] = strings.TrimSpace(toolList[i]) + } + applyToolAllowlist(cfg, toolList) + } + + // Skills filter: inject into agent config so NewAgentInstance picks it up + if skillsFlag != "" { + skillList := strings.Split(skillsFlag, ",") + for i := range skillList { + skillList[i] = strings.TrimSpace(skillList[i]) + } + applySkillsFilter(cfg, skillList) + } + provider, modelID, err := providers.CreateProvider(cfg) if err != nil { return fmt.Errorf("error creating provider: %w", err) @@ -51,6 +77,11 @@ func agentCmd(message, sessionKey, model string, debug bool) error { defer msgBus.Close() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + // Copy bootstrap files from config-dir to workspace + if configDir != "" { + copyBootstrapFiles(configDir, cfg.Agents.Defaults.Workspace) + } + // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("agent", "Agent initialized", @@ -76,6 +107,65 @@ func agentCmd(message, sessionKey, model string, debug bool) error { return nil } +// applyToolAllowlist disables all tools, then enables only the listed ones. +func applyToolAllowlist(cfg *config.Config, allowed []string) { + allowSet := make(map[string]bool, len(allowed)) + for _, t := range allowed { + allowSet[t] = true + } + + cfg.Tools.ReadFile.Enabled = allowSet["read_file"] + cfg.Tools.WriteFile.Enabled = allowSet["write_file"] + cfg.Tools.EditFile.Enabled = allowSet["edit_file"] + cfg.Tools.AppendFile.Enabled = allowSet["append_file"] + cfg.Tools.ListDir.Enabled = allowSet["list_dir"] + cfg.Tools.Exec.Enabled = allowSet["exec"] + cfg.Tools.Spawn.Enabled = allowSet["spawn"] + cfg.Tools.Cron.Enabled = allowSet["cron"] + cfg.Tools.Web.Enabled = allowSet["web"] || allowSet["web_search"] + cfg.Tools.WebFetch.Enabled = allowSet["web_fetch"] + cfg.Tools.Skills.Enabled = allowSet["skills"] + cfg.Tools.FindSkills.Enabled = allowSet["find_skills"] + cfg.Tools.InstallSkill.Enabled = allowSet["install_skill"] + cfg.Tools.Subagent.Enabled = allowSet["subagent"] + cfg.Tools.Message.Enabled = allowSet["message"] + cfg.Tools.MCP.Enabled = allowSet["mcp"] + cfg.Tools.I2C.Enabled = allowSet["i2c"] + cfg.Tools.SPI.Enabled = allowSet["spi"] +} + +// applySkillsFilter injects a skills filter into the agent config. +func applySkillsFilter(cfg *config.Config, skills []string) { + if len(cfg.Agents.List) == 0 { + // Create an implicit main agent with skills filter + cfg.Agents.List = []config.AgentConfig{ + {ID: "main", Default: true, Skills: skills}, + } + } else { + // Apply to all agents + for i := range cfg.Agents.List { + cfg.Agents.List[i].Skills = skills + } + } +} + +// copyBootstrapFiles copies recognized bootstrap files (AGENTS.md, IDENTITY.md, +// SOUL.md, USER.md) from srcDir into the workspace directory. +func copyBootstrapFiles(srcDir, workspace string) { + bootstrapFiles := []string{"AGENTS.md", "IDENTITY.md", "SOUL.md", "USER.md"} + for _, filename := range bootstrapFiles { + srcPath := filepath.Join(srcDir, filename) + data, err := os.ReadFile(srcPath) + if err != nil { + continue // file not present in config-dir, skip + } + dstPath := filepath.Join(workspace, filename) + if err := os.WriteFile(dstPath, data, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write %s: %v\n", dstPath, err) + } + } +} + func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { prompt := fmt.Sprintf("%s You: ", internal.Logo) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 719b0cb6d..24ab3a58c 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -39,6 +39,54 @@ type ContextBuilder struct { // build time. This catches nested file creations/deletions/mtime changes // that may not update the top-level skill root directory mtime. skillFilesAtCache map[string]time.Time + + // skillsFilter limits which skills are included in the system prompt. + // When non-empty, only skills whose name matches an entry are included. + // ["*"] means include all skills. + skillsFilter []string +} + +// SetSkillsFilter sets the skills filter on the context builder. +// When non-empty, only skills matching these names are included in the system prompt. +// Use ["*"] to include all skills. +func (cb *ContextBuilder) SetSkillsFilter(filter []string) { + cb.skillsFilter = filter + cb.InvalidateCache() +} + +// buildFilteredSkillsSummary returns the skills summary, filtered by skillsFilter +// if one is set. When no filter is set, all skills are included. +func (cb *ContextBuilder) buildFilteredSkillsSummary() string { + if len(cb.skillsFilter) == 0 { + return cb.skillsLoader.BuildSkillsSummary() + } + + // Check for wildcard + for _, f := range cb.skillsFilter { + if f == "*" { + return cb.skillsLoader.BuildSkillsSummary() + } + } + + // Build filter set + filterSet := make(map[string]bool, len(cb.skillsFilter)) + for _, f := range cb.skillsFilter { + filterSet[f] = true + } + + allSkills := cb.skillsLoader.ListSkills() + var filtered []skills.SkillInfo + for _, s := range allSkills { + if filterSet[s.Name] { + filtered = append(filtered, s) + } + } + + if len(filtered) == 0 { + return "" + } + + return skills.FormatSkillsSummary(filtered) } func getGlobalConfigDir() string { @@ -107,7 +155,7 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { } // Skills - show summary, AI can read full content with read_file tool - skillsSummary := cb.skillsLoader.BuildSkillsSummary() + skillsSummary := cb.buildFilteredSkillsSummary() if skillsSummary != "" { parts = append(parts, fmt.Sprintf(`# Skills diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 97cf0fa05..f6d036dfb 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -98,6 +98,7 @@ func NewAgentInstance( contextBuilder := NewContextBuilder(workspace) + // SkillsFilter will be applied after we know the agent config agentID := routing.DefaultAgentID agentName := "" var subagents *config.SubagentsConfig @@ -110,6 +111,11 @@ func NewAgentInstance( skillsFilter = agentCfg.Skills } + // Apply skills filter to context builder so only matching skills appear in the prompt + if len(skillsFilter) > 0 { + contextBuilder.SetSkillsFilter(skillsFilter) + } + maxIter := defaults.MaxToolIterations if maxIter == 0 { maxIter = 20 diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index 30d84635a..b9c666c35 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -191,14 +191,18 @@ func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string { } func (sl *SkillsLoader) BuildSkillsSummary() string { - allSkills := sl.ListSkills() - if len(allSkills) == 0 { + return FormatSkillsSummary(sl.ListSkills()) +} + +// FormatSkillsSummary renders a list of SkillInfo entries as an XML summary. +func FormatSkillsSummary(skillList []SkillInfo) string { + if len(skillList) == 0 { return "" } var lines []string lines = append(lines, "") - for _, s := range allSkills { + for _, s := range skillList { escapedName := escapeXML(s.Name) escapedDesc := escapeXML(s.Description) escapedPath := escapeXML(s.Path)