package tools import ( "context" "encoding/json" "fmt" "io" "log" "strings" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/workspace" ) // ── Late Registration ──────────────────────── // Workspace tools need the workspace.FS and store for execution. // Registered from main.go after workspace FS init. // RegisterWorkspaceTools registers all workspace tools with captured dependencies. func RegisterWorkspaceTools(stores store.Stores, wfs *workspace.FS) { Register(&workspaceLsTool{stores: stores, wfs: wfs}) Register(&workspaceReadTool{stores: stores, wfs: wfs}) Register(&workspaceWriteTool{stores: stores, wfs: wfs}) Register(&workspaceRmTool{stores: stores, wfs: wfs}) Register(&workspaceMvTool{stores: stores, wfs: wfs}) Register(&workspacePatchTool{stores: stores, wfs: wfs}) } // WorkspaceToolNames returns the names of all workspace tools. // Used by the completion handler to filter them out when no workspace is bound. func WorkspaceToolNames() []string { return []string{ "workspace_ls", "workspace_read", "workspace_write", "workspace_rm", "workspace_mv", "workspace_patch", } } // ── Shared ───────────────────────────────── // loadWorkspace resolves the workspace from the execution context. func loadWorkspace(ctx context.Context, stores store.Stores, execCtx ExecutionContext) (*models.Workspace, error) { if execCtx.WorkspaceID == "" { return nil, fmt.Errorf("no workspace bound to this channel") } w, err := stores.Workspaces.GetByID(ctx, execCtx.WorkspaceID) if err != nil { return nil, fmt.Errorf("workspace not found: %w", err) } return w, nil } const ( // maxReadBytes is the soft limit for workspace_read (50 KB). maxReadBytes = 50 * 1024 // maxPatchFileBytes is the limit for files that can be patched (1 MB). maxPatchFileBytes = 1024 * 1024 ) // ═══════════════════════════════════════════ // workspace_ls // ═══════════════════════════════════════════ type workspaceLsTool struct { stores store.Stores wfs *workspace.FS } func (t *workspaceLsTool) Definition() ToolDef { return ToolDef{ Name: "workspace_ls", DisplayName: "List Files", Description: "List files and directories in the workspace. Returns name, type, size, and content type for each entry. Use with path=\"\" or path=\".\" to list root.", Category: "workspace", Parameters: JSONSchema(map[string]interface{}{ "path": Prop("string", "Directory path to list (empty or \".\" for root)"), "recursive": Prop("boolean", "If true, list all files recursively. Default false."), }, nil), } } func (t *workspaceLsTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Path string `json:"path"` Recursive bool `json:"recursive"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } w, err := loadWorkspace(ctx, t.stores, execCtx) if err != nil { return "", err } entries, err := t.wfs.ListDir(ctx, w, args.Path, args.Recursive) if err != nil { return "", fmt.Errorf("list failed: %w", err) } type entry struct { Path string `json:"path"` IsDir bool `json:"is_directory"` ContentType string `json:"content_type,omitempty"` SizeBytes int64 `json:"size_bytes,omitempty"` } result := make([]entry, 0, len(entries)) for _, e := range entries { result = append(result, entry{ Path: e.Path, IsDir: e.IsDirectory, ContentType: e.ContentType, SizeBytes: e.SizeBytes, }) } b, _ := json.Marshal(map[string]interface{}{ "path": args.Path, "entries": result, "count": len(result), }) return string(b), nil } // ═══════════════════════════════════════════ // workspace_read // ═══════════════════════════════════════════ type workspaceReadTool struct { stores store.Stores wfs *workspace.FS } func (t *workspaceReadTool) Definition() ToolDef { return ToolDef{ Name: "workspace_read", DisplayName: "Read File", Description: "Read the contents of a file in the workspace. Text files are returned as content; binary files return a summary. Large files are truncated at ~50KB with an indicator.", Category: "workspace", Parameters: JSONSchema(map[string]interface{}{ "path": Prop("string", "File path relative to workspace root"), }, []string{"path"}), } } func (t *workspaceReadTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Path string `json:"path"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Path == "" { return "", fmt.Errorf("path is required") } w, err := loadWorkspace(ctx, t.stores, execCtx) if err != nil { return "", err } // Stat first for content type and directory check info, err := t.wfs.Stat(ctx, w, args.Path) if err != nil { return "", fmt.Errorf("file not found: %w", err) } if info.IsDirectory { return "", fmt.Errorf("cannot read a directory; use workspace_ls instead") } // Binary detection if !isTextContentType(info.ContentType) { b, _ := json.Marshal(map[string]interface{}{ "path": args.Path, "content_type": info.ContentType, "size_bytes": info.SizeBytes, "binary": true, "message": fmt.Sprintf("Binary file (%s), %d bytes", info.ContentType, info.SizeBytes), }) return string(b), nil } // Read content r, size, err := t.wfs.ReadFile(ctx, w, args.Path) if err != nil { return "", fmt.Errorf("read failed: %w", err) } defer r.Close() // Read with truncation guard buf := make([]byte, maxReadBytes+1) n, readErr := io.ReadFull(r, buf) if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF { return "", fmt.Errorf("read error: %w", readErr) } truncated := n > maxReadBytes if truncated { n = maxReadBytes } resp := map[string]interface{}{ "path": args.Path, "content": string(buf[:n]), "content_type": info.ContentType, "size_bytes": size, } if truncated { resp["truncated"] = true resp["message"] = fmt.Sprintf("File truncated at %d bytes (total: %d bytes)", maxReadBytes, size) } b, _ := json.Marshal(resp) return string(b), nil } // isTextContentType returns true for text/* and known source code types. func isTextContentType(ct string) bool { if strings.HasPrefix(ct, "text/") { return true } switch ct { case "application/json", "application/xml", "application/javascript", "application/x-yaml", "application/toml", "application/x-sh", "application/sql", "application/graphql": return true } return false } // ═══════════════════════════════════════════ // workspace_write // ═══════════════════════════════════════════ type workspaceWriteTool struct { stores store.Stores wfs *workspace.FS } func (t *workspaceWriteTool) Definition() ToolDef { return ToolDef{ Name: "workspace_write", DisplayName: "Write File", Description: "Create or overwrite a file in the workspace. Parent directories are created automatically. Returns the path and size of the written file.", Category: "workspace", Parameters: JSONSchema(map[string]interface{}{ "path": Prop("string", "File path relative to workspace root"), "content": Prop("string", "File content to write"), }, []string{"path", "content"}), } } func (t *workspaceWriteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Path string `json:"path"` Content string `json:"content"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Path == "" { return "", fmt.Errorf("path is required") } w, err := loadWorkspace(ctx, t.stores, execCtx) if err != nil { return "", err } if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(args.Content), int64(len(args.Content))); err != nil { return "", fmt.Errorf("write failed: %w", err) } size := len(args.Content) log.Printf("📝 workspace_write: %s (%d bytes)", args.Path, size) b, _ := json.Marshal(map[string]interface{}{ "path": args.Path, "size_bytes": size, "message": fmt.Sprintf("Written %d bytes to %s", size, args.Path), }) return string(b), nil } // ═══════════════════════════════════════════ // workspace_rm // ═══════════════════════════════════════════ type workspaceRmTool struct { stores store.Stores wfs *workspace.FS } func (t *workspaceRmTool) Definition() ToolDef { return ToolDef{ Name: "workspace_rm", DisplayName: "Delete File", Description: "Delete a file or directory from the workspace. For non-empty directories, set recursive=true. This cannot be undone.", Category: "workspace", Parameters: JSONSchema(map[string]interface{}{ "path": Prop("string", "File or directory path to delete"), "recursive": Prop("boolean", "Required for non-empty directories. Default false."), }, []string{"path"}), } } func (t *workspaceRmTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Path string `json:"path"` Recursive bool `json:"recursive"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Path == "" { return "", fmt.Errorf("path is required") } w, err := loadWorkspace(ctx, t.stores, execCtx) if err != nil { return "", err } if err := t.wfs.DeleteFile(ctx, w, args.Path, args.Recursive); err != nil { return "", fmt.Errorf("delete failed: %w", err) } log.Printf("🗑️ workspace_rm: %s (recursive=%v)", args.Path, args.Recursive) b, _ := json.Marshal(map[string]interface{}{ "path": args.Path, "message": fmt.Sprintf("Deleted %s", args.Path), }) return string(b), nil } // ═══════════════════════════════════════════ // workspace_mv // ═══════════════════════════════════════════ type workspaceMvTool struct { stores store.Stores wfs *workspace.FS } func (t *workspaceMvTool) Definition() ToolDef { return ToolDef{ Name: "workspace_mv", DisplayName: "Move/Rename File", Description: "Move or rename a file or directory within the workspace. Destination parent directories are created automatically.", Category: "workspace", Parameters: JSONSchema(map[string]interface{}{ "source": Prop("string", "Current file path"), "destination": Prop("string", "New file path"), }, []string{"source", "destination"}), } } func (t *workspaceMvTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Source string `json:"source"` Destination string `json:"destination"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Source == "" || args.Destination == "" { return "", fmt.Errorf("source and destination are required") } w, err := loadWorkspace(ctx, t.stores, execCtx) if err != nil { return "", err } if err := t.wfs.MoveFile(ctx, w, args.Source, args.Destination); err != nil { return "", fmt.Errorf("move failed: %w", err) } log.Printf("📦 workspace_mv: %s → %s", args.Source, args.Destination) b, _ := json.Marshal(map[string]interface{}{ "source": args.Source, "destination": args.Destination, "message": fmt.Sprintf("Moved %s → %s", args.Source, args.Destination), }) return string(b), nil } // ═══════════════════════════════════════════ // workspace_patch // ═══════════════════════════════════════════ type workspacePatchTool struct { stores store.Stores wfs *workspace.FS } func (t *workspacePatchTool) Definition() ToolDef { return ToolDef{ Name: "workspace_patch", DisplayName: "Patch File", Description: "Apply find-and-replace operations to a text file. Each operation's 'find' string must match exactly once in the file. Operations are applied sequentially. Use empty 'replace' to delete matched text.", Category: "workspace", Parameters: JSONSchema(map[string]interface{}{ "path": Prop("string", "File path to patch"), "operations": map[string]interface{}{ "type": "array", "description": "List of find/replace operations to apply sequentially", "items": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "find": Prop("string", "Exact string to find (must match exactly once)"), "replace": Prop("string", "String to replace with (empty to delete)"), }, "required": []string{"find", "replace"}, }, }, }, []string{"path", "operations"}), } } func (t *workspacePatchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Path string `json:"path"` Operations []struct { Find string `json:"find"` Replace string `json:"replace"` } `json:"operations"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Path == "" { return "", fmt.Errorf("path is required") } if len(args.Operations) == 0 { return "", fmt.Errorf("at least one operation is required") } w, err := loadWorkspace(ctx, t.stores, execCtx) if err != nil { return "", err } // Read current content r, size, err := t.wfs.ReadFile(ctx, w, args.Path) if err != nil { return "", fmt.Errorf("read failed: %w", err) } defer r.Close() if size > maxPatchFileBytes { return "", fmt.Errorf("file too large for patching (%d bytes, max %d)", size, maxPatchFileBytes) } data, err := io.ReadAll(r) if err != nil { return "", fmt.Errorf("read error: %w", err) } content := string(data) // Apply operations sequentially applied := make([]string, 0, len(args.Operations)) for i, op := range args.Operations { if op.Find == "" { return "", fmt.Errorf("operation %d: find string is empty", i+1) } count := strings.Count(content, op.Find) if count == 0 { return "", fmt.Errorf("operation %d: find string not found in file", i+1) } if count > 1 { return "", fmt.Errorf("operation %d: find string matches %d times (must match exactly once)", i+1, count) } content = strings.Replace(content, op.Find, op.Replace, 1) applied = append(applied, fmt.Sprintf("op %d: replaced %d chars → %d chars", i+1, len(op.Find), len(op.Replace))) } // Write back if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(content), int64(len(content))); err != nil { return "", fmt.Errorf("write failed: %w", err) } log.Printf("🔧 workspace_patch: %s (%d ops, %d bytes)", args.Path, len(args.Operations), len(content)) b, _ := json.Marshal(map[string]interface{}{ "path": args.Path, "operations": applied, "size_bytes": len(content), "message": fmt.Sprintf("Applied %d patch(es) to %s", len(args.Operations), args.Path), }) return string(b), nil }