package store import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" ) // Tool — one act in the allowlist. Cmd is the fixed argv prefix run with the // utterance's args appended (no shell). Status 'proposed' is a scaffold that // drives nothing; 'enabled' is the human-flipped, runnable form. type Tool struct { Name string Cmd []string Destructive bool Status string // proposed | enabled Utterance string // provenance: the utterance that scaffolded a proposal CreatedTs time.Time UpdatedTs time.Time } var ( // ErrToolNotFound — no tool row with this name. ErrToolNotFound = errors.New("store: tool not found") // ErrToolCmd — an enable supplied an empty argv (an enabled tool must run something). ErrToolCmd = errors.New("store: enabled tool needs a non-empty cmd") ) // ProposeTool inserts a 'proposed' scaffold for name (provenance = utterance) // if no row for name exists yet. Returns true when a new proposal was written, // false when a row (proposed or enabled) already existed. maven calls this when // she classifies an act whose verb isn't on the enabled allowlist — she drafts // the registration; a human enables it. Never overwrites an enabled tool. func (s *Store) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) { res, err := s.db.ExecContext(ctx, ` INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts) VALUES (?, '[]', 0, 'proposed', ?, ?, ?) ON CONFLICT(name) DO NOTHING`, name, utterance, ts.UnixMilli(), ts.UnixMilli()) if err != nil { return false, fmt.Errorf("propose tool: %w", err) } n, _ := res.RowsAffected() return n > 0, nil } // EnableTool fills cmd + destructive and flips status to 'enabled'. This is the // human "enable" act (the authed surface calls it); it upserts so enabling a // name that was never proposed still works. An empty cmd is refused — an // enabled tool that runs nothing is a footgun, not a tool. func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error { if len(cmd) == 0 { return ErrToolCmd } raw, err := json.Marshal(cmd) if err != nil { return fmt.Errorf("enable tool: %w", err) } d := 0 if destructive { d = 1 } _, err = s.db.ExecContext(ctx, ` INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts) VALUES (?, ?, ?, 'enabled', '', ?, ?) ON CONFLICT(name) DO UPDATE SET cmd=excluded.cmd, destructive=excluded.destructive, status='enabled', updated_ts=excluded.updated_ts`, name, string(raw), d, ts.UnixMilli(), ts.UnixMilli()) if err != nil { return fmt.Errorf("enable tool: %w", err) } return nil } // LookupTool returns the tool by name. ErrToolNotFound when absent. func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) { row := s.db.QueryRowContext(ctx, ` SELECT name, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools WHERE name = ?`, name) t, err := scanTool(row) if errors.Is(err, sql.ErrNoRows) { return Tool{}, ErrToolNotFound } return t, err } // ListTools returns tools filtered by status ("" ⇒ all), name-sorted. func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) { q := `SELECT name, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools` var args []any if status != "" { q += ` WHERE status = ?` args = append(args, status) } q += ` ORDER BY name ASC` rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("list tools: %w", err) } defer rows.Close() var out []Tool for rows.Next() { t, err := scanTool(rows) if err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // scanner is the shared shape of *sql.Row and *sql.Rows. type scanner interface{ Scan(...any) error } func scanTool(sc scanner) (Tool, error) { var t Tool var cmdJSON string var d int var created, updated int64 if err := sc.Scan(&t.Name, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil { return Tool{}, err } if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil { return Tool{}, fmt.Errorf("scan tool %q cmd: %w", t.Name, err) } t.Destructive = d != 0 t.CreatedTs = time.UnixMilli(created).UTC() t.UpdatedTs = time.UnixMilli(updated).UTC() return t, nil }