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:
parent
1945436dd4
commit
b5043c9287
5 changed files with 166 additions and 6 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue