commit da740133df335072ed2b3f1dd997915e06976061 Author: Levi Neely Date: Fri Mar 20 15:17:02 2026 +0100 Initial commit: standalone anvilmcp server MCP server for tool execution with sandboxing. Extracted from anvillm/cmd/anvilmcp. diff --git a/anvillm-mcp b/anvillm-mcp new file mode 100755 index 0000000..d73f435 Binary files /dev/null and b/anvillm-mcp differ diff --git a/execute.go b/execute.go new file mode 100644 index 0000000..eb0f3f3 --- /dev/null +++ b/execute.go @@ -0,0 +1,387 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "9fans.net/go/plan9/client" + "anvillm/pkg/sandbox" +) + +// readTool reads a tool from the 9P tools directory. +func readTool(name string) (string, error) { + // Prevent path traversal + if strings.Contains(name, "/") || strings.Contains(name, "..") { + return "", fmt.Errorf("invalid tool name") + } + + ns := fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER")) + fsys, err := client.Mount("unix", filepath.Join(ns, "anvillm")) + if err != nil { + return "", fmt.Errorf("failed to mount 9P: %v", err) + } + defer fsys.Close() + + fid, err := fsys.Open("/tools/"+name, 0) + if err != nil { + return "", fmt.Errorf("tool not found: %s", name) + } + defer fid.Close() + + var buf []byte + tmp := make([]byte, 8192) + for { + n, err := fid.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil || n < len(tmp) { + break + } + } + return string(buf), nil +} + +// PipeStep is one stage in a tool pipeline. +// Exactly one of Tool or Code must be set. +type PipeStep struct { + Tool string // named tool read from 9P (trusted) + Code string // inline bash code (untrusted, validated) + Args []string +} + +// buildPipeline constructs a single bash pipeline string from the given steps. +// Each step is wrapped in a subshell: ( set -- args; ) | ... +// Tool steps are trusted (sourced from 9P); inline code steps are validated +// individually here so the combined string is always returned as trusted. +func buildPipeline(steps []PipeStep) (string, bool, error) { + if len(steps) == 0 { + return "", false, fmt.Errorf("pipe requires at least one step") + } + parts := make([]string, 0, len(steps)) + for _, step := range steps { + var code string + if step.Tool != "" { + var err error + code, err = readTool(step.Tool) + if err != nil { + return "", false, fmt.Errorf("pipe step %q: %v", step.Tool, err) + } + } else if step.Code != "" { + if err := validateCode(step.Code); err != nil { + return "", false, fmt.Errorf("pipe step code: %v", err) + } + code = step.Code + } else { + return "", false, fmt.Errorf("each pipe step requires either 'tool' or 'code'") + } + if len(step.Args) > 0 { + var escaped []string + for _, arg := range step.Args { + escaped = append(escaped, "'"+strings.ReplaceAll(arg, "'", "'\\''")+"'") + } + parts = append(parts, fmt.Sprintf("( set -- %s\n%s )", strings.Join(escaped, " "), code)) + } else { + parts = append(parts, fmt.Sprintf("(\n%s\n)", code)) + } + } + return strings.Join(parts, " |\n"), true, nil +} + +var dangerousPatterns = []*regexp.Regexp{ + regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s*/(home|var|usr|etc|boot|root|bin|sbin|lib|opt|srv)?`), // rm -rf, rm -r -f on sensitive paths + regexp.MustCompile(`rm\s+(-[a-z]*f[a-z]*\s+)*-[a-z]*r[a-z]*\s*/(home|var|usr|etc|boot|root|bin|sbin|lib|opt|srv)?`), // rm -fr, rm -f -r on sensitive paths + regexp.MustCompile(`rm\s+.*--recursive.*--force`), // rm --recursive --force + regexp.MustCompile(`rm\s+.*--force.*--recursive`), // rm --force --recursive + regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+\.\.?(/|$)`), // rm -rf ./ or rm -rf ../ + regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+~`), // rm -rf ~ (home dir) + regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+\*`), // rm -rf * (glob expansion) + regexp.MustCompile(`:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&`), // fork bomb + regexp.MustCompile(`\bmkfs\b`), // filesystem format + regexp.MustCompile(`\bdd\b.*\bif=/dev/`), // dd from device + regexp.MustCompile(`>\s*/dev/sd`), // write to block device + regexp.MustCompile(`\beval\s+".*\$`), // eval with variable expansion + regexp.MustCompile(`\b(sudo|su)\s`), // privilege escalation + regexp.MustCompile(`/etc/(shadow|sudoers)`), // sensitive files (not passwd) +} + +var whitespacePattern = regexp.MustCompile(`\s+`) + +// Rate limiting for validation failures +var ( + rateLimitMu sync.Mutex + validationFailures int + lastFailure time.Time + blockedUntil time.Time +) + +const ( + maxFailures = 5 + blockDuration = 30 * time.Second + failureWindow = 60 * time.Second +) + +func checkRateLimit() error { + rateLimitMu.Lock() + defer rateLimitMu.Unlock() + + now := time.Now() + if now.Before(blockedUntil) { + remaining := blockedUntil.Sub(now).Round(time.Second) + return fmt.Errorf("rate limited: too many validation failures, blocked for %v", remaining) + } + return nil +} + +func recordValidationFailure() { + rateLimitMu.Lock() + defer rateLimitMu.Unlock() + + now := time.Now() + // Reset counter if outside failure window + if now.Sub(lastFailure) > failureWindow { + validationFailures = 0 + } + + validationFailures++ + lastFailure = now + + if validationFailures >= maxFailures { + blockedUntil = now.Add(blockDuration) + validationFailures = 0 + logSecurityEvent(SecurityEvent{ + Timestamp: now, + EventType: "rate_limit_triggered", + Details: fmt.Sprintf("blocked for %v after %d failures", blockDuration, maxFailures), + }) + } +} + +func isPermissionError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "permission denied") || + strings.Contains(msg, "no such file or directory") +} + +func validateCode(code string) error { + if err := checkRateLimit(); err != nil { + return err + } + + // Normalize: collapse whitespace, lowercase for pattern matching + normalized := strings.ToLower(code) + normalized = whitespacePattern.ReplaceAllString(normalized, " ") + + for _, pattern := range dangerousPatterns { + if pattern.MatchString(normalized) { + recordValidationFailure() + logSecurityEvent(SecurityEvent{ + Timestamp: time.Now(), + EventType: "validation_failure", + Details: fmt.Sprintf("dangerous pattern: %s", pattern.String()), + }) + return fmt.Errorf("dangerous pattern detected") + } + } + return nil +} + +// loadLayeredConfig loads sandbox config using the layered approach: +// global.yaml -> backend (anvilmcp) -> sandbox/.yaml +func loadLayeredConfig(name string) (*sandbox.Config, error) { + // Load global.yaml as base + baseCfg, err := sandbox.Load() + if err != nil { + return nil, fmt.Errorf("failed to load global config: %w", err) + } + + // Convert base config to layered format + baseLayer := sandbox.LayeredConfig{ + Filesystem: baseCfg.Filesystem, + Network: baseCfg.Network, + Env: baseCfg.Env, + } + layers := []sandbox.LayeredConfig{baseLayer} + + // Load sandbox layer + if name == "" { + name = "anvilmcp" + } + sbxLayer, err := sandbox.LoadSandbox(name) + if err != nil { + return nil, fmt.Errorf("failed to load sandbox %q: %w", name, err) + } + layers = append(layers, sbxLayer) + + // Merge layers + general := sandbox.GeneralConfig{ + BestEffort: baseCfg.General.BestEffort, + LogLevel: baseCfg.General.LogLevel, + } + advanced := sandbox.AdvancedConfig{ + LDD: baseCfg.Advanced.LDD, + AddExec: baseCfg.Advanced.AddExec, + } + + return sandbox.Merge(general, advanced, layers...), nil +} + +func executeCode(code, language string, timeout int, sandboxName string, trusted bool) (string, error) { + start := time.Now() + + if timeout <= 0 { + timeout = 30 + } + + if !trusted { + if err := validateCode(code); err != nil { + logExecution(ExecutionLog{ + Timestamp: start, + CodeHash: hashCode(code), + Language: language, + Duration: time.Since(start), + Success: false, + OutputSize: 0, + Error: err.Error(), + }) + return "", err + } + } + + // Load layered sandbox config + cfg, err := loadLayeredConfig(sandboxName) + if err != nil { + return "", err + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home dir: %v", err) + } + + // Use current working directory + workDir, _ := os.Getwd() + + // For anvilmcp sandbox, use temp workspace + var cleanupWorkDir bool + if sandboxName == "" || sandboxName == "anvilmcp" { + workspaceBase := filepath.Join(homeDir, ".cache", "anvillm", "exec") + if err := os.MkdirAll(workspaceBase, 0700); err != nil { + return "", fmt.Errorf("failed to create workspace base: %v", err) + } + workDir, err = os.MkdirTemp(workspaceBase, "anvilmcp-*") + if err != nil { + return "", fmt.Errorf("failed to create workspace: %v", err) + } + cleanupWorkDir = true + } + + if cleanupWorkDir { + defer os.RemoveAll(workDir) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + var cmd *exec.Cmd + switch language { + case "bash", "": + wrapped := sandbox.WrapCommand(cfg, []string{"bash", "-c", code}, workDir) + cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...) + cmd.Dir = workDir + // Add new language cases here: + // case "python": + // wrapped := sandbox.WrapCommand(cfg, []string{"python3", "-c", code}, workDir) + default: + return "", fmt.Errorf("unsupported language: %s (supported: bash)", language) + } + + // Limit output size (10MB raw, 8KB returned) + const maxToolOutputSize = 8000 + var outputBuf bytes.Buffer + lw := &limitedWriter{w: &outputBuf, limit: 10 * 1024 * 1024} + cmd.Stdout = lw + cmd.Stderr = lw + + err = cmd.Run() + output := outputBuf.Bytes() + + if lw.truncated { + output = append(output, []byte("\n[output truncated at 10MB]")...) + } + + duration := time.Since(start) + + execLog := ExecutionLog{ + Timestamp: start, + CodeHash: hashCode(code), + Language: language, + Duration: duration, + Success: err == nil && ctx.Err() != context.DeadlineExceeded, + OutputSize: len(output), + } + + if ctx.Err() == context.DeadlineExceeded { + execLog.Error = fmt.Sprintf("execution timeout after %d seconds", timeout) + logExecution(execLog) + logSecurityEvent(SecurityEvent{ + Timestamp: start, + EventType: "timeout", + Language: language, + Details: fmt.Sprintf("timeout after %d seconds", timeout), + }) + return "", fmt.Errorf("execution timeout after %d seconds", timeout) + } + if err != nil { + execLog.Error = err.Error() + logExecution(execLog) + return string(output), fmt.Errorf("execution failed: %v\nOutput: %s", err, string(output)) + } + + logExecution(execLog) + result := string(output) + if len(result) > maxToolOutputSize { + result = result[:maxToolOutputSize] + "\n... (output truncated)" + } + return result, nil +} + +type limitedWriter struct { + w io.Writer + written int + limit int + truncated bool +} + +func (lw *limitedWriter) Write(p []byte) (n int, err error) { + if lw.written >= lw.limit { + lw.truncated = true + return len(p), nil // Discard, report all bytes consumed + } + + remaining := lw.limit - lw.written + toWrite := p + if len(p) > remaining { + toWrite = p[:remaining] + lw.truncated = true + } + + written, err := lw.w.Write(toWrite) + lw.written += written + if err != nil { + return written, err + } + return len(p), nil // Report all input bytes as consumed +} diff --git a/execute_test.go b/execute_test.go new file mode 100644 index 0000000..70b7fbe --- /dev/null +++ b/execute_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestValidateCode(t *testing.T) { + tests := []struct { + name string + code string + wantErr bool + }{ + {"safe code", "echo hello", false}, + {"rm -rf /", "rm -rf /", true}, + {"rm -rf / with spaces", "rm -rf /", true}, + {"rm -rf / with tabs", "rm\t-rf\t/", true}, + {"rm -rf/ no space", "rm -rf/", true}, + {"rm -fr /", "rm -fr /", true}, + {"rm -fr/ no space", "rm -fr/", true}, + {"rm -rf /home", "rm -rf /home", true}, + {"rm -rf /var", "rm -rf /var", true}, + {"rm -rf /etc", "rm -rf /etc", true}, + {"rm -rf /usr", "rm -rf /usr", true}, + {"fork bomb", ":(){ :|:& };:", true}, + {"fork bomb with spaces", ": ( ) { : | : & } ; :", true}, + {"mkfs", "mkfs.ext4 /dev/sda", true}, + {"dd from device", "dd if=/dev/zero of=/dev/sda", true}, + {"chmod 777", "chmod 777 /etc/passwd", true}, + {"curl http", "curl http://example.com", true}, + {"curl https", "curl https://example.com", true}, + {"curl ftp", "curl ftp://example.com/file", true}, + {"curl file", "curl file:///etc/passwd", true}, + {"wget http", "wget http://example.com/file", true}, + {"wget https", "wget https://example.com/file", true}, + {"wget ftp", "wget ftp://example.com/file", true}, + {"device write", "echo data > /dev/sda", true}, + {"exec", "exec(malicious)", true}, + {"eval", "eval(dangerous)", true}, + {"sudo", "sudo rm file", true}, + {"su", "su - root", true}, + {"etc passwd", "cat /etc/passwd", true}, + {"etc shadow", "cat /etc/shadow", true}, + {"safe 9p", "9p read anvillm/inbox/user", false}, + {"safe jq", "echo '{}' | jq .field", false}, + {"safe rm", "rm file.txt", false}, + {"rm -rf ./", "rm -rf ./", true}, + {"rm -rf ../", "rm -rf ../", true}, + {"rm -rf .", "rm -rf .", true}, + {"rm -r -f /", "rm -r -f /", true}, + {"rm -f -r /", "rm -f -r /", true}, + {"rm --recursive --force /", "rm --recursive --force /", true}, + {"rm --force --recursive /", "rm --force --recursive /", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCode(tt.code) + if (err != nil) != tt.wantErr { + t.Errorf("validateCode() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDangerousPatternsCompile(t *testing.T) { + // Verify all patterns are valid regexes (they compile at init, but this documents the expectation) + if len(dangerousPatterns) == 0 { + t.Error("dangerousPatterns should not be empty") + } + for i, p := range dangerousPatterns { + if p == nil { + t.Errorf("dangerousPatterns[%d] is nil", i) + } + } +} + +func TestLimitedWriter(t *testing.T) { + tests := []struct { + name string + limit int + writes []string + wantTotal int + wantTruncated bool + }{ + { + name: "under limit", + limit: 100, + writes: []string{"hello", " ", "world"}, + wantTotal: 11, + wantTruncated: false, + }, + { + name: "at limit", + limit: 5, + writes: []string{"hello"}, + wantTotal: 5, + wantTruncated: false, + }, + { + name: "over limit", + limit: 5, + writes: []string{"hello", "world"}, + wantTotal: 5, + wantTruncated: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + lw := &limitedWriter{w: &buf, limit: tt.limit} + + for _, write := range tt.writes { + lw.Write([]byte(write)) + } + + if lw.truncated != tt.wantTruncated { + t.Errorf("limitedWriter truncated = %v, want %v", lw.truncated, tt.wantTruncated) + } + if buf.Len() != tt.wantTotal { + t.Errorf("limitedWriter wrote %d bytes, want %d", buf.Len(), tt.wantTotal) + } + }) + } +} + +func resetRateLimitState() { + rateLimitMu.Lock() + defer rateLimitMu.Unlock() + validationFailures = 0 + lastFailure = time.Time{} + blockedUntil = time.Time{} +} + +func TestRateLimitCounterResetAfterWindow(t *testing.T) { + resetRateLimitState() + defer resetRateLimitState() + + // Record failures but not enough to trigger block + for i := 0; i < maxFailures-1; i++ { + recordValidationFailure() + } + + // Simulate time passing beyond failureWindow + rateLimitMu.Lock() + lastFailure = time.Now().Add(-failureWindow - time.Second) + rateLimitMu.Unlock() + + // Next failure should reset counter, not trigger block + recordValidationFailure() + + if err := checkRateLimit(); err != nil { + t.Errorf("expected no rate limit after window reset, got: %v", err) + } +} + +func TestRateLimitBlocksAfterMaxFailures(t *testing.T) { + resetRateLimitState() + defer resetRateLimitState() + + for i := 0; i < maxFailures; i++ { + recordValidationFailure() + } + + if err := checkRateLimit(); err == nil { + t.Error("expected rate limit error after maxFailures") + } +} + +func TestRateLimitUnblocksAfterDuration(t *testing.T) { + resetRateLimitState() + defer resetRateLimitState() + + for i := 0; i < maxFailures; i++ { + recordValidationFailure() + } + + // Simulate block duration passing + rateLimitMu.Lock() + blockedUntil = time.Now().Add(-time.Second) + rateLimitMu.Unlock() + + if err := checkRateLimit(); err != nil { + t.Errorf("expected no rate limit after block duration, got: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cfe9fd1 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/lneely/anvillm-mcp + +go 1.25.6 + +require ( + 9fans.net/go v0.0.7 + anvillm v0.0.0-00010101000000-000000000000 +) + +require gopkg.in/yaml.v3 v3.0.1 // indirect + +replace anvillm => ../anvillm/main diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1137047 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM= +9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..481288b --- /dev/null +++ b/main.go @@ -0,0 +1,285 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +var ( + executionSemaphore = make(chan struct{}, 3) // Max 3 concurrent executions +) + +type MCPRequest struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type MCPResponse struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Result interface{} `json:"result,omitempty"` + Error *MCPError `json:"error,omitempty"` +} + +type MCPError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema InputSchema `json:"inputSchema"` +} + +type InputSchema struct { + Type string `json:"type"` + Properties map[string]Property `json:"properties"` + Required []string `json:"required,omitempty"` +} + +type Property struct { + Type string `json:"type"` + Description string `json:"description"` + Items *Items `json:"items,omitempty"` + Enum []string `json:"enum,omitempty"` +} + +type Items struct { + Type string `json:"type"` +} + +func main() { + fmt.Fprintln(os.Stderr, "[anvilmcp] Starting MCP server") + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := scanner.Bytes() + fmt.Fprintf(os.Stderr, "[anvilmcp] Received: %s\n", string(line)) + var req MCPRequest + if err := json.Unmarshal(line, &req); err != nil { + fmt.Fprintf(os.Stderr, "[anvilmcp] Parse error: %v\n", err) + sendError(nil, -32700, "Parse error") + continue + } + + fmt.Fprintf(os.Stderr, "[anvilmcp] Method: %s\n", req.Method) + switch req.Method { + case "initialize": + sendResponse(req.ID, map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{ + "tools": map[string]bool{}, + }, + "serverInfo": map[string]string{ + "name": "anvilmcp", + "version": "0.1.0", + }, + }) + case "notifications/initialized": + // Notification - no response needed + fmt.Fprintln(os.Stderr, "[anvilmcp] Initialized notification received") + case "tools/list": + sendResponse(req.ID, map[string]interface{}{ + "tools": []Tool{ + { + Name: "execute_code", + Description: "Execute bash via one of three mutually exclusive modes (pipe wins if present):\n1. tool+args — run a named library tool: {tool: 'list_sessions.sh', args: ['foo']}\n2. code+args — run inline bash: {code: 'echo hello'}\n3. pipe — Unix pipeline of stages, stdout→stdin: {pipe: [{tool: 'list_sessions.sh'}, {code: 'grep active'}, {tool: 'format_table.sh', args: ['--compact']}]}\nEach pipe stage is {tool?, args?, code?}. All-tool pipes are trusted (skip validation); any inline code stage triggers validation.", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "tool": {Type: "string", Description: "Mode 1: named tool from anvillm/tools/ (e.g. 'list_sessions.sh')"}, + "args": {Type: "array", Description: "Positional args ($1 $2 …) for the tool or code", Items: &Items{Type: "string"}}, + "code": {Type: "string", Description: "Mode 2: inline bash to execute directly"}, + "language": {Type: "string", Description: "Programming language", Enum: []string{"bash"}}, + "timeout": {Type: "integer", Description: "Timeout in seconds (default: 30). Recommended: 600 for builds, 120 for network ops, 1800 for remote builds"}, + "sandbox": {Type: "string", Description: "Sandbox config name (default: anvilmcp)"}, + "pipe": {Type: "array", Description: "Mode 3: ordered pipeline stages, each {tool?, args?, code?}. Stages run as a single bash -c; stdout of each feeds stdin of the next.", Items: &Items{Type: "object"}}, + }, + }, + }, + }, + }) + case "tools/call": + handleToolCall(req) + default: + sendError(req.ID, -32601, "Method not found") + } + } +} + +func handleToolCall(req MCPRequest) { + var params struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + fmt.Fprintf(os.Stderr, "[anvilmcp] Invalid params: %v\n", err) + sendError(req.ID, -32602, "Invalid params") + return + } + + fmt.Fprintf(os.Stderr, "[anvilmcp] Tool call: %s with args: %v\n", params.Name, params.Arguments) + switch params.Name { + case "execute_code": + tool, _ := params.Arguments["tool"].(string) + var toolArgs []string + if argsRaw, ok := params.Arguments["args"].([]interface{}); ok { + for _, a := range argsRaw { + if s, ok := a.(string); ok { + toolArgs = append(toolArgs, s) + } + } + } else if argsStr, ok := params.Arguments["args"].(string); ok { + // LLM passed args as a JSON-encoded string instead of an array + var parsed []string + if err := json.Unmarshal([]byte(argsStr), &parsed); err == nil { + toolArgs = parsed + } + } + code, _ := params.Arguments["code"].(string) + language, _ := params.Arguments["language"].(string) + + // Parse pipe steps + var pipeSteps []PipeStep + if pipeRaw, ok := params.Arguments["pipe"].([]interface{}); ok { + for _, stepRaw := range pipeRaw { + stepMap, ok := stepRaw.(map[string]interface{}) + if !ok { + continue + } + step := PipeStep{} + step.Tool, _ = stepMap["tool"].(string) + step.Code, _ = stepMap["code"].(string) + if argsRaw, ok := stepMap["args"].([]interface{}); ok { + for _, a := range argsRaw { + if s, ok := a.(string); ok { + step.Args = append(step.Args, s) + } + } + } else if argsStr, ok := stepMap["args"].(string); ok { + var parsed []string + if err := json.Unmarshal([]byte(argsStr), &parsed); err == nil { + step.Args = parsed + } + } + pipeSteps = append(pipeSteps, step) + } + } + + // Resolve execution mode: pipe > tool > inline code + trusted := false + if len(pipeSteps) > 0 { + var err error + code, trusted, err = buildPipeline(pipeSteps) + if err != nil { + sendError(req.ID, -32000, err.Error()) + return + } + } else if tool != "" { + toolCode, err := readTool(tool) + if err != nil { + sendError(req.ID, -32000, fmt.Sprintf("failed to read tool %s: %v", tool, err)) + return + } + code = toolCode + trusted = true + // For bash with args, wrap script to receive positional params + if len(toolArgs) > 0 { + // Escape args for bash using single quotes (prevents all expansion) + var escaped []string + for _, arg := range toolArgs { + // Replace ' with '\'' (end quote, escaped quote, start quote) + escaped = append(escaped, "'"+strings.ReplaceAll(arg, "'", "'\\''")+"'") + } + code = fmt.Sprintf("set -- %s\n%s", strings.Join(escaped, " "), code) + } + } + + if code == "" { + sendError(req.ID, -32602, "either 'tool', 'code', or 'pipe' is required") + return + } + + if language == "" { + language = "bash" + } + timeout := 30 + if t, ok := params.Arguments["timeout"].(float64); ok { + timeout = int(t) + } + sandbox, _ := params.Arguments["sandbox"].(string) + sandboxExplicit := sandbox != "" + if sandbox == "" { + sandbox = "anvilmcp" + } + + // Acquire execution slot + executionSemaphore <- struct{}{} + defer func() { <-executionSemaphore }() + fmt.Fprintf(os.Stderr, "[anvilmcp] Executing %s code (timeout: %ds, sandbox: %s, trusted: %v)\n", language, timeout, sandbox, trusted) + result, err := executeCode(code, language, timeout, sandbox, trusted) + + // Fallback to default sandbox on permission errors (only if sandbox wasn't explicit) + if err != nil && !sandboxExplicit && isPermissionError(err) { + fmt.Fprintf(os.Stderr, "[anvilmcp] Permission error in %s sandbox, falling back to default\n", sandbox) + result, err = executeCode(code, language, timeout, "default", trusted) + } + + // Log token comparison + codeTokens := estimateTokens(code) + outputTokens := estimateTokens(result) + reduction := 0.0 + if codeTokens > 0 { + reduction = (1.0 - float64(outputTokens)/float64(codeTokens)) * 100 + } + logTokens(TokenLog{ + Timestamp: time.Now(), + Method: "execute_code", + DirectTokens: codeTokens, + CodeExecTokens: outputTokens, + Reduction: reduction, + }) + + if err != nil { + fmt.Fprintf(os.Stderr, "[anvilmcp] Error: %v\n", err) + sendError(req.ID, -32000, err.Error()) + return + } + fmt.Fprintf(os.Stderr, "[anvilmcp] Execution complete: %d bytes\n", len(result)) + sendResponse(req.ID, map[string]interface{}{ + "content": []map[string]string{ + {"type": "text", "text": result}, + }, + }) + default: + sendError(req.ID, -32601, "Tool not found") + } +} + +func sendResponse(id interface{}, result interface{}) { + resp := MCPResponse{ + JSONRPC: "2.0", + ID: id, + Result: result, + } + data, _ := json.Marshal(resp) + fmt.Println(string(data)) + os.Stdout.Sync() +} + +func sendError(id interface{}, code int, message string) { + resp := MCPResponse{ + JSONRPC: "2.0", + ID: id, + Error: &MCPError{Code: code, Message: message}, + } + data, _ := json.Marshal(resp) + fmt.Println(string(data)) + os.Stdout.Sync() +} diff --git a/mcptools/add_dependency.sh b/mcptools/add_dependency.sh new file mode 100755 index 0000000..1836bca --- /dev/null +++ b/mcptools/add_dependency.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Add a dependency (child depends on parent) +# Usage: add_dependency.sh --mount --child --parent +set -euo pipefail + + +MOUNT="" +CHILD="" +PARENT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --child) CHILD="$2"; shift 2 ;; + --parent) PARENT="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$CHILD" ] || [ -z "$PARENT" ]; then + echo "usage: add_dependency.sh --mount --child --parent " >&2 + exit 1 +fi + +echo "dep $CHILD $PARENT" | 9p write beads/$MOUNT/ctl +echo "$CHILD depends on $PARENT" diff --git a/mcptools/batch_create_beads.sh b/mcptools/batch_create_beads.sh new file mode 100755 index 0000000..c7ebbee --- /dev/null +++ b/mcptools/batch_create_beads.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# capabilities: beads +# description: Batch create beads from JSON array. Scope is auto-derived from cwd. +# Usage: batch_create_beads.sh --mount --json +set -euo pipefail + + +MOUNT="" +JSON="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --json) JSON="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$JSON" ]; then + echo "usage: batch_create_beads.sh --mount --json " >&2 + exit 1 +fi + +# Derive scope from cwd relative to mount's cwd +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) +SCOPE="" +if [ -n "$MOUNT_CWD" ]; then + REL_PATH="${PWD#"$MOUNT_CWD"}" + REL_PATH="${REL_PATH#/}" + SCOPE="${REL_PATH%%/*}" +fi + +# Inject scope into each bead in the JSON array +if [ -n "$SCOPE" ]; then + JSON=$(echo "$JSON" | jq --arg scope "$SCOPE" '[.[] | . + {scope: $scope}]') +fi + +echo "batch-create $JSON" | 9p write "beads/$MOUNT/ctl" +echo "batch created beads" diff --git a/mcptools/calibrate_time_estimate.sh b/mcptools/calibrate_time_estimate.sh new file mode 100644 index 0000000..24738b0 --- /dev/null +++ b/mcptools/calibrate_time_estimate.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# capabilities: estimation +# description: Calibrate time estimation model from completion data; auto-updates taxonomy when 'other' accumulates +# Usage: calibrate_time_estimate.sh +set -euo pipefail + + +TAXONOMY_FILE="$HOME/.config/anvillm/estimation/taxonomy.json" +MODEL_FILE="$HOME/.local/share/anvillm/estimation/model.json" +LOG_FILE="$HOME/.local/share/anvillm/estimation/completions.jsonl" +MIN_SAMPLES=3 + +if [ ! -f "$LOG_FILE" ]; then + echo "No completion data at $LOG_FILE — nothing to calibrate." + exit 0 +fi + +TOTAL=$(wc -l < "$LOG_FILE") +echo "Processing $TOTAL completion records..." + +OTHER_THRESHOLD=$(jq -r '.other_threshold // 5' "$TAXONOMY_FILE") + +# Compute per-domain median actual_seconds (only for domains with >= MIN_SAMPLES records) +NEW_BASE=$(jq -rs --argjson min "$MIN_SAMPLES" ' + [ .[] | select(.actual_seconds != null) ] | + group_by(.domain) | + map(select(length >= $min) | { + key: .[0].domain, + value: ( map(.actual_seconds) | sort | .[(length / 2 | floor)] ) + }) | + from_entries +' "$LOG_FILE") + +# Load or initialize model +if [ -f "$MODEL_FILE" ]; then + CURRENT_MODEL=$(cat "$MODEL_FILE") +else + CURRENT_MODEL='{"base_seconds": {}, "child_multiplier": 300, "desc_multiplier": 0.5}' +fi + +# Merge updated base_seconds into model +echo "$CURRENT_MODEL" | jq --argjson new "$NEW_BASE" '.base_seconds += $new' > "$MODEL_FILE" +echo "Model updated: $MODEL_FILE" + +# Check 'other' accumulation for taxonomy auto-update +if jq -e 'select(.domain == "other")' "$LOG_FILE" >/dev/null 2>&1; then + # Count inferred_labels within 'other' records + while IFS=$'\t' read -r COUNT LABEL; do + [ -z "$LABEL" ] || [ "$LABEL" = "null" ] || [ "$LABEL" = "other" ] && continue + if [ "$COUNT" -ge "$OTHER_THRESHOLD" ]; then + EXISTS=$(jq -r --arg l "$LABEL" '.categories[] | select(. == $l)' "$TAXONOMY_FILE" 2>/dev/null || true) + if [ -z "$EXISTS" ]; then + echo "Auto-adding taxonomy category: $LABEL (${COUNT} samples)" + jq --arg c "$LABEL" '.categories += [$c]' "$TAXONOMY_FILE" > "${TAXONOMY_FILE}.tmp" + mv "${TAXONOMY_FILE}.tmp" "$TAXONOMY_FILE" + fi + fi + done < <(jq -r 'select(.domain == "other") | .inferred_label // "null"' "$LOG_FILE" \ + | sort | uniq -c | sort -rn \ + | awk '{print $1"\t"$2}') +fi + +echo "Calibration complete." diff --git a/mcptools/capture_browser.sh b/mcptools/capture_browser.sh new file mode 100755 index 0000000..d7a9a78 --- /dev/null +++ b/mcptools/capture_browser.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# capabilities: web +# description: Capture browser window showing a specific URL: capture_browser.sh [output_path] [url_pattern] +set -e + +# Verify running under landrun (test filesystem restriction) + +OUTPUT="${1:-${TMPDIR:-/tmp}/browser_capture.png}" +URL_PATTERN="${2:-localhost}" + +case "$(uname -s)" in + Darwin) + # macOS: use osascript + screencapture + osascript -e 'tell application "Google Chrome" to activate' 2>/dev/null + sleep 0.3 + osascript -e "tell application \"Google Chrome\" to set index of (first window whose name contains \"$URL_PATTERN\") to 1" 2>/dev/null + sleep 0.3 + screencapture -x "$OUTPUT" + ;; + Linux) + if [ -z "$DISPLAY" ]; then + echo "ERROR: DISPLAY not set" >&2 + exit 1 + fi + + # Linux: use brotab + wmctrl + import + TAB_INFO=$(brotab list | grep -i "$URL_PATTERN" | head -1) + if [ -z "$TAB_INFO" ]; then + echo "ERROR: No tab matching '$URL_PATTERN'" >&2 + exit 1 + fi + + TAB_ID=$(echo "$TAB_INFO" | cut -f1) + TAB_TITLE=$(echo "$TAB_INFO" | cut -f2) + echo "Found tab: $TAB_ID ($TAB_TITLE)" >&2 + + brotab activate "$TAB_ID" + sleep 0.5 + + WIN_ID=$(wmctrl -l | grep -i "$TAB_TITLE" | head -1 | awk '{print $1}') + if [ -z "$WIN_ID" ]; then + WIN_ID=$(wmctrl -l | grep -iE "firefox|chrome|chromium" | head -1 | awk '{print $1}') + fi + + if [ -z "$WIN_ID" ]; then + echo "ERROR: Could not find browser window" >&2 + exit 1 + fi + + echo "Capturing window: $WIN_ID" >&2 + wmctrl -i -a "$WIN_ID" + sleep 0.3 + import -window "$WIN_ID" "$OUTPUT" + ;; + *) + echo "ERROR: Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +if [ -f "$OUTPUT" ]; then + echo "$OUTPUT" +else + echo "ERROR: Failed to capture" >&2 + exit 1 +fi diff --git a/mcptools/check_inbox.sh b/mcptools/check_inbox.sh new file mode 100755 index 0000000..1d6a915 --- /dev/null +++ b/mcptools/check_inbox.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# capabilities: messaging +# description: Read messages from agent inbox (uses $AGENT_ID env var) +set -euo pipefail + +# Verify running under landrun (test filesystem restriction) + +if [ -z "${AGENT_ID:-}" ]; then + echo "Error: \$AGENT_ID is not set. Stop work and inform the user that \$AGENT_ID is not set." >&2 + exit 1 +fi +agent_id="$AGENT_ID" +inbox_path="anvillm/${agent_id}/inbox" + +files=$(9p ls "$inbox_path" 2>/dev/null | grep '\.json$' || true) +if [ -z "$files" ]; then + echo "No messages" + exit 0 +fi + +msg_file=$(echo "$files" | head -1) +msg_path="${inbox_path}/${msg_file}" +msg_id="${msg_file%.json}" + +data=$(9p read "$msg_path" 2>/dev/null || echo '{}') +from=$(echo "$data" | jq -r '.from // "unknown"') +type=$(echo "$data" | jq -r '.type // "unknown"') +subject=$(echo "$data" | jq -r '.subject // ""') +body=$(echo "$data" | jq -r '.body // ""') + +echo "complete ${msg_id}" | 9p write "anvillm/${agent_id}/ctl" 2>/dev/null || true + +echo "From: ${from}" +echo "Type: ${type}" +echo "Subject: ${subject}" +echo "" +echo "${body}" diff --git a/mcptools/claim_bead.sh b/mcptools/claim_bead.sh new file mode 100755 index 0000000..6a2b033 --- /dev/null +++ b/mcptools/claim_bead.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# capabilities: beads +# description: Claim a bead for work. Assignee is taken from $AGENT_ID. +# Usage: claim_bead.sh --mount --id +set -euo pipefail + + +MOUNT="" +BEAD_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then + echo "usage: claim_bead.sh --mount --id " >&2 + exit 1 +fi + +if [ -z "${AGENT_ID:-}" ]; then + echo "error: AGENT_ID is not set" >&2 + exit 1 +fi + +echo "claim $BEAD_ID $AGENT_ID" | 9p write beads/$MOUNT/ctl + +assignee=$(9p read beads/$MOUNT/$BEAD_ID/json 2>/dev/null | jq -r '.assignee // empty') +if [ "$assignee" != "$AGENT_ID" ]; then + echo "error: claim failed (bead is assigned to '$assignee')" >&2 + exit 1 +fi +echo "claimed $BEAD_ID → $AGENT_ID" diff --git a/mcptools/code_explorer.sh b/mcptools/code_explorer.sh new file mode 100755 index 0000000..300e3db --- /dev/null +++ b/mcptools/code_explorer.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# capabilities: code-exploration +# description: Token-efficient code exploration with preview/fetch phases +set -euo pipefail + +CACHE_BASE="${XDG_CACHE_HOME:-$HOME/.cache}/anvillm/code-explorer" + +slugify() { + echo "$1" | sed 's/-/--/g; s|/|-|g' +} + +get_cache_dir() { + local dir="${1:-$PWD}" + local slug=$(slugify "$dir") + echo "$CACHE_BASE/$slug" +} + +cmd_init() { + local dir="${1:-$PWD}" + local cache=$(get_cache_dir "$dir") + mkdir -p "$cache" + + # Build file index + find "$dir" -type f \ + ! -path '*/.git/*' \ + ! -path '*/node_modules/*' \ + ! -path '*/vendor/*' \ + ! -path '*/__pycache__/*' \ + ! -path '*/target/*' \ + ! -path '*/dist/*' \ + ! -path '*/build/*' \ + -printf '%P\t%s\t%T@\n' 2>/dev/null > "$cache/index.tsv" + + # Build symbol index if ctags available + if command -v ctags &>/dev/null; then + ctags -R --fields=+n -f "$cache/tags" "$dir" 2>/dev/null || true + fi + + echo "Indexed $(wc -l < "$cache/index.tsv") files in $cache" +} + +cmd_preview() { + local pattern="$1" + local dir="${2:-$PWD}" + local lang="${3:-}" + + local include="" + [ -n "$lang" ] && include="--include=*.$lang" + + rg -c $include \ + --glob '!.git' \ + --glob '!node_modules' \ + --glob '!vendor' \ + --glob '!__pycache__' \ + --glob '!target' \ + --glob '!dist' \ + --glob '!build' \ + "$pattern" "$dir" 2>/dev/null | head -50 +} + +cmd_fetch() { + local pattern="$1" + local file="$2" + local context="${3:-2}" + + rg -n -C "$context" "$pattern" "$file" 2>/dev/null | head -100 +} + +cmd_symbols() { + local pattern="$1" + local dir="${2:-$PWD}" + local cache=$(get_cache_dir "$dir") + + if [ -f "$cache/tags" ]; then + grep -i "$pattern" "$cache/tags" | head -30 + else + echo "No symbol index. Run: code_explorer init" >&2 + exit 1 + fi +} + +cmd_tree() { + local dir="${1:-$PWD}" + local depth="${2:-2}" + local lang="${3:-}" + + local name_filter="" + [ -n "$lang" ] && name_filter="-name '*.$lang'" + + find "$dir" -maxdepth "$depth" -type f \ + ! -path '*/.git/*' \ + ! -path '*/node_modules/*' \ + $name_filter \ + 2>/dev/null | head -100 +} + +case "${1:-help}" in + init) cmd_init "${2:-}" ;; + preview) cmd_preview "$2" "${3:-}" "${4:-}" ;; + fetch) cmd_fetch "$2" "$3" "${4:-2}" ;; + symbols) cmd_symbols "$2" "${3:-}" ;; + tree) cmd_tree "${2:-}" "${3:-2}" "${4:-}" ;; + cache-dir) get_cache_dir "${2:-$PWD}" ;; + *) + cat < [args] + +Commands: + init [dir] Build index for directory (default: \$PWD) + preview [dir] [ext] Count matches per file (cheap) + fetch [ctx] Get matches with context (default: 2 lines) + symbols [dir] Search symbol index + tree [dir] [depth] [ext] List files (default depth: 2) + cache-dir [dir] Show cache location +EOF + ;; +esac diff --git a/mcptools/comment_bead.sh b/mcptools/comment_bead.sh new file mode 100755 index 0000000..7fe4676 --- /dev/null +++ b/mcptools/comment_bead.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Add a comment to a bead +# Usage: comment_bead.sh --mount --id --text +set -euo pipefail + + +MOUNT="" +BEAD_ID="" +TEXT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + --text) TEXT="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$TEXT" ]; then + echo "usage: comment_bead.sh --mount --id --text " >&2 + exit 1 +fi + +printf "comment %s '%s'\n" "$BEAD_ID" "$TEXT" | 9p write beads/$MOUNT/ctl +echo "commented on $BEAD_ID" diff --git a/mcptools/complete_bead.sh b/mcptools/complete_bead.sh new file mode 100755 index 0000000..dc087bf --- /dev/null +++ b/mcptools/complete_bead.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# capabilities: beads +# description: Complete a bead +# Usage: complete_bead.sh --mount --id +set -euo pipefail + + +MOUNT="" +BEAD_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then + echo "usage: complete_bead.sh --mount --id " >&2 + exit 1 +fi + +echo "complete $BEAD_ID" | 9p write beads/$MOUNT/ctl +echo "completed $BEAD_ID" diff --git a/mcptools/config_beads.sh b/mcptools/config_beads.sh new file mode 100755 index 0000000..b18a201 --- /dev/null +++ b/mcptools/config_beads.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Set a beads database configuration key +# Usage: config_beads.sh --mount --key --value +set -euo pipefail + + +MOUNT="" +KEY="" +VALUE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --key) KEY="$2"; shift 2 ;; + --value) VALUE="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$KEY" ]; then + echo "usage: config_beads.sh --mount --key --value " >&2 + exit 1 +fi + +echo "config $KEY $VALUE" | 9p write beads/$MOUNT/ctl +echo "config $KEY = $VALUE" diff --git a/mcptools/control_session.sh b/mcptools/control_session.sh new file mode 100755 index 0000000..f93875e --- /dev/null +++ b/mcptools/control_session.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# capabilities: agents +# description: Control a session (stop|restart|kill|refresh) +# Usage: control_session.sh --session-id --command +set -euo pipefail + + +SESSION_ID="" +COMMAND="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --session-id) SESSION_ID="$2"; shift 2 ;; + --command) COMMAND="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$SESSION_ID" ] || [ -z "$COMMAND" ]; then + echo "usage: control_session.sh --session-id --command " >&2 + exit 1 +fi + +echo "$COMMAND" | 9p write "anvillm/$SESSION_ID/ctl" +echo "$COMMAND: $SESSION_ID" diff --git a/mcptools/create_bead.sh b/mcptools/create_bead.sh new file mode 100755 index 0000000..8f00aad --- /dev/null +++ b/mcptools/create_bead.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# capabilities: beads +# description: Create a new bead. Scope is auto-derived from cwd relative to mount. +# Usage: create_bead.sh --mount --title [--desc <desc>] [--parent <id>] [--no-lint] [--capability low|standard|high] +set -euo pipefail + + +MOUNT="" +TITLE="" +DESC="" +PARENT="" +NOLINT="" +CAP="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --title) TITLE="$2"; shift 2 ;; + --desc) DESC="$2"; shift 2 ;; + --parent) PARENT="$2"; shift 2 ;; + --no-lint) NOLINT="--no-lint"; shift ;; + --capability) CAP="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$TITLE" ]; then + echo "usage: create_bead.sh --mount <mount> --title <title> [--desc <desc>] [--parent <id>] [--no-lint] [--capability low|standard|high]" >&2 + exit 1 +fi + +# Derive scope from cwd relative to mount's cwd +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) +SCOPE="" +if [ -n "$MOUNT_CWD" ]; then + REL_PATH="${PWD#"$MOUNT_CWD"}" + REL_PATH="${REL_PATH#/}" + SCOPE="${REL_PATH%%/*}" +fi + +CMD="new '$TITLE' '$DESC'" +[ -n "$PARENT" ] && CMD="$CMD $PARENT" +[ -n "$NOLINT" ] && CMD="$CMD $NOLINT" +[ -n "$CAP" ] && CMD="$CMD capability=$CAP" +[ -n "$SCOPE" ] && CMD="$CMD scope=$SCOPE" + +echo "$CMD" | 9p write beads/$MOUNT/ctl +echo "created (deferred): $TITLE" diff --git a/mcptools/create_session.sh b/mcptools/create_session.sh new file mode 100755 index 0000000..daf815a --- /dev/null +++ b/mcptools/create_session.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# capabilities: agents +# description: Create a new agent session +# Usage: create_session.sh --backend <backend> --cwd <cwd> [--sandbox <sandbox>] [--model <model>] +set -euo pipefail + + +BACKEND="" +CWD="" +SANDBOX="" +MODEL="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --backend) BACKEND="$2"; shift 2 ;; + --cwd) CWD="$2"; shift 2 ;; + --sandbox) SANDBOX="$2"; shift 2 ;; + --model) MODEL="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$BACKEND" ] || [ -z "$CWD" ]; then + echo "usage: create_session.sh --backend <backend> --cwd <cwd> [--sandbox <sandbox>] [--model <model>]" >&2 + exit 1 +fi + +CMD="new $BACKEND $CWD" +[ -n "$SANDBOX" ] && CMD="$CMD sandbox=$SANDBOX" +[ -n "$MODEL" ] && CMD="$CMD model=$MODEL" + +echo "$CMD" | 9p write anvillm/ctl +echo "created session: $BACKEND $CWD" diff --git a/mcptools/defer_bead.sh b/mcptools/defer_bead.sh new file mode 100755 index 0000000..1ec85f2 --- /dev/null +++ b/mcptools/defer_bead.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# capabilities: beads +# description: Defer a bead, optionally until a specific time +# Usage: defer_bead.sh --mount <mount> --id <bead-id> [--until <RFC3339-time>] +set -euo pipefail + + +MOUNT="" +BEAD_ID="" +UNTIL="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + --until) UNTIL="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then + echo "usage: defer_bead.sh --mount <mount> --id <bead-id> [--until <RFC3339-time>]" >&2 + exit 1 +fi + +if [ -n "$UNTIL" ]; then + echo "defer $BEAD_ID until $UNTIL" | 9p write beads/$MOUNT/ctl + echo "deferred $BEAD_ID until $UNTIL" +else + echo "defer $BEAD_ID" | 9p write beads/$MOUNT/ctl + echo "deferred $BEAD_ID" +fi diff --git a/mcptools/delete_bead.sh b/mcptools/delete_bead.sh new file mode 100755 index 0000000..21c3f64 --- /dev/null +++ b/mcptools/delete_bead.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# capabilities: beads +# description: Delete a bead +# Usage: delete_bead.sh --mount <mount> --id <bead-id> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then + echo "usage: delete_bead.sh --mount <mount> --id <bead-id>" >&2 + exit 1 +fi + +echo "delete $BEAD_ID" | 9p write beads/$MOUNT/ctl +echo "deleted $BEAD_ID" diff --git a/mcptools/discover_skill.sh b/mcptools/discover_skill.sh new file mode 100755 index 0000000..f920003 --- /dev/null +++ b/mcptools/discover_skill.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# capabilities: discovery +# description: Discover available skills by keyword +set -euo pipefail + +if [ $# -ne 1 ]; then + echo "Usage: discover_skill <keyword>" >&2 + exit 1 +fi + +keyword="$1" +results="" + +# Search each skill file for the keyword +while IFS= read -r file; do + [ "${file%.md}" = "$file" ] && continue # skip non-.md entries + name="${file%.md}" + content=$(9p read "anvillm/skills/$file") + if echo "$content" | grep -qi "$keyword"; then + desc=$(echo "$content" | grep -m1 '^description:' | sed 's/^description: *//') + results="${results:+$results +}$name $desc" + fi +done < <(9p ls anvillm/skills) + +if [ -n "$results" ]; then + echo "$results" | sort -u +else + echo "No skills found matching: $keyword" +fi diff --git a/mcptools/estimate_time.sh b/mcptools/estimate_time.sh new file mode 100644 index 0000000..5cbe402 --- /dev/null +++ b/mcptools/estimate_time.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# capabilities: estimation +# description: Estimate LLM task completion time from bead signals +# Usage: estimate_time.sh '<json>' +# Input JSON: {"domain": "api", "child_bead_count": 3, "description_length": 450, "inferred_label": "api"} +# Output JSON: {"estimate_seconds": 1800, "tier": "moderate", "deadline_seconds": 3600} +set -euo pipefail + + +if [ $# -lt 1 ]; then + echo "usage: estimate_time.sh '<json-signals>'" >&2 + exit 1 +fi + +TAXONOMY_FILE="$HOME/.config/anvillm/estimation/taxonomy.json" +MODEL_FILE="$HOME/.local/share/anvillm/estimation/model.json" + +INPUT="$1" + +# Extract signals +DOMAIN=$(echo "$INPUT" | jq -r '.domain // "other"') +CHILD_COUNT=$(echo "$INPUT" | jq -r '.child_bead_count // 0') +DESC_LEN=$(echo "$INPUT" | jq -r '.description_length // 0') + +# Validate domain against taxonomy; fall back to "other" if unrecognized +if [ -f "$TAXONOMY_FILE" ]; then + VALID=$(jq -r --arg d "$DOMAIN" '.categories[] | select(. == $d)' "$TAXONOMY_FILE" 2>/dev/null || true) + if [ -z "$VALID" ]; then + DOMAIN="other" + fi +fi + +# Load fitted constants from model.json, or use cold-start defaults +if [ -f "$MODEL_FILE" ]; then + BASE=$(jq -r --arg d "$DOMAIN" '.base_seconds[$d] // 1200' "$MODEL_FILE") + CHILD_MULT=$(jq -r '.child_multiplier // 300' "$MODEL_FILE") + DESC_MULT=$(jq -r '.desc_multiplier // 0.5' "$MODEL_FILE") +else + case "$DOMAIN" in + ui) BASE=600 ;; + api) BASE=900 ;; + data-model) BASE=1200 ;; + infra) BASE=1500 ;; + testing) BASE=600 ;; + refactor) BASE=1800 ;; + docs) BASE=300 ;; + security) BASE=1800 ;; + performance) BASE=1500 ;; + research) BASE=1200 ;; + *) BASE=1200 ;; + esac + CHILD_MULT=300 + DESC_MULT=0.5 +fi + +# Compute estimate and deadline (2x estimate) +ESTIMATE=$(awk -v base="$BASE" -v cc="$CHILD_COUNT" -v cm="$CHILD_MULT" -v dl="$DESC_LEN" -v dm="$DESC_MULT" \ + 'BEGIN { printf "%d", base + (cc * cm) + (dl * dm) }') +DEADLINE=$(awk -v e="$ESTIMATE" 'BEGIN { printf "%d", e * 2 }') + +# Classify tier +if [ "$ESTIMATE" -lt 300 ]; then TIER="trivial" +elif [ "$ESTIMATE" -lt 1800 ]; then TIER="moderate" +elif [ "$ESTIMATE" -lt 7200 ]; then TIER="large" +else TIER="very_large" +fi + +jq -n \ + --argjson est "$ESTIMATE" \ + --arg tier "$TIER" \ + --argjson dl "$DEADLINE" \ + '{"estimate_seconds": $est, "tier": $tier, "deadline_seconds": $dl}' diff --git a/mcptools/fail_bead.sh b/mcptools/fail_bead.sh new file mode 100755 index 0000000..ffc932c --- /dev/null +++ b/mcptools/fail_bead.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Fail a bead with reason +# Usage: fail_bead.sh --mount <mount> --id <bead-id> --reason <reason> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" +REASON="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + --reason) REASON="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$REASON" ]; then + echo "usage: fail_bead.sh --mount <mount> --id <bead-id> --reason <reason>" >&2 + exit 1 +fi + +printf "fail %s '%s'\n" "$BEAD_ID" "$REASON" | 9p write beads/$MOUNT/ctl +echo "failed $BEAD_ID: $REASON" diff --git a/mcptools/github_to_beads.sh b/mcptools/github_to_beads.sh new file mode 100755 index 0000000..4901c50 --- /dev/null +++ b/mcptools/github_to_beads.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# capabilities: beads +# description: Import GitHub issue into beads. Scope is auto-derived from cwd. +# Usage: github_to_beads.sh --mount <mount> --repo <owner/repo> --issue <issue-number> +set -euo pipefail + +MOUNT="" +REPO="" +ISSUE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --issue) ISSUE="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$REPO" ] || [ -z "$ISSUE" ]; then + echo "usage: github_to_beads.sh --mount <mount> --repo <owner/repo> --issue <issue-number>" >&2 + exit 1 +fi + +# Derive scope from cwd relative to mount's cwd +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) +SCOPE="" +if [ -n "$MOUNT_CWD" ]; then + REL_PATH="${PWD#"$MOUNT_CWD"}" + REL_PATH="${REL_PATH#/}" + SCOPE="${REL_PATH%%/*}" +fi +SCOPE_ARG="" +[ -n "$SCOPE" ] && SCOPE_ARG="scope=$SCOPE" + +# Check if already imported +if 9p read "beads/$MOUNT/list" | jq -e ".[] | select(.title | contains(\"#$ISSUE\"))" >/dev/null 2>&1; then + echo "Issue #$ISSUE already imported" >&2 + exit 0 +fi + +# Fetch issue data +data=$(gh issue view "$ISSUE" --repo "$REPO" --json number,title,body,state,labels 2>/dev/null) + +number=$(echo "$data" | jq -r '.number') +title=$(echo "$data" | jq -r '.title') +body=$(echo "$data" | jq -r '.body // empty') +state=$(echo "$data" | jq -r '.state') +labels=$(echo "$data" | jq -r '.labels[].name' | tr '\n' ' ') + +# Determine issue type from labels +issue_type="task" +if echo "$labels" | grep -qi "bug"; then + issue_type="bug" +fi + +# Determine status +status="open" +if [[ "$state" == "CLOSED" ]]; then + status="closed" +fi + +# Build title: #NUMBER: title +bead_title="#$number: $title" + +# Create parent bead +echo "new \"$bead_title\" \"$body\" '' $SCOPE_ARG" | 9p write "beads/$MOUNT/ctl" + +# Get created bead ID +bead_id=$(9p read "beads/$MOUNT/list" | jq -r ".[] | select(.title | contains(\"#$number\")) | .id" | head -1) + +# Update issue type if not task +if [[ "$issue_type" != "task" ]]; then + echo "update $bead_id issue_type $issue_type" | 9p write "beads/$MOUNT/ctl" +fi + +# Update status if closed +if [[ "$status" == "closed" ]]; then + echo "update $bead_id status closed" | 9p write "beads/$MOUNT/ctl" +fi + +# Parse task list from body +if [[ -n "$body" ]]; then + # Extract unchecked tasks: - [ ] Task name + echo "$body" | grep -E '^\s*-\s+\[\s+\]' | sed -E 's/^\s*-\s+\[\s+\]\s*//' | while IFS= read -r task; do + if [[ -n "$task" ]]; then + echo "new \"$task\" \"\" $bead_id $SCOPE_ARG" | 9p write "beads/$MOUNT/ctl" + fi + done +fi + +echo "$bead_id" diff --git a/mcptools/init_beads.sh b/mcptools/init_beads.sh new file mode 100755 index 0000000..8c8b4e5 --- /dev/null +++ b/mcptools/init_beads.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# capabilities: beads +# description: Initialize beads project with issue prefix +# Usage: init_beads.sh --mount <mount> [--prefix <prefix>] +set -euo pipefail + + +MOUNT="" +PREFIX="bd" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --prefix) PREFIX="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ]; then + echo "usage: init_beads.sh --mount <mount> [--prefix <prefix>]" >&2 + exit 1 +fi + +echo "init $PREFIX" | 9p write "beads/$MOUNT/ctl" +echo "initialized $MOUNT (prefix: $PREFIX)" diff --git a/mcptools/jira_to_beads.sh b/mcptools/jira_to_beads.sh new file mode 100755 index 0000000..531252b --- /dev/null +++ b/mcptools/jira_to_beads.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# capabilities: beads +# description: Import Jira ticket hierarchy into beads. Scope is auto-derived from cwd. +# Usage: jira_to_beads.sh --mount <mount> --ticket <ticket-key> +set -euo pipefail + +MOUNT="" +TICKET="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --ticket) TICKET="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$TICKET" ]; then + echo "usage: jira_to_beads.sh --mount <mount> --ticket <ticket-key>" >&2 + exit 1 +fi + +# Derive scope from cwd relative to mount's cwd +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) +SCOPE="" +if [ -n "$MOUNT_CWD" ]; then + REL_PATH="${PWD#"$MOUNT_CWD"}" + REL_PATH="${REL_PATH#/}" + SCOPE="${REL_PATH%%/*}" +fi +SCOPE_ARG="" +[ -n "$SCOPE" ] && SCOPE_ARG="scope=$SCOPE" + +# Check if already imported +if 9p read "beads/$MOUNT/list" | jq -e ".[] | select(.title | contains(\"$TICKET\"))" >/dev/null 2>&1; then + echo "Ticket $TICKET already imported" >&2 + exit 0 +fi + +# Find root ticket (walk up parent chain) +find_root() { + local key="$1" + local parent + parent=$(jira issue view "$key" --raw 2>/dev/null | jq -r '.fields.parent.key // empty') + if [[ -n "$parent" ]]; then + find_root "$parent" + else + echo "$key" + fi +} + +# Create bead from ticket +create_bead() { + local key="$1" + local parent_bead="${2:-}" + + # Fetch ticket data + local data + data=$(jira issue view "$key" --raw 2>/dev/null) + + local summary + summary=$(echo "$data" | jq -r '.fields.summary') + + local description + description=$(echo "$data" | jq -r '.fields.description // empty') + + # Build title: KEY: summary + local title="$key: $summary" + + # Create bead + if [[ -n "$parent_bead" ]]; then + echo "new \"$title\" \"$description\" $parent_bead $SCOPE_ARG" | 9p write "beads/$MOUNT/ctl" + else + echo "new \"$title\" \"$description\" '' $SCOPE_ARG" | 9p write "beads/$MOUNT/ctl" + fi + + # Get created bead ID + local bead_id + bead_id=$(9p read "beads/$MOUNT/list" | jq -r ".[] | select(.title | contains(\"$key\")) | .id" | head -1) + + # Process children + local children + children=$(jira issue list --parent "$key" --plain --no-truncate 2>/dev/null | tail -n +2 | awk '{print $1}' || true) + + if [[ -n "$children" ]]; then + while IFS= read -r child; do + [[ -n "$child" ]] && create_bead "$child" "$bead_id" + done <<< "$children" + fi + + echo "$bead_id" +} + +# Start import from root +root=$(find_root "$TICKET") +echo "Importing from root: $root" >&2 +create_bead "$root" diff --git a/mcptools/kill_agent.sh b/mcptools/kill_agent.sh new file mode 100755 index 0000000..e5a8521 --- /dev/null +++ b/mcptools/kill_agent.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: agents +# description: Kill agent session(s) running in the given directory +# Usage: kill_agent.sh --agent-id <agent-id> +set -euo pipefail + + +AGENT_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --agent-id) AGENT_ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$AGENT_ID" ]; then + echo "usage: kill_agent.sh --agent-id <agent-id>" >&2 + exit 1 +fi + +if echo "kill" | 9p write "anvillm/$AGENT_ID/ctl" 2>/dev/null; then + echo "Killed $AGENT_ID" +else + echo "No session found with id $AGENT_ID" >&2 + exit 1 +fi diff --git a/mcptools/label_bead.sh b/mcptools/label_bead.sh new file mode 100755 index 0000000..5c8b483 --- /dev/null +++ b/mcptools/label_bead.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Add a label to a bead +# Usage: label_bead.sh --mount <mount> --id <bead-id> --label <label> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" +LABEL="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + --label) LABEL="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$LABEL" ]; then + echo "usage: label_bead.sh --mount <mount> --id <bead-id> --label <label>" >&2 + exit 1 +fi + +echo "label $BEAD_ID $LABEL" | 9p write beads/$MOUNT/ctl +echo "labeled $BEAD_ID: $LABEL" diff --git a/mcptools/list_beads.sh b/mcptools/list_beads.sh new file mode 100755 index 0000000..5aada07 --- /dev/null +++ b/mcptools/list_beads.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# capabilities: beads +# description: List all open beads (JSON array). Filters by scope derived from cwd (strict match). +# Usage: list_beads.sh --mount <mount> +set -euo pipefail + + +MOUNT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ]; then + echo "usage: list_beads.sh --mount <mount>" >&2 + exit 1 +fi + +# Derive scope from cwd relative to mount's cwd +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) +MY_SCOPE="" +if [ -n "$MOUNT_CWD" ]; then + REL_PATH="${PWD#"$MOUNT_CWD"}" + REL_PATH="${REL_PATH#/}" + MY_SCOPE="${REL_PATH%%/*}" +fi + +# Strict match: scope must equal (both empty or both same value) +9p read "beads/$MOUNT/list" 2>/dev/null | jq --arg scope "$MY_SCOPE" '[.[] | select((.scope // "") == $scope) | {id, priority, title, status, scope}]' || echo "[]" diff --git a/mcptools/list_mounts.sh b/mcptools/list_mounts.sh new file mode 100755 index 0000000..fa119c7 --- /dev/null +++ b/mcptools/list_mounts.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# capabilities: beads +# description: List mounted beads projects +set -euo pipefail + + +9p read beads/mtab 2>/dev/null || echo "No mounted projects" diff --git a/mcptools/list_ready_beads.sh b/mcptools/list_ready_beads.sh new file mode 100755 index 0000000..50713e3 --- /dev/null +++ b/mcptools/list_ready_beads.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# capabilities: beads +# description: List ready/claimable beads (JSON array). Filters by scope derived from cwd (strict match). +# Usage: list_ready_beads.sh --mount <mount> +set -euo pipefail + + +MOUNT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ]; then + echo "usage: list_ready_beads.sh --mount <mount>" >&2 + exit 1 +fi + +# Derive scope from cwd relative to mount's cwd +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) +MY_SCOPE="" +if [ -n "$MOUNT_CWD" ]; then + REL_PATH="${PWD#"$MOUNT_CWD"}" + REL_PATH="${REL_PATH#/}" + MY_SCOPE="${REL_PATH%%/*}" +fi + +# Strict match: scope must equal (both empty or both same value) +9p read "beads/$MOUNT/ready" 2>/dev/null | jq --arg scope "$MY_SCOPE" '[.[] | select((.scope // "") == $scope) | {id, priority, title, status, scope}]' || echo "[]" diff --git a/mcptools/list_roles.sh b/mcptools/list_roles.sh new file mode 100755 index 0000000..8e61d7c --- /dev/null +++ b/mcptools/list_roles.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# capabilities: discovery +# description: List all available roles (JSON array) +set -euo pipefail + +# Verify running under landrun (test filesystem restriction) + +9p ls anvillm/roles 2>/dev/null | grep -v '^help$' | while read focus_area; do + 9p ls "anvillm/roles/$focus_area" 2>/dev/null | while read role_file; do + role_name="${role_file%.md}" + desc=$(9p read "anvillm/roles/$focus_area/$role_file" 2>/dev/null | awk '/^---$/,/^---$/ {if (/^description:/) {sub(/^description: */, ""); print; exit}}') + printf '%s\t%s\t%s\n' "$focus_area" "$role_name" "$desc" + done +done | jq -Rs ' + split("\n") | map(select(length > 0)) | map( + split("\t") | {focus_area: .[0], name: .[1], description: .[2]} + ) +' diff --git a/mcptools/list_sessions.sh b/mcptools/list_sessions.sh new file mode 100755 index 0000000..b11b8c2 --- /dev/null +++ b/mcptools/list_sessions.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# capabilities: agents +# description: List all active agent sessions (tab-separated: id, backend, state, alias, role, cwd) +set -euo pipefail + +# Verify running under landrun (test filesystem restriction) + +9p read anvillm/list 2>/dev/null || true diff --git a/mcptools/list_skills.sh b/mcptools/list_skills.sh new file mode 100755 index 0000000..082eee0 --- /dev/null +++ b/mcptools/list_skills.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# capabilities: discovery +# description: List all available skills (JSON array) +set -euo pipefail + +# Verify running under landrun (test filesystem restriction) + +if [ -z "${ANVILLM_SKILLS_PATH:-}" ]; then + echo "[]" + exit 0 +fi + +for dir in "$ANVILLM_SKILLS_PATH"/*; do + if [ -d "$dir" ] && [ -f "$dir/SKILL.md" ]; then + name=$(basename "$dir") + desc=$(grep -m1 '^description:' "$dir/SKILL.md" | sed 's/^description: *//' || true) + printf '%s\t%s\n' "$name" "$desc" + fi +done | jq -Rs ' + split("\n") | map(select(length > 0)) | map( + split("\t") | {name: .[0], description: .[1]} + ) +' diff --git a/mcptools/load_skill.sh b/mcptools/load_skill.sh new file mode 100755 index 0000000..87a36bc --- /dev/null +++ b/mcptools/load_skill.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# capabilities: discovery +# description: Load a skill's SKILL.md by name +set -euo pipefail + +# Usage: load_skill <skill-name> [skill-name...] +# +# Accepts either bare names or paths (the last component is used): +# anvillm-sessions +# skills/agents/anvillm-sessions (legacy path format; last component is used) + +if [ $# -lt 1 ]; then + echo "Usage: load_skill <skill-name> [skill-name...]" >&2 + exit 1 +fi + +for arg in "$@"; do + name="${arg##*/}" + 9p read "anvillm/skills/${name}.md" +done diff --git a/mcptools/mail_history.sh b/mcptools/mail_history.sh new file mode 100755 index 0000000..fc49b62 --- /dev/null +++ b/mcptools/mail_history.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Display message history for an agent or user. +# Usage: mail_history.sh --agent-id <agent-id|user> [--date YYYYMMdd] +set -euo pipefail + +AGENT_ID="" +DATE_FILTER="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --agent-id) AGENT_ID="$2"; shift 2 ;; + --date) DATE_FILTER="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$AGENT_ID" ]]; then + echo "usage: mail_history.sh --agent-id <agent-id|user> [--date YYYYMMdd]" >&2 + exit 1 +fi + +MAIL_DIR="$HOME/.local/share/anvillm/mail/$AGENT_ID" + +shopt -s nullglob +if [[ -n "$DATE_FILTER" ]]; then + SENT="$MAIL_DIR/${DATE_FILTER}-sent.jsonl" + RECV="$MAIL_DIR/${DATE_FILTER}-recv.jsonl" + FILES=() + [[ -f "$SENT" ]] && FILES+=("$SENT") || echo "Could not get sent messages for $DATE_FILTER" >&2 + [[ -f "$RECV" ]] && FILES+=("$RECV") || echo "Could not get received messages for $DATE_FILTER" >&2 +else + FILES=("$MAIL_DIR"/*-sent.jsonl "$MAIL_DIR"/*-recv.jsonl) +fi +shopt -u nullglob + +if [[ ${#FILES[@]} -eq 0 ]]; then + echo "No messages for $AGENT_ID" + exit 0 +fi + +cat "${FILES[@]}" | jq -cs 'sort_by(.ts) | .[]' diff --git a/mcptools/mail_search.sh b/mcptools/mail_search.sh new file mode 100755 index 0000000..dba203e --- /dev/null +++ b/mcptools/mail_search.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Search message history for an agent or user by regex pattern. +# Usage: mail_search.sh --agent-id <agent-id|user> --pattern <regex> [--date YYYYMMdd] +set -euo pipefail + +AGENT_ID="" +PATTERN="" +DATE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --agent-id) AGENT_ID="$2"; shift 2 ;; + --pattern) PATTERN="$2"; shift 2 ;; + --date) DATE="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$AGENT_ID" ]] || [[ -z "$PATTERN" ]]; then + echo "usage: mail_search.sh --agent-id <agent-id|user> --pattern <regex> [--date YYYYMMdd]" >&2 + exit 1 +fi + +MAIL_DIR="$HOME/.local/share/anvillm/mail/$AGENT_ID" + +shopt -s nullglob +if [[ -n "$DATE" ]]; then + SENT="$MAIL_DIR/${DATE}-sent.jsonl" + RECV="$MAIL_DIR/${DATE}-recv.jsonl" + FILES=() + [[ -f "$SENT" ]] && FILES+=("$SENT") + [[ -f "$RECV" ]] && FILES+=("$RECV") +else + FILES=("$MAIL_DIR"/*-sent.jsonl "$MAIL_DIR"/*-recv.jsonl) +fi +shopt -u nullglob + +if [[ ${#FILES[@]} -eq 0 ]]; then + exit 0 +fi + +cat "${FILES[@]}" | grep -E "$PATTERN" diff --git a/mcptools/mount_beads.sh b/mcptools/mount_beads.sh new file mode 100755 index 0000000..1883def --- /dev/null +++ b/mcptools/mount_beads.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# capabilities: beads +# description: Mount a beads project +# Usage: mount_beads.sh --cwd <path> +# Mount name is the first segment of a random UUID (8 hex chars, 32-bit entropy). +set -euo pipefail + + +CWD="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --cwd) CWD="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$CWD" ]; then + echo "usage: mount_beads.sh --cwd <path>" >&2 + exit 1 +fi + +MOUNT=$(uuidgen | cut -d- -f1) + +echo "mount $CWD $MOUNT" | 9p write beads/ctl + +# Verify mount succeeded +if ! 9p read beads/mtab 2>/dev/null | grep -q "^$MOUNT "; then + echo "error: mount failed for $CWD" >&2 + exit 1 +fi + +echo "$MOUNT" diff --git a/mcptools/open_bead.sh b/mcptools/open_bead.sh new file mode 100755 index 0000000..5734afb --- /dev/null +++ b/mcptools/open_bead.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# capabilities: beads +# description: Promote a deferred bead to open (ready for scheduling) +# Usage: open_bead.sh --mount <mount> --id <bead-id> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then + echo "usage: open_bead.sh --mount <mount> --id <bead-id>" >&2 + exit 1 +fi + +echo "open $BEAD_ID" | 9p write beads/$MOUNT/ctl +echo "opened $BEAD_ID" diff --git a/mcptools/read_bead.sh b/mcptools/read_bead.sh new file mode 100755 index 0000000..67e3496 --- /dev/null +++ b/mcptools/read_bead.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Read a bead property +# Usage: read_bead.sh --mount <mount> --id <bead-id> --property <property> +# Properties: json, status, title, description, assignee, comments, labels +set -euo pipefail + + +MOUNT="" +BEAD_ID="" +PROPERTY="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + --property) PROPERTY="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$PROPERTY" ]; then + echo "usage: read_bead.sh --mount <mount> --id <bead-id> --property <property>" >&2 + exit 1 +fi + +9p read "beads/$MOUNT/$BEAD_ID/$PROPERTY" 2>/dev/null || echo "Property not found: $PROPERTY" diff --git a/mcptools/read_bead_comments.sh b/mcptools/read_bead_comments.sh new file mode 100755 index 0000000..b5ea5ce --- /dev/null +++ b/mcptools/read_bead_comments.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# capabilities: beads +# description: Read comments for a bead +# Usage: read_bead_comments.sh --mount <mount> --id <bead-id> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then + echo "usage: read_bead_comments.sh --mount <mount> --id <bead-id>" >&2 + exit 1 +fi + +9p read "beads/$MOUNT/$BEAD_ID/comments" 2>/dev/null || echo "[]" diff --git a/mcptools/relate_beads.sh b/mcptools/relate_beads.sh new file mode 100755 index 0000000..f1b2668 --- /dev/null +++ b/mcptools/relate_beads.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Add a relates-to link between two beads +# Usage: relate_beads.sh --mount <mount> --bead1 <id> --bead2 <id> +set -euo pipefail + + +MOUNT="" +BEAD1="" +BEAD2="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --bead1) BEAD1="$2"; shift 2 ;; + --bead2) BEAD2="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD1" ] || [ -z "$BEAD2" ]; then + echo "usage: relate_beads.sh --mount <mount> --bead1 <id> --bead2 <id>" >&2 + exit 1 +fi + +echo "relate $BEAD1 $BEAD2" | 9p write beads/$MOUNT/ctl +echo "related $BEAD1 ↔ $BEAD2" diff --git a/mcptools/remove_dependency.sh b/mcptools/remove_dependency.sh new file mode 100755 index 0000000..0325839 --- /dev/null +++ b/mcptools/remove_dependency.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Remove a dependency +# Usage: remove_dependency.sh --mount <mount> --child <child-id> --parent <parent-id> +set -euo pipefail + + +MOUNT="" +CHILD="" +PARENT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --child) CHILD="$2"; shift 2 ;; + --parent) PARENT="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$CHILD" ] || [ -z "$PARENT" ]; then + echo "usage: remove_dependency.sh --mount <mount> --child <child-id> --parent <parent-id>" >&2 + exit 1 +fi + +echo "undep $CHILD $PARENT" | 9p write beads/$MOUNT/ctl +echo "$CHILD no longer depends on $PARENT" diff --git a/mcptools/reopen_bead.sh b/mcptools/reopen_bead.sh new file mode 100755 index 0000000..60ea79d --- /dev/null +++ b/mcptools/reopen_bead.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# capabilities: beads +# description: Reopen a closed bead (reset to open) +# Usage: reopen_bead.sh --mount <mount> --id <bead-id> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then + echo "usage: reopen_bead.sh --mount <mount> --id <bead-id>" >&2 + exit 1 +fi + +echo "reopen $BEAD_ID" | 9p write beads/$MOUNT/ctl +echo "reopened $BEAD_ID" diff --git a/mcptools/search_beads.sh b/mcptools/search_beads.sh new file mode 100755 index 0000000..db10c8b --- /dev/null +++ b/mcptools/search_beads.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# capabilities: beads +# description: Search beads by id, title, or description content. Filters by scope derived from cwd. +# Usage: search_beads.sh --mount <mount> --query <query> +# search_beads.sh --mount <mount> --id <bead-id> +set -euo pipefail + + +MOUNT="" +QUERY="" +BEAD_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --query) QUERY="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ]; then + echo "usage: search_beads.sh --mount <mount> --query <query>" >&2 + echo " search_beads.sh --mount <mount> --id <bead-id>" >&2 + exit 1 +fi + +# Direct ID lookup +if [ -n "$BEAD_ID" ]; then + 9p read "beads/$MOUNT/$BEAD_ID/json" 2>/dev/null || echo "null" + exit 0 +fi + +if [ -z "$QUERY" ]; then + echo "usage: search_beads.sh --mount <mount> --query <query>" >&2 + echo " search_beads.sh --mount <mount> --id <bead-id>" >&2 + exit 1 +fi + +# Derive scope from cwd relative to mount's cwd +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) +MY_SCOPE="" +if [ -n "$MOUNT_CWD" ]; then + REL_PATH="${PWD#"$MOUNT_CWD"}" + REL_PATH="${REL_PATH#/}" + MY_SCOPE="${REL_PATH%%/*}" +fi + +9p read "beads/$MOUNT/search/$QUERY" 2>/dev/null | jq --arg scope "$MY_SCOPE" '[.[] | select((.scope // "") == $scope) | {id, title, status, scope, match_in: (if .id | test("'"$QUERY"'"; "i") then "id" elif .title | test("'"$QUERY"'"; "i") then "title" else "description" end)}]' || echo "[]" diff --git a/mcptools/send_message.sh b/mcptools/send_message.sh new file mode 100755 index 0000000..bd627d1 --- /dev/null +++ b/mcptools/send_message.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# capabilities: messaging +# description: Send message to agent or user (FROM uses $AGENT_ID) +# Usage: send_message.sh --to <id|user> --type <type> --subject <subject> --body <body> +set -euo pipefail + + +if [ -z "${AGENT_ID:-}" ]; then + echo "Error: \$AGENT_ID is not set. Stop work and inform the user that \$AGENT_ID is not set." >&2 + exit 1 +fi +from="$AGENT_ID" + +to="" +type="" +subject="" +body="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --to) to="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --subject) subject="$2"; shift 2 ;; + --body) body="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$to" ] || [ -z "$type" ] || [ -z "$subject" ] || [ -z "$body" ]; then + echo "usage: send_message.sh --to <id|user> --type <type> --subject <subject> --body <body>" >&2 + exit 1 +fi + +# Validate recipient exists (allow "user" as a special recipient) +if [ "$to" != "user" ]; then + if ! 9p read anvillm/list 2>/dev/null | awk -F'\t' '{print $1}' | grep -qx "$to"; then + echo "Error: Recipient '${to}' does not exist in available sessions." >&2 + exit 1 + fi +fi + +json=$(jq -n \ + --arg from "$from" \ + --arg to "$to" \ + --arg type "$type" \ + --arg subject "$subject" \ + --arg body "$body" \ + '{from: $from, to: $to, type: $type, subject: $subject, body: $body}') + +echo "$json" | 9p write "anvillm/${from}/mail" +echo "sent: $type → $to" diff --git a/mcptools/set_capability.sh b/mcptools/set_capability.sh new file mode 100755 index 0000000..273d8a9 --- /dev/null +++ b/mcptools/set_capability.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Set capability level on a bead (low|standard|high) +# Usage: set_capability.sh --mount <mount> --id <bead-id> --level <low|standard|high> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" +LEVEL="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + --level) LEVEL="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$LEVEL" ]; then + echo "usage: set_capability.sh --mount <mount> --id <bead-id> --level <low|standard|high>" >&2 + exit 1 +fi + +echo "set-capability $BEAD_ID $LEVEL" | 9p write beads/$MOUNT/ctl +echo "capability set: $BEAD_ID → $LEVEL" diff --git a/mcptools/spawn_agent.sh b/mcptools/spawn_agent.sh new file mode 100755 index 0000000..92f965b --- /dev/null +++ b/mcptools/spawn_agent.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# capabilities: agents +# description: Spawn a new agent session +# Usage: spawn_agent.sh --agent-id <agent-id> [--cwd <path>] [--prompt <initial-context>] +set -euo pipefail + +AGENT_ID="" +CWD="$PWD" +PROMPT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --agent-id) AGENT_ID="$2"; shift 2 ;; + --cwd) CWD="$2"; shift 2 ;; + --prompt) PROMPT="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$AGENT_ID" ]; then + echo "usage: spawn_agent.sh --agent-id <agent-id> [--cwd <path>] [--prompt <text>]" >&2 + exit 1 +fi + +backend=$(9p read "anvillm/$AGENT_ID/backend") +echo "new $backend $CWD" | 9p write anvillm/ctl + +if [ -n "$PROMPT" ]; then + session_id=$(9p read anvillm/list 2>/dev/null | awk -F'\t' -v cwd="$CWD" '$5 == cwd {print $1; exit}') + if [ -n "$session_id" ]; then + echo "$PROMPT" | 9p write "anvillm/$session_id/context" + fi +fi diff --git a/mcptools/sync_beads.sh b/mcptools/sync_beads.sh new file mode 100755 index 0000000..1804c38 --- /dev/null +++ b/mcptools/sync_beads.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# capabilities: beads +# description: Sync a mounted beads project +# Usage: sync_beads.sh --mount <mount> +set -euo pipefail + + +MOUNT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ]; then + echo "usage: sync_beads.sh --mount <mount>" >&2 + exit 1 +fi + +echo "sync" | 9p write "beads/$MOUNT/ctl" +echo "synced $MOUNT" diff --git a/mcptools/umount_beads.sh b/mcptools/umount_beads.sh new file mode 100755 index 0000000..2ec268c --- /dev/null +++ b/mcptools/umount_beads.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# capabilities: beads +# description: Unmount a beads project +# Usage: umount_beads.sh --mount <name> +set -euo pipefail + + +MOUNT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ]; then + echo "usage: umount_beads.sh --mount <name>" >&2 + exit 1 +fi + +echo "umount $MOUNT" | 9p write beads/ctl +echo "unmounted $MOUNT" diff --git a/mcptools/unclaim_bead.sh b/mcptools/unclaim_bead.sh new file mode 100755 index 0000000..01ef793 --- /dev/null +++ b/mcptools/unclaim_bead.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# capabilities: beads +# description: Unclaim a bead (reset to open) +# Usage: unclaim_bead.sh --mount <mount> --id <bead-id> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then + echo "usage: unclaim_bead.sh --mount <mount> --id <bead-id>" >&2 + exit 1 +fi + +echo "unclaim $BEAD_ID" | 9p write beads/$MOUNT/ctl +echo "unclaimed $BEAD_ID" diff --git a/mcptools/unlabel_bead.sh b/mcptools/unlabel_bead.sh new file mode 100755 index 0000000..9f516ee --- /dev/null +++ b/mcptools/unlabel_bead.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Remove a label from a bead +# Usage: unlabel_bead.sh --mount <mount> --id <bead-id> --label <label> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" +LABEL="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + --label) LABEL="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$LABEL" ]; then + echo "usage: unlabel_bead.sh --mount <mount> --id <bead-id> --label <label>" >&2 + exit 1 +fi + +echo "unlabel $BEAD_ID $LABEL" | 9p write beads/$MOUNT/ctl +echo "unlabeled $BEAD_ID: $LABEL" diff --git a/mcptools/unrelate_beads.sh b/mcptools/unrelate_beads.sh new file mode 100755 index 0000000..6663b6b --- /dev/null +++ b/mcptools/unrelate_beads.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# capabilities: beads +# description: Remove a relates-to link between two beads +# Usage: unrelate_beads.sh --mount <mount> --bead1 <id> --bead2 <id> +set -euo pipefail + + +MOUNT="" +BEAD1="" +BEAD2="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --bead1) BEAD1="$2"; shift 2 ;; + --bead2) BEAD2="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD1" ] || [ -z "$BEAD2" ]; then + echo "usage: unrelate_beads.sh --mount <mount> --bead1 <id> --bead2 <id>" >&2 + exit 1 +fi + +echo "unrelate $BEAD1 $BEAD2" | 9p write beads/$MOUNT/ctl +echo "unrelated $BEAD1 ↔ $BEAD2" diff --git a/mcptools/update_bead.sh b/mcptools/update_bead.sh new file mode 100755 index 0000000..eb95b96 --- /dev/null +++ b/mcptools/update_bead.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# capabilities: beads +# description: Update a bead field directly +# Usage: update_bead.sh --mount <mount> --id <bead-id> --field <field> --value <value> +set -euo pipefail + + +MOUNT="" +BEAD_ID="" +FIELD="" +VALUE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + --id) BEAD_ID="$2"; shift 2 ;; + --field) FIELD="$2"; shift 2 ;; + --value) VALUE="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$FIELD" ]; then + echo "usage: update_bead.sh --mount <mount> --id <bead-id> --field <field> --value <value>" >&2 + exit 1 +fi + +if [ "$FIELD" = "parent" ]; then + # Reparent: create new bead under parent with same data, then delete original + JSON=$(9p read "beads/$MOUNT/$BEAD_ID/json") + TITLE=$(echo "$JSON" | jq -r '.title') + DESC=$(echo "$JSON" | jq -r '.description // ""') + STATUS=$(echo "$JSON" | jq -r '.status') + + # Create under new parent + printf "new '%s' '%s' %s\n" "$TITLE" "$DESC" "$VALUE" | 9p write beads/$MOUNT/ctl + + # Delete original + printf "delete %s\n" "$BEAD_ID" | 9p write beads/$MOUNT/ctl + + echo "reparented $BEAD_ID under $VALUE" +else + printf "update %s %s '%s'\n" "$BEAD_ID" "$FIELD" "$VALUE" | 9p write beads/$MOUNT/ctl + echo "updated $BEAD_ID.$FIELD: $VALUE" +fi diff --git a/mcptools/view_denote.sh b/mcptools/view_denote.sh new file mode 100755 index 0000000..46d69f8 --- /dev/null +++ b/mcptools/view_denote.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# capabilities: agent-kb +# description: View a denote document by identifier +# Usage: view_denote.sh --id <denote:identifier|identifier> +set -euo pipefail + + +ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$ID" ]; then + echo "usage: view_denote.sh --id <denote:identifier|identifier>" >&2 + exit 1 +fi + +ID="${ID#denote:}" +FILE=$(find "$HOME/doc" -type f -name "${ID}--*" | head -1) + +if [ -z "$FILE" ]; then + echo "Error: no denote document found for identifier $ID" >&2 + exit 1 +fi + +cat "$FILE" diff --git a/mcptools/wait_for_bead.sh b/mcptools/wait_for_bead.sh new file mode 100755 index 0000000..41ca997 --- /dev/null +++ b/mcptools/wait_for_bead.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# capabilities: beads +# description: Block until a bead is ready on the given mount, then print its full JSON (including comments) and exit. Uses the anvillm/events stream — no polling. Filters by scope derived from cwd. +# Usage: wait_for_bead.sh --mount <mount> +set -euo pipefail + +MOUNT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mount) MOUNT="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$MOUNT" ]; then + echo "usage: wait_for_bead.sh --mount <mount>" >&2 + exit 1 +fi + +# Derive scope from cwd relative to mount's cwd +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) +if [ -n "$MOUNT_CWD" ]; then + REL_PATH="${PWD#"$MOUNT_CWD"}" + REL_PATH="${REL_PATH#/}" + MY_SCOPE="${REL_PATH%%/*}" +else + MY_SCOPE="" +fi + +EXPECTED_SOURCE="beads/$MOUNT" + +exec 3< <(9p read anvillm/events 2>/dev/null) +EVENTS_PID=$! +trap 'kill $EVENTS_PID 2>/dev/null; exec 3<&-' EXIT + +while IFS= read -r line <&3; do + type=$(echo "$line" | jq -r '.type // empty' 2>/dev/null) + [ "$type" = "BeadReady" ] || continue + + source=$(echo "$line" | jq -r '.source // empty' 2>/dev/null) + [ "$source" = "$EXPECTED_SOURCE" ] || continue + + # Strict scope match: both must be equal (empty or same value) + bead_scope=$(echo "$line" | jq -r '.data.scope // empty' 2>/dev/null) + [ "$bead_scope" = "$MY_SCOPE" ] || continue + + # Emit full bead data and exit — bot decides whether to claim + echo "$line" | jq '.data' + exit 0 +done diff --git a/metrics.go b/metrics.go new file mode 100644 index 0000000..629b5cf --- /dev/null +++ b/metrics.go @@ -0,0 +1,129 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +type ExecutionLog struct { + Timestamp time.Time `json:"timestamp"` + CodeHash string `json:"code_hash"` + Language string `json:"language"` + Duration time.Duration `json:"duration"` + Success bool `json:"success"` + OutputSize int `json:"output_size"` + Error string `json:"error,omitempty"` +} + +type TokenLog struct { + Timestamp time.Time `json:"timestamp"` + Method string `json:"method"` + DirectTokens int `json:"direct_tokens"` + CodeExecTokens int `json:"code_exec_tokens"` + Reduction float64 `json:"reduction_percent"` +} + +type SecurityEvent struct { + Timestamp time.Time `json:"timestamp"` + EventType string `json:"event_type"` + Language string `json:"language"` + Details string `json:"details"` +} + +func hashCode(code string) string { + h := sha256.Sum256([]byte(code)) + return hex.EncodeToString(h[:]) +} + +func logExecution(log ExecutionLog) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + logDir := filepath.Join(home, ".local", "state", "anvilmcp") + if err := os.MkdirAll(logDir, 0755); err != nil { + return err + } + + logFile := filepath.Join(logDir, "executions.jsonl") + // 0644: world-readable, contains only hashes and metadata (no sensitive data) + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + data, err := json.Marshal(log) + if err != nil { + return err + } + + _, err = fmt.Fprintf(f, "%s\n", data) + return err +} + +func logTokens(log TokenLog) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + logDir := filepath.Join(home, ".local", "state", "anvilmcp") + if err := os.MkdirAll(logDir, 0755); err != nil { + return err + } + + logFile := filepath.Join(logDir, "tokens.jsonl") + // 0644: world-readable, contains only aggregate metrics (no sensitive data) + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + data, err := json.Marshal(log) + if err != nil { + return err + } + + _, err = fmt.Fprintf(f, "%s\n", data) + return err +} + +func logSecurityEvent(event SecurityEvent) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + logDir := filepath.Join(home, ".local", "state", "anvilmcp") + if err := os.MkdirAll(logDir, 0755); err != nil { + return err + } + + logFile := filepath.Join(logDir, "security.jsonl") + // 0600: owner-only, contains security events (rate limits, validation failures, timeouts) + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer f.Close() + + data, err := json.Marshal(event) + if err != nil { + return err + } + + _, err = fmt.Fprintf(f, "%s\n", data) + return err +} + +func estimateTokens(text string) int { + return len(text) / 4 +} diff --git a/mkfile b/mkfile new file mode 100644 index 0000000..a45bc0b --- /dev/null +++ b/mkfile @@ -0,0 +1,14 @@ +INSTALL_PATH=$HOME/bin + +all:V: install + +build:V: + go build -o $INSTALL_PATH/anvilmcp . + mkdir -p $HOME/.config/anvillm/mcptools + cp -rf mcptools/* $HOME/.config/anvillm/mcptools/ + chmod 0755 $HOME/.config/anvillm/mcptools/* + +install:V: build + +clean:V: + rm -f $INSTALL_PATH/anvilmcp