feat: add picoclaw sessions subcommand for session management
Fixes #575: new sessions command with list, show, delete, and clear subcommands. Lists sessions with message count and last modified time. Show displays last messages. Includes 10 tests covering core logic.
This commit is contained in:
parent
5582760cec
commit
b6a71c62c4
3 changed files with 588 additions and 55 deletions
362
cmd/picoclaw/cmd_sessions.go
Normal file
362
cmd/picoclaw/cmd_sessions.go
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionData is a minimal struct for reading session JSON files.
|
||||||
|
// We only need the fields required for display — no dependency on pkg/session.
|
||||||
|
type sessionData struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Messages json.RawMessage `json:"messages"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionMessage is a minimal struct for reading individual messages.
|
||||||
|
type sessionMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionsCmd() {
|
||||||
|
args := os.Args[2:]
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
sessionsHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommand := args[0]
|
||||||
|
|
||||||
|
if subcommand == "--help" || subcommand == "-h" {
|
||||||
|
sessionsHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsDir := getSessionsDir()
|
||||||
|
|
||||||
|
switch subcommand {
|
||||||
|
case "list":
|
||||||
|
sessionsListCmd(sessionsDir)
|
||||||
|
case "show":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Println("Usage: picoclaw sessions show <id>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
sessionsShowCmd(sessionsDir, args[1])
|
||||||
|
case "delete":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Println("Usage: picoclaw sessions delete <id>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
sessionsDeleteCmd(sessionsDir, args[1])
|
||||||
|
case "clear":
|
||||||
|
sessionsClearCmd(sessionsDir)
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown sessions command: %s\n", subcommand)
|
||||||
|
sessionsHelp()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionsHelp() {
|
||||||
|
fmt.Println("Usage: picoclaw sessions <command>")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Commands:")
|
||||||
|
fmt.Println(" list List all sessions")
|
||||||
|
fmt.Println(" show <id> Show session details")
|
||||||
|
fmt.Println(" delete <id> Delete a session")
|
||||||
|
fmt.Println(" clear Delete all sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSessionsDir() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".picoclaw", "workspace", "sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionEntry struct {
|
||||||
|
id string
|
||||||
|
messages int
|
||||||
|
modTime time.Time
|
||||||
|
size int64
|
||||||
|
corrupt bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionsListCmd(sessionsDir string) {
|
||||||
|
entries, err := listSessionEntries(sessionsDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("No sessions found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by modification time, most recent first
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
return entries[i].modTime.After(entries[j].modTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Println("Sessions:")
|
||||||
|
fmt.Printf(" %-30s %8s %s\n", "ID", "Messages", "Last Modified")
|
||||||
|
for _, e := range entries {
|
||||||
|
msgStr := fmt.Sprintf("%d", e.messages)
|
||||||
|
if e.corrupt {
|
||||||
|
msgStr = "(corrupt)"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-30s %8s %s\n", e.id, msgStr, e.modTime.Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%d session(s) found\n", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionsShowCmd(sessionsDir, id string) {
|
||||||
|
filePath := findSessionFile(sessionsDir, id)
|
||||||
|
if filePath == "" {
|
||||||
|
fmt.Printf("Session '%s' not found\n", id)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading session: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading session: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sess sessionData
|
||||||
|
if err := json.Unmarshal(data, &sess); err != nil {
|
||||||
|
fmt.Printf("Session: %s\n", id)
|
||||||
|
fmt.Printf("Size: %s\n", formatSize(info.Size()))
|
||||||
|
fmt.Printf("Last Modified: %s\n", info.ModTime().Format("2006-01-02 15:04"))
|
||||||
|
fmt.Println("Status: corrupt (invalid JSON)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgs []sessionMessage
|
||||||
|
_ = json.Unmarshal(sess.Messages, &msgs)
|
||||||
|
|
||||||
|
fmt.Printf("Session: %s\n", sess.Key)
|
||||||
|
fmt.Printf("Messages: %d\n", len(msgs))
|
||||||
|
fmt.Printf("Last Modified: %s\n", info.ModTime().Format("2006-01-02 15:04"))
|
||||||
|
fmt.Printf("Size: %s\n", formatSize(info.Size()))
|
||||||
|
|
||||||
|
if len(msgs) > 0 {
|
||||||
|
fmt.Println()
|
||||||
|
start := len(msgs) - 3
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
fmt.Println("Last messages:")
|
||||||
|
for _, m := range msgs[start:] {
|
||||||
|
content := strings.TrimSpace(m.Content)
|
||||||
|
content = strings.ReplaceAll(content, "\n", " ")
|
||||||
|
if len(content) > 80 {
|
||||||
|
content = content[:77] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf(" [%s] %s\n", m.Role, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionsDeleteCmd(sessionsDir, id string) {
|
||||||
|
filePath := findSessionFile(sessionsDir, id)
|
||||||
|
if filePath == "" {
|
||||||
|
fmt.Printf("Session '%s' not found\n", id)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Delete session '%s'? (y/n): ", id)
|
||||||
|
if !confirmPrompt() {
|
||||||
|
fmt.Println("Cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
fmt.Printf("Error deleting session: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Deleted session %s\n", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionsClearCmd(sessionsDir string) {
|
||||||
|
entries, err := listSessionEntries(sessionsDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("No sessions found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Delete all %d sessions? (y/n): ", len(entries))
|
||||||
|
if !confirmPrompt() {
|
||||||
|
fmt.Println("Cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
filePath := findSessionFile(sessionsDir, e.id)
|
||||||
|
if filePath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
fmt.Printf("Error deleting session '%s': %v\n", e.id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Cleared %d session(s).\n", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listSessionEntries reads the sessions directory and returns parsed entries.
|
||||||
|
func listSessionEntries(sessionsDir string) ([]sessionEntry, error) {
|
||||||
|
if _, err := os.Stat(sessionsDir); os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("No sessions found (sessions directory does not exist)")
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := os.ReadDir(sessionsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading sessions directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []sessionEntry
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() || filepath.Ext(f.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(sessionsDir, f.Name())
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var sess sessionData
|
||||||
|
if err := json.Unmarshal(data, &sess); err != nil {
|
||||||
|
// Corrupt file — derive ID from filename
|
||||||
|
id := strings.TrimSuffix(f.Name(), ".json")
|
||||||
|
entries = append(entries, sessionEntry{
|
||||||
|
id: id,
|
||||||
|
modTime: info.ModTime(),
|
||||||
|
size: info.Size(),
|
||||||
|
corrupt: true,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the key from the JSON if present, otherwise derive from filename
|
||||||
|
id := sess.Key
|
||||||
|
if id == "" {
|
||||||
|
id = strings.TrimSuffix(f.Name(), ".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgs []sessionMessage
|
||||||
|
_ = json.Unmarshal(sess.Messages, &msgs)
|
||||||
|
|
||||||
|
entries = append(entries, sessionEntry{
|
||||||
|
id: id,
|
||||||
|
messages: len(msgs),
|
||||||
|
modTime: info.ModTime(),
|
||||||
|
size: info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSessionFile locates the session file for a given ID.
|
||||||
|
// It first tries matching by the key inside the JSON, then falls back
|
||||||
|
// to matching by filename (with .json extension).
|
||||||
|
func findSessionFile(sessionsDir string, id string) string {
|
||||||
|
files, err := os.ReadDir(sessionsDir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() || filepath.Ext(f.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(sessionsDir, f.Name())
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var sess sessionData
|
||||||
|
if err := json.Unmarshal(data, &sess); err != nil {
|
||||||
|
// Corrupt file — match by filename
|
||||||
|
name := strings.TrimSuffix(f.Name(), ".json")
|
||||||
|
if name == id {
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.Key == id {
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try direct filename match (id + .json or sanitized id + .json)
|
||||||
|
direct := filepath.Join(sessionsDir, id+".json")
|
||||||
|
if _, err := os.Stat(direct); err == nil {
|
||||||
|
return direct
|
||||||
|
}
|
||||||
|
sanitized := filepath.Join(sessionsDir, strings.ReplaceAll(id, ":", "_")+".json")
|
||||||
|
if _, err := os.Stat(sanitized); err == nil {
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmPrompt() bool {
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
response, _ := reader.ReadString('\n')
|
||||||
|
response = strings.TrimSpace(strings.ToLower(response))
|
||||||
|
return response == "y" || response == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSize(bytes int64) string {
|
||||||
|
const (
|
||||||
|
kb = 1024
|
||||||
|
mb = kb * 1024
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case bytes >= mb:
|
||||||
|
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
|
||||||
|
case bytes >= kb:
|
||||||
|
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
222
cmd/picoclaw/cmd_sessions_test.go
Normal file
222
cmd/picoclaw/cmd_sessions_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListSessionEntries_NoDirectory(t *testing.T) {
|
||||||
|
entries, err := listSessionEntries("/nonexistent/path/sessions")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent directory")
|
||||||
|
}
|
||||||
|
if entries != nil {
|
||||||
|
t.Fatalf("expected nil entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSessionEntries_EmptyDirectory(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
entries, err := listSessionEntries(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("expected 0 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSessionEntries_ValidSessions(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create two valid session files
|
||||||
|
writeTestSession(t, tmpDir, "telegram_123456.json", sessionData{
|
||||||
|
Key: "telegram:123456",
|
||||||
|
Messages: json.RawMessage(`[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"}]`),
|
||||||
|
Created: time.Now().Add(-1 * time.Hour),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
writeTestSession(t, tmpDir, "discord_789.json", sessionData{
|
||||||
|
Key: "discord:789",
|
||||||
|
Messages: json.RawMessage(`[{"role":"user","content":"test"}]`),
|
||||||
|
Created: time.Now().Add(-2 * time.Hour),
|
||||||
|
Updated: time.Now().Add(-30 * time.Minute),
|
||||||
|
})
|
||||||
|
|
||||||
|
entries, err := listSessionEntries(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the telegram entry
|
||||||
|
var telegramEntry *sessionEntry
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].id == "telegram:123456" {
|
||||||
|
telegramEntry = &entries[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if telegramEntry == nil {
|
||||||
|
t.Fatal("telegram:123456 entry not found")
|
||||||
|
}
|
||||||
|
if telegramEntry.messages != 2 {
|
||||||
|
t.Errorf("expected 2 messages, got %d", telegramEntry.messages)
|
||||||
|
}
|
||||||
|
if telegramEntry.corrupt {
|
||||||
|
t.Error("expected non-corrupt session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSessionEntries_CorruptSession(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Write a corrupt JSON file
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "bad_session.json"), []byte("{invalid json"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := listSessionEntries(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if !entries[0].corrupt {
|
||||||
|
t.Error("expected corrupt flag to be set")
|
||||||
|
}
|
||||||
|
if entries[0].id != "bad_session" {
|
||||||
|
t.Errorf("expected id 'bad_session', got %q", entries[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSessionEntries_SkipsNonJSON(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Write a non-JSON file
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("not a session"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a subdirectory
|
||||||
|
err = os.Mkdir(filepath.Join(tmpDir, "subdir"), 0755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := listSessionEntries(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("expected 0 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSessionFile_ByKey(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestSession(t, tmpDir, "telegram_123456.json", sessionData{
|
||||||
|
Key: "telegram:123456",
|
||||||
|
Messages: json.RawMessage(`[]`),
|
||||||
|
})
|
||||||
|
|
||||||
|
path := findSessionFile(tmpDir, "telegram:123456")
|
||||||
|
if path == "" {
|
||||||
|
t.Fatal("expected to find session file by key")
|
||||||
|
}
|
||||||
|
expected := filepath.Join(tmpDir, "telegram_123456.json")
|
||||||
|
if path != expected {
|
||||||
|
t.Errorf("expected path %q, got %q", expected, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSessionFile_BySanitizedName(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Write a session file where the key doesn't match the ID we're looking for,
|
||||||
|
// but the sanitized filename does
|
||||||
|
writeTestSession(t, tmpDir, "cli_default.json", sessionData{
|
||||||
|
Key: "cli:default",
|
||||||
|
Messages: json.RawMessage(`[]`),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should find by key
|
||||||
|
path := findSessionFile(tmpDir, "cli:default")
|
||||||
|
if path == "" {
|
||||||
|
t.Fatal("expected to find session file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSessionFile_NotFound(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
path := findSessionFile(tmpDir, "nonexistent")
|
||||||
|
if path != "" {
|
||||||
|
t.Fatalf("expected empty path for nonexistent session, got %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatSize(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
bytes int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{0, "0 B"},
|
||||||
|
{512, "512 B"},
|
||||||
|
{1024, "1.0 KB"},
|
||||||
|
{2560, "2.5 KB"},
|
||||||
|
{1048576, "1.0 MB"},
|
||||||
|
{1572864, "1.5 MB"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.want, func(t *testing.T) {
|
||||||
|
got := formatSize(tt.bytes)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSessionEntries_FallbackToFilename(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Session with empty key — should fall back to filename
|
||||||
|
writeTestSession(t, tmpDir, "orphan.json", sessionData{
|
||||||
|
Key: "",
|
||||||
|
Messages: json.RawMessage(`[{"role":"user","content":"hi"}]`),
|
||||||
|
})
|
||||||
|
|
||||||
|
entries, err := listSessionEntries(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].id != "orphan" {
|
||||||
|
t.Errorf("expected id 'orphan', got %q", entries[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestSession(t *testing.T, dir, filename string, sess sessionData) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(sess)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal session: %v", err)
|
||||||
|
}
|
||||||
|
err = os.WriteFile(filepath.Join(dir, filename), data, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write session file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,6 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -117,61 +116,10 @@ func main() {
|
||||||
cronCmd()
|
cronCmd()
|
||||||
case "doctor":
|
case "doctor":
|
||||||
doctorCmd()
|
doctorCmd()
|
||||||
|
case "sessions":
|
||||||
|
sessionsCmd()
|
||||||
case "skills":
|
case "skills":
|
||||||
if len(os.Args) < 3 {
|
skillsCmd()
|
||||||
skillsHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subcommand := os.Args[2]
|
|
||||||
|
|
||||||
if subcommand == "--help" || subcommand == "-h" {
|
|
||||||
skillsHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
|
||||||
fmt.Println("Run 'picoclaw doctor' to check for common problems.")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
|
||||||
installer := skills.NewSkillInstaller(workspace)
|
|
||||||
// 获取全局配置目录和内置 skills 目录
|
|
||||||
globalDir := filepath.Dir(getConfigPath())
|
|
||||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
|
||||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
|
||||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
|
|
||||||
|
|
||||||
switch subcommand {
|
|
||||||
case "list":
|
|
||||||
skillsListCmd(skillsLoader)
|
|
||||||
case "install":
|
|
||||||
skillsInstallCmd(installer, cfg)
|
|
||||||
case "remove", "uninstall":
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw skills remove <skill-name>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
skillsRemoveCmd(installer, os.Args[3])
|
|
||||||
case "install-builtin":
|
|
||||||
skillsInstallBuiltinCmd(workspace)
|
|
||||||
case "list-builtin":
|
|
||||||
skillsListBuiltinCmd()
|
|
||||||
case "search":
|
|
||||||
skillsSearchCmd(installer)
|
|
||||||
case "show":
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw skills show <skill-name>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
skillsShowCmd(skillsLoader, os.Args[3])
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown skills command: %s\n", subcommand)
|
|
||||||
skillsHelp()
|
|
||||||
}
|
|
||||||
case "version", "--version", "-v":
|
case "version", "--version", "-v":
|
||||||
printVersion()
|
printVersion()
|
||||||
case "--help", "-h":
|
case "--help", "-h":
|
||||||
|
|
@ -196,6 +144,7 @@ func printHelp() {
|
||||||
fmt.Println(" cron Manage scheduled tasks")
|
fmt.Println(" cron Manage scheduled tasks")
|
||||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
||||||
fmt.Println(" skills Manage skills (install, list, remove)")
|
fmt.Println(" skills Manage skills (install, list, remove)")
|
||||||
|
fmt.Println(" sessions Manage sessions (list, show, delete, clear)")
|
||||||
fmt.Println(" doctor Diagnose and fix common problems (--fix to auto-repair)")
|
fmt.Println(" doctor Diagnose and fix common problems (--fix to auto-repair)")
|
||||||
fmt.Println(" version Show version information")
|
fmt.Println(" version Show version information")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue