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
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
}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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, "<skills>")
for _, s := range allSkills {
for _, s := range skillList {
escapedName := escapeXML(s.Name)
escapedDesc := escapeXML(s.Description)
escapedPath := escapeXML(s.Path)