From f9b5d5cdce1b25320520837336a7b4e7fcbf7e00 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sat, 20 Jun 2026 14:14:20 +0000 Subject: [PATCH] feat(setup): add Trae integration Add Trae as a first-class setup target with native .trae skill and hook projection. The setup flow now detects Trae, writes Mnemon skill and prompt assets, registers SessionStart/UserPromptSubmit/Stop hooks in .trae/hooks.json, and supports targeted eject without removing unrelated hook entries. Document the new target in English and Chinese usage docs. Validation: go test ./internal/setup, go test ./..., go build -o mnemon ., and a temporary-directory install check for mnemon setup --target trae --yes. --- README.md | 10 ++ cmd/setup.go | 101 +++++++++++++- docs/USAGE.md | 5 +- docs/zh/README.md | 10 ++ docs/zh/USAGE.md | 4 +- internal/setup/assets/assets.go | 14 +- internal/setup/assets/trae/SKILL.md | 46 +++++++ internal/setup/assets/trae/prime.sh | 22 ++++ internal/setup/assets/trae/stop.sh | 14 ++ internal/setup/assets/trae/user_prompt.sh | 3 + internal/setup/detect.go | 46 ++++++- internal/setup/trae.go | 154 ++++++++++++++++++++++ internal/setup/trae_test.go | 143 ++++++++++++++++++++ 13 files changed, 560 insertions(+), 12 deletions(-) create mode 100644 internal/setup/assets/trae/SKILL.md create mode 100644 internal/setup/assets/trae/prime.sh create mode 100644 internal/setup/assets/trae/stop.sh create mode 100644 internal/setup/assets/trae/user_prompt.sh create mode 100644 internal/setup/trae.go create mode 100644 internal/setup/trae_test.go diff --git a/README.md b/README.md index 2683f7cc..7834558f 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,16 @@ One command deploys the mnemon skill, prompt files, and Cursor lifecycle hooks to `.cursor/`. The integration primes new agent sessions with Mnemon guidance and memory status, then nudges for durable-memory writeback after responses. +### [Trae](https://www.trae.ai/) + +```bash +mnemon setup --target trae --yes +``` + +One command deploys the mnemon skill, prompt files, and Trae native hooks to +`.trae/`. The integration uses `SessionStart`, `UserPromptSubmit`, and `Stop` +hooks in `.trae/hooks.json`. + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash diff --git a/cmd/setup.go b/cmd/setup.go index 1958c509..a2fd204a 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -22,17 +22,18 @@ var setupCmd = &cobra.Command{ Short: "Deploy mnemon into LLM CLI environments", Long: `Detect installed LLM CLIs and deploy mnemon integration. -By default, installs to project-local config (.claude/, .codex/, .cursor/, .openclaw/, .nanobot/, .pi/). -Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.cursor/, ~/.openclaw/, ~/.nanobot/workspace/, ~/.pi/agent/). +By default, installs to project-local config (.claude/, .codex/, .cursor/, .trae/, .openclaw/, .nanobot/, .pi/). +Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.cursor/, ~/.trae/, ~/.openclaw/, ~/.nanobot/workspace/, ~/.pi/agent/). Hermes Agent uses its native user config at ~/.hermes/. -Supported environments: Claude Code, Codex, Cursor, OpenClaw, Nanobot, Pi, Hermes Agent. +Supported environments: Claude Code, Codex, Cursor, Trae, OpenClaw, Nanobot, Pi, Hermes Agent. Examples: mnemon setup # Interactive: project-local install mnemon setup --global # Interactive: user-wide install mnemon setup --target claude-code # Non-interactive: Claude Code only mnemon setup --target cursor # Non-interactive: Cursor skill only + mnemon setup --target trae # Non-interactive: Trae skill and hooks mnemon setup --target hermes # Non-interactive: Hermes Agent only mnemon setup --eject # Interactive: remove integrations mnemon setup --eject --target claude-code # Non-interactive: remove Claude Code only @@ -41,7 +42,7 @@ Examples: } func init() { - setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, cursor, openclaw, nanobot, pi, hermes)") + setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, cursor, trae, openclaw, nanobot, pi, hermes)") setupCmd.Flags().BoolVar(&setupEject, "eject", false, "remove mnemon integrations") setupCmd.Flags().BoolVar(&setupYes, "yes", false, "auto-confirm all prompts (CI-friendly)") setupCmd.Flags().BoolVar(&setupGlobal, "global", false, "install to user-wide config instead of project-local config") @@ -49,8 +50,8 @@ func init() { } func runSetup(cmd *cobra.Command, args []string) error { - if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "cursor" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" && setupTarget != "hermes" { - return fmt.Errorf("invalid target %q (must be claude-code, codex, cursor, openclaw, nanobot, pi, or hermes)", setupTarget) + if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "cursor" && setupTarget != "trae" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" && setupTarget != "hermes" { + return fmt.Errorf("invalid target %q (must be claude-code, codex, cursor, trae, openclaw, nanobot, pi, or hermes)", setupTarget) } envs := setup.DetectEnvironments(setupGlobal) @@ -86,7 +87,7 @@ func runInstallFlow(envs []setup.Environment) error { if len(detected) == 0 { fmt.Println("\nNo supported LLM CLI environments detected.") - fmt.Println("Install Claude Code, Codex, Cursor, OpenClaw, Nanobot, Pi, or Hermes Agent, then run 'mnemon setup' again.") + fmt.Println("Install Claude Code, Codex, Cursor, Trae, OpenClaw, Nanobot, Pi, or Hermes Agent, then run 'mnemon setup' again.") return nil } @@ -132,6 +133,8 @@ func installEnv(env *setup.Environment) error { err = installCodex(env) case "cursor": err = installCursor(env) + case "trae": + err = installTrae(env) case "openclaw": err = installOpenClaw(env) case "nanobot": @@ -489,6 +492,83 @@ func selectCursorOptionalHooks() setup.HookSelection { return sel } +// ─── Trae ─────────────────────────────────────────────────────────── + +func installTrae(env *setup.Environment) error { + configDir := env.ConfigDir + + if !setupGlobal && !setupYes && setup.IsInteractive() { + home := setup.HomeDir() + localDir := ".trae" + globalDir := home + "/.trae" + idx := setup.SelectOne("Install scope", + []string{ + fmt.Sprintf("Local — this project only (%s/)", localDir), + fmt.Sprintf("Global — all projects (%s/)", globalDir), + }, 0) + if idx == 1 { + configDir = globalDir + } else { + configDir = localDir + } + } + + fmt.Printf("\nSetting up Trae (%s)...\n", configDir) + + fmt.Println("\n[1/3] Skill") + if path, err := setup.TraeWriteSkill(configDir); err != nil { + setup.StatusError(0, 0, "Skill", err) + return err + } else { + setup.StatusOK(0, 0, "Skill", path) + } + + fmt.Println("\n[2/3] Prompts") + var promptPath string + if path, err := setup.WritePromptFiles(); err != nil { + setup.StatusError(0, 0, "Prompts", err) + return err + } else { + setup.StatusOK(0, 0, "Prompts", path) + promptPath = path + } + + fmt.Println("\n[3/3] Hooks") + for _, hook := range []struct { + label string + filename string + content []byte + }{ + {"Hook: prime", "prime.sh", assets.TraePrimeHook}, + {"Hook: remind", "user_prompt.sh", assets.TraeUserPromptHook}, + {"Hook: nudge", "stop.sh", assets.TraeStopHook}, + } { + if path, err := setup.TraeWriteHook(configDir, hook.filename, hook.content); err != nil { + setup.StatusError(0, 0, hook.label, err) + return err + } else { + setup.StatusOK(0, 0, hook.label, path) + } + } + if path, err := setup.TraeRegisterHooks(configDir); err != nil { + setup.StatusError(0, 0, "Hooks config", err) + return err + } else { + setup.StatusUpdated(0, 0, "Hooks config", path) + } + + fmt.Println() + fmt.Println("Setup complete!") + fmt.Printf(" Skill %s/skills/mnemon/SKILL.md\n", configDir) + fmt.Printf(" Hooks %s/hooks.json (SessionStart, UserPromptSubmit, Stop)\n", configDir) + fmt.Printf(" Prompts %s/ (guide.md, skill.md)\n", promptPath) + fmt.Println() + fmt.Println("Restart Trae to activate the mnemon skill and hooks.") + fmt.Println("Run 'mnemon setup --eject --target trae' to remove.") + + return nil +} + // ─── OpenClaw ─────────────────────────────────────────────────────── func installOpenClaw(env *setup.Environment) error { @@ -923,6 +1003,13 @@ func ejectEnv(env *setup.Environment) error { return errs[0] } + case "trae": + errs := setup.TraeEject(env.ConfigDir) + ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") + if len(errs) > 0 { + return errs[0] + } + case "openclaw": errs := setup.OpenClawEject(env.ConfigDir) ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") diff --git a/docs/USAGE.md b/docs/USAGE.md index 120ea016..9f62741c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -29,6 +29,9 @@ mnemon setup --global # Non-interactive: specific target only mnemon setup --target claude-code +mnemon setup --target codex +mnemon setup --target cursor +mnemon setup --target trae mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global @@ -45,7 +48,7 @@ mnemon setup --eject --target claude-code | Flag | Default | Description | |---|---|---| | `--global` | `false` | Install to user-wide config instead of project-local (recommended for Nanobot: installs to `~/.nanobot/workspace/`; Pi installs to `~/.pi/agent/`; Hermes installs to `~/.hermes/`) | -| `--target ` | (auto-detect) | Target environment: `claude-code`, `codex`, `openclaw`, `nanobot`, `pi`, or `hermes` | +| `--target ` | (auto-detect) | Target environment: `claude-code`, `codex`, `cursor`, `trae`, `openclaw`, `nanobot`, `pi`, or `hermes` | | `--eject` | `false` | Remove mnemon integrations | | `--yes` | `false` | Auto-confirm all prompts | diff --git a/docs/zh/README.md b/docs/zh/README.md index 3badcc8a..26f93b87 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -92,6 +92,16 @@ mnemon setup `mnemon setup` 自动检测 Claude Code,交互式部署技能文件、钩子和行为引导。启动新会话 — 记忆自动运作。 +### [Trae](https://www.trae.ai/) + +```bash +mnemon setup --target trae --yes +``` + +一条命令将 mnemon skill、prompt 文件和 Trae 原生 hooks 部署到 `.trae/`。 +该集成使用 `.trae/hooks.json` 中的 `SessionStart`、`UserPromptSubmit` 和 +`Stop` hooks。 + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 621dcc91..d5ac2f4e 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -30,6 +30,8 @@ mnemon setup --global # 非交互式:仅指定目标 mnemon setup --target claude-code mnemon setup --target codex +mnemon setup --target cursor +mnemon setup --target trae mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global @@ -46,7 +48,7 @@ mnemon setup --eject --target claude-code | 标志 | 默认值 | 说明 | |---|---|---| | `--global` | `false` | 安装到用户级配置而非项目本地(Nanobot 推荐安装到 `~/.nanobot/workspace/`;Pi 安装到 `~/.pi/agent/`;Hermes 安装到 `~/.hermes/`) | -| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`openclaw`、`nanobot`、`pi` 或 `hermes` | +| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`cursor`、`trae`、`openclaw`、`nanobot`、`pi` 或 `hermes` | | `--eject` | `false` | 移除 mnemon 集成 | | `--yes` | `false` | 自动确认所有提示 | diff --git a/internal/setup/assets/assets.go b/internal/setup/assets/assets.go index 69e205b2..a967b2fd 100644 --- a/internal/setup/assets/assets.go +++ b/internal/setup/assets/assets.go @@ -44,6 +44,18 @@ var CursorStopHook []byte //go:embed cursor/compact.sh var CursorCompactHook []byte +//go:embed trae/SKILL.md +var TraeSkill []byte + +//go:embed trae/prime.sh +var TraePrimeHook []byte + +//go:embed trae/user_prompt.sh +var TraeUserPromptHook []byte + +//go:embed trae/stop.sh +var TraeStopHook []byte + //go:embed openclaw/SKILL.md var OpenClawSkill []byte @@ -94,5 +106,5 @@ var HermesCompactHook []byte // All returns the embedded filesystem for inspection. // -//go:embed claude codex cursor openclaw nanoclaw nanobot pi hermes +//go:embed claude codex cursor trae openclaw nanoclaw nanobot pi hermes var All embed.FS diff --git a/internal/setup/assets/trae/SKILL.md b/internal/setup/assets/trae/SKILL.md new file mode 100644 index 00000000..2dba308d --- /dev/null +++ b/internal/setup/assets/trae/SKILL.md @@ -0,0 +1,46 @@ +--- +name: mnemon +description: Persistent memory CLI for LLM agents. Store facts, recall past knowledge, link related memories, manage lifecycle. +--- + +# mnemon + +## Workflow + +1. **Remember**: `mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent` + - Diff is built in: duplicates are skipped, conflicts are auto-replaced. + - Output includes `action` (added/updated/skipped), `semantic_candidates`, and `causal_candidates`. +2. **Link** (evaluate candidates from step 1 using judgment): + - Review `causal_candidates`: link only when the memories are genuinely causally related. + - Review `semantic_candidates`: high `similarity` alone is not enough; skip unrelated keyword matches. + - Syntax: `mnemon link --type --weight <0-1> [--meta '']` +3. **Recall**: `mnemon recall "" --limit 10` + +## Commands + +```bash +mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent +mnemon link --type --weight <0-1> [--meta ''] +mnemon recall "" --limit 10 +mnemon search "" --limit 10 +mnemon import --dry-run +mnemon import +mnemon forget +mnemon related --edge causal +mnemon gc --threshold 0.4 +mnemon gc --keep +mnemon status +mnemon log +mnemon store list +mnemon store create +mnemon store set +mnemon store remove +``` + +## Guardrails + +- Use memory only when it can materially improve continuity or task quality. +- Do not store secrets, passwords, tokens, private keys, or short-lived operational noise. +- Categories: `preference`, `decision`, `insight`, `fact`, `context` +- Edge types: `temporal`, `semantic`, `causal`, `entity` +- Max 8,000 chars per insight. diff --git a/internal/setup/assets/trae/prime.sh b/internal/setup/assets/trae/prime.sh new file mode 100644 index 00000000..694a2d0a --- /dev/null +++ b/internal/setup/assets/trae/prime.sh @@ -0,0 +1,22 @@ +#!/bin/bash +PROMPT_DIR="${MNEMON_DATA_DIR:-$HOME/.mnemon}/prompt" +if [ ! -f "${PROMPT_DIR}/guide.md" ] && [ -f "${HOME}/.mnemon/prompt/guide.md" ]; then + PROMPT_DIR="${HOME}/.mnemon/prompt" +fi + +if ! command -v mnemon >/dev/null 2>&1; then + echo "[mnemon] Warning: mnemon not found in PATH." + [ -f "${PROMPT_DIR}/guide.md" ] && cat "${PROMPT_DIR}/guide.md" + exit 0 +fi + +STATS=$(mnemon status 2>/dev/null) +if [ -n "$STATS" ]; then + INSIGHTS=$(echo "$STATS" | sed -n 's/.*"total_insights": *\([0-9]*\).*/\1/p' | head -1) + EDGES=$(echo "$STATS" | sed -n 's/.*"edge_count": *\([0-9]*\).*/\1/p' | head -1) + echo "[mnemon] Memory active (${INSIGHTS:-0} insights, ${EDGES:-0} edges)." +else + echo "[mnemon] Memory active." +fi + +[ -f "${PROMPT_DIR}/guide.md" ] && cat "${PROMPT_DIR}/guide.md" diff --git a/internal/setup/assets/trae/stop.sh b/internal/setup/assets/trae/stop.sh new file mode 100644 index 00000000..37db1299 --- /dev/null +++ b/internal/setup/assets/trae/stop.sh @@ -0,0 +1,14 @@ +#!/bin/bash +INPUT=$(cat) + +MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null) +if echo "$MSG" | grep -qiE "mnemon remember|mnemon recall|mnemon link|Stored.*imp="; then + exit 0 +fi + +cat <<'JSON' +{ + "decision": "block", + "reason": "[mnemon] Before stopping, evaluate whether this exchange contains durable preferences, decisions, insights, facts, or context worth remembering. If yes, run mnemon remember/link; if no, state that no memory update is needed, then finish." +} +JSON diff --git a/internal/setup/assets/trae/user_prompt.sh b/internal/setup/assets/trae/user_prompt.sh new file mode 100644 index 00000000..6e5d6d2e --- /dev/null +++ b/internal/setup/assets/trae/user_prompt.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cat >/dev/null || true +echo "[mnemon] Evaluate: recall needed? After responding, evaluate: remember needed?" diff --git a/internal/setup/detect.go b/internal/setup/detect.go index 87dbd840..d1fd787b 100644 --- a/internal/setup/detect.go +++ b/internal/setup/detect.go @@ -9,8 +9,8 @@ import ( // Environment describes a detected LLM CLI environment. type Environment struct { - Name string // "claude-code", "codex", "cursor", "openclaw", "nanobot", "pi", "hermes" - Display string // "Claude Code", "Codex", "Cursor", "OpenClaw", "Nanobot", "Pi", "Hermes Agent" + Name string // "claude-code", "codex", "cursor", "trae", "openclaw", "nanobot", "pi", "hermes" + Display string // "Claude Code", "Codex", "Cursor", "Trae", "OpenClaw", "Nanobot", "Pi", "Hermes Agent" Detected bool // CLI binary or global config dir found BinPath string // exec.LookPath result Installed bool // mnemon integration present at ConfigDir @@ -32,6 +32,7 @@ func DetectEnvironments(global bool) []Environment { detectClaude(global), detectCodex(global), detectCursor(global), + detectTrae(global), detectOpenClaw(global), detectNanobot(global), detectPi(global), @@ -157,6 +158,47 @@ func detectCursor(global bool) Environment { return env } +func detectTrae(global bool) Environment { + home := HomeDir() + globalDir := filepath.Join(home, ".trae") + localDir := ".trae" + + configDir := localDir + if global { + configDir = globalDir + } + + env := Environment{ + Name: "trae", + Display: "Trae", + ConfigDir: configDir, + } + + if binPath, err := exec.LookPath("trae"); err == nil { + env.Detected = true + env.BinPath = binPath + } + if _, err := os.Stat(globalDir); err == nil { + env.Detected = true + } + + skillPath := filepath.Join(configDir, "skills", "mnemon", "SKILL.md") + hooksPath := filepath.Join(configDir, "hooks.json") + if _, err := os.Stat(skillPath); err == nil { + env.Installed = true + } else if data, err := ReadJSONFile(hooksPath); err == nil && containsMnemon(data) { + env.Installed = true + } + + if env.BinPath != "" { + if out, err := exec.Command(env.BinPath, "--version").Output(); err == nil { + env.Version = cleanVersion(strings.TrimSpace(string(out))) + } + } + + return env +} + func detectOpenClaw(global bool) Environment { home := HomeDir() globalDir := filepath.Join(home, ".openclaw") diff --git a/internal/setup/trae.go b/internal/setup/trae.go new file mode 100644 index 00000000..5fc74167 --- /dev/null +++ b/internal/setup/trae.go @@ -0,0 +1,154 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/mnemon-dev/mnemon/internal/setup/assets" +) + +// TraeWriteSkill writes the mnemon skill to the Trae skills directory. +func TraeWriteSkill(configDir string) (string, error) { + skillDir := filepath.Join(configDir, "skills", "mnemon") + if err := os.MkdirAll(skillDir, 0755); err != nil { + return "", err + } + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillPath, assets.TraeSkill, 0644); err != nil { + return "", err + } + return skillPath, nil +} + +// TraeWriteHook writes a hook script to the Trae hooks directory. +func TraeWriteHook(configDir, filename string, content []byte) (string, error) { + hooksDir := filepath.Join(configDir, "hooks", "mnemon") + if err := os.MkdirAll(hooksDir, 0755); err != nil { + return "", err + } + hookPath := filepath.Join(hooksDir, filename) + if err := os.WriteFile(hookPath, content, 0755); err != nil { + return "", err + } + return hookPath, nil +} + +// TraeRegisterHooks registers Mnemon lifecycle hooks in Trae hooks.json. +func TraeRegisterHooks(configDir string) (string, error) { + hooksDir := filepath.Join(configDir, "hooks", "mnemon") + absHooksDir, err := filepath.Abs(hooksDir) + if err != nil { + return "", err + } + hooksPath := filepath.Join(configDir, "hooks.json") + data, err := ReadJSONFile(hooksPath) + if err != nil { + return "", err + } + addTraeHooks(data, absHooksDir) + if err := WriteJSONFile(hooksPath, data); err != nil { + return "", err + } + return hooksPath, nil +} + +// TraeEject removes mnemon skill and hooks from the given Trae config dir. +func TraeEject(configDir string) []error { + var errs []error + + fmt.Printf("\nRemoving Trae integration (%s)...\n", configDir) + + hooksDir := filepath.Join(configDir, "hooks", "mnemon") + if err := os.RemoveAll(hooksDir); err != nil { + StatusError(1, 3, "Hooks", err) + errs = append(errs, err) + } else { + StatusOK(1, 3, "Hooks", hooksDir+" removed") + } + removeIfEmpty(filepath.Join(configDir, "hooks")) + + hooksPath := filepath.Join(configDir, "hooks.json") + data, err := ReadJSONFile(hooksPath) + if err != nil { + StatusError(2, 3, "Hooks config", err) + errs = append(errs, err) + } else { + removeTraeHooks(data) + if err := WriteOrRemoveJSONFile(hooksPath, data); err != nil { + StatusError(2, 3, "Hooks config", err) + errs = append(errs, err) + } else { + StatusOK(2, 3, "Hooks config", hooksPath+" cleaned") + } + } + + skillDir := filepath.Join(configDir, "skills", "mnemon") + if err := os.RemoveAll(skillDir); err != nil { + StatusError(3, 3, "Skill", err) + errs = append(errs, err) + } else { + StatusOK(3, 3, "Skill", skillDir+" removed") + } + removeIfEmpty(filepath.Join(configDir, "skills")) + removeIfEmpty(configDir) + + return errs +} + +func addTraeHooks(data map[string]interface{}, hooksDir string) { + removeTraeHooks(data) + if _, ok := data["version"]; !ok { + data["version"] = 1 + } + hooks := ensureHooksMap(data) + + addTraeHook(hooks, "SessionStart", "", 0, filepath.Join(hooksDir, "prime.sh")) + addTraeHook(hooks, "UserPromptSubmit", "", 0, filepath.Join(hooksDir, "user_prompt.sh")) + addTraeHook(hooks, "Stop", "", 1, filepath.Join(hooksDir, "stop.sh")) +} + +func addTraeHook(hooks map[string]interface{}, event, matcher string, loopLimit int, command string) { + entry := map[string]interface{}{ + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": command, + "timeout": 30, + }, + }, + } + if matcher != "" { + entry["matcher"] = matcher + } + if loopLimit > 0 { + entry["loop_limit"] = loopLimit + } + arr, _ := hooks[event].([]interface{}) + hooks[event] = append(arr, entry) +} + +func removeTraeHooks(data map[string]interface{}) { + hooks, ok := data["hooks"].(map[string]interface{}) + if !ok { + return + } + for _, key := range []string{"SessionStart", "UserPromptSubmit", "Stop", "PreToolUse", "PostToolUse", "Notification"} { + arr, ok := hooks[key].([]interface{}) + if !ok { + continue + } + filtered := filterHookArray(arr) + if len(filtered) == 0 { + delete(hooks, key) + } else { + hooks[key] = filtered + } + } + if len(hooks) == 0 { + delete(data, "hooks") + if _, ok := data["version"]; ok && len(data) == 1 { + delete(data, "version") + } + } +} diff --git a/internal/setup/trae_test.go b/internal/setup/trae_test.go new file mode 100644 index 00000000..40336345 --- /dev/null +++ b/internal/setup/trae_test.go @@ -0,0 +1,143 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTraeWriteSkill(t *testing.T) { + dir := t.TempDir() + + skillPath, err := TraeWriteSkill(dir) + if err != nil { + t.Fatalf("write skill: %v", err) + } + if skillPath != filepath.Join(dir, "skills", "mnemon", "SKILL.md") { + t.Fatalf("skill path = %q", skillPath) + } + if _, err := os.Stat(skillPath); err != nil { + t.Fatalf("stat skill: %v", err) + } +} + +func TestTraeWriteHook(t *testing.T) { + dir := t.TempDir() + + hookPath, err := TraeWriteHook(dir, "prime.sh", []byte("#!/bin/bash\n")) + if err != nil { + t.Fatalf("write hook: %v", err) + } + if hookPath != filepath.Join(dir, "hooks", "mnemon", "prime.sh") { + t.Fatalf("hook path = %q", hookPath) + } + info, err := os.Stat(hookPath) + if err != nil { + t.Fatalf("stat hook: %v", err) + } + if info.Mode().Perm() != 0755 { + t.Fatalf("hook permissions = %v, want 0755", info.Mode().Perm()) + } +} + +func TestTraeRegisterHooksPreservesUnrelatedConfig(t *testing.T) { + dir := t.TempDir() + hooksPath := filepath.Join(dir, "hooks.json") + if err := os.WriteFile(hooksPath, []byte(`{ + "version": 1, + "hooks": { + "SessionStart": [ + {"hooks": [{"type": "command", "command": "/old/mnemon/prime.sh"}]}, + {"hooks": [{"type": "command", "command": "/keep/custom.sh"}]} + ], + "Stop": [ + {"hooks": [{"type": "command", "command": "/old/mnemon/stop.sh"}]} + ] + } +}`), 0644); err != nil { + t.Fatalf("write hooks config: %v", err) + } + + if _, err := TraeRegisterHooks(dir); err != nil { + t.Fatalf("register hooks: %v", err) + } + + data, err := ReadJSONFile(hooksPath) + if err != nil { + t.Fatalf("read hooks config: %v", err) + } + hooks := data["hooks"].(map[string]any) + sessionStart := hooks["SessionStart"].([]any) + if len(sessionStart) != 2 { + t.Fatalf("expected custom hook plus new prime hook: %#v", sessionStart) + } + if !strings.Contains(sessionStart[1].(map[string]any)["hooks"].([]any)[0].(map[string]any)["command"].(string), "hooks/mnemon/prime.sh") { + t.Fatalf("expected new prime hook, got %#v", sessionStart[1]) + } + if _, ok := hooks["UserPromptSubmit"]; !ok { + t.Fatalf("user prompt hook should be registered: %#v", hooks) + } + stop := hooks["Stop"].([]any) + if len(stop) != 1 || stop[0].(map[string]any)["loop_limit"].(float64) != 1 { + t.Fatalf("expected one nudge hook with loop limit: %#v", stop) + } +} + +func TestTraeEjectRemovesOnlyMnemonFilesAndHooks(t *testing.T) { + dir := t.TempDir() + if _, err := TraeWriteSkill(dir); err != nil { + t.Fatalf("write skill: %v", err) + } + if _, err := TraeWriteHook(dir, "prime.sh", []byte("#!/bin/bash\n")); err != nil { + t.Fatalf("write hook: %v", err) + } + if _, err := TraeRegisterHooks(dir); err != nil { + t.Fatalf("register hooks: %v", err) + } + customSkillDir := filepath.Join(dir, "skills", "custom") + if err := os.MkdirAll(customSkillDir, 0755); err != nil { + t.Fatalf("create custom skill: %v", err) + } + hooksPath := filepath.Join(dir, "hooks.json") + data, err := ReadJSONFile(hooksPath) + if err != nil { + t.Fatalf("read hooks: %v", err) + } + hooks := data["hooks"].(map[string]any) + hooks["SessionStart"] = append(hooks["SessionStart"].([]any), map[string]any{ + "hooks": []any{map[string]any{"type": "command", "command": "/keep/custom.sh"}}, + }) + if err := WriteJSONFile(hooksPath, data); err != nil { + t.Fatalf("write hooks: %v", err) + } + + errs := TraeEject(dir) + if len(errs) > 0 { + t.Fatalf("eject errors: %v", errs) + } + if _, err := os.Stat(filepath.Join(dir, "skills", "mnemon")); !os.IsNotExist(err) { + t.Fatalf("mnemon skill should be removed, err=%v", err) + } + if _, err := os.Stat(customSkillDir); err != nil { + t.Fatalf("custom skill should be preserved: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "hooks", "mnemon")); !os.IsNotExist(err) { + t.Fatalf("mnemon hooks should be removed, err=%v", err) + } + data, err = ReadJSONFile(hooksPath) + if err != nil { + t.Fatalf("read hooks after eject: %v", err) + } + hooks = data["hooks"].(map[string]any) + sessionStart := hooks["SessionStart"].([]any) + if len(sessionStart) != 1 || containsMnemon(sessionStart[0]) { + t.Fatalf("custom hook should be preserved and mnemon removed: %#v", sessionStart) + } + if _, ok := hooks["UserPromptSubmit"]; ok { + t.Fatalf("user prompt hooks should be removed: %#v", hooks) + } + if _, ok := hooks["Stop"]; ok { + t.Fatalf("stop hooks should be removed: %#v", hooks) + } +}