Initial commit: standalone anvilmcp server
MCP server for tool execution with sandboxing. Extracted from anvillm/cmd/anvilmcp.
This commit is contained in:
commit
da740133df
Binary file not shown.
|
|
@ -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; <code> ) | ...
|
||||
// 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/<name>.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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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=
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
# capabilities: beads
|
||||
# description: Add a dependency (child depends on parent)
|
||||
# Usage: add_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: add_dependency.sh --mount <mount> --child <child-id> --parent <parent-id>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "dep $CHILD $PARENT" | 9p write beads/$MOUNT/ctl
|
||||
echo "$CHILD depends on $PARENT"
|
||||
|
|
@ -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 <mount> --json <json-array>
|
||||
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 <mount> --json <json-array>" >&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"
|
||||
|
|
@ -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."
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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 <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: claim_bead.sh --mount <mount> --id <bead-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"
|
||||
|
|
@ -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 <<EOF
|
||||
Usage: code_explorer <command> [args]
|
||||
|
||||
Commands:
|
||||
init [dir] Build index for directory (default: \$PWD)
|
||||
preview <pattern> [dir] [ext] Count matches per file (cheap)
|
||||
fetch <pattern> <file> [ctx] Get matches with context (default: 2 lines)
|
||||
symbols <pattern> [dir] Search symbol index
|
||||
tree [dir] [depth] [ext] List files (default depth: 2)
|
||||
cache-dir [dir] Show cache location
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
# capabilities: beads
|
||||
# description: Add a comment to a bead
|
||||
# Usage: comment_bead.sh --mount <mount> --id <bead-id> --text <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 <mount> --id <bead-id> --text <text>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "comment %s '%s'\n" "$BEAD_ID" "$TEXT" | 9p write beads/$MOUNT/ctl
|
||||
echo "commented on $BEAD_ID"
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
#!/bin/bash
|
||||
# capabilities: beads
|
||||
# description: Complete a bead
|
||||
# Usage: complete_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: complete_bead.sh --mount <mount> --id <bead-id>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "complete $BEAD_ID" | 9p write beads/$MOUNT/ctl
|
||||
echo "completed $BEAD_ID"
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
# capabilities: beads
|
||||
# description: Set a beads database configuration key
|
||||
# Usage: config_beads.sh --mount <mount> --key <key> --value <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 <mount> --key <key> --value <value>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "config $KEY $VALUE" | 9p write beads/$MOUNT/ctl
|
||||
echo "config $KEY = $VALUE"
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
#!/bin/bash
|
||||
# capabilities: agents
|
||||
# description: Control a session (stop|restart|kill|refresh)
|
||||
# Usage: control_session.sh --session-id <session-id> --command <stop|restart|kill|refresh>
|
||||
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 <session-id> --command <stop|restart|kill|refresh>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$COMMAND" | 9p write "anvillm/$SESSION_ID/ctl"
|
||||
echo "$COMMAND: $SESSION_ID"
|
||||
|
|
@ -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 <mount> --title <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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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}'
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 "[]"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 "[]"
|
||||
|
|
@ -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]}
|
||||
)
|
||||
'
|
||||
|
|
@ -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
|
||||
|
|
@ -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]}
|
||||
)
|
||||
'
|
||||
|
|
@ -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
|
||||
|
|
@ -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) | .[]'
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 "[]"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 "[]"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
Reference in New Issue