Files
homeagent-sdk/example/files/plugin.go
JianFeeeee b6a6168e65 chore(module)!: module 路径随托管地迁移 gitcode → github
SDK 仓已迁到 GitHub 作为权威源,go module 路径同步:
`gitcode.com/JianFeeeee/homeagent-sdk` → `github.com/JianFeeeee/homeagentsdk`

## 范围

74 个文件:71 个 Go 源码 + go.mod/go.sum + hmapdev 模板 + CI 与打包脚本。

主仓(HomeAgent)侧对应改动是 6d38a164。本仓这些文件当时**不在工作树上**
(见下),所以那次的批量替换扫不到它们——这里是补偿。

## 为何这批文件当时不在工作树上(事故与恢复)

10-08 07:58 主仓做 checkout/cherry-pick 序列时,把本仓工作树的 20 个文件
误清理了(`example/*/plugin.go` + `plg.json`,10 个示例)。

根因是**结构误判**:本仓是「一目录两仓库」结构下主仓 `third_party/` 里的
**独立 git 仓库**,而主仓 .gitignore 忽略了 `example/`,于是它在主仓
`git status` 里不可见 —— 看着像「不受跟踪的构建产物」,实际是本仓受跟踪的源码。

已全部恢复(`git checkout -- <D 文件>`,只恢复删除、不动迁移改动),
20 个文件与 HEAD 逐字节一致。

**破坏的连锁后果**:恢复出来的 `plugin.go` 仍写着旧 module 路径,
因为迁移时它们已不在工作树上。所以这次补偿是必须的,不是可选优化。

## 保留 gitcode 引用

以下位置的 `gitcode` **有意保留**,不要"顺手清掉":
- README.md / docs 里「二进制实际分发地址目前是 gitcode」的说明
- mkdocs.yml 的 gitcode 镜像链接(国内直连更快)
- `tools/hmapdev/templates.go` 里「SDK 已从 gitcode 迁到 GitHub」的注释

gitcode 仍是**只读镜像**(源码同步;release 附件暂时仍在那边)。

## 验证

- 本仓根 module:`go build ./...` + `go test ./...` 全通过
- 全部 20 个 example:`go vet` 通过(补偿前 10 个因旧 module 路径失败)
- example/qq:`go test` 通过,且能从源码 build 出可用 plugin 产物
2026-10-09 07:25:25 +08:00

558 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
filesDir string
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "dir",
Default: "",
Type: "string",
DisplayName: "文件系统根目录",
Description: "文件操作允许访问的根目录;留空时使用默认沙箱目录(主数据目录/files_sandbox),不建议设为 /",
Category: "files",
})
dir := getSetting[string](s.Settings(), "dir", "")
if strings.HasPrefix(dir, "~/") {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, dir[2:])
}
if dir == "" {
dataDir, err := s.Settings().GetCore("core.daemon.data_dir")
base := "."
if err == nil {
if ds, ok := dataDir.(string); ok && ds != "" {
base = ds
}
}
dir = filepath.Join(base, "files_sandbox")
}
abs, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("resolve files.dir: %w", err)
}
if err := os.MkdirAll(abs, 0755); err != nil {
return fmt.Errorf("mkdir files.dir: %w", err)
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
p.filesDir = abs
tp := p.name + "_"
s.RegisterTool(tp+"read", sdk.ToolDef{
Name: tp + "read",
Description: fmt.Sprintf("Read file contents within the sandbox directory (%s). Supports offset/limit for large files.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
"offset": map[string]interface{}{"type": "integer", "description": "Starting line number (1-indexed, optional)"},
"limit": map[string]interface{}{"type": "integer", "description": "Max lines to return (optional)"},
},
"required": []string{"path"},
},
NoMemory: false,
Cleaner: func(output string) string {
var r struct{ Content string }
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
return r.Content
}
return output
},
}, p.handleRead)
s.RegisterTool(tp+"write", sdk.ToolDef{
Name: tp + "write",
Description: fmt.Sprintf("Write content to a file. Creates parent directories automatically. Sandbox: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path"},
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
"mode": map[string]interface{}{"type": "string", "description": "Write mode: overwrite (default) | append | insert | create"},
"line": map[string]interface{}{"type": "integer", "description": "Line number for insert mode (1-indexed)"},
},
"required": []string{"path", "content"},
},
}, p.handleWrite)
s.RegisterTool(tp+"edit", sdk.ToolDef{
Name: tp + "edit",
Description: fmt.Sprintf("Apply exact string replacements to a file within the sandbox (%s). All edits are matched against the original file content.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
"edits": map[string]interface{}{
"type": "array",
"description": "One or more targeted replacements. Each old must match exactly once in the original file. Do not include overlapping edits.",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"old": map[string]interface{}{"type": "string", "description": "Exact text to find (must be unique)"},
"new": map[string]interface{}{"type": "string", "description": "Replacement text"},
},
"required": []string{"old", "new"},
},
},
},
"required": []string{"path", "edits"},
},
}, p.handleEdit)
s.RegisterTool(tp+"ls", sdk.ToolDef{
Name: tp + "ls",
Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory path (optional, defaults to sandbox root)"},
"limit": map[string]interface{}{"type": "integer", "description": "Max entries (optional, default 500)"},
},
},
}, p.handleLs)
log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir)
return nil
}
func (p *Plugin) Stop() error {
log.Printf("[%s] stopped", p.name)
return nil
}
// resolvePath resolves user-provided path to an absolute path within filesDir.
func (p *Plugin) resolvePath(userPath string) (string, error) {
if userPath == "" {
userPath = "."
}
if !filepath.IsAbs(userPath) {
userPath = filepath.Join(p.filesDir, userPath)
}
abs, err := filepath.Abs(userPath)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
base := filepath.Clean(p.filesDir)
if !withinSandbox(base, abs) {
return "", fmt.Errorf("path outside sandbox: %s", userPath)
}
real, err := evalReal(base, abs)
if err != nil {
return "", err
}
if !withinSandbox(base, real) {
return "", fmt.Errorf("path escapes sandbox via symlink: %s", userPath)
}
return real, nil
}
func withinSandbox(base, abs string) bool {
if base == "/" {
return true
}
return abs == base || strings.HasPrefix(abs, base+string(filepath.Separator))
}
func evalReal(base, abs string) (string, error) {
existing := abs
var tail []string
for {
real, err := filepath.EvalSymlinks(existing)
if err == nil {
full := real
for i := len(tail) - 1; i >= 0; i-- {
full = filepath.Join(full, tail[i])
}
return full, nil
}
if !os.IsNotExist(err) {
return "", fmt.Errorf("resolve path: %w", err)
}
if link, lerr := os.Readlink(existing); lerr == nil {
target := link
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(existing), target)
}
if t, aerr := filepath.Abs(target); aerr == nil {
target = filepath.Clean(t)
}
if !withinSandbox(base, target) {
return "", fmt.Errorf("path escapes sandbox via symlink: %s", abs)
}
}
parent := filepath.Dir(existing)
if parent == existing {
return "", fmt.Errorf("resolve path: %w", err)
}
tail = append(tail, filepath.Base(existing))
existing = parent
}
}
// handleRead implements the read tool.
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if info.IsDir() {
return errorResult("is a directory, use ls instead: " + path), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
return errorResult("read error: " + err.Error()), nil
}
text := string(data)
lines := strings.Split(text, "\n")
totalLines := len(lines)
offset := 0
if v, ok := args["offset"].(float64); ok && v > 0 {
offset = int(v) - 1
}
if offset >= totalLines {
return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil
}
limit := totalLines - offset
if v, ok := args["limit"].(float64); ok && v > 0 {
if int(v) < limit {
limit = int(v)
}
}
end := offset + limit
if end > totalLines {
end = totalLines
}
selected := lines[offset:end]
output := strings.Join(selected, "\n")
truncated := false
if limit < totalLines-offset {
truncated = true
}
var sb strings.Builder
sb.WriteString(output)
if truncated {
nextOffset := end + 1
sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset))
} else if offset > 0 || end < totalLines {
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
}
return map[string]interface{}{
"content": sb.String(),
}, nil
}
// handleWrite implements the write tool.
func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
content, _ := args["content"].(string)
mode, _ := args["mode"].(string)
if mode == "" {
mode = "overwrite"
}
line := 0
if v, ok := args["line"].(float64); ok && v > 0 {
line = int(v)
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
switch mode {
case "create":
if _, err := os.Stat(absPath); err == nil {
return errorResult("file already exists: " + path), nil
}
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)),
}, nil
case "append":
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return errorResult("open error: " + err.Error()), nil
}
defer f.Close()
if _, err := f.WriteString(content); err != nil {
return errorResult("append error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Appended %d bytes to %s", len(content), path),
}, nil
case "insert":
if line < 1 {
return errorResult("line must be >= 1 for insert mode"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
lines := strings.Split(string(data), "\n")
if line > len(lines)+1 {
return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil
}
idx := line - 1
newLines := make([]string, 0, len(lines)+1)
newLines = append(newLines, lines[:idx]...)
newLines = append(newLines, content)
newLines = append(newLines, lines[idx:]...)
result := strings.Join(newLines, "\n")
if err := os.WriteFile(absPath, []byte(result), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path),
}, nil
default: // overwrite
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path),
}, nil
}
}
// handleEdit implements the edit tool.
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
rawEdits, ok := args["edits"].([]interface{})
if !ok || len(rawEdits) == 0 {
return errorResult("edits must be a non-empty array"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
original := string(data)
content := original
applied := 0
var errors []string
for i, raw := range rawEdits {
edit, ok := raw.(map[string]interface{})
if !ok {
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
continue
}
oldText, _ := edit["old"].(string)
newText, _ := edit["new"].(string)
if oldText == "" {
errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i))
continue
}
count := strings.Count(content, oldText)
if count == 0 {
errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path))
continue
}
if count > 1 {
errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText))
continue
}
content = strings.Replace(content, oldText, newText, 1)
applied++
}
if applied == 0 {
msg := "no edits applied"
if len(errors) > 0 {
msg += ": " + strings.Join(errors, "; ")
}
return errorResult(msg), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path)
if len(errors) > 0 {
msg += "\nWarnings:\n" + strings.Join(errors, "\n")
}
return map[string]interface{}{
"content": msg,
}, nil
}
// handleLs implements the ls tool.
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
path = "."
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("path not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if !info.IsDir() {
return errorResult("not a directory: " + path), nil
}
entries, err := os.ReadDir(absPath)
if err != nil {
return errorResult("readdir error: " + err.Error()), nil
}
limit := 500
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
sort.Slice(entries, func(i, j int) bool {
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
})
var lines []string
entryLimitReached := false
for i, entry := range entries {
if i >= limit {
entryLimitReached = true
break
}
name := entry.Name()
if entry.IsDir() {
name += "/"
}
lines = append(lines, name)
}
if len(lines) == 0 {
return map[string]interface{}{
"content": "(empty directory)",
}, nil
}
output := strings.Join(lines, "\n")
if entryLimitReached {
output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit)
}
return map[string]interface{}{
"content": output,
}, nil
}
// errorResult returns a standardized error result.
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
// getSetting reads a setting with generic type assertion.
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return def
}
val, ok := v.(T)
if !ok {
return def
}
return val
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}