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 <noreply@anthropic.com>
This commit is contained in:
nuestraai 2026-03-05 23:14:58 -06:00 committed by admin-mf
parent 1945436dd4
commit b5043c9287
5 changed files with 166 additions and 6 deletions

View file

@ -10,6 +10,11 @@ func NewAgentCommand() *cobra.Command {
sessionKey string sessionKey string
model string model string
debug bool debug bool
// Workspace and config overrides
workspace string
configDir string
tools string
skills string
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -17,7 +22,8 @@ func NewAgentCommand() *cobra.Command {
Short: "Interact with the agent directly", Short: "Interact with the agent directly",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { 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(&sessionKey, "session", "s", "cli:default", "Session key")
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use") 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 return cmd
} }

View file

@ -14,11 +14,13 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "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 == "" { if sessionKey == "" {
sessionKey = "cli:default" sessionKey = "cli:default"
} }
@ -37,6 +39,30 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
cfg.Agents.Defaults.ModelName = model 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) provider, modelID, err := providers.CreateProvider(cfg)
if err != nil { if err != nil {
return fmt.Errorf("error creating provider: %w", err) return fmt.Errorf("error creating provider: %w", err)
@ -51,6 +77,11 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
defer msgBus.Close() defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) 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) // Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo() startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized", logger.InfoCF("agent", "Agent initialized",
@ -76,6 +107,65 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
return nil 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) { func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", internal.Logo) prompt := fmt.Sprintf("%s You: ", internal.Logo)

View file

@ -39,6 +39,54 @@ type ContextBuilder struct {
// build time. This catches nested file creations/deletions/mtime changes // build time. This catches nested file creations/deletions/mtime changes
// that may not update the top-level skill root directory mtime. // that may not update the top-level skill root directory mtime.
skillFilesAtCache map[string]time.Time 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 { func getGlobalConfigDir() string {
@ -107,7 +155,7 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
} }
// Skills - show summary, AI can read full content with read_file tool // Skills - show summary, AI can read full content with read_file tool
skillsSummary := cb.skillsLoader.BuildSkillsSummary() skillsSummary := cb.buildFilteredSkillsSummary()
if skillsSummary != "" { if skillsSummary != "" {
parts = append(parts, fmt.Sprintf(`# Skills parts = append(parts, fmt.Sprintf(`# Skills

View file

@ -98,6 +98,7 @@ func NewAgentInstance(
contextBuilder := NewContextBuilder(workspace) contextBuilder := NewContextBuilder(workspace)
// SkillsFilter will be applied after we know the agent config
agentID := routing.DefaultAgentID agentID := routing.DefaultAgentID
agentName := "" agentName := ""
var subagents *config.SubagentsConfig var subagents *config.SubagentsConfig
@ -110,6 +111,11 @@ func NewAgentInstance(
skillsFilter = agentCfg.Skills 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 maxIter := defaults.MaxToolIterations
if maxIter == 0 { if maxIter == 0 {
maxIter = 20 maxIter = 20

View file

@ -191,14 +191,18 @@ func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string {
} }
func (sl *SkillsLoader) BuildSkillsSummary() string { func (sl *SkillsLoader) BuildSkillsSummary() string {
allSkills := sl.ListSkills() return FormatSkillsSummary(sl.ListSkills())
if len(allSkills) == 0 { }
// FormatSkillsSummary renders a list of SkillInfo entries as an XML summary.
func FormatSkillsSummary(skillList []SkillInfo) string {
if len(skillList) == 0 {
return "" return ""
} }
var lines []string var lines []string
lines = append(lines, "<skills>") lines = append(lines, "<skills>")
for _, s := range allSkills { for _, s := range skillList {
escapedName := escapeXML(s.Name) escapedName := escapeXML(s.Name)
escapedDesc := escapeXML(s.Description) escapedDesc := escapeXML(s.Description)
escapedPath := escapeXML(s.Path) escapedPath := escapeXML(s.Path)