diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bb479c..f182d03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Validate package manifests working-directory: plugins run: | - node -e 'for (const f of ["package.json","agent-sdk/package.json","memory-mcp/package.json","openclaw/package.json","claude-code/package.json","codex/package.json","cli/package.json"]) JSON.parse(require("fs").readFileSync(f,"utf8"))' + node -e 'for (const f of ["package.json","agent-sdk/package.json","memory-mcp/package.json","openclaw/package.json","claude-code/package.json","kimicode/package.json","codex/package.json","cursor/package.json","devin/package.json","dsh/package.json","cli/package.json"]) JSON.parse(require("fs").readFileSync(f,"utf8"))' node -e 'JSON.parse(require("fs").readFileSync("everme/.codex-plugin/plugin.json","utf8"))' node -e 'JSON.parse(require("fs").readFileSync("everme/hooks/hooks.json","utf8"))' @@ -177,7 +177,12 @@ jobs: - name: Private key check run: | # ci.yml is excluded so the pattern string doesn't self-match. - if grep -R --exclude-dir=.git --exclude-dir=node_modules --exclude=ci.yml -n "BEGIN .*PRIVATE KEY" .; then + # redact.go / redact_test.go are excluded for the same reason: the + # import redactor exists to *strip* credentials, so it necessarily + # carries the PEM header pattern, and its fixtures are fabricated + # placeholder bodies the test asserts get replaced. + if grep -R --exclude-dir=.git --exclude-dir=node_modules --exclude=ci.yml \ + --exclude=redact.go --exclude=redact_test.go -n "BEGIN .*PRIVATE KEY" .; then echo "::error::Private key material detected" exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d9075c..e1a8b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ the open-source home for EverMe CLI and agent plugins. ### CLI +- Sync the `cli/` Go tree to the `evercli` v0.32.0 release. Earlier public + snapshots trailed the published binaries; the tree now matches what + `npm install -g @everme/cli` and the release archives ship. +- Add `evercli skill` — browse / install / manage EverMe skills. +- Restore `evercli plugin uninstall `: it removes only EverMe-owned + local state (host config entry, hooks, `everme.env`) and then disconnects the + cloud agent registered for this machine. `--keep-agent` skips the disconnect. +- Replace the flat-file import pipeline with `evercli import conversations` + (`scan` / `run`): markdown import, an idempotency ledger scoped to one + account and environment, and an `--async` two-phase bulk mode for cold-start + uploads. +- Extend `evercli plugin install` past Claude Code and OpenClaw to Codex, + Cursor, Claude Desktop, DeepSeek Harness, Devin, Hermes, Kimi Code, + opencode, Raven, and WorkBuddy. +- Force `0600` on host config files that store an EverMe agent token. +- Stop dropping Codex tool families and non-main OpenClaw agents during import. +- Bump the `@everme/cli` npm wrapper to 0.32.0 so wrapper and binary versions + match, and print a per-host install hint from its postinstall refresh. +- Format the tree with `gofmt` and tidy `go.mod` / `go.sum`. + - Rework `evercli plugin install hermes` to install a native Hermes `MemoryProvider` plugin instead of an MCP server entry. The Python provider is embedded in the `evercli` binary (`cli/internal/plugin/hermesassets/`) @@ -32,6 +52,23 @@ the open-source home for EverMe CLI and agent plugins. ### Plugins +- Release the protocol packages at 0.6.1 (from 0.4.2). +- Open-source four more host plugins: `@everme/kimicode`, `@everme/cursor`, + `@everme/devin`, and `@everme/dsh` (DeepSeek Harness), each with its own test + suite in the npm workspace. +- Refresh the Codex marketplace plugin (`plugins/everme`) to 0.6.1 so + `codex plugin marketplace upgrade EverMind-AI/EverMe` picks up the current + lifecycle Hook runner — the marketplace reads this repository directly, so + the npm releases alone never reached Codex users. The bundled + `bin/hook.mjs` is rebuilt from `plugins/codex/scripts/build-marketplace.mjs` + and is byte-identical to the runner published on npm. +- `@everme/agent-sdk`: give lifecycle hooks a time budget that fits inside the + host's kill deadline, and fire the watchdog after the request deadline rather + than before it. +- Surface one trace id across plugin logs and tool results. +- Close the v2 first-flush gap so the opening turn of a session is extracted + instead of silently dropped. + - Release the protocol packages at 0.4.2. `@everme/agent-sdk` now builds the recall query from the user's intent instead of the host's raw prompt (stripping injected reminders and host boilerplate before searching), and @@ -80,6 +117,10 @@ the open-source home for EverMe CLI and agent plugins. ### Docs +- Ship `cli/.goreleaser.yml` so the published binaries are reproducible from + this tree, and correct its release-notes header, which still described the + distribution as closed-source. + - Add root `README.md` and `README.zh.md`. - Add `CONTRIBUTING.md`. - Add `AGENTS.md` with contribution goals, pre-PR checks, source layout, and @@ -91,6 +132,13 @@ the open-source home for EverMe CLI and agent plugins. ### CI +- Validate all ten workspace manifests in `fast-gate`, not just the original + six. +- Exempt the import redactor and its fixtures from the private-key scan. The + redactor exists to strip PEM blocks, so it necessarily contains the header + pattern the scan looks for, and its fixture bodies are fabricated + placeholders the tests assert get replaced. + - Add layered GitHub Actions CI inspired by `larksuite/cli`: `fast-gate`, `cli-test`, `plugin-test`, `coverage`, `package-smoke`, `security`, and a final `results` gate. @@ -100,6 +148,11 @@ the open-source home for EverMe CLI and agent plugins. ### Security +- Pin `hono`, `fast-uri`, and `ip-address` to patched floors through workspace + `overrides`, clearing advisories that reach the tree via + `@modelcontextprotocol/sdk`. The published package manifests are untouched — + each pin stays inside its consumer's declared range. + - Add `SECURITY.md` with private vulnerability reporting guidance. - Expand `.gitignore` for local env files, logs, build artifacts, Node dependency directories, and editor state. diff --git a/cli/.goreleaser.yml b/cli/.goreleaser.yml new file mode 100644 index 0000000..f245dcf --- /dev/null +++ b/cli/.goreleaser.yml @@ -0,0 +1,70 @@ +version: 2 + +project_name: evercli + +# monorepo: this config is intentionally scoped to cli/. Run goreleaser +# with `cd cli && goreleaser release --clean` (CI sets workdir: cli). +# Tag scheme: bare semver `vX.Y.Z`. + +builds: + - id: evercli + main: ./ + binary: evercli + env: + - CGO_ENABLED=0 + flags: + - -trimpath + - -buildvcs=false + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}_{{ .Os }}_{{ .Arch }} + format_overrides: + - goos: windows + format: zip + # Force binary-only archives: goreleaser v2 auto-includes README*/ + # LICENSE*/CHANGELOG* when `files` is missing OR empty list, so we + # use a glob that matches nothing to suppress the defaults. Docs are + # read from the repository, not shipped inside the archive. + files: + - src: nothing-here-do-not-match-do-not-remove* + +checksum: + name_template: sha256sums.txt + algorithm: sha256 + +release: + github: + owner: EverMind-AI + name: EverMe + prerelease: false + header: | + ## EverMe CLI + + Prebuilt `evercli` binaries for AI Agents (Claude Code, OpenClaw, Codex, …). + Source lives in [`cli/`](https://github.com/EverMind-AI/EverMe/tree/main/cli) + under Apache-2.0. + + ### Install (recommended) + + ```bash + npm install -g @everme/cli + ``` + + Or download an archive below and unpack manually. + +changelog: + disable: true diff --git a/cli/AGENTS.md b/cli/AGENTS.md index d4025dc..fa6723a 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -5,21 +5,24 @@ - Build: `make build` from `cli/`, or `make -C cli build` from repo root → `cli/_output/evercli` - Test: `make test` (unit + contract) - Dev: `cd cli && go run . ` (or `make dev ARGS="auth status"`) -- Public contract: `/docs/contracts.md` + +This file is self-contained and governs `cli/` only — it references no files outside this directory. ## Command surface (post-slim) -Four subcommands, intentionally minimal: +Five subcommands, intentionally minimal: - `auth` — login / logout / status / me -- `plugin` — list / install (Claude Code, OpenClaw) -- `import` — scan / run (cold-start memory upload) +- `plugin` — list / install / uninstall (Claude Code, OpenClaw, Cursor, Claude Desktop, Codex, Hermes, Devin, WorkBuddy, opencode, Kimi Code, Raven) +- `import` — conversations scan / run (cold-start session + markdown import) - `doctor` — slim self-checks (network reachability + credential backend) +- `skill` — browse / install / manage EverMe skills Binary identity is exposed via `evercli --version` (cobra-native flag). -The slimming pass also retired: -- `evercli onboard` — users run `auth login` → `plugin install` → `import run` manually -- `evercli plugin uninstall` — users disconnect agents from the EverMe web UI and clean up host plugin entries by hand +The slimming passes also retired: +- `evercli onboard` — users run `auth login` → `plugin install` → `import conversations run` manually +- `evercli agents disconnect` — `plugin uninstall ` covers the cloud disconnect; there is no standalone revoke command (Web UI remains the manual fallback) +- `evercli plugin scan` (and the post-success background scan after login/install/import) — replaced by an update-time hint: the npm wrapper's postinstall refresh (`plugins/cli/scripts/upgrade-plugins.js`) prints one line per detected host that has no EverMe entry, with the exact `evercli plugin install ` command - `evercli version` / `update` / `config` / `debug` subcommands - `doctor --print-skills` / `--cleanup` flavors @@ -29,16 +32,67 @@ Reintroduce any of these only with a documented user need. ```bash evercli auth login # Device Flow; --no-wait + --device-code for AI Agents -evercli plugin install claude-code # or `openclaw`; rotates evt + writes the host plugin entry (Claude Code MCP / OpenClaw plugins.entries) -evercli import run # cold-start memory upload (optional) +evercli plugin install claude-code # or `openclaw` / `dsh`; rotates evt + writes the host integration +evercli import conversations run # cold-start session + markdown import (optional) +``` + +**Kimi Code is one CLI command + one TUI step** (not fully hands-off). Kimi +Code has no headless *registration* command (`/plugins install` is TUI-only) +and its `plugins/installed.json` record is an internal, manifest-embedding +format we don't hand-write. So `evercli plugin install kimicode` does the +credential + bundle work — including auto-running `npm install -g +@everme/kimicode` when the bundle isn't already on disk (mirrors claude-code; +fail-hard if npm is missing) — and the user finishes *registration* inside +Kimi Code: + +```bash +evercli plugin install kimicode # rotates evt → writes ~/.kimi-code/everme.env (0600) + # + `npm install -g @everme/kimicode` if the bundle is missing + # + stages the bundle (with node_modules) at ~/.kimi-code/everme/ +# then, inside the Kimi Code TUI: +# /plugins install ~/.kimi-code/everme ← Kimi Code copies it to plugins/managed/ + writes the record +# /plugins reload (or start a new session) ``` -## Manual uninstall flow (replaces `plugin uninstall`) +**DeepSeek Harness supports both Web and Headless through native Cordis hooks + MCP**: `evercli plugin install dsh` prefers the installed `dsh` launcher and falls back to `npx --yes @deepseek-ai/dsh@latest`, refreshes the `@everme/dsh@latest` bundle in both the `web` and `headless` profiles, writes an MCP insertion block to each profile that starts `@everme/memory-mcp@latest` through `npx`, and stores their shared credentials in `~/.dsh/.env` with mode `0600`. DSH installs receive a five-minute minimum operation budget so a cold npm fallback is not clipped by EverCLI's default 60-second command timeout. The native plugin performs automatic recall on `agent/pre-step` and saves complete turns from `session/event`; `session/flush` ensures the one-shot Headless runner waits for the upload before exiting. DSH scrubs inherited credential-shaped variables for stdio servers, so both MCP configs explicitly re-add the three EverMe variables from that env file. Restart Web sessions after install if their patch watcher has not reloaded the change; subsequent Headless runs load the updated profile directly. + +**Raven is fully headless** but single-slot: `evercli plugin install raven` +drops the embedded Python backend at `~/.raven/plugins/everme-memory/` and +patches `~/.raven/config.json` (`memory.backend=everme` + +`plugins.config["everme-memory"]` credentials — Raven's config.json is its +canonical credential store, so there is no everme.env). Selecting `everme` +supersedes Raven's bundled `everos` local-memory backend for the session +(same exclusivity as OpenClaw's contextEngine slot); the pre-install config +is kept at `config.json-bak`. Requires a Raven version whose plugin registry +adds user-dir plugins to `sys.path` before factory import — older versions +discover the manifest but fail the `everme_raven` import at boot. + +## Uninstall flow + +`evercli plugin uninstall ` performs local cleanup first and then +disconnects the cloud agent whose platform AND machine fingerprint exactly +match this machine. It never guesses: when no agent carries this machine's +fingerprint, nothing is disconnected and the result reports +`noMatchingCloudAgent` plus a NextSteps pointer at the Web UI — revoking a +fingerprint-less agent could kill another machine's token. It never deletes +an entire host config or another plugin's state. Every supported platform's +writer implements the `Remover` interface — including Hermes +(`internal/plugin/hermes.go`), which clears `memory.provider`, drops the +legacy `mcp_servers` entry, and removes `~/.hermes/plugins/everme/` + +`everme.env`. In an interactive tty the command asks a y/N confirmation +(default No) unless `--yes` is passed; `--no-prompt` requires `--yes`; +`--keep-agent` skips the cloud disconnect. There is no standalone +cloud-revoke command; disconnecting without local cleanup is a Web UI action. + +If the CLI is unavailable, the manual fallback is: 1. Disconnect the agent in the EverMe web UI (account → agents → revoke). 2. Remove the host plugin entry: - Claude Code: `claude plugin uninstall everme && claude plugin marketplace remove everme && rm ~/.claude/everme.env` - OpenClaw: edit `~/.openclaw/openclaw.json` and drop everything `plugin install openclaw` wrote — `plugins.entries["@everme/openclaw"]` (the per-agent config), `plugins.slots.contextEngine` (the slot binding), and `"@everme/openclaw"` from `plugins.allow`. The plugin id mirrors `cli/internal/plugin/openclaw.go:OpenClawPluginID` — keep them in sync if it ever moves. + - Kimi Code: in the TUI run `/plugins remove everme`, then `rm -rf ~/.kimi-code/everme ~/.kimi-code/everme.env`. + - Raven: edit `~/.raven/config.json` — restore `memory.backend` to its previous value (see `config.json-bak`) and drop `plugins.config["everme-memory"]` — then `rm -rf ~/.raven/plugins/everme-memory`. The plugin id mirrors `cli/internal/plugin/raven.go:RavenPluginID` — keep them in sync if it ever moves. + - DeepSeek Harness: run `dsh plugin --profile remove @everme/dsh` for both managed profiles, then remove only the evercli-managed blocks from each profile's `cordis.patch.yml` and from `~/.dsh/.env`; preserve every unrelated patch and env entry. If a patch becomes empty, leave `[]`; if the env file becomes empty, remove it. ## Layered import rules @@ -55,7 +109,7 @@ Business packages (`auth`, `plugin`, `importer`) **do not import each other**; t ## Output contract is sacred -`internal/output/` defines the AI-Agent ABI (envelope shape, exit codes, error type taxonomy). Changing field names, exit code semantics, or `error.type` values is a breaking change. Update `/docs/contracts.md` together with code, and refresh golden test fixtures in `internal/output/testdata/golden/`. +`internal/output/` defines the AI-Agent ABI (envelope shape, exit codes, error type taxonomy). Changing field names, exit code semantics, or `error.type` values is a breaking change. Refresh the golden test fixtures in `internal/output/testdata/golden/` together with the code. ## stdout vs stderr diff --git a/cli/README.md b/cli/README.md index 955b92e..9aef411 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,7 +2,8 @@ EverMe cloud-memory CLI for AI Agents (Claude Code, OpenClaw, …). -> Public AI-agent contract: [`docs/contracts.md`](../docs/contracts.md). +> Conventions: [`AGENTS.md`](AGENTS.md). Public AI-agent +> contract: [`docs/contracts.md`](../docs/contracts.md). ## Build & run @@ -16,8 +17,8 @@ make test ```bash evercli auth login # Device Flow; AI Agents pass --no-wait + --device-code -evercli plugin install claude-code # or `openclaw`; rotates evt + writes MCP config -evercli import run # optional cold-start memory upload +evercli plugin install claude-code # or `openclaw` / `dsh`; rotates evt + writes host config +evercli import conversations run # optional cold-start session + markdown import evercli doctor # connectivity + credential health check evercli --version # build identity ``` @@ -27,15 +28,22 @@ evercli --version # build identity | Command | Purpose | |-----------|------------------------------------------------------| | `auth` | login / logout / status / me | -| `plugin` | list / install (Claude Code, OpenClaw) | -| `import` | scan / run (cold-start memory upload) | +| `plugin` | list / install / uninstall (Claude Code, OpenClaw, Cursor, Claude Desktop, Codex, DeepSeek Harness, Hermes, Devin, WorkBuddy, opencode, Kimi Code, Raven) | +| `import` | conversations scan / run (cold-start session + markdown import) | | `doctor` | minimal self-checks (connectivity + credential) | - -Retired in the slimming pass and replaced by the manual flow above: -`onboard`, `plugin uninstall`, `version` subcommand, `update`, -`config`, `debug bundle`. Reintroduce only on documented user need. -See [`AGENTS.md`](AGENTS.md) for the manual install / uninstall -sequences. +| `skill` | browse / install / manage EverMe skills | + +`plugin uninstall ` is back after the slimming pass: it removes +only EverMe-owned local state (config entry, hooks, everme.env) and then +disconnects the cloud agent matching this machine's fingerprint +(`--keep-agent` skips the disconnect). Devin installs land the shared MCP +entry plus a native `post_cascade_response_with_transcript` lifecycle +hook in `hooks.json` next to `~/.codeium/windsurf/mcp_config.json`. DeepSeek Harness installs add the `@everme/dsh` bundle to the Web profile for native Cordis recall/save hooks and keep `@everme/memory-mcp` available as the stdio tool server. + +Still retired and replaced by the manual flow above: `onboard`, +`version` subcommand, `update`, `config`, `debug bundle`. Reintroduce +only on documented user need. See [`AGENTS.md`](AGENTS.md) for the +manual install / uninstall sequences. ## Contributor notes diff --git a/cli/cmd/auth/login.go b/cli/cmd/auth/login.go index 7af6c55..72fbd41 100644 --- a/cli/cmd/auth/login.go +++ b/cli/cmd/auth/login.go @@ -8,6 +8,7 @@ import ( "evercli/internal/auth" "evercli/internal/cmdctx" + "evercli/internal/output" ) func newLogin() *cobra.Command { @@ -60,7 +61,7 @@ is already known).`, if err != nil { return deps.Out.Err(err) } - return deps.Out.OK(res, nil) + return completeLogin(deps.Out, res) }, } c.Flags().StringVar(&apiKey, "api-key", "", "log in directly with an existing emk (skips Device Flow)") @@ -74,6 +75,10 @@ is already known).`, return c } +func completeLogin(out *output.Writer, result *auth.LoginResult) error { + return out.OK(result, nil) +} + // renderLogin renders the human-readable form of LoginResult on stdout. // AI Agents read JSON; this branch is purely cosmetic and may evolve. func renderLogin(w io.Writer, data interface{}) error { diff --git a/cli/cmd/auth/login_text_test.go b/cli/cmd/auth/login_text_test.go index 2ddd1de..cb2f520 100644 --- a/cli/cmd/auth/login_text_test.go +++ b/cli/cmd/auth/login_text_test.go @@ -2,6 +2,7 @@ package auth import ( "bytes" + "io" "strings" "testing" @@ -9,8 +10,21 @@ import ( "github.com/stretchr/testify/require" "evercli/internal/auth" + "evercli/internal/output" ) +func TestCompleteLoginWritesEnvelope(t *testing.T) { + for _, status := range []string{"approved", "pending", "denied", "expired"} { + t.Run(status, func(t *testing.T) { + var stdout bytes.Buffer + out := output.NewWriterTo(&stdout, io.Discard, output.FormatJSON) + require.NoError(t, completeLogin(out, &auth.LoginResult{Status: status})) + assert.Contains(t, stdout.String(), `"ok": true`) + assert.Contains(t, stdout.String(), status) + }) + } +} + // These tests cover the text renderers in isolation. The end-to-end // integration of the cobra commands (flag parsing, deps wiring, RunE // calling into the service) is exercised by internal/auth/service_test.go diff --git a/cli/cmd/imports/conversations.go b/cli/cmd/imports/conversations.go new file mode 100644 index 0000000..d0c6d3a --- /dev/null +++ b/cli/cmd/imports/conversations.go @@ -0,0 +1,1105 @@ +package imports + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "evercli/internal/auth" + "evercli/internal/cmdctx" + "evercli/internal/importer/conversation" + "evercli/internal/output" + "evercli/internal/runctx" +) + +// --------------------------------------------------------------------------- +// Session timeout floor (pure functions) +// --------------------------------------------------------------------------- + +// syncImportSessionTimeoutFloor bounds a single session's sync upload+flush. +// The sync path blocks until server-side extraction triggers, so the global +// --timeout default (tuned for quick API calls) is far too tight here. +const syncImportSessionTimeoutFloor = 5 * time.Minute + +// effectiveSessionTimeout floors a configured timeout at the sync import +// minimum. If configured is non-positive (unlimited), it is returned unchanged. +// If configured is between 0 (exclusive) and the floor, it is raised to the floor. +// Otherwise, the configured value is returned. +func effectiveSessionTimeout(configured time.Duration) time.Duration { + if configured > 0 && configured < syncImportSessionTimeoutFloor { + return syncImportSessionTimeoutFloor + } + return configured +} + +// statusExtractionPending is the RunResult.Status the server reports when a +// session's upload (add) succeeded but the final flush failed to trigger +// extraction upstream. The data landed; extraction was merely deferred, not +// lost — but a plain success line would hide that from the user. +const statusExtractionPending = "extraction_pending" + +// asyncFlushRetryDelay is how long FlushOne waits before its single retry +// when a flush reports no_extraction — usually a sign the session's async +// adds had not landed upstream yet when the flush arrived. +const asyncFlushRetryDelay = 5 * time.Second + +// isExtractionDeferred reports whether a session's upload result status is +// the deferred-extraction status, so the run loop can surface an extra +// warning instead of treating it like any other success. +func isExtractionDeferred(status string) bool { + return status == statusExtractionPending +} + +// --------------------------------------------------------------------------- +// View types (pure data; testable without cobra) +// --------------------------------------------------------------------------- + +// scanItemView is a single session entry for the scan table. +type scanItemView struct { + Platform string `json:"platform"` + Path string `json:"path"` + Date string `json:"date"` + Messages int `json:"messages"` + ToolCalls int `json:"toolCalls"` + Status string `json:"status,omitempty"` +} + +// scanView is the full scan render payload. Items is the full per-session +// detail (machine-read; used for per-item --exclude); Groups is the compact, +// consent-oriented summary rendered to humans by default. +type scanView struct { + Items []scanItemView `json:"items"` + Summary scanSummaryView `json:"summary"` + Groups []scanGroupView `json:"groups,omitempty"` + NotFound map[string]string `json:"notFound,omitempty"` + DriftWarnings []string `json:"driftWarnings,omitempty"` + SkippedActive []string `json:"skippedActive,omitempty"` +} + +// scanSummaryView is an additive scanView field (spec E3 §3): how many of the +// previewed sessions are new, already submitted per local idempotency state, +// or unsupported. Additive only — never rename/remove existing envelope +// fields; this is a new one. +type scanSummaryView struct { + New int `json:"new"` + AlreadySubmitted int `json:"alreadySubmitted"` + Unsupported int `json:"unsupported"` +} + +// summarizeScanItems counts items by status for scanView.Summary. +func summarizeScanItems(items []conversation.Item) scanSummaryView { + var s scanSummaryView + for _, it := range items { + switch it.Status { + case "submitted": + s.AlreadySubmitted++ + case "unsupported": + s.Unsupported++ + default: + s.New++ + } + } + return s +} + +// --------------------------------------------------------------------------- +// Idempotency state annotation (shared by scan and run — spec E3) +// --------------------------------------------------------------------------- + +// loadIdempotencyState loads the local idempotency state file used to +// annotate scan/run items with their prior-submission status. A load failure +// (e.g. permission denied) is never fatal here: scan/run must keep working +// even without idempotency info — a warning is printed and callers fall back +// to stateless behavior (nil; nothing gets annotated). A corrupt file that +// LoadState already recovered from (backed up + reset) is also surfaced, +// since already-submitted sessions may re-upload after the reset. +func loadIdempotencyState(stderr io.Writer, path, scope string) *conversation.State { + st, err := conversation.LoadState(path, scope) + if err != nil { + fmt.Fprintf(stderr, "warning: failed to load import state (%v); continuing without idempotency info\n", err) + return nil + } + if st.RecoveredFrom != "" { + fmt.Fprintf(stderr, + "warning: import state was unreadable; backed up to %s and started fresh — already-submitted sessions may re-upload\n", + st.RecoveredFrom) + } + if st.AdoptedLegacyEntries > 0 { + fmt.Fprintf(stderr, + "note: adopted %d import-state entries written before per-account tracking; they now belong to the account you are logged in as — pass --force to re-import if any of them were another account's\n", + st.AdoptedLegacyEntries) + } + return st +} + +// importStateScope pins the ledger to the account + environment doing the +// import. A missing/unreadable account.json still yields a stable scope +// for the environment: falling back to an empty identity would restore +// the cross-account bleed the scope exists to prevent. +func importStateScope(deps *cmdctx.Deps) string { + accountID := "" + if a, err := auth.LoadAccount(deps.Config.Paths.AccountFile()); err == nil && a != nil { + accountID = a.AccountID + } + return conversation.StateScope(deps.Config.APIBaseURL, accountID) +} + +// annotateSubmitted marks item.Status = "submitted" (in place) for every item +// already recorded as submitted in st, using the same platform+path key +// stateKey/RunOne derive (conversation.ItemStateKey). A nil st (the stateless +// fallback from a load failure) is a no-op. +func annotateSubmitted(items []conversation.Item, st *conversation.State) { + if st == nil { + return + } + for i := range items { + if st.ShouldSkip(conversation.ItemStateKey(items[i])) { + items[i].Status = "submitted" + } + } +} + +// submittedPlan is what a run does about sessions the ledger already +// records as submitted for this account + environment. +type submittedPlan int + +const ( + // submittedSkip drops them, as every run has always done. + submittedSkip submittedPlan = iota + // submittedReimport uploads them again. + submittedReimport + // submittedAsk puts the choice to the user before previewing. + submittedAsk +) + +// planForSubmitted decides between those three. +// +// Until now the only way to re-import was knowing that --force exists: a +// run printed "pass --force to re-upload" and moved on. An interactive +// user gets asked instead. A non-interactive one must not be — AI agents +// drive that path and a prompt there would hang the run. +func planForSubmitted(alreadySubmitted int, force, isTTY, noPrompt bool) submittedPlan { + if force { + return submittedReimport + } + if alreadySubmitted == 0 { + return submittedSkip + } + if isTTY && !noPrompt { + return submittedAsk + } + return submittedSkip +} + +// readYes reads one line and reports whether it is an affirmative +// answer. Everything else — including EOF and a bare newline — is No, so +// a prompt can never default to doing the destructive thing. +func readYes(r io.Reader) bool { + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + return false + } + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "y" || answer == "yes" +} + +// countSubmitted counts items annotated as already submitted. +func countSubmitted(items []conversation.Item) int { + n := 0 + for _, it := range items { + if it.Status == "submitted" { + n++ + } + } + return n +} + +// dropSubmittedUnlessForce removes items already annotated "submitted" from +// the set run() will upload, so a previously-imported session never consumes +// --limit's budget. force keeps today's meaning (re-upload everything) by +// skipping the drop entirely — RunOne's own force path still handles state +// bookkeeping per session. Returns the surviving items and how many were +// dropped, so the caller can print ONE stderr summary line instead of +// itemizing every skip. +func dropSubmittedUnlessForce(items []conversation.Item, force bool) (kept []conversation.Item, dropped int) { + if force { + return items, 0 + } + kept = make([]conversation.Item, 0, len(items)) + for _, it := range items { + if it.Status == "submitted" { + dropped++ + continue + } + kept = append(kept, it) + } + return kept, dropped +} + +// --------------------------------------------------------------------------- +// Render functions (pure: accept a view, return a string) +// --------------------------------------------------------------------------- + +const privacyBanner = ` +╔══════════════════════════════════════════════════════════════════════════╗ +║ PRIVACY NOTICE (隐私提示) ║ +║ Local sessions and documents may contain secrets, PII, or confidential ║ +║ data. Uploading is irreversible. Automatic redaction runs but is NOT a ║ +║ guarantee. Review the file list above carefully before confirming. ║ +║ Run 'evercli import conversations run' when you are ready. ║ +╚══════════════════════════════════════════════════════════════════════════╝ +` + +// renderConversationScan renders a scan report. By default it prints the +// compact grouped summary (one row per project / zone). With detail=true it +// prints the full per-session table instead. +func renderConversationScan(v scanView, detail bool) string { + var b strings.Builder + + if len(v.Items) == 0 && len(v.NotFound) == 0 { + b.WriteString("No sessions found.\n") + b.WriteString(privacyBanner) + return b.String() + } + + if detail { + renderScanDetailTable(&b, v.Items) + } else { + renderScanGroupTable(&b, v.Groups, len(v.Items), v.Summary) + } + + // Not-found notices + if len(v.NotFound) > 0 { + b.WriteString("Not found:\n") + for platform, hint := range v.NotFound { + b.WriteString(fmt.Sprintf(" [%s] %s\n", platform, hint)) + } + b.WriteString("\n") + } + + // Drift warnings + for _, w := range v.DriftWarnings { + b.WriteString(fmt.Sprintf(" WARNING: %s\n", w)) + } + if len(v.DriftWarnings) > 0 { + b.WriteString("\n") + } + + // Active sessions skipped (still being written by the live plugin) + for _, p := range v.SkippedActive { + b.WriteString(fmt.Sprintf(" skipped (active session, still being written): %s\n", p)) + } + if len(v.SkippedActive) > 0 { + b.WriteString("\n") + } + + b.WriteString(privacyBanner) + return b.String() +} + +// renderScanGroupTable writes the compact grouped summary: one row per +// project / zone with session+message counts and a date range, plus a totals +// line. totalSessions is the full per-session count behind the groups. +// summary appends the new/already-imported breakdown to the TOTAL line +// (spec E3 §3) so a user can tell "N new / M already imported" without +// switching to --detail. +func renderScanGroupTable(b *strings.Builder, groups []scanGroupView, totalSessions int, summary scanSummaryView) { + if len(groups) == 0 { + return + } + b.WriteString(fmt.Sprintf("%-12s %-42s %5s %8s %s\n", + "PLATFORM", "AREA/PROJECT", "SESS", "MESSAGES", "DATE RANGE")) + b.WriteString(strings.Repeat("-", 92) + "\n") + totalMsgs := 0 + for _, g := range groups { + totalMsgs += g.Messages + area := g.Area + if len(area) > 42 { + area = "…" + area[len(area)-41:] + } + rng := g.DateFrom + if g.DateFrom != g.DateTo && g.DateTo != "" { + rng = g.DateFrom + "→" + g.DateTo + } + b.WriteString(fmt.Sprintf("%-12s %-42s %5d %8d %s\n", + g.Platform, area, g.Sessions, g.Messages, rng)) + } + b.WriteString(strings.Repeat("-", 92) + "\n") + b.WriteString(fmt.Sprintf("TOTAL: %d groups · %d sessions · %d messages (%d new · %d imported)\n", + len(groups), totalSessions, totalMsgs, summary.New, summary.AlreadySubmitted)) + b.WriteString("(run with --detail to list every session; --format json for machine detail)\n\n") +} + +// renderScanDetailTable writes the full per-session table (one row per file). +func renderScanDetailTable(b *strings.Builder, items []scanItemView) { + if len(items) == 0 { + return + } + b.WriteString(fmt.Sprintf("%-14s %-55s %-12s %8s %9s %s\n", + "PLATFORM", "PATH", "DATE", "MESSAGES", "TOOLCALLS", "STATUS")) + b.WriteString(strings.Repeat("-", 115) + "\n") + for _, item := range items { + status := item.Status + if status == "" { + status = "ready" + } + path := item.Path + if len(path) > 55 { + path = "..." + path[len(path)-52:] + } + b.WriteString(fmt.Sprintf("%-14s %-55s %-12s %8d %9d %s\n", + item.Platform, path, item.Date, item.Messages, item.ToolCalls, status)) + } + b.WriteString("\n") +} + +// --------------------------------------------------------------------------- +// Guard (consent / non-interactive policy) +// --------------------------------------------------------------------------- + +type runGuardInput struct { + IsTTY bool + NoPrompt bool + Platforms []string + + // DryRun bypasses the guard entirely: --dry-run uploads nothing (it + // only scans and prints a preview), so the unattended-bulk-upload risk + // this guard exists for never applies. Requiring --no-prompt + + // --platform for a plain preview in CI/non-TTY use was an ECA E2E + // finding — a harmless `run --dry-run` should never need them. + DryRun bool +} + +// runConversationsGuard enforces consent policy: +// - interactive TTY: allowed (caller will show preview + prompt) +// - dry-run: always allowed (no upload happens regardless of TTY/scope) +// - non-interactive without --no-prompt: refused +// - non-interactive with --no-prompt but no explicit platform: refused +// - non-interactive with --no-prompt and explicit platform(s): allowed +// +// The two refusal errors are input-validation errors (bad flags/invocation +// for this environment), not internal failures — output.Invalid maps them +// to error.type=validation / exit code 2 instead of the misleading +// error.type=internal a bare fmt.Errorf would produce. +func runConversationsGuard(input runGuardInput) error { + if input.DryRun { + return nil // no upload happens; the guard's risk doesn't apply + } + if input.IsTTY { + return nil // interactive: will prompt + } + if !input.NoPrompt { + return output.Invalid( + "non-interactive session detected; use --no-prompt with an explicit platform scope "+ + "(e.g. --platform claude-code) to run without a TTY", + "") + } + if len(input.Platforms) == 0 { + return output.Invalid( + "--no-prompt requires an explicit platform scope "+ + "(e.g. --platform claude-code) to prevent accidental bulk import in CI", + "") + } + return nil +} + +// --------------------------------------------------------------------------- +// cobra command wiring +// --------------------------------------------------------------------------- + +func newConversations() *cobra.Command { + c := &cobra.Command{ + Use: "conversations", + Short: "Import agent conversation sessions (Claude Code, Codex, Hermes, OpenClaw, Markdown, Kimi Code, Raven)", + } + c.AddCommand(newConversationsScan()) + c.AddCommand(newConversationsRun()) + return c +} + +func newConversationsScan() *cobra.Command { + var ( + platforms []string + paths []string + detail bool + since string + until string + limit int + ) + c := &cobra.Command{ + Use: "scan [platform...]", + Short: "Preview local agent sessions that can be imported (no upload)", + Long: `Scan walks per-platform session directories and lists each discovered session +with its file path, date, message/tool counts, and status. + +No files are uploaded. A prominent privacy notice is printed to remind you that +sessions may contain secrets or personal data before you run 'conversations run'. + +Sessions already recorded as submitted in local idempotency state show +STATUS=submitted (--detail) and are counted in the "summary" field +(new / alreadySubmitted / unsupported) and the grouped TOTAL line — unlike +'run', scan never drops them, so you still see the full picture. + +Missing platforms are announced explicitly — they are never silently empty. +Use --path to override the scan root for a specific platform.`, + Example: ` evercli import conversations scan + evercli import conversations scan claude-code codex + evercli import conversations scan --format json`, + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + // Determine which platforms to scan + requested := args + if len(platforms) > 0 { + requested = append(requested, platforms...) + } + var platformIDs []conversation.PlatformID + if len(requested) == 0 { + // Default: all platforms + reg := conversation.DefaultRegistry() + for _, sc := range reg.Scanners() { + platformIDs = append(platformIDs, sc.Platform()) + } + } else { + ids, perr := conversation.ParsePlatforms(requested) + if perr != nil { + return deps.Out.Err(output.Invalid(perr.Error(), "Use one of: claude-code, codex, hermes, openclaw, markdown, kimicode, raven, workbuddy")) + } + platformIDs = ids + } + + if err := conversation.ValidateSince(since); err != nil { + return deps.Out.Err(err) + } + if until != "" { + if _, perr := time.Parse("2006-01-02", until); perr != nil { + return deps.Out.Err(output.Invalid( + fmt.Sprintf("--until must be YYYY-MM-DD, got %q", until), "")) + } + } + + // Build custom roots from --path overrides (format: platform=path) + customRoots := map[conversation.PlatformID][]string{} + for _, p := range paths { + parts := strings.SplitN(p, "=", 2) + if len(parts) != 2 { + fmt.Fprintf(cmd.ErrOrStderr(), "--path %q: expected format platform=path, ignoring\n", p) + continue + } + pid := conversation.PlatformID(strings.TrimSpace(parts[0])) + customRoots[pid] = append(customRoots[pid], strings.TrimSpace(parts[1])) + } + + cleanup, platformIDs, herr := bridgeHermes(cmd, len(requested) == 0, platformIDs, customRoots, until) + if herr != nil { + return deps.Out.Err(herr) + } + defer cleanup() + + svc := conversation.NewService(conversation.ServiceDeps{ + Roots: customRoots, + }) + + rep, err := svc.Scan(platformIDs) + if err != nil { + return deps.Out.Err(err) + } + + // Annotate items already recorded as submitted (spec E3 §1) so the + // preview shows the full picture — scan does NOT drop them, only + // `run` does; see below. + statePath := deps.Config.Paths.DataDir + "/conversations_import_state.json" + stateScope := importStateScope(deps) + st := loadIdempotencyState(cmd.ErrOrStderr(), statePath, stateScope) + annotateSubmitted(rep.Items, st) + + // Apply --since then --limit so scan previews the same set (and order) + // `run --since --limit` would upload. + items := conversation.FilterItemsSince(rep.Items, since) + if limit > 0 { + items = conversation.SortItemsNewestFirst(items) + } + items = conversation.LimitItems(items, limit) + + // Build the view (full detail + compact groups summary) + view := buildScanView(rep, items) + + deps.Out.WithTextRenderer(func(w io.Writer, data interface{}) error { + sv, ok := data.(scanView) + if !ok { + _, err := fmt.Fprintln(w, "(no scan data)") + return err + } + _, err := fmt.Fprint(w, renderConversationScan(sv, detail)) + return err + }) + + return deps.Out.OK(view, &output.Meta{Count: len(items)}) + }, + } + c.Flags().StringSliceVar(&platforms, "platform", nil, "platforms to scan (default: all)") + c.Flags().StringSliceVar(&paths, "path", nil, "override scan root: platform=path (e.g. claude-code=/custom/dir)") + c.Flags().BoolVar(&detail, "detail", false, "list every session instead of the grouped summary") + c.Flags().StringVar(&since, "since", "", "only preview sessions updated on or after YYYY-MM-DD") + c.Flags().StringVar(&until, "until", "", "hermes only: import sessions that ended before YYYY-MM-DD (cold-start upper bound)") + c.Flags().IntVar(&limit, "limit", 0, "preview at most the N most recent sessions (0 = unlimited; applied after --since)") + return c +} + +func newConversationsRun() *cobra.Command { + var ( + dryRun bool + platforms []string + since string + until string + limit int + force bool + noPrompt bool + paths []string + exclude []string + detail bool + async bool + ) + c := &cobra.Command{ + Use: "run [platform...]", + Short: "Upload discovered agent sessions to EverMe memory", + Long: `Run scans local agent sessions and uploads them to EverMe agent-memory. + +Each platform writes under its own identity (per-platform evt token). +Upload is synchronous by default: each session is added with sync batches and +flushed at the end, so extraction has been triggered by the time a session +reports done. This is slower than the old fire-and-forget path but results +are visible in Memory Hub as soon as each session completes. If the server +reports a session as "extraction_pending", the upload landed but extraction +was deferred upstream. Very large sessions may need a higher --timeout; each +session gets at least a 5-minute budget. + +--async switches to the bulk path meant for large backgrounds imports: every +session's batches are sent fire-and-forget (the server ACKs "queued" +immediately), and only after ALL sessions are uploaded does the run issue one +flush per session to trigger extraction. Deferring the flushes keeps them +from racing async adds that have not landed upstream yet; a flush that still +reports no extraction is retried once and then surfaced as +extraction_pending (re-run with --force to retry the flush later). Sessions +reported "queued" are still being processed server-side — memories appear on +the page progressively, not by the time the command exits. + +Sessions already marked submitted in local idempotency state are dropped +before --limit selects the N most recent, so a previously-imported session +never eats into your --limit budget; the drop is summarized in one stderr +line rather than listed session-by-session. --force disables the drop and +re-uploads everything, including sessions marked submitted. + +In an interactive terminal, a preview table and privacy notice are shown +before prompting for confirmation. In CI / non-interactive use, pass +--no-prompt together with an explicit --platform scope. + +Flags: + --dry-run Scan and print preview; do not upload anything. + --platform Limit to specific platform(s) (repeatable). + --since Only include sessions updated on or after this date (YYYY-MM-DD). + --limit Upload at most the N most recent sessions (0 = unlimited; applied + after --since and after dropping previously-imported sessions). + --force Re-upload even sessions already marked submitted (also skips the + pre-limit drop, so submitted sessions can consume --limit again). + --no-prompt Skip interactive confirmation (requires --platform). + --path Override scan root: platform=path (repeatable). + --exclude Exclude a specific session by path (repeatable; use the path shown in scan). + --async Fire-and-forget adds for every session first, then one flush per + session at the end (fast bulk path; sessions report "queued").`, + Example: ` evercli import conversations run + evercli import conversations run --platform claude-code --dry-run + evercli import conversations run --platform claude-code --no-prompt + evercli import conversations run --platform claude-code --no-prompt --async`, + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + // Merge positional args + --platform flag + effectivePlatforms := append(args, platforms...) + + isTTY := isatty.IsTerminal(os.Stdin.Fd()) + + if err := runConversationsGuard(runGuardInput{ + IsTTY: isTTY, + NoPrompt: noPrompt, + Platforms: effectivePlatforms, + DryRun: dryRun, + }); err != nil { + return deps.Out.Err(err) + } + + // Build platform IDs + var platformIDs []conversation.PlatformID + if len(effectivePlatforms) == 0 { + reg := conversation.DefaultRegistry() + for _, sc := range reg.Scanners() { + platformIDs = append(platformIDs, sc.Platform()) + } + } else { + ids, perr := conversation.ParsePlatforms(effectivePlatforms) + if perr != nil { + return deps.Out.Err(output.Invalid(perr.Error(), "Use one of: claude-code, codex, hermes, openclaw, markdown, kimicode, raven, workbuddy")) + } + platformIDs = ids + } + + // Fix 4: validate --since format before doing any work + if err := conversation.ValidateSince(since); err != nil { + return deps.Out.Err(err) + } + if until != "" { + if _, perr := time.Parse("2006-01-02", until); perr != nil { + return deps.Out.Err(output.Invalid( + fmt.Sprintf("--until must be YYYY-MM-DD, got %q", until), "")) + } + } + + // Build custom roots from --path overrides (format: platform=path) + customRoots := map[conversation.PlatformID][]string{} + for _, p := range paths { + parts := strings.SplitN(p, "=", 2) + if len(parts) != 2 { + fmt.Fprintf(cmd.ErrOrStderr(), "--path %q: expected format platform=path, ignoring\n", p) + continue + } + pid := conversation.PlatformID(strings.TrimSpace(parts[0])) + customRoots[pid] = append(customRoots[pid], strings.TrimSpace(parts[1])) + } + + cleanup, platformIDs, herr := bridgeHermes(cmd, len(effectivePlatforms) == 0, platformIDs, customRoots, until) + if herr != nil { + return deps.Out.Err(herr) + } + defer cleanup() + + svc := conversation.NewService(conversation.ServiceDeps{ + Roots: customRoots, + }) + + rep, err := svc.Scan(platformIDs) + if err != nil { + return deps.Out.Err(err) + } + + // Annotate items already recorded as submitted (spec E3 §1), then drop + // them before --limit selection (spec E3 §2) so a previously-imported + // session never eats into the --limit budget by getting counted here + // and then skipped one-by-one later by RunOne. statePath is reused + // below for runSvc so both the preview annotation and the actual + // upload/skip decisions read the same state file. + statePath := deps.Config.Paths.DataDir + "/conversations_import_state.json" + stateScope := importStateScope(deps) + st := loadIdempotencyState(cmd.ErrOrStderr(), statePath, stateScope) + annotateSubmitted(rep.Items, st) + + // Fix 3: apply --since BEFORE --limit so limit acts on the filtered set + items := conversation.FilterItemsSince(rep.Items, since) + + // Fix 2 (per-item exclusion, spec §7.0.2): resolve --exclude against + // the full since-filtered set — BEFORE dropSubmittedUnlessForce and + // BEFORE --limit — so excluding an already-submitted session (or one + // --limit would never have reached) still matches instead of a false + // "matched no session" warning (E3 review finding: dropping submitted + // items first made a real match look unmatched). --exclude always + // drops unconditionally, including under --force: force only + // disables the *submitted* drop below, not user-requested exclusion. + var excluded, unmatchedExcl, ambiguousExcl []string + items, excluded, unmatchedExcl, ambiguousExcl = applyExcludePaths(items, exclude) + for _, p := range excluded { + fmt.Fprintf(cmd.ErrOrStderr(), "excluded by --exclude: %s\n", p) + } + for _, e := range unmatchedExcl { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: --exclude %q matched no session\n", e) + } + for _, e := range ambiguousExcl { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: --exclude %q is ambiguous (matches multiple sessions by filename); pass the full session path shown in scan\n", e) + } + + // Decide what to do about already-imported sessions before + // dropping them, so the preview and --limit budget reflect the + // answer. + reimport := force + switch planForSubmitted(countSubmitted(items), force, isTTY, noPrompt) { + case submittedReimport: + reimport = true + case submittedAsk: + fmt.Fprintf(cmd.ErrOrStderr(), + "%d session(s) were already imported for this account. Import them again? [y/N]: ", + countSubmitted(items)) + reimport = readYes(os.Stdin) + case submittedSkip: + } + + var skippedSubmitted int + items, skippedSubmitted = dropSubmittedUnlessForce(items, reimport) + + if limit > 0 { + items = conversation.SortItemsNewestFirst(items) + } + items = conversation.LimitItems(items, limit) + if skippedSubmitted > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "skipped %d previously imported session(s); pass --force to re-upload\n", skippedSubmitted) + } + + // Build scan view for preview + view := buildScanView(rep, items) + + // Dry-run: render and return early + if dryRun { + fmt.Fprint(cmd.OutOrStdout(), renderConversationScan(view, detail)) + fmt.Fprintln(cmd.OutOrStdout(), "DRY RUN: no files uploaded.") + return nil + } + + // Show preview + privacy banner and prompt for confirmation only when + // interactive AND --no-prompt was not given. --no-prompt (with an + // explicit platform, enforced by the guard) skips confirmation even in + // a TTY; a non-interactive session is already gated by the guard. + consented := false + if needsConfirm(isTTY, noPrompt) { + fmt.Fprint(cmd.ErrOrStderr(), renderConversationScan(view, detail)) + fmt.Fprint(cmd.ErrOrStderr(), "Confirm import? [y/N]: ") + consented = readYes(os.Stdin) + if !consented { + fmt.Fprintln(cmd.ErrOrStderr(), "Import cancelled. Run 'evercli import conversations run' again when ready.") + return nil + } + } else { + // --no-prompt (TTY or not) with an explicit platform — guard checked. + consented = true + } + + // Fix 1: wire Uploader so the run path does not nil-panic. Reuses + // statePath from the annotation step above (same file, same + // corruption-recovery warning already surfaced by + // loadIdempotencyState there — no need to re-check here). + runSvc := conversation.NewService(conversation.ServiceDeps{ + Roots: customRoots, + StatePath: statePath, + StateScope: stateScope, + Uploader: conversation.NewUploader(deps.Config.APIBaseURL, nil), + // EvtResolver nil → RunOne defaults to conversation.ResolveEvt + }) + + // Detach from the shared command deadline (see per-session note + // in the loop). base is the un-deadlined signal source — cancelled + // only by a genuine SIGINT — and perSessionTimeout re-applies the + // configured --timeout to each session in isolation. + base := runctx.BaseContext(cmd.Context()) + perSessionTimeout := effectiveSessionTimeout(cmdctx.Snapshot().Timeout) + + // Run each item. Track upload failures so the command can exit + // non-zero (CI must be able to detect a partially-failed bulk run). + // In --async mode this loop is phase 1 (fire-and-forget adds); + // successfully-added conversations queue up for the phase-2 flush + // pass below, which runs only after EVERY add has been sent so a + // flush never races an async add that has not landed upstream. + failedCount := 0 + var toFlush []*conversation.Conversation + reg := conversation.DefaultRegistry() + for _, item := range items { + sc := reg.ScannerFor(item.Platform) + if sc == nil { + fmt.Fprintf(cmd.ErrOrStderr(), " [%s] %s → no scanner, skipped\n", item.Platform, item.Path) + continue + } + conv, readErr := sc.Read(item) + if readErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " [%s] %s → read error: %v\n", item.Platform, item.Path, readErr) + continue + } + // Defense-in-depth: the server rejects empty messages, so never + // POST a 0-message conversation. + if len(conv.Messages) == 0 { + fmt.Fprintf(cmd.ErrOrStderr(), " [%s] %s → skipped (no parseable messages)\n", item.Platform, item.Path) + continue + } + + // run is a bulk, long-running upload. BuildDeps wrapped + // cmd.Context() with the global --timeout as ONE budget for the + // whole command; sharing it across this sequential loop fails + // every session once cumulative wall-clock crosses the deadline + // (queued ... then a contiguous block of "context deadline + // exceeded"). Bound each session independently instead, derived + // from the un-deadlined signal source so SIGINT still aborts. + if err := base.Err(); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "import aborted: %v\n", err) + break + } + sessCtx, cancel := perSessionContext(cmd.Context(), perSessionTimeout) + res, runErr := runSvc.RunOne(sessCtx, conv, conversation.RunOpts{ + Consented: consented, + // reimport, not force: an interactive "yes" to the + // already-imported prompt must reach RunOne's own skip + // check too, or it would drop every session the prompt + // just kept. + Force: reimport, + Async: async, + }) + cancel() + if runErr != nil { + failedCount++ + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s → failed: %v\n", item.Platform, item.Path, runErr) + continue + } + if res.Skipped { + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s → skipped (%s)\n", + item.Platform, item.Path, res.SkipReason) + } else { + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s → %s\n", + item.Platform, item.Path, res.Status) + if isExtractionDeferred(res.Status) { + fmt.Fprintf(cmd.ErrOrStderr(), " warning: [%s] %s → upload landed but extraction was deferred upstream (extraction_pending); data is safe on the server\n", item.Platform, item.Path) + } + if async { + toFlush = append(toFlush, conv) + } + } + } + + // Phase 2 (--async only): one flush per successfully-added session. + // The data of a failed flush is already on the server; report it + // as extraction_pending rather than a failed upload. + for _, conv := range toFlush { + if err := base.Err(); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "flush pass aborted: %v\n", err) + break + } + sessCtx, cancel := perSessionContext(cmd.Context(), perSessionTimeout) + res, flushErr := runSvc.FlushOne(sessCtx, conv, asyncFlushRetryDelay) + cancel() + if flushErr != nil { + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s → extraction_pending\n", conv.Item.Platform, conv.Item.Path) + fmt.Fprintf(cmd.ErrOrStderr(), " warning: [%s] %s → flush failed (%v); data is safe on the server and the session stays unmarked — re-run the same command to retry\n", conv.Item.Platform, conv.Item.Path, flushErr) + continue + } + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s → %s\n", conv.Item.Platform, conv.Item.Path, res.Status) + if isExtractionDeferred(res.Status) { + fmt.Fprintf(cmd.ErrOrStderr(), " warning: [%s] %s → upload landed but extraction was deferred upstream (extraction_pending); data is safe on the server\n", conv.Item.Platform, conv.Item.Path) + } + } + + if e := runExitError(failedCount, len(items)); e != nil { + return deps.Out.Err(e) + } + return nil + }, + } + c.Flags().BoolVar(&dryRun, "dry-run", false, "scan and print preview; do not upload") + c.Flags().StringSliceVar(&platforms, "platform", nil, "platforms to include (default: all)") + c.Flags().StringVar(&since, "since", "", "only include sessions updated on or after YYYY-MM-DD") + c.Flags().StringVar(&until, "until", "", "hermes only: import sessions that ended before YYYY-MM-DD (cold-start upper bound)") + c.Flags().IntVar(&limit, "limit", 0, "upload at most the N most recent sessions (0 = unlimited; applied after --since and after dropping previously-imported sessions)") + c.Flags().BoolVar(&force, "force", false, "re-upload even sessions already marked submitted (also skips the pre-limit drop)") + c.Flags().BoolVar(&noPrompt, "no-prompt", false, "skip interactive confirmation (requires --platform)") + c.Flags().StringSliceVar(&paths, "path", nil, "override scan root: platform=path (repeatable)") + c.Flags().StringSliceVar(&exclude, "exclude", nil, "exclude a session by full path or filename (repeatable; matches the path or filename shown in scan)") + c.Flags().BoolVar(&detail, "detail", false, "list every session instead of the grouped summary") + c.Flags().BoolVar(&async, "async", false, "fire-and-forget adds for all sessions, then one flush per session at the end (fast bulk path)") + return c +} + +// perSessionContext derives a fresh, independently-bounded context for a +// single session's upload. It builds off the un-deadlined signal source +// stashed by cmdctx (via runctx.BaseContext) rather than the parent's global +// --timeout deadline, so a long bulk run does not accumulate one shared budget +// across sessions — each session gets the full timeout, while a genuine SIGINT +// (which cancels the base source) still aborts the whole run. timeout <= 0 +// yields an un-deadlined context (matching --timeout 0). The returned cancel +// must always be called. +func perSessionContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + base := runctx.BaseContext(parent) + if timeout <= 0 { + return context.WithCancel(base) + } + return context.WithTimeout(base, timeout) +} + +// applyExcludePaths drops every item the user excluded via --exclude, returning +// the surviving items, the full paths actually excluded (for user-facing +// notes), and the --exclude values that matched nothing (so the caller can warn +// instead of silently ignoring them — the TC-IMPORT-017 failure). +// +// A value matches an item when it equals the item's full path OR shares a +// basename that is UNIQUE across the candidate set. Basename matching keeps the +// scan-preview path usable (the preview abbreviates long paths to "...suffix", +// so a human copying the filename would never match on exact equality), but it +// is disabled for a basename shared by multiple sessions: kimicode transcripts +// are all named "wire.jsonl" (identity lives in the session_ dir), so a +// basename match there would collide with every session and drop them all. +// Shared basenames therefore require an exact full-path match; the excluded +// list always reports the resolved full path. +func applyExcludePaths(items []conversation.Item, exclude []string) (kept []conversation.Item, excluded []string, unmatched []string, ambiguous []string) { + if len(exclude) == 0 { + return items, nil, nil, nil + } + // Count basenames so basename matching only applies when a basename + // uniquely identifies a session; a basename shared by >1 item (e.g. every + // kimicode session's "wire.jsonl") must not match by basename, or excluding + // one session would drop them all. + baseCount := make(map[string]int, len(items)) + for _, it := range items { + baseCount[filepath.Base(it.Path)]++ + } + matched := make([]bool, len(exclude)) // dropped via exact path or unique basename + collided := make([]bool, len(exclude)) // basename matched >1 session (ambiguous, not dropped) + kept = make([]conversation.Item, 0, len(items)) + for _, it := range items { + base := filepath.Base(it.Path) + drop := false + for i, e := range exclude { + e = strings.TrimSpace(e) + if e == "" { + continue + } + if it.Path == e { + drop = true + matched[i] = true + continue + } + if base == filepath.Base(e) { + if baseCount[base] == 1 { + drop = true + matched[i] = true + } else { + collided[i] = true + } + } + } + if drop { + excluded = append(excluded, it.Path) + continue + } + kept = append(kept, it) + } + for i, e := range exclude { + e = strings.TrimSpace(e) + switch { + case matched[i]: + // dropped by exact path or unique basename; no warning + case collided[i]: + ambiguous = append(ambiguous, e) + default: + unmatched = append(unmatched, e) + } + } + return kept, excluded, unmatched, ambiguous +} + +// needsConfirm reports whether the run command should pause for the interactive +// "Confirm import? [y/N]" prompt. --no-prompt suppresses it even in a TTY (the +// screenshot showed `run ... --no-prompt` still prompting); a non-interactive +// session never prompts (the guard already gated it on an explicit platform). +func needsConfirm(isTTY, noPrompt bool) bool { + return isTTY && !noPrompt +} + +// runExitError returns a non-nil business error when any session failed to +// upload, so the process exits non-zero. A bulk run that prints per-session +// "failed" lines but still exits 0 hides failures from CI (the TC-IMPORT-020 +// follow-on gap). +func runExitError(failed, total int) error { + if failed == 0 { + return nil + } + return output.Conflict( + fmt.Sprintf("%d of %d session(s) failed to upload", failed, total), + map[string]interface{}{"failed": failed, "total": total}, + ) +} + +// dropPlatform returns ids without drop (used to soft-skip hermes under a +// default all-platforms import when materialization fails). +func dropPlatform(ids []conversation.PlatformID, drop conversation.PlatformID) []conversation.PlatformID { + out := make([]conversation.PlatformID, 0, len(ids)) + for _, p := range ids { + if p != drop { + out = append(out, p) + } + } + return out +} + +// bridgeHermes wires the Hermes DB bridge into a scan/run command. When hermes +// is in scope and not overridden by --path hermes=, it materializes state.db +// into a temp dir and points customRoots[hermes] at it. Returns a cleanup func +// (always non-nil — defer it), the possibly-filtered platform list (hermes +// dropped on a soft-fail under default all-platforms), and an error (only when +// hermes was named explicitly and materialization failed). +func bridgeHermes( + cmd *cobra.Command, + defaultAll bool, + platformIDs []conversation.PlatformID, + customRoots map[conversation.PlatformID][]string, + until string, +) (func(), []conversation.PlatformID, error) { + cleanup := func() {} + if !conversation.ShouldBridgeHermes(platformIDs, customRoots) { + return cleanup, platformIDs, nil + } + m, err := conversation.MaterializeHermes(until) + if err != nil { + if defaultAll { + fmt.Fprintf(cmd.ErrOrStderr(), "hermes: skipped (%v)\n", err) + return cleanup, dropPlatform(platformIDs, conversation.PlatformHermes), nil + } + return cleanup, platformIDs, output.IOErr("hermes", "materialize", err) + } + if m.SessionCount == 0 { + // Nothing matched (all in-flight, or --until filtered everything out). + // Pointing the scanner at an empty dir would trip Service.Scan's generic + // "directory layout may have changed" drift warning, which is misleading + // here. Drop hermes cleanly and say why. + m.Cleanup() + fmt.Fprintln(cmd.ErrOrStderr(), "hermes: no ended sessions to import") + return func() {}, dropPlatform(platformIDs, conversation.PlatformHermes), nil + } + customRoots[conversation.PlatformHermes] = []string{m.Dir} + return m.Cleanup, platformIDs, nil +} + +// buildScanView converts a ScanReport + filtered items into a scanView. +func buildScanView(rep *conversation.ScanReport, items []conversation.Item) scanView { + view := scanView{ + NotFound: map[string]string{}, + DriftWarnings: rep.DriftWarnings, + SkippedActive: rep.SkippedActive, + } + for p, hint := range rep.NotFound { + view.NotFound[string(p)] = hint + } + for _, item := range items { + date := item.StartedAt + if date == "" { + date = item.UpdatedAt + } + if len(date) >= 10 { + date = date[:10] + } + view.Items = append(view.Items, scanItemView{ + Platform: string(item.Platform), + Path: item.Path, + Date: date, + Messages: item.MessageCount, + ToolCalls: item.ToolCallCount, + Status: item.Status, + }) + } + view.Groups = groupScanItems(items) + view.Summary = summarizeScanItems(items) + return view +} diff --git a/cli/cmd/imports/conversations_async_test.go b/cli/cmd/imports/conversations_async_test.go new file mode 100644 index 0000000..b43e472 --- /dev/null +++ b/cli/cmd/imports/conversations_async_test.go @@ -0,0 +1,156 @@ +package imports + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// asyncCapture records every agent-memory request in arrival order so the +// test can assert the two-phase contract: every add for every session is +// sent before the first flush. +type asyncCapture struct { + mu sync.Mutex + requests []asyncReq +} + +type asyncReq struct { + ConversationID string + Flush bool + SyncSet bool + MessageCount int +} + +func (c *asyncCapture) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload struct { + ConversationID string `json:"conversationId"` + Messages []map[string]any `json:"messages"` + Flush bool `json:"flush"` + Sync *bool `json:"sync"` + } + json.Unmarshal(body, &payload) + c.mu.Lock() + c.requests = append(c.requests, asyncReq{ + ConversationID: payload.ConversationID, + Flush: payload.Flush, + SyncSet: payload.Sync != nil, + MessageCount: len(payload.Messages), + }) + c.mu.Unlock() + w.WriteHeader(202) + if payload.Flush { + w.Write([]byte(`{"status":0,"result":{"sessionId":"s1","status":"extracted","flushed":true}}`)) + return + } + w.Write([]byte(`{"status":0,"result":{"sessionId":"s1","status":"queued","messageCount":1,"flushed":false}}`)) + } +} + +// TestRunAsyncTwoPhaseUploadsThenFlushes drives the real command with --async +// against a capturing BFF: phase 1 must send every session's adds without +// sync/flush, and only after all adds are sent may the per-session flush-only +// requests go out (deferring flushes is what keeps them from racing async +// adds that have not landed upstream). +func TestRunAsyncTwoPhaseUploadsThenFlushes(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, "data")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) + + cap := &asyncCapture{} + srv := httptest.NewServer(cap.handler()) + defer srv.Close() + t.Setenv("EVERCLI_API_BASE_URL", srv.URL) + + // The codex evt lives in $CODEX_HOME/config.toml under + // [mcp_servers.everme.env] (see resolveCodexEvt). + codexHome := t.TempDir() + t.Setenv("CODEX_HOME", codexHome) + cfg := "[mcp_servers.everme]\n[mcp_servers.everme.env]\nEVERME_AGENT_TOKEN = \"evt_async_test\"\n" + if err := os.WriteFile(filepath.Join(codexHome, "config.toml"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + root := t.TempDir() + sessions := map[string]string{ + "one.jsonl": `{"timestamp":1749001000000,"type":"session_meta","payload":{}} +{"timestamp":1749001001000,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello one"}]}} +`, + "two.jsonl": `{"timestamp":1759001000000,"type":"session_meta","payload":{}} +{"timestamp":1759001001000,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello two"}]}} +`, + } + old := time.Now().Add(-1 * time.Hour) + for name, content := range sessions { + p := filepath.Join(root, name) + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + os.Chtimes(p, old, old) + } + + cmd := newConversationsRun() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{ + "codex", + "--path", "codex=" + root, + "--no-prompt", + "--async", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("async run must not error: %v (stderr=%s)", err, errOut.String()) + } + + cap.mu.Lock() + reqs := append([]asyncReq(nil), cap.requests...) + cap.mu.Unlock() + + if len(reqs) != 4 { + t.Fatalf("want 2 adds + 2 flushes = 4 requests, got %d: %+v", len(reqs), reqs) + } + // Phase 1: adds only — no sync, no flush, carrying the messages. + addIDs := map[string]bool{} + for i, r := range reqs[:2] { + if r.Flush || r.SyncSet { + t.Fatalf("request %d must be an async add (no sync/flush), got %+v", i, r) + } + if r.MessageCount == 0 { + t.Fatalf("add request %d must carry messages, got %+v", i, r) + } + addIDs[r.ConversationID] = true + } + // Phase 2: flush-only — one per session, no messages, matching the adds. + for i, r := range reqs[2:] { + if !r.Flush || r.MessageCount != 0 { + t.Fatalf("request %d must be flush-only, got %+v", i+2, r) + } + if !addIDs[r.ConversationID] { + t.Fatalf("flush %d targets unknown conversation %q", i+2, r.ConversationID) + } + delete(addIDs, r.ConversationID) + } + if len(addIDs) != 0 { + t.Fatalf("every added session must be flushed; missing flushes for %v", addIDs) + } + + // Reporting: adds surface as queued, flushes as extracted. + if !strings.Contains(out.String(), "queued") { + t.Fatalf("phase-1 status must be reported, got:\n%s", out.String()) + } + if !strings.Contains(out.String(), "extracted") { + t.Fatalf("phase-2 flush status must be reported, got:\n%s", out.String()) + } +} diff --git a/cli/cmd/imports/conversations_group.go b/cli/cmd/imports/conversations_group.go new file mode 100644 index 0000000..e4b417d --- /dev/null +++ b/cli/cmd/imports/conversations_group.go @@ -0,0 +1,119 @@ +package imports + +import ( + "regexp" + "sort" + "strings" + + "evercli/internal/importer/conversation" +) + +// scanGroupView is one row of the compact, consent-oriented scan summary: +// sessions collapsed by their natural container (claude-code → project, +// markdown → owner:zone, flat platforms → a single "(all sessions)" row). +type scanGroupView struct { + Platform string `json:"platform"` + Area string `json:"area"` + Sessions int `json:"sessions"` + Messages int `json:"messages"` + DateFrom string `json:"dateFrom,omitempty"` + DateTo string `json:"dateTo,omitempty"` +} + +var ( + ccProjectRe = regexp.MustCompile(`/projects/([^/]+)/`) + // homePrefixRe abbreviates a decoded absolute path to ~/... regardless of + // the actual user, so the displayed location is stable and approximate. + homePrefixRe = regexp.MustCompile(`^/(?:Users|home)/[^/]+/`) +) + +// scanGroupArea returns the grouping area for an item: the (approximate) +// project location for claude-code, owner:zone for markdown, and a single +// bucket for the flat session platforms. +func scanGroupArea(it conversation.Item) string { + switch it.Platform { + case conversation.PlatformClaudeCode: + return decodeProjectArea(it.Path) + case conversation.PlatformMarkdown: + owner := string(it.OwnerPlatform) + if owner == "" { + owner = "?" + } + zone := "persona" + lp := strings.ToLower(it.Path) + if strings.Contains(lp, "/memory/") || strings.Contains(lp, "/memories/") { + zone = "memory/notes" + } + return owner + ":" + zone + default: + return "(all sessions)" + } +} + +// decodeProjectArea turns a Claude Code project session path into an +// approximate, human-readable project location. Claude Code encodes the +// project's absolute path by replacing "/" with "-"; we reverse that (a +// best-effort approximation — literal dashes in names are not recoverable) +// and abbreviate the home prefix to "~/". +func decodeProjectArea(path string) string { + m := ccProjectRe.FindStringSubmatch(path) + if m == nil { + return "(root)" + } + dec := strings.ReplaceAll(m[1], "-", "/") + dec = homePrefixRe.ReplaceAllString(dec, "~/") + return dec +} + +// groupScanItems collapses per-session items into the compact summary, +// returned in a deterministic order (platform, then area). +func groupScanItems(items []conversation.Item) []scanGroupView { + type acc struct { + sessions, messages int + dmin, dmax string + } + m := map[string]*acc{} + for _, it := range items { + key := string(it.Platform) + "|" + scanGroupArea(it) + a := m[key] + if a == nil { + a = &acc{} + m[key] = a + } + a.sessions++ + a.messages += it.MessageCount + d := it.StartedAt + if d == "" { + d = it.UpdatedAt + } + if len(d) >= 10 { + d = d[:10] + if a.dmin == "" || d < a.dmin { + a.dmin = d + } + if a.dmax == "" || d > a.dmax { + a.dmax = d + } + } + } + + groups := make([]scanGroupView, 0, len(m)) + for key, a := range m { + parts := strings.SplitN(key, "|", 2) + groups = append(groups, scanGroupView{ + Platform: parts[0], + Area: parts[1], + Sessions: a.sessions, + Messages: a.messages, + DateFrom: a.dmin, + DateTo: a.dmax, + }) + } + sort.Slice(groups, func(i, j int) bool { + if groups[i].Platform != groups[j].Platform { + return groups[i].Platform < groups[j].Platform + } + return groups[i].Area < groups[j].Area + }) + return groups +} diff --git a/cli/cmd/imports/conversations_group_test.go b/cli/cmd/imports/conversations_group_test.go new file mode 100644 index 0000000..2851b97 --- /dev/null +++ b/cli/cmd/imports/conversations_group_test.go @@ -0,0 +1,88 @@ +package imports + +import ( + "strings" + "testing" + + "evercli/internal/importer/conversation" +) + +// The default scan render is the compact grouped summary: it shows the +// project area + a TOTAL line, and does NOT dump per-session file paths. +func TestRenderConversationScan_GroupedByDefault(t *testing.T) { + v := scanView{ + Items: []scanItemView{ + {Platform: "claude-code", Path: "/h/.claude/projects/-Users-me-code-app/aaa.jsonl", Date: "2026-05-18", Messages: 10}, + }, + Groups: []scanGroupView{ + {Platform: "claude-code", Area: "~/code/app", Sessions: 1, Messages: 10, DateFrom: "2026-05-18", DateTo: "2026-05-18"}, + }, + } + out := renderConversationScan(v, false) + if !strings.Contains(out, "AREA/PROJECT") || !strings.Contains(out, "~/code/app") { + t.Fatalf("grouped view must show area header + project:\n%s", out) + } + if !strings.Contains(out, "TOTAL") { + t.Fatalf("grouped view must show a TOTAL line:\n%s", out) + } + if strings.Contains(out, "aaa.jsonl") { + t.Fatalf("grouped view must NOT dump per-session paths:\n%s", out) + } +} + +// groupScanItems collapses the per-session item list into a compact, +// consent-oriented summary: claude-code by project, markdown by owner+zone, +// flat platforms into a single "(all sessions)" row. Each group carries +// session/message counts and a date range. This is what the default scan +// preview renders so the user sees WHICH projects/areas would upload without +// scrolling hundreds of rows. +func TestGroupScanItems_ByProjectAndZone(t *testing.T) { + items := []conversation.Item{ + // claude-code: two sessions under the same project → one group + {Platform: "claude-code", Path: "/Users/me/.claude/projects/-Users-me-code-app/aaa.jsonl", StartedAt: "2026-05-18T10:00:00Z", MessageCount: 10}, + {Platform: "claude-code", Path: "/Users/me/.claude/projects/-Users-me-code-app/bbb.jsonl", StartedAt: "2026-06-01T10:00:00Z", MessageCount: 5}, + // claude-code: different project → separate group + {Platform: "claude-code", Path: "/Users/me/.claude/projects/-Users-me-code-other/ccc.jsonl", StartedAt: "2026-06-10T10:00:00Z", MessageCount: 7}, + // codex: flat → single "(all sessions)" group + {Platform: "codex", Path: "/Users/me/.codex/sessions/x.jsonl", StartedAt: "2026-06-05T10:00:00Z", MessageCount: 3}, + {Platform: "codex", Path: "/Users/me/.codex/sessions/y.jsonl", StartedAt: "2026-06-06T10:00:00Z", MessageCount: 4}, + // markdown: persona (depth-1) vs memory subtree, attributed to openclaw + {Platform: "markdown", Path: "/Users/me/.openclaw/workspace/USER.md", OwnerPlatform: "openclaw", UpdatedAt: "2026-03-18T10:00:00Z", MessageCount: 1}, + {Platform: "markdown", Path: "/Users/me/.openclaw/workspace/memory/2026-04-04.md", OwnerPlatform: "openclaw", UpdatedAt: "2026-04-04T10:00:00Z", MessageCount: 1}, + } + + groups := groupScanItems(items) + + // Index by platform+area for assertions. + byKey := map[string]scanGroupView{} + for _, g := range groups { + byKey[g.Platform+"|"+g.Area] = g + } + + if len(groups) != 5 { + t.Fatalf("expected 5 groups, got %d: %+v", len(groups), groups) + } + + app, ok := byKey["claude-code|~/code/app"] + if !ok { + t.Fatalf("expected decoded claude-code project group ~/code/app; groups=%+v", groups) + } + if app.Sessions != 2 || app.Messages != 15 { + t.Errorf("app group: want 2 sessions/15 msgs, got %d/%d", app.Sessions, app.Messages) + } + if app.DateFrom != "2026-05-18" || app.DateTo != "2026-06-01" { + t.Errorf("app group date range: want 2026-05-18→2026-06-01, got %s→%s", app.DateFrom, app.DateTo) + } + + codex, ok := byKey["codex|(all sessions)"] + if !ok || codex.Sessions != 2 || codex.Messages != 7 { + t.Errorf("codex group: want 2 sessions/7 msgs in '(all sessions)', got %+v (ok=%v)", codex, ok) + } + + if _, ok := byKey["markdown|openclaw:persona"]; !ok { + t.Errorf("expected markdown persona group; groups=%+v", groups) + } + if _, ok := byKey["markdown|openclaw:memory/notes"]; !ok { + t.Errorf("expected markdown memory/notes group; groups=%+v", groups) + } +} diff --git a/cli/cmd/imports/conversations_platform_test.go b/cli/cmd/imports/conversations_platform_test.go new file mode 100644 index 0000000..c73f4e1 --- /dev/null +++ b/cli/cmd/imports/conversations_platform_test.go @@ -0,0 +1,62 @@ +package imports + +import ( + "bytes" + "testing" + + "evercli/internal/importer/conversation" + "evercli/internal/plugin" +) + +// FIX 5 — an unknown platform name on `scan` is a hard error, not a silent ok. +func TestScanUnknownPlatformErrors(t *testing.T) { + // Isolate config/data dirs so BuildDeps bootstraps against an empty home. + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + cmd := newConversationsScan() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"nope"}) + + err := cmd.Execute() + if err == nil { + t.Fatalf("unknown platform must return a non-nil error; stdout=%q stderr=%q", out.String(), errOut.String()) + } + // The error routes through the output envelope as a non-zero exit + // (invalid_args). Exact wording lives in the package-level ParsePlatforms + // test; here we only assert it is a hard, non-ok failure. +} + +// A known platform still succeeds (no error from validation). +func TestScanKnownPlatformOK(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + cmd := newConversationsScan() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"claude-code"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("known platform must not error: %v (stderr=%q)", err, errOut.String()) + } +} + +// loadHookMarks casts plugin.Platform -> conversation.PlatformID by bare +// string, trusting the two literals stay spelled the same (see the comment +// beside that cast in conversations.go). Neither package's own registry +// test can catch a future drift on its own since importer and plugin must +// not import each other (cli/AGENTS.md); this one can, from cmd/. +func TestWorkBuddyPlatformStringMatchesPlugin(t *testing.T) { + if string(conversation.PlatformWorkBuddy) != string(plugin.PlatformWorkBuddy) { + t.Fatalf("conversation.PlatformWorkBuddy=%q and plugin.PlatformWorkBuddy=%q have drifted", + conversation.PlatformWorkBuddy, plugin.PlatformWorkBuddy) + } +} diff --git a/cli/cmd/imports/conversations_run_test.go b/cli/cmd/imports/conversations_run_test.go new file mode 100644 index 0000000..ee18dcf --- /dev/null +++ b/cli/cmd/imports/conversations_run_test.go @@ -0,0 +1,241 @@ +package imports + +import ( + "testing" + + "evercli/internal/importer/conversation" + "evercli/internal/output" +) + +func TestApplyExcludePathsDropsMatchingPaths(t *testing.T) { + items := []conversation.Item{ + {Platform: conversation.PlatformClaudeCode, Path: "/a/keep.jsonl"}, + {Platform: conversation.PlatformCodex, Path: "/b/drop.jsonl"}, + {Platform: conversation.PlatformHermes, Path: "/c/keep2.json"}, + } + kept, excluded, unmatched, _ := applyExcludePaths(items, []string{"/b/drop.jsonl"}) + if len(kept) != 2 { + t.Fatalf("expected 2 kept, got %d: %+v", len(kept), kept) + } + for _, it := range kept { + if it.Path == "/b/drop.jsonl" { + t.Fatalf("excluded path must not survive: %s", it.Path) + } + } + if len(excluded) != 1 || excluded[0] != "/b/drop.jsonl" { + t.Fatalf("expected excluded=[/b/drop.jsonl], got %v", excluded) + } + if len(unmatched) != 0 { + t.Fatalf("expected no unmatched, got %v", unmatched) + } +} + +// A bare filename (as a human copies from the scan preview, which abbreviates +// long paths to "...suffix") must still match the full session path by +// basename — the original exact-only match silently dropped these, which is +// the TC-IMPORT-017 failure (no "excluded by" line was ever printed). +func TestApplyExcludePathsMatchesByBasename(t *testing.T) { + items := []conversation.Item{ + {Platform: conversation.PlatformClaudeCode, Path: "/very/long/dir/abc-123.jsonl"}, + {Platform: conversation.PlatformClaudeCode, Path: "/very/long/dir/keep.jsonl"}, + } + kept, excluded, unmatched, _ := applyExcludePaths(items, []string{"abc-123.jsonl"}) + if len(kept) != 1 || kept[0].Path != "/very/long/dir/keep.jsonl" { + t.Fatalf("basename exclude should drop abc-123.jsonl, kept=%+v", kept) + } + if len(excluded) != 1 || excluded[0] != "/very/long/dir/abc-123.jsonl" { + t.Fatalf("excluded must report the full path, got %v", excluded) + } + if len(unmatched) != 0 { + t.Fatalf("expected no unmatched, got %v", unmatched) + } +} + +// Kimicode session transcripts are all named "wire.jsonl" (identity lives in +// the session_ directory, not the filename), so basename matching would +// collide across every session. Excluding ONE session by its full path must +// drop only that session; the OR match on filepath.Base used to make every +// wire.jsonl match and dropped all kimicode sessions ("No sessions found"). +func TestApplyExcludePathsBasenameCollisionKeepsOthers(t *testing.T) { + items := []conversation.Item{ + {Platform: conversation.PlatformKimicode, Path: "/h/.kimi-code/sessions/wd_p/session_AAAA/agents/main/wire.jsonl"}, + {Platform: conversation.PlatformKimicode, Path: "/h/.kimi-code/sessions/wd_p/session_BBBB/agents/main/wire.jsonl"}, + {Platform: conversation.PlatformKimicode, Path: "/h/.kimi-code/sessions/wd_p/session_CCCC/agents/main/wire.jsonl"}, + } + target := items[0].Path + kept, excluded, unmatched, _ := applyExcludePaths(items, []string{target}) + if len(kept) != 2 { + t.Fatalf("excluding one wire.jsonl by full path must keep the other 2, got %d kept: %+v", len(kept), kept) + } + for _, it := range kept { + if it.Path == target { + t.Fatalf("excluded target must not survive: %s", it.Path) + } + } + if len(excluded) != 1 || excluded[0] != target { + t.Fatalf("expected excluded=[target], got %v", excluded) + } + if len(unmatched) != 0 { + t.Fatalf("expected no unmatched, got %v", unmatched) + } +} + +// An --exclude value that matches nothing must be surfaced (fail-loud), not +// silently ignored. +func TestApplyExcludePathsReportsUnmatched(t *testing.T) { + items := []conversation.Item{ + {Platform: conversation.PlatformClaudeCode, Path: "/a/keep.jsonl"}, + } + kept, excluded, unmatched, _ := applyExcludePaths(items, []string{"/nope.jsonl", "keep.jsonl"}) + if len(kept) != 0 { + t.Fatalf("keep.jsonl matches by basename, expected 0 kept, got %+v", kept) + } + if len(excluded) != 1 || excluded[0] != "/a/keep.jsonl" { + t.Fatalf("expected excluded=[/a/keep.jsonl], got %v", excluded) + } + if len(unmatched) != 1 || unmatched[0] != "/nope.jsonl" { + t.Fatalf("expected unmatched=[/nope.jsonl], got %v", unmatched) + } +} + +// A bare basename that collides across sessions (e.g. "wire.jsonl") must NOT +// drop anything and must be surfaced as ambiguous (guiding the user to pass the +// full session path), distinct from a value that matched nothing at all. +func TestApplyExcludePathsAmbiguousBasenameReported(t *testing.T) { + items := []conversation.Item{ + {Platform: conversation.PlatformKimicode, Path: "/h/sessions/wd_p/session_AAAA/agents/main/wire.jsonl"}, + {Platform: conversation.PlatformKimicode, Path: "/h/sessions/wd_p/session_BBBB/agents/main/wire.jsonl"}, + } + kept, excluded, unmatched, ambiguous := applyExcludePaths(items, []string{"wire.jsonl"}) + if len(kept) != 2 { + t.Fatalf("ambiguous basename must drop nothing, got %d kept", len(kept)) + } + if len(excluded) != 0 { + t.Fatalf("expected no excluded, got %v", excluded) + } + if len(unmatched) != 0 { + t.Fatalf("ambiguous is not the same as unmatched, got unmatched=%v", unmatched) + } + if len(ambiguous) != 1 || ambiguous[0] != "wire.jsonl" { + t.Fatalf("expected ambiguous=[wire.jsonl], got %v", ambiguous) + } +} + +// --no-prompt must skip the interactive confirmation even in a TTY. The +// screenshot showed `run ... --no-prompt` still printing "Confirm import?". +func TestNeedsConfirm(t *testing.T) { + if !needsConfirm(true, false) { + t.Fatal("interactive TTY without --no-prompt must confirm") + } + if needsConfirm(true, true) { + t.Fatal("--no-prompt in a TTY must skip confirmation") + } + if needsConfirm(false, true) || needsConfirm(false, false) { + t.Fatal("non-interactive never prompts (guard already gated it)") + } +} + +// A run where some sessions failed must exit non-zero, not 0. +func TestRunExitError(t *testing.T) { + if err := runExitError(0, 5); err != nil { + t.Fatalf("no failures must yield nil, got %v", err) + } + if err := runExitError(2, 5); err == nil { + t.Fatal("failures must yield a non-nil (non-zero exit) error") + } +} + +func TestRunRefusesNonInteractiveWithoutScope(t *testing.T) { + err := runConversationsGuard(runGuardInput{IsTTY: false, NoPrompt: false, Platforms: nil}) + if err == nil { + t.Fatal("non-interactive without explicit scope must be refused") + } + assertGuardErrIsInvalidArgs(t, err) +} + +func TestRunNoPromptNeedsExplicitPlatform(t *testing.T) { + err := runConversationsGuard(runGuardInput{IsTTY: false, NoPrompt: true, Platforms: nil}) + if err == nil { + t.Fatal("--no-prompt still needs explicit platform/scope") + } + assertGuardErrIsInvalidArgs(t, err) + + if err := runConversationsGuard(runGuardInput{IsTTY: false, NoPrompt: true, Platforms: []string{"claude-code"}}); err != nil { + t.Fatalf("explicit scope under --no-prompt should pass guard: %v", err) + } +} + +// assertGuardErrIsInvalidArgs pins the ABI-visible taxonomy for guard +// refusals: these are bad-input errors (exit code 2), not TypeInternal. +// A bare fmt.Errorf classifies as TypeInternal and mismaps to exit 1 with +// a misleading "internal" error.type in the envelope — the ECA E2E finding +// this fix addresses. +func assertGuardErrIsInvalidArgs(t *testing.T, err error) { + t.Helper() + ce, ok := output.AsCLIError(err) + if !ok { + t.Fatalf("guard error must be a *output.CLIError, got %T: %v", err, err) + } + if ce.Type != output.TypeInvalidArgs { + t.Fatalf("guard error Type = %q, want %q (validation, exit code 2)", ce.Type, output.TypeInvalidArgs) + } +} + +// --dry-run uploads nothing; the guard exists to stop unattended bulk +// UPLOADS in CI, not previews. A non-interactive dry-run with no +// --no-prompt/--platform must be let through. +func TestRunDryRunBypassesGuardEvenWithoutScope(t *testing.T) { + err := runConversationsGuard(runGuardInput{IsTTY: false, NoPrompt: false, Platforms: nil, DryRun: true}) + if err != nil { + t.Fatalf("dry-run must bypass the guard, got %v", err) + } +} + +func TestRunDryRunBypassesGuardEvenWithNoPromptNoScope(t *testing.T) { + err := runConversationsGuard(runGuardInput{IsTTY: false, NoPrompt: true, Platforms: nil, DryRun: true}) + if err != nil { + t.Fatalf("dry-run must bypass the guard regardless of --no-prompt/platform state, got %v", err) + } +} + +// TestPlanForSubmitted covers the 2026-08-17 review item 1.2.3, second +// half: the only way to re-import a session the ledger already knows +// about was to know that --force exists. An interactive run should ask +// instead. Non-interactive runs must NOT gain a prompt - AI agents drive +// that path and would hang on it. +func TestPlanForSubmitted(t *testing.T) { + cases := []struct { + name string + alreadySubmitted int + force bool + isTTY bool + noPrompt bool + want submittedPlan + }{ + {"nothing submitted, nothing to decide", 0, false, true, false, submittedSkip}, + {"force still means re-upload everything", 3, true, false, true, submittedReimport}, + {"interactive run asks", 3, false, true, false, submittedAsk}, + {"--no-prompt keeps skipping silently", 3, false, true, true, submittedSkip}, + {"non-tty keeps skipping silently", 3, false, false, false, submittedSkip}, + } + for _, tc := range cases { + got := planForSubmitted(tc.alreadySubmitted, tc.force, tc.isTTY, tc.noPrompt) + if got != tc.want { + t.Fatalf("%s: planForSubmitted(%d,%v,%v,%v) = %v, want %v", + tc.name, tc.alreadySubmitted, tc.force, tc.isTTY, tc.noPrompt, got, tc.want) + } + } +} + +// TestCountSubmitted pins the input planForSubmitted works from. +func TestCountSubmitted(t *testing.T) { + items := []conversation.Item{ + {Status: "submitted"}, + {Status: "ready"}, + {Status: "submitted"}, + {Status: "unsupported"}, + } + if got := countSubmitted(items); got != 2 { + t.Fatalf("countSubmitted = %d, want 2", got) + } +} diff --git a/cli/cmd/imports/conversations_state_test.go b/cli/cmd/imports/conversations_state_test.go new file mode 100644 index 0000000..71ab297 --- /dev/null +++ b/cli/cmd/imports/conversations_state_test.go @@ -0,0 +1,428 @@ +package imports + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "evercli/internal/core" + "evercli/internal/importer/conversation" +) + +// --------------------------------------------------------------------------- +// annotateSubmitted +// --------------------------------------------------------------------------- + +func TestAnnotateSubmittedMarksMatchingItems(t *testing.T) { + dir := t.TempDir() + st, err := conversation.LoadState(filepath.Join(dir, "state.json"), "") + if err != nil { + t.Fatal(err) + } + st.MarkSubmitted(conversation.ItemStateKey(conversation.Item{Platform: conversation.PlatformCodex, Path: "/a/submitted.jsonl"}), "cid-1") + + items := []conversation.Item{ + {Platform: conversation.PlatformCodex, Path: "/a/submitted.jsonl"}, + {Platform: conversation.PlatformCodex, Path: "/a/new.jsonl"}, + } + annotateSubmitted(items, st) + + if items[0].Status != "submitted" { + t.Fatalf("expected submitted.jsonl to be annotated submitted, got %q", items[0].Status) + } + if items[1].Status == "submitted" { + t.Fatalf("new.jsonl must not be annotated submitted") + } +} + +// The same file path scanned under a different platform must not collide — +// the key incorporates platform, matching stateKey's derivation exactly. +func TestAnnotateSubmittedKeyIncludesPlatform(t *testing.T) { + dir := t.TempDir() + st, err := conversation.LoadState(filepath.Join(dir, "state.json"), "") + if err != nil { + t.Fatal(err) + } + st.MarkSubmitted(conversation.ItemStateKey(conversation.Item{Platform: conversation.PlatformCodex, Path: "/a/shared.jsonl"}), "cid-1") + + items := []conversation.Item{ + {Platform: conversation.PlatformClaudeCode, Path: "/a/shared.jsonl"}, + } + annotateSubmitted(items, st) + + if items[0].Status == "submitted" { + t.Fatal("a different platform sharing the same path must not be marked submitted") + } +} + +// A nil state (stateless fallback after a load failure) must be a no-op — +// scan/run must keep working, just without the submitted annotation. +func TestAnnotateSubmittedNilStateIsNoop(t *testing.T) { + items := []conversation.Item{ + {Platform: conversation.PlatformCodex, Path: "/a/x.jsonl"}, + } + annotateSubmitted(items, nil) + if items[0].Status != "" { + t.Fatalf("nil state must not mutate item status, got %q", items[0].Status) + } +} + +// --------------------------------------------------------------------------- +// loadIdempotencyState +// --------------------------------------------------------------------------- + +// A missing state file is the common first-run case: no error, no warning, +// just an empty usable state. +func TestLoadIdempotencyStateMissingFileIsSilent(t *testing.T) { + dir := t.TempDir() + var errBuf bytes.Buffer + st := loadIdempotencyState(&errBuf, filepath.Join(dir, "does-not-exist.json"), "") + if st == nil { + t.Fatal("missing file must yield a usable empty state, not nil") + } + if errBuf.Len() != 0 { + t.Fatalf("missing file must not warn, got: %s", errBuf.String()) + } +} + +// A corrupt file that LoadState recovers from must be surfaced — the same +// warning the run command already printed via EnsureStateLoaded before this +// change — since already-submitted sessions may re-upload after the reset. +func TestLoadIdempotencyStateWarnsOnCorruptRecovery(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + if err := os.WriteFile(path, []byte(`{"entries":{}}{"entries":{}}`), 0o644); err != nil { + t.Fatal(err) + } + var errBuf bytes.Buffer + st := loadIdempotencyState(&errBuf, path, "") + if st == nil { + t.Fatal("corrupt-file recovery must still yield a usable state") + } + if !strings.Contains(errBuf.String(), "backed up") { + t.Fatalf("must warn about the corrupt-state recovery, got: %s", errBuf.String()) + } +} + +// A hard read error (not "file does not exist") must warn and fall back to +// stateless (nil), never fatal — scan/run must keep working. +func TestLoadIdempotencyStateWarnsOnHardErrorAndReturnsNil(t *testing.T) { + dir := t.TempDir() + // A directory where a file is expected forces a read error distinct from + // "not exist" on every platform. + badPath := filepath.Join(dir, "not-a-file") + if err := os.Mkdir(badPath, 0o755); err != nil { + t.Fatal(err) + } + var errBuf bytes.Buffer + st := loadIdempotencyState(&errBuf, badPath, "") + if st != nil { + t.Fatal("hard read error must fall back to nil (stateless)") + } + if errBuf.Len() == 0 { + t.Fatal("hard read error must warn on stderr") + } +} + +// --------------------------------------------------------------------------- +// dropSubmittedUnlessForce +// --------------------------------------------------------------------------- + +func TestDropSubmittedUnlessForceDropsSubmitted(t *testing.T) { + items := []conversation.Item{ + {Platform: conversation.PlatformCodex, Path: "/a.jsonl", Status: "submitted"}, + {Platform: conversation.PlatformCodex, Path: "/b.jsonl", Status: ""}, + {Platform: conversation.PlatformCodex, Path: "/c.jsonl", Status: "submitted"}, + } + kept, dropped := dropSubmittedUnlessForce(items, false) + if dropped != 2 { + t.Fatalf("expected 2 dropped, got %d", dropped) + } + if len(kept) != 1 || kept[0].Path != "/b.jsonl" { + t.Fatalf("expected only /b.jsonl to survive, got %+v", kept) + } +} + +func TestDropSubmittedUnlessForceKeepsAllWhenForced(t *testing.T) { + items := []conversation.Item{ + {Platform: conversation.PlatformCodex, Path: "/a.jsonl", Status: "submitted"}, + {Platform: conversation.PlatformCodex, Path: "/b.jsonl", Status: ""}, + } + kept, dropped := dropSubmittedUnlessForce(items, true) + if dropped != 0 { + t.Fatalf("--force must drop nothing, got dropped=%d", dropped) + } + if len(kept) != 2 { + t.Fatalf("--force must keep all items, got %+v", kept) + } +} + +// --------------------------------------------------------------------------- +// summarizeScanItems / buildScanView / render +// --------------------------------------------------------------------------- + +func TestSummarizeScanItemsCountsByStatus(t *testing.T) { + items := []conversation.Item{ + {Status: ""}, + {Status: "ready"}, + {Status: "submitted"}, + {Status: "submitted"}, + {Status: "unsupported"}, + } + s := summarizeScanItems(items) + if s.New != 2 { + t.Errorf("expected 2 new (empty + ready), got %d", s.New) + } + if s.AlreadySubmitted != 2 { + t.Errorf("expected 2 alreadySubmitted, got %d", s.AlreadySubmitted) + } + if s.Unsupported != 1 { + t.Errorf("expected 1 unsupported, got %d", s.Unsupported) + } +} + +func TestBuildScanViewIncludesSummary(t *testing.T) { + rep := &conversation.ScanReport{} + items := []conversation.Item{ + {Platform: conversation.PlatformCodex, Path: "/a.jsonl", Status: "submitted"}, + {Platform: conversation.PlatformCodex, Path: "/b.jsonl", Status: ""}, + } + view := buildScanView(rep, items) + if view.Summary.New != 1 || view.Summary.AlreadySubmitted != 1 { + t.Fatalf("expected summary {new:1, alreadySubmitted:1}, got %+v", view.Summary) + } +} + +// The grouped TOTAL line must append the new/imported counts additively — +// existing content (groups/sessions/messages) must remain intact. +func TestRenderScanGroupTableAppendsNewImportedCounts(t *testing.T) { + v := scanView{ + Items: []scanItemView{ + {Platform: "codex", Path: "/a.jsonl", Messages: 3, Status: ""}, + {Platform: "codex", Path: "/b.jsonl", Messages: 4, Status: "submitted"}, + }, + Groups: []scanGroupView{ + {Platform: "codex", Area: "(all sessions)", Sessions: 2, Messages: 7}, + }, + Summary: scanSummaryView{New: 1, AlreadySubmitted: 1}, + } + out := renderConversationScan(v, false) + if !strings.Contains(out, "TOTAL:") { + t.Fatalf("must still show TOTAL line:\n%s", out) + } + if !strings.Contains(out, "1 new") || !strings.Contains(out, "1 imported") { + t.Fatalf("TOTAL line must append new/imported counts:\n%s", out) + } +} + +// --------------------------------------------------------------------------- +// Full pipeline integration: run --dry-run drops previously-submitted +// sessions before --limit selection and prints one stderr summary line. +// --------------------------------------------------------------------------- + +func TestRunDryRunDropsSubmittedBeforeLimit(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + dataDir := filepath.Join(home, "data") + t.Setenv("XDG_DATA_HOME", dataDir) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) + + // Two codex session files in a scan root: one will be pre-marked + // submitted in state, the other left new. Distinct timestamps make + // newest-first ordering deterministic. + root := t.TempDir() + oldContent := `{"timestamp":1749001000000,"type":"session_meta","payload":{}} +{"timestamp":1749001001000,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello old"}]}} +` + newContent := `{"timestamp":1759001000000,"type":"session_meta","payload":{}} +{"timestamp":1759001001000,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello new"}]}} +` + oldPath := filepath.Join(root, "old_session.jsonl") + newPath := filepath.Join(root, "new_session.jsonl") + if err := os.WriteFile(oldPath, []byte(oldContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newPath, []byte(newContent), 0o644); err != nil { + t.Fatal(err) + } + // Backdate both files past the active-session window so neither is + // excluded as "still being written". + old := time.Now().Add(-1 * time.Hour) + os.Chtimes(oldPath, old, old) + os.Chtimes(newPath, old, old) + + cmd := newConversationsRun() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{ + "codex", + "--path", "codex=" + root, + "--no-prompt", + "--dry-run", + "--detail", + "--limit", "1", + }) + + // First dry-run with no state seeded: discover both sessions are "new" + // and confirm the command runs end-to-end (also exercises the annotation + // no-op path when the state file does not exist yet). + if err := cmd.Execute(); err != nil { + t.Fatalf("dry-run must not error: %v (stderr=%s)", err, errOut.String()) + } + if !strings.Contains(out.String(), "new_session.jsonl") { + t.Fatalf("with no state seeded, --limit 1 must pick the newest session, got:\n%s", out.String()) + } + + // Now seed state marking the NEW session as already submitted, and + // re-run: --limit 1 must fall through to the OLD session instead of + // wasting the budget on (and then dropping) the submitted one. + statePath := filepath.Join(dataDir, "evercli", "conversations_import_state.json") + st, err := conversation.LoadState(statePath, seedStateScope(t)) + if err != nil { + t.Fatal(err) + } + st.MarkSubmitted(conversation.ItemStateKey(conversation.Item{Platform: conversation.PlatformCodex, Path: newPath}), "cid-new") + if err := st.Save(); err != nil { + t.Fatal(err) + } + + cmd2 := newConversationsRun() + var out2, errOut2 bytes.Buffer + cmd2.SetOut(&out2) + cmd2.SetErr(&errOut2) + cmd2.SetArgs([]string{ + "codex", + "--path", "codex=" + root, + "--no-prompt", + "--dry-run", + "--detail", + "--limit", "1", + }) + if err := cmd2.Execute(); err != nil { + t.Fatalf("second dry-run must not error: %v (stderr=%s)", err, errOut2.String()) + } + if strings.Contains(out2.String(), "new_session.jsonl") { + t.Fatalf("submitted session must be dropped pre-limit, not shown:\n%s", out2.String()) + } + if !strings.Contains(out2.String(), "old_session.jsonl") { + t.Fatalf("--limit 1 must fall through to the next (old) session once the submitted one is dropped:\n%s", out2.String()) + } + if !strings.Contains(errOut2.String(), "skipped 1 previously imported session(s); pass --force to re-upload") { + t.Fatalf("must print the one-line stderr summary, got: %s", errOut2.String()) + } +} + +// --------------------------------------------------------------------------- +// Review finding fix: --exclude of an already-submitted session must not +// print a false "matched no session" warning. dropSubmittedUnlessForce used +// to run before applyExcludePaths, so a submitted session was already gone +// from the candidate set by the time --exclude tried to match it — a real +// match on a session scan actually discovered was misreported as +// "matched no session". applyExcludePaths must now resolve against the +// full since-filtered (pre-drop) set. +// --------------------------------------------------------------------------- + +func TestRunExcludeSubmittedSessionNoFalseUnmatchedWarning(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + dataDir := filepath.Join(home, "data") + t.Setenv("XDG_DATA_HOME", dataDir) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) + + root := t.TempDir() + content := `{"timestamp":1749001000000,"type":"session_meta","payload":{}} +{"timestamp":1749001001000,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello submitted"}]}} +` + sessPath := filepath.Join(root, "submitted_session.jsonl") + if err := os.WriteFile(sessPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-1 * time.Hour) + os.Chtimes(sessPath, old, old) + + // Mark this session submitted BEFORE the run, exactly like a prior + // successful import would have. + statePath := filepath.Join(dataDir, "evercli", "conversations_import_state.json") + st, err := conversation.LoadState(statePath, seedStateScope(t)) + if err != nil { + t.Fatal(err) + } + st.MarkSubmitted(conversation.ItemStateKey(conversation.Item{Platform: conversation.PlatformCodex, Path: sessPath}), "cid-submitted") + if err := st.Save(); err != nil { + t.Fatal(err) + } + + runExcludeCase := func(t *testing.T, extraArgs ...string) (stdout, stderr string) { + t.Helper() + cmd := newConversationsRun() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + args := []string{ + "codex", + "--path", "codex=" + root, + "--no-prompt", + "--dry-run", + "--detail", + "--exclude", sessPath, + } + args = append(args, extraArgs...) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("dry-run must not error: %v (stderr=%s)", err, errOut.String()) + } + return out.String(), errOut.String() + } + + // Without --force: the session is both submitted AND explicitly + // excluded. It must not upload, must not print the false "matched no + // session" warning, and the exclusion itself should be acknowledged. + out, errOut := runExcludeCase(t) + if strings.Contains(errOut, "matched no session") { + t.Fatalf("excluding an already-submitted session must not report a false unmatched warning, got stderr:\n%s", errOut) + } + if strings.Contains(out, "submitted_session.jsonl") { + t.Fatalf("excluded session must not appear in the preview/upload set:\n%s", out) + } + if !strings.Contains(errOut, "excluded by --exclude: "+sessPath) { + t.Fatalf("exclusion must be acknowledged on stderr, got:\n%s", errOut) + } + + // With --force: force disables the submitted-drop, but --exclude must + // still remove the path unconditionally — it must never upload/appear, + // and still no false unmatched warning. + outForce, errOutForce := runExcludeCase(t, "--force") + if strings.Contains(errOutForce, "matched no session") { + t.Fatalf("--force + --exclude on a submitted session must not report a false unmatched warning, got stderr:\n%s", errOutForce) + } + if strings.Contains(outForce, "submitted_session.jsonl") { + t.Fatalf("--force must not resurrect an explicitly excluded session:\n%s", outForce) + } + if !strings.Contains(errOutForce, "excluded by --exclude: "+sessPath) { + t.Fatalf("exclusion must be acknowledged on stderr even with --force, got:\n%s", errOutForce) + } + // --force must not print the "skipped ... previously imported" summary + // for this session either — it was excluded, not drop-skipped. + if strings.Contains(errOutForce, "previously imported session(s)") { + t.Fatalf("with --force, the excluded session must not be counted as a submitted-drop, got:\n%s", errOutForce) + } +} + +// seedStateScope returns the ledger scope a run in this test environment +// will use, so a test can seed the state file the same way a real prior +// import would have. Deriving it (rather than hardcoding the default api +// base) keeps the fixture honest if the default ever moves. +func seedStateScope(t *testing.T) string { + t.Helper() + cfg, err := core.LoadConfig("") + if err != nil { + t.Fatal(err) + } + return conversation.StateScope(cfg.APIBaseURL, "") +} diff --git a/cli/cmd/imports/conversations_test.go b/cli/cmd/imports/conversations_test.go new file mode 100644 index 0000000..fc83574 --- /dev/null +++ b/cli/cmd/imports/conversations_test.go @@ -0,0 +1,80 @@ +package imports + +import ( + "strings" + "testing" + "time" +) + +func TestEffectiveSessionTimeout(t *testing.T) { + cases := []struct { + in, want time.Duration + }{ + {0, 0}, // unlimited stays unlimited + {-1, -1}, // defensive: non-positive untouched + {30 * time.Second, 5 * time.Minute}, // short global timeout is floored + {5 * time.Minute, 5 * time.Minute}, + {10 * time.Minute, 10 * time.Minute}, + } + for _, c := range cases { + if got := effectiveSessionTimeout(c.in); got != c.want { + t.Errorf("effectiveSessionTimeout(%v)=%v, want %v", c.in, got, c.want) + } + } +} + +func TestIsExtractionDeferred(t *testing.T) { + cases := []struct { + status string + want bool + }{ + {"extraction_pending", true}, + {"submitted", false}, + {"", false}, + {"EXTRACTION_PENDING", false}, // exact match only, no case-folding + } + for _, c := range cases { + if got := isExtractionDeferred(c.status); got != c.want { + t.Errorf("isExtractionDeferred(%q)=%v, want %v", c.status, got, c.want) + } + } +} + +func TestRenderScanShowsPathDateAndPrivacy(t *testing.T) { + out := renderConversationScan(scanView{ + Items: []scanItemView{{Platform: "claude-code", Path: "/h/.claude/projects/p/s.jsonl", Date: "2026-05-01", Messages: 12, ToolCalls: 8}}, + }, true) + if !strings.Contains(out, "/h/.claude/projects/p/s.jsonl") || !strings.Contains(out, "2026-05-01") { + t.Fatalf("must show path + date:\n%s", out) + } + if !strings.Contains(out, "隐私") && !strings.Contains(strings.ToLower(out), "privacy") { + t.Fatalf("must show privacy warning:\n%s", out) + } +} + +func TestRenderScanShowsNotFoundHint(t *testing.T) { + out := renderConversationScan(scanView{ + Items: nil, + NotFound: map[string]string{ + "codex": "directory not found for codex", + }, + }, false) + if !strings.Contains(out, "codex") { + t.Fatalf("must show not-found hint:\n%s", out) + } +} + +func TestRenderScanShowsMultipleItems(t *testing.T) { + out := renderConversationScan(scanView{ + Items: []scanItemView{ + {Platform: "claude-code", Path: "/a/sess1.jsonl", Date: "2026-05-01", Messages: 5, ToolCalls: 2}, + {Platform: "codex", Path: "/b/sess2.jsonl", Date: "2026-05-15", Messages: 10, ToolCalls: 3}, + }, + }, true) + if !strings.Contains(out, "/a/sess1.jsonl") || !strings.Contains(out, "/b/sess2.jsonl") { + t.Fatalf("must show both items:\n%s", out) + } + if !strings.Contains(out, "2026-05-01") || !strings.Contains(out, "2026-05-15") { + t.Fatalf("must show both dates:\n%s", out) + } +} diff --git a/cli/cmd/imports/conversations_timeout_test.go b/cli/cmd/imports/conversations_timeout_test.go new file mode 100644 index 0000000..bd1341e --- /dev/null +++ b/cli/cmd/imports/conversations_timeout_test.go @@ -0,0 +1,63 @@ +package imports + +import ( + "context" + "testing" + "time" + + "evercli/internal/runctx" +) + +// The run loop is a bulk, long-running upload. BuildDeps wraps cmd.Context() +// with the global --timeout as a SINGLE budget for the whole command; applied +// to a sequential per-session loop that wrongly fails every session past the +// deadline (queued ... then a contiguous block of "context deadline exceeded"). +// perSessionContext must re-derive a FRESH deadline per session from the +// un-deadlined signal source, so --timeout bounds each session, not the loop. + +func TestPerSessionContext_DerivesFreshDeadlineFromBase(t *testing.T) { + type marker struct{} + base := context.WithValue(context.Background(), marker{}, "signal") + + // Simulate the command ctx whose global --timeout has already elapsed. + elapsed, cancel := context.WithTimeout(base, 0) + defer cancel() + elapsed = runctx.WithBaseContext(elapsed, base) + if elapsed.Err() == nil { + t.Fatalf("precondition: command ctx must already be past its deadline") + } + + sess, scancel := perSessionContext(elapsed, 30*time.Second) + defer scancel() + + if sess.Err() != nil { + t.Fatalf("per-session ctx must be live, got Err=%v", sess.Err()) + } + dl, ok := sess.Deadline() + if !ok { + t.Fatalf("per-session ctx must carry a deadline") + } + if !dl.After(time.Now()) { + t.Fatalf("per-session deadline must be in the future, got %v", dl) + } + if sess.Value(marker{}) != "signal" { + t.Fatalf("per-session ctx must derive from the un-deadlined signal source") + } +} + +func TestPerSessionContext_TimeoutZeroNoDeadline(t *testing.T) { + base := context.Background() + elapsed, cancel := context.WithTimeout(base, 0) + defer cancel() + elapsed = runctx.WithBaseContext(elapsed, base) + + sess, scancel := perSessionContext(elapsed, 0) + defer scancel() + + if _, ok := sess.Deadline(); ok { + t.Fatalf("--timeout 0 must yield an un-deadlined per-session ctx") + } + if sess.Err() != nil { + t.Fatalf("per-session ctx must be live, got Err=%v", sess.Err()) + } +} diff --git a/cli/cmd/imports/imports.go b/cli/cmd/imports/imports.go index 1a9e1b1..1a31348 100644 --- a/cli/cmd/imports/imports.go +++ b/cli/cmd/imports/imports.go @@ -4,13 +4,14 @@ package imports import "github.com/spf13/cobra" -// New returns the parent `evercli import` command. +// New returns the parent `evercli import` command. The legacy flat-file +// scan/run pipeline (presign + object storage) is retired; conversation +// import covers the same curated markdown zones via /mem/agent-memory. func New() *cobra.Command { c := &cobra.Command{ Use: "import", Short: "Cold-start import: scan and upload local AI Agent memory to EverMe", } - c.AddCommand(newScan()) - c.AddCommand(newRun()) + c.AddCommand(newConversations()) return c } diff --git a/cli/cmd/imports/run.go b/cli/cmd/imports/run.go deleted file mode 100644 index 96f4c77..0000000 --- a/cli/cmd/imports/run.go +++ /dev/null @@ -1,114 +0,0 @@ -package imports - -import ( - "fmt" - "io" - "strings" - - "github.com/spf13/cobra" - - "evercli/internal/cmdctx" - "evercli/internal/importer" - "evercli/internal/output" -) - -func newRun() *cobra.Command { - var ( - resume bool - dryRun bool - exclude []string - ) - c := &cobra.Command{ - Use: "run [...]", - Short: "Merge and upload cold-start memory for one or more Agents", - Long: `Run executes the cold-start pipeline (scan → merge → presign → S3 → -CreateRecord) per platform. - -With no platform args, runs every registered scanner. Pass platform -names to narrow. - ---resume reuses an existing checkpoint (saved on per-step success) so -a network interruption mid-upload doesn't force a full re-merge. - ---dry-run skips the upload entirely; prints a preview describing what -would have been sent.`, - Example: ` evercli import run claude-code --no-prompt --format json - evercli import run --dry-run`, - RunE: func(cmd *cobra.Command, args []string) error { - deps, err := cmdctx.BuildDeps(cmd) - if err != nil { - return deps.Out.Err(err) - } - deps.Out.WithTextRenderer(renderRun) - - platforms := make([]importer.PlatformID, 0, len(args)) - for _, a := range args { - platforms = append(platforms, importer.PlatformID(strings.TrimSpace(a))) - } - svc := importer.NewService(deps.Client, deps.Config.Paths, deps.Config.APIBaseURL) - rep, err := svc.Run(cmd.Context(), importer.RunOptions{ - Platforms: platforms, - Resume: resume, - DryRun: dryRun, - Exclude: exclude, - }) - if err != nil { - return deps.Out.Err(err) - } - - if len(rep.Failed) > 0 { - body := output.Conflict( - fmt.Sprintf("%d platform(s) failed during import", len(rep.Failed)), - map[string]interface{}{ - "imports": rep.Imports, - "skipped": rep.Skipped, - "failed": rep.Failed, - "previews": rep.Previews, - }, - ) - body.Hint = "See error.detail.failed; use `evercli import run --resume` to retry" - return deps.Out.Err(body) - } - - return deps.Out.OK(rep, &output.Meta{Count: len(rep.Imports) + len(rep.Previews)}) - }, - } - c.Flags().BoolVar(&resume, "resume", false, "reuse the previous checkpoint instead of starting from scratch") - c.Flags().BoolVar(&dryRun, "dry-run", false, "skip upload; print what would be sent") - c.Flags().StringSliceVar(&exclude, "exclude", nil, "extra directory names to prune during scan") - return c -} - -func renderRun(w io.Writer, data interface{}) error { - rep, ok := data.(*importer.RunReport) - if !ok { - _, err := fmt.Fprintln(w, "(no run report)") - return err - } - if rep.DryRun { - for _, p := range rep.Previews { - if _, err := fmt.Fprintf(w, "(dry-run) %s files=%d merged=%d doc=%s\n", - p.Platform, p.FileCount, p.MergedBytes, p.DocumentKey); err != nil { - return err - } - } - return nil - } - for _, e := range rep.Imports { - if _, err := fmt.Fprintf(w, "✓ %s rec=%s files=%d merged=%d\n", - e.Platform, e.RecordID, e.FileCount, e.MergedBytes); err != nil { - return err - } - } - for _, s := range rep.Skipped { - if _, err := fmt.Fprintf(w, "— %s skipped: %s\n", s.Platform, s.Reason); err != nil { - return err - } - } - for _, f := range rep.Failed { - if _, err := fmt.Fprintf(w, "✗ %s failed: [%s] %s\n", f.Platform, f.Error.Type, f.Error.Message); err != nil { - return err - } - } - return nil -} diff --git a/cli/cmd/imports/scan.go b/cli/cmd/imports/scan.go deleted file mode 100644 index 8bdbf98..0000000 --- a/cli/cmd/imports/scan.go +++ /dev/null @@ -1,70 +0,0 @@ -package imports - -import ( - "fmt" - "io" - - "github.com/spf13/cobra" - - "evercli/internal/cmdctx" - "evercli/internal/importer" - "evercli/internal/output" -) - -type scanData struct { - Sources []importer.ScanSummary `json:"sources"` -} - -func newScan() *cobra.Command { - var exclude []string - c := &cobra.Command{ - Use: "scan", - Short: "List candidate cold-start memory sources without uploading", - Long: `Scan walks the per-Agent memory directories (Claude Code, OpenClaw) -and reports which markdown files would be merged into a single record -on the next 'evercli import run'. No backend calls are made; no files -are read past their metadata.`, - RunE: func(cmd *cobra.Command, _ []string) error { - deps, err := cmdctx.BuildDeps(cmd) - if err != nil { - return deps.Out.Err(err) - } - deps.Out.WithTextRenderer(renderScan) - - svc := importer.NewService(deps.Client, deps.Config.Paths, deps.Config.APIBaseURL) - summaries, err := svc.Scan(cmd.Context(), exclude) - if err != nil { - return deps.Out.Err(err) - } - return deps.Out.OK(scanData{Sources: summaries}, &output.Meta{Count: len(summaries)}) - }, - } - c.Flags().StringSliceVar(&exclude, "exclude", nil, "extra directory names to prune during scan") - return c -} - -func renderScan(w io.Writer, data interface{}) error { - d, ok := data.(scanData) - if !ok { - _, err := fmt.Fprintln(w, "(no sources)") - return err - } - for _, s := range d.Sources { - if _, err := fmt.Fprintf(w, "%s files=%d bytes=%d root=%s\n", - s.Platform, s.FileCount, s.TotalBytes, s.RootPath); err != nil { - return err - } - if s.SkippedCount > 0 { - _, _ = fmt.Fprintf(w, " skipped=%d (samples: %v)\n", s.SkippedCount, sampleReasons(s.SkippedSamples)) - } - } - return nil -} - -func sampleReasons(s []importer.SkipEntry) []string { - out := make([]string, 0, len(s)) - for _, x := range s { - out = append(out, x.Reason) - } - return out -} diff --git a/cli/cmd/plugin/install.go b/cli/cmd/plugin/install.go index 75b295e..a741fbe 100644 --- a/cli/cmd/plugin/install.go +++ b/cli/cmd/plugin/install.go @@ -97,7 +97,7 @@ and never mutates a file.`, body.Hint = "See error.detail.failed for per-platform reasons; retry that platform alone" return deps.Out.Err(body) } - return deps.Out.OK(rep, &output.Meta{Count: len(rep.Installed)}) + return completeInstall(deps.Out, rep) }, } c.Flags().BoolVar(&force, "force", false, "proceed even when the target Agent is not detected on this machine") @@ -105,6 +105,10 @@ and never mutates a file.`, return c } +func completeInstall(out *output.Writer, report *plugin.InstallReport) error { + return out.OK(report, &output.Meta{Count: len(report.Installed)}) +} + // buildPrompt returns the PromptFn passed into Service.Install. With // --no-prompt (or no tty) we return nil so Service knows to default to // "skip" instead of asking. With a real tty we issue a y/N prompt on @@ -150,6 +154,15 @@ func renderInstall(w io.Writer, data interface{}) error { } warningCount++ } + // NextSteps are required manual follow-ups (e.g. Kimi Code's TUI + // `/plugins install` registration). Unlike warnings they are not a + // tripped sanity check, so they render as a plain arrow and never + // trigger the doctor hint. + for _, step := range e.NextSteps { + if _, err := fmt.Fprintf(w, " → %s\n", step); err != nil { + return err + } + } } for _, s := range rep.Skipped { if _, err := fmt.Fprintf(w, "— %s skipped: %s\n", s.Platform, s.Reason); err != nil { diff --git a/cli/cmd/plugin/install_test.go b/cli/cmd/plugin/install_test.go index b0dc962..7786fbf 100644 --- a/cli/cmd/plugin/install_test.go +++ b/cli/cmd/plugin/install_test.go @@ -2,15 +2,24 @@ package plugin import ( "bytes" + "io" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "evercli/internal/output" "evercli/internal/plugin" ) +func TestCompleteInstallWritesEnvelope(t *testing.T) { + var stdout bytes.Buffer + out := output.NewWriterTo(&stdout, io.Discard, output.FormatJSON) + require.NoError(t, completeInstall(out, &plugin.InstallReport{})) + assert.Contains(t, stdout.String(), `"ok": true`) +} + // TestRenderInstall_WarningsSurfaceInText pins the post-fix behavior: // when Verify trips a warning, the text renderer MUST print it so a // human running `evercli plugin install codex` doesn't only see the @@ -41,6 +50,35 @@ func TestRenderInstall_WarningsSurfaceInText(t *testing.T) { "footer must point users at doctor when any entry has warnings") } +// TestRenderInstall_NextStepsSurfaceInText pins that a per-entry NextSteps +// (e.g. Kimi Code's manual TUI `/plugins install` registration) is printed +// under the ✓ line so a human doesn't stop at the green check and miss the +// required follow-up. Distinct from Warnings: a next-step is not a sanity- +// check failure and must NOT trigger the doctor hint. +func TestRenderInstall_NextStepsSurfaceInText(t *testing.T) { + rep := &plugin.InstallReport{ + Installed: []plugin.InstallEntry{ + { + Platform: "kimicode", + AgentID: "agt_kc", + TokenPrefix: "evt_kc", + ConfigPath: "/home/u/.kimi-code/everme.env", + NextSteps: []string{"in the Kimi Code TUI, run `/plugins install /home/u/.kimi-code/everme` to register (no headless install)"}, + }, + }, + } + + var buf bytes.Buffer + require.NoError(t, renderInstall(&buf, rep)) + + out := buf.String() + assert.Contains(t, out, "✓ kimicode", "happy-path line must still render") + assert.Contains(t, out, "/plugins install /home/u/.kimi-code/everme", + "the next-step instruction must appear under the entry") + assert.False(t, strings.Contains(out, "evercli doctor"), + "a next-step is not a warning; it must not trigger the doctor hint") +} + // TestRenderInstall_NoWarningsOmitsDoctorHint avoids spamming the // doctor-recommendation when every install succeeded cleanly. The // "Restart" line is still expected, but the doctor line is only for diff --git a/cli/cmd/plugin/plugin.go b/cli/cmd/plugin/plugin.go index 604a53b..0bbdbf0 100644 --- a/cli/cmd/plugin/plugin.go +++ b/cli/cmd/plugin/plugin.go @@ -1,18 +1,19 @@ -// Package plugin registers `evercli plugin list / install`. +// Package plugin registers `evercli plugin list / install / uninstall`. // -// `register` was retired in V1 (mcp-codex-hermes-iteration-plan-2026-05-26.md -// §D.3) once the install matrix covered all five V1 hosts (Claude Code, -// OpenClaw, Cursor, Claude Desktop, Codex). Issuing one-shot tokens for -// users to paste by hand violated the "全 install, 零 register" hard -// constraint — every supported host now lands its agent token via -// `evercli plugin install ` with zero copy-paste. The backend -// endpoint (`POST /agents`) and the internal RegisterAgent client method -// remain — install drives them — but the CLI-facing `register` command -// is gone. +// `register` was retired in V1 once the install matrix covered all five +// V1 hosts (Claude Code, OpenClaw, Cursor, Claude Desktop, Codex). +// Issuing one-shot tokens for users to paste by hand violated the +// all-install / zero-register hard constraint — every supported host now +// lands its agent token via `evercli plugin install ` with zero +// copy-paste. The backend endpoint (`POST /agents`) and the internal +// RegisterAgent client method remain — install drives them — but the +// CLI-facing `register` command is gone. // -// `uninstall` was retired in the earlier slimming pass — users disconnect -// agents from the EverMe web UI and remove local MCP entries manually -// if needed. See H.4 for why DisconnectAgent isn't being restored in V1. +// `uninstall` removes EverMe-owned local state and then disconnects the +// cloud agent whose machine fingerprint matches this machine. There is +// no standalone cloud-only disconnect command — disconnecting without +// local cleanup is a Web UI action; the inverse (local cleanup without +// disconnect) is `uninstall --keep-agent`. package plugin import "github.com/spf13/cobra" @@ -25,5 +26,6 @@ func New() *cobra.Command { } c.AddCommand(newList()) c.AddCommand(newInstall()) + c.AddCommand(newUninstall()) return c } diff --git a/cli/cmd/plugin/uninstall.go b/cli/cmd/plugin/uninstall.go new file mode 100644 index 0000000..f44ea34 --- /dev/null +++ b/cli/cmd/plugin/uninstall.go @@ -0,0 +1,128 @@ +package plugin + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "evercli/internal/cmdctx" + "evercli/internal/output" + "evercli/internal/plugin" +) + +func newUninstall() *cobra.Command { + var ( + yes bool + keepAgent bool + ) + c := &cobra.Command{ + Use: "uninstall ", + Short: "Remove the EverMe plugin and disconnect its cloud agent", + Long: `Uninstall removes only EverMe-owned local state (config entry, hooks, +everme.env) for the named Agent, then disconnects the cloud agent whose +machine fingerprint matches this machine. Sibling entries and the host's +own configuration are never touched. + +--keep-agent skips the cloud disconnect (local cleanup only). +--yes skips the confirmation prompt; required with --no-prompt.`, + Example: ` evercli plugin uninstall claude-code --yes --format json + evercli plugin uninstall cursor --yes --keep-agent`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + deps.Out.WithTextRenderer(renderUninstall) + + p := plugin.Platform(strings.TrimSpace(args[0])) + if p == "" { + return deps.Out.Err(output.Invalid("platform name is empty", "Pass a platform name")) + } + if !yes { + if cmdctx.Snapshot().NoPrompt { + return deps.Out.Err(output.Invalid("this is a destructive operation", "Pass --yes in --no-prompt mode")) + } + // Interactive tty: confirm before touching anything; + // default is No. Non-tty stdin without --no-prompt keeps + // the historical proceed-without-asking behavior + // (buildPrompt returns nil there). + if prompt := buildPrompt(false); prompt != nil { + ok, promptErr := prompt(fmt.Sprintf( + "Uninstall EverMe from %s and disconnect its cloud agent?", p)) + if promptErr != nil || !ok { + return deps.Out.Err(&output.CLIError{ + Type: output.TypeCancelled, + Message: "uninstall cancelled", + Hint: "Re-run with --yes to skip the confirmation", + }) + } + } + } + + svc := plugin.NewService(deps.Client, deps.Config.APIBaseURL) + res, err := svc.Uninstall(cmd.Context(), p, plugin.UninstallOptions{KeepAgent: keepAgent}) + if err != nil { + return deps.Out.Err(err) + } + return deps.Out.OK(res, nil) + }, + } + c.Flags().BoolVar(&yes, "yes", false, "skip confirmation") + c.Flags().BoolVar(&keepAgent, "keep-agent", false, "skip cloud disconnect") + return c +} + +// renderUninstall is the text-mode renderer for the uninstall result. +// The JSON envelope already carries every field; this makes sure a human +// running in a terminal sees the same facts — most importantly a failed +// cloud disconnect (the token is still live!) and any mandatory manual +// follow-up carried in NextSteps (e.g. Kimi Code's `/plugins remove`). +func renderUninstall(w io.Writer, data interface{}) error { + res, ok := data.(*plugin.UninstallResult) + if !ok { + _, err := fmt.Fprintln(w, "(no uninstall result)") + return err + } + if res.Removed { + line := fmt.Sprintf("✓ %s local EverMe state removed", res.Platform) + if res.ConfigPath != "" { + line += " config=" + res.ConfigPath + } + if res.BackupPath != "" { + line += " backup=" + res.BackupPath + } + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } else { + if _, err := fmt.Fprintf(w, "— %s: no local EverMe entry found (already clean)\n", res.Platform); err != nil { + return err + } + } + if res.LocalDetectError != nil { + if _, err := fmt.Fprintf(w, " ⚠ warning: local detection failed: [%s] %s\n", + res.LocalDetectError.Type, res.LocalDetectError.Message); err != nil { + return err + } + } + switch { + case res.AgentDisconnected: + if _, err := fmt.Fprintln(w, "✓ cloud agent disconnected"); err != nil { + return err + } + case res.DisconnectError != nil: + if _, err := fmt.Fprintf(w, "⚠ WARNING: cloud disconnect failed — the agent token is STILL LIVE.\n [%s] %s\n Disconnect this agent in the EverMe web UI (account → agents → revoke).\n", + res.DisconnectError.Type, res.DisconnectError.Message); err != nil { + return err + } + } + for _, step := range res.NextSteps { + if _, err := fmt.Fprintf(w, " → %s\n", step); err != nil { + return err + } + } + return nil +} diff --git a/cli/cmd/plugin/uninstall_test.go b/cli/cmd/plugin/uninstall_test.go new file mode 100644 index 0000000..e2891ae --- /dev/null +++ b/cli/cmd/plugin/uninstall_test.go @@ -0,0 +1,138 @@ +package plugin + +import ( + "bytes" + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "evercli/internal/cmdctx" + "evercli/internal/output" + "evercli/internal/plugin" +) + +// newUninstallRoot builds a minimal root that carries the persistent +// global flags (--no-prompt et al.) exactly like the production root, +// so Snapshot() sees what cobra parsed. +func newUninstallRoot() *cobra.Command { + root := &cobra.Command{Use: "evercli"} + cmdctx.RegisterGlobalFlags(root) + root.AddCommand(newUninstall()) + return root +} + +// TestUninstall_NoPromptWithoutYes_ExitsValidation pins the guard: in +// --no-prompt mode the destructive uninstall requires --yes and must +// fail with the invalid_args exit code (2) before touching anything. +func TestUninstall_NoPromptWithoutYes_ExitsValidation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + root := newUninstallRoot() + var out, errOut bytes.Buffer + root.SetOut(&out) + root.SetErr(&errOut) + root.SetArgs([]string{"uninstall", "cursor", "--no-prompt"}) + + err := root.Execute() + require.Error(t, err, "--no-prompt without --yes must be a hard failure") + var ee *output.ExitError + require.True(t, errors.As(err, &ee), "failure must carry the canonical exit code") + assert.Equal(t, output.ExitValidation, ee.Code, "invalid_args maps to exit code 2") + assert.Equal(t, 2, int(ee.Code)) +} + +// ---- text renderer -------------------------------------------------- + +// TestRenderUninstall_DisconnectErrorSurfacesWarning pins the most +// important text-mode fact: when the cloud disconnect failed, the token +// is still live and the human must be told to revoke it in the web UI. +func TestRenderUninstall_DisconnectErrorSurfacesWarning(t *testing.T) { + res := &plugin.UninstallResult{ + Platform: "claude-code", + Removed: true, + ConfigPath: "/home/u/.claude/everme.env", + DisconnectError: &plugin.DisconnectErrorDetail{ + Type: output.TypeAuth, + Code: 30001, + Message: "ErrUnauthorized", + AgentID: "agt_x", + }, + } + + var buf bytes.Buffer + require.NoError(t, renderUninstall(&buf, res)) + got := buf.String() + assert.Contains(t, got, "✓ claude-code", "local removal line must render") + assert.Contains(t, got, "STILL LIVE", + "a failed disconnect means the token still works — the warning must be unmissable") + assert.Contains(t, got, "web UI", "user needs the manual revoke pointer") + assert.Contains(t, got, "ErrUnauthorized") +} + +// TestRenderUninstall_NextStepsAndDetectErrorSurface covers the +// remaining channels: kimicode's mandatory TUI step only travels via +// NextSteps, and a LocalDetectError must not vanish in text mode. +func TestRenderUninstall_NextStepsAndDetectErrorSurface(t *testing.T) { + res := &plugin.UninstallResult{ + Platform: "kimicode", + Removed: true, + LocalDetectError: &plugin.DetectErrorItem{ + Type: string(output.TypeIO), + Message: "config unreadable", + }, + NextSteps: []string{"in the Kimi Code TUI, run `/plugins remove everme` to unregister"}, + } + + var buf bytes.Buffer + require.NoError(t, renderUninstall(&buf, res)) + got := buf.String() + assert.Contains(t, got, "/plugins remove everme", + "the mandatory TUI step travels only via NextSteps") + assert.Contains(t, got, "config unreadable", "detect error must surface") +} + +// TestRenderUninstall_CleanAndDisconnected is the happy path: local +// state removed and the cloud agent disconnected, no warnings. +func TestRenderUninstall_CleanAndDisconnected(t *testing.T) { + res := &plugin.UninstallResult{ + Platform: "cursor", + Removed: true, + AgentDisconnected: true, + ConfigPath: "/home/u/.cursor/mcp.json", + BackupPath: "/home/u/.cursor/mcp.json-bak", + } + + var buf bytes.Buffer + require.NoError(t, renderUninstall(&buf, res)) + got := buf.String() + assert.Contains(t, got, "✓ cursor") + assert.Contains(t, got, "backup=/home/u/.cursor/mcp.json-bak") + assert.Contains(t, got, "cloud agent disconnected") + assert.NotContains(t, got, "WARNING") +} + +// TestRenderUninstall_NoLocalEntryStillRendersNoMatch covers the +// already-clean local state combined with the no-fingerprint-match +// outcome: both facts must be visible. +func TestRenderUninstall_NoLocalEntryStillRendersNoMatch(t *testing.T) { + res := &plugin.UninstallResult{ + Platform: "devin", + Removed: false, + NoMatchingCloudAgent: true, + NextSteps: []string{ + "no cloud agent matched this machine's fingerprint for devin, so none was disconnected — if an agent for this machine still appears in the EverMe web UI, disconnect it there", + }, + } + + var buf bytes.Buffer + require.NoError(t, renderUninstall(&buf, res)) + got := buf.String() + assert.Contains(t, got, "no local EverMe entry found") + assert.Contains(t, got, "no cloud agent matched this machine's fingerprint") +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 3ddfe30..74e9287 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -10,6 +10,7 @@ import ( doctorcmd "evercli/cmd/doctor" importscmd "evercli/cmd/imports" plugincmd "evercli/cmd/plugin" + skillcmd "evercli/cmd/skill" "evercli/internal/cmdctx" ) @@ -61,6 +62,7 @@ become a structured envelope rather than an interactive prompt.`, root.AddCommand(plugincmd.New()) root.AddCommand(importscmd.New()) root.AddCommand(doctorcmd.New()) + root.AddCommand(skillcmd.New()) return root } diff --git a/cli/cmd/skill/browse.go b/cli/cmd/skill/browse.go new file mode 100644 index 0000000..7faf455 --- /dev/null +++ b/cli/cmd/skill/browse.go @@ -0,0 +1,73 @@ +package skill + +import ( + "encoding/json" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "evercli/internal/cmdctx" + "evercli/internal/output" + "evercli/internal/skill/tui" +) + +func newBrowseCmd() *cobra.Command { + var jsonOut bool + + cmd := &cobra.Command{ + Use: "browse [query]", + Short: "Search and browse the EverMe skill hub", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + q := "" + if len(args) > 0 { + q = args[0] + } + + hub := buildHubClient(deps) + isTTY := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) + + // Non-interactive: JSON or paginated text output. + if !isTTY || jsonOut { + ctx := cmd.Context() + results, err := hub.SearchSkills(ctx, q, 1, 20) + if err != nil { + return deps.Out.Err(err) + } + if jsonOut { + raw, _ := json.MarshalIndent(results, "", " ") + fmt.Println(string(raw)) + return nil + } + return deps.Out.OK(results, &output.Meta{Count: len(results.Items)}) + } + + // Interactive TUI — browse only, no install inside alt-screen. + projectRoot, _ := os.Getwd() + m := tui.NewWithQuery(hub, q) + p := tea.NewProgram(m, tea.WithAltScreen()) + final, err := p.Run() + if err != nil { + return deps.Out.Err(output.Internal(err)) + } + + // If user pressed Enter on a skill, hand off to the install flow. + if fm, ok := final.(tui.Model); ok && fm.PendingInstall != "" { + fmt.Println() + return runInstallInteractive(cmd, deps, fm.PendingInstall, projectRoot) + } + return nil + }, + } + + cmd.Flags().BoolVar(&jsonOut, "json", false, "Output results as JSON (implies non-interactive)") + return cmd +} diff --git a/cli/cmd/skill/config.go b/cli/cmd/skill/config.go new file mode 100644 index 0000000..dbcc011 --- /dev/null +++ b/cli/cmd/skill/config.go @@ -0,0 +1,213 @@ +package skill + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + + "evercli/internal/cmdctx" + "evercli/internal/core" + "evercli/internal/output" +) + +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage skill configuration (agents, install mode)", + } + cmd.AddCommand( + newConfigShowCmd(), + newConfigAgentsCmd(), + newConfigSetCmd(), + ) + return cmd +} + +func newConfigShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Show current skill configuration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + cfg := deps.Config.Skill + deps.Out.WithTextRenderer(renderConfig) + return deps.Out.OK(&cfg, nil) + }, + } +} + +func newConfigAgentsCmd() *cobra.Command { + agentsCmd := &cobra.Command{ + Use: "agents", + Short: "Manage agent list", + } + + agentsCmd.AddCommand( + &cobra.Command{ + Use: "add ", + Short: "Add an agent and link existing skills to it", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + agentName := args[0] + + if _, ok := skillAgentByName(agentName); !ok { + return deps.Out.Err(output.Invalid( + fmt.Sprintf("unknown agent %q", agentName), + "Known agents: "+strings.Join(knownAgentNames(), ", "), + )) + } + + cfg := deps.Config + for _, a := range cfg.Skill.Agents { + if a == agentName { + fmt.Fprintf(deps.Out.Stderr(), "agent %q already configured\n", agentName) + return nil + } + } + cfg.Skill.Agents = append(cfg.Skill.Agents, agentName) + if err := cfg.SaveSkillConfig(); err != nil { + return deps.Out.Err(err) + } + + // Link existing skills into the new agent, count successes. + projectRoot, _ := os.Getwd() + svc, _, err := buildService(cmd.Context(), deps, false, projectRoot) + linkedCount := 0 + if err == nil && svc != nil { + skills, _ := svc.List(cmd.Context()) + for _, sk := range skills { + if err := svc.Link(agentName, sk.Name); err == nil { + linkedCount++ + } + } + } + + if linkedCount > 0 { + fmt.Fprintf(deps.Out.Stdout(), "✓ added %s — copied %d existing skill(s)\n", agentName, linkedCount) + } else { + fmt.Fprintf(deps.Out.Stdout(), "✓ added %s\n", agentName) + } + return nil + }, + }, + &cobra.Command{ + Use: "remove ", + Short: "Remove an agent and clean up its skill links", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + agentName := args[0] + cfg := deps.Config + + var updated []string + found := false + for _, a := range cfg.Skill.Agents { + if a == agentName { + found = true + continue + } + updated = append(updated, a) + } + if !found { + fmt.Fprintf(deps.Out.Stderr(), "agent %q not configured\n", agentName) + return nil + } + + // Count skills whose copies will be removed. + projectRoot, _ := os.Getwd() + svc, _, err := buildService(cmd.Context(), deps, false, projectRoot) + removedCount := 0 + if err == nil && svc != nil { + skills, _ := svc.List(cmd.Context()) + removedCount = len(skills) + _ = svc.UnlinkAgent(agentName) + } + + cfg.Skill.Agents = updated + if err := cfg.SaveSkillConfig(); err != nil { + return deps.Out.Err(err) + } + if removedCount > 0 { + fmt.Fprintf(deps.Out.Stdout(), "✓ removed %s — removed %d skill copy/copies\n", agentName, removedCount) + } else { + fmt.Fprintf(deps.Out.Stdout(), "✓ removed %s\n", agentName) + } + return nil + }, + }, + ) + return agentsCmd +} + +func newConfigSetCmd() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Set a skill config value", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + key := args[0] + return deps.Out.Err(output.Invalid(fmt.Sprintf("unknown config key %q", key), "No configurable keys at this time")) + }, + } +} + +func renderConfig(w io.Writer, data interface{}) error { + cfg, ok := data.(*core.SkillConfig) + if !ok { + fmt.Fprintln(w, data) + return nil + } + + agents := "(none configured)" + if len(cfg.Agents) > 0 { + agents = strings.Join(cfg.Agents, ", ") + } + loginPrompt := cfg.LoginPrompt + if loginPrompt == "" { + loginPrompt = "pending" + } + hubURL := cfg.HubBaseURL + if hubURL == "" { + hubURL = "https://skillhub.evermind.ai" + } + + fmt.Fprintf(w, " %-16s %s\n", "Hub URL:", hubURL) + fmt.Fprintf(w, " %-16s %s\n", "Agents:", agents) + fmt.Fprintf(w, " %-16s %s\n", "Login prompt:", loginPrompt) + return nil +} + +func skillAgentByName(name string) (interface{}, bool) { + for _, ka := range skillKnownAgents() { + if ka == name { + return ka, true + } + } + return nil, false +} + +func skillKnownAgents() []string { + return knownAgentNames() +} + +func knownAgentNames() []string { + return []string{"claude-code", "universal"} +} diff --git a/cli/cmd/skill/info.go b/cli/cmd/skill/info.go new file mode 100644 index 0000000..67f848b --- /dev/null +++ b/cli/cmd/skill/info.go @@ -0,0 +1,123 @@ +package skill + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "evercli/internal/cmdctx" + "evercli/internal/skill" +) + +func newInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show full details for a skill", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + hub := buildHubClient(deps) + detail, err := hub.GetSkill(cmd.Context(), args[0]) + if err != nil { + return deps.Out.Err(err) + } + + // JSON: use standard envelope. + formatFlag, _ := cmd.Root().PersistentFlags().GetString("format") + if formatFlag == "json" { + deps.Out.WithTextRenderer(renderInfo) + return deps.Out.OK(detail, nil) + } + + // Text: render directly, then offer install prompt in TTY. + if err := renderInfo(os.Stdout, detail); err != nil { + return err + } + + isTTY := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) + if isTTY { + fmt.Print("\nInstall now? (y/N) ") + reader := bufio.NewReader(os.Stdin) + line, _ := reader.ReadString('\n') + if strings.ToLower(strings.TrimSpace(line)) == "y" { + return runInstallInteractive(cmd, deps, args[0], mustGetwd()) + } + } + return nil + }, + } +} + +func renderInfo(w io.Writer, data interface{}) error { + d, ok := data.(*skill.SkillDetail) + if !ok { + _, err := fmt.Fprintln(w, "(no skill detail)") + return err + } + + tw := termWidth() + rule := strings.Repeat("─", min(tw, 72)) + + fmt.Fprintf(w, "\n%s\n", rule) + fmt.Fprintf(w, " %-14s %s\n", "Name:", d.Name) + fmt.Fprintf(w, " %-14s %s\n", "ID:", d.SkillID) + if d.Source != "" { + fmt.Fprintf(w, " %-14s %s\n", "Source:", d.Source) + } + if d.Category != "" { + fmt.Fprintf(w, " %-14s %s\n", "Category:", d.Category) + } + if d.QualityScore > 0 { + fmt.Fprintf(w, " %-14s ★%.2f\n", "Quality:", d.QualityScore) + } + fmt.Fprintf(w, " %-14s %s\n", "Installs:", formatListCount(d.InstallCount)) + if len(d.Tags) > 0 { + fmt.Fprintf(w, " %-14s %s\n", "Tags:", strings.Join(d.Tags, ", ")) + } + if d.License != "" { + fmt.Fprintf(w, " %-14s %s\n", "License:", d.License) + } + if d.AddedAt != "" { + fmt.Fprintf(w, " %-14s %s\n", "Added:", d.AddedAt) + } + if len(d.Files) > 0 { + fmt.Fprintf(w, " %-14s %s\n", "Files:", strings.Join(d.Files, ", ")) + } + fmt.Fprintf(w, "%s\n", rule) + + if d.Description != "" { + fmt.Fprintf(w, "\n%s\n", d.Description) + } + if d.SkillMD != "" { + fmt.Fprintf(w, "\n%s\n", rule) + fmt.Fprintf(w, "\n%s\n", d.SkillMD) + } + + fmt.Fprintf(w, "\n%s\n", skill.AgentInstallPrompt(d.SkillID)) + return nil +} + +func formatListCount(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%.1fk", float64(n)/1_000) + default: + return fmt.Sprintf("%d", n) + } +} + +func mustGetwd() string { + d, _ := os.Getwd() + return d +} diff --git a/cli/cmd/skill/install.go b/cli/cmd/skill/install.go new file mode 100644 index 0000000..177d24d --- /dev/null +++ b/cli/cmd/skill/install.go @@ -0,0 +1,149 @@ +package skill + +import ( + "fmt" + "io" + "os" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "evercli/internal/cmdctx" + "evercli/internal/skill" + "evercli/internal/skill/tui" +) + +func newInstallCmd() *cobra.Command { + var ( + global bool + dryRun bool + yes bool + noPrompt bool + ) + + cmd := &cobra.Command{ + Use: "install ", + Short: "Install a skill from the EverMe hub", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + isTTY := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) + formatFlag, _ := cmd.Root().PersistentFlags().GetString("format") + interactive := isTTY && !yes && !noPrompt && !dryRun && !global && formatFlag == "" + + projectRoot, _ := os.Getwd() + + if interactive { + return runInstallInteractive(cmd, deps, args[0], projectRoot) + } + + // Non-interactive: use auto-detected scope. + svc, _, err := buildService(cmd.Context(), deps, global, projectRoot) + if err != nil { + return deps.Out.Err(err) + } + if svc == nil { + return nil + } + + stopSpin := startSpinner(fmt.Sprintf("Downloading %s…", args[0])) + result, installErr := svc.Install(cmd.Context(), args[0], skill.InstallOpts{ + Global: global, + DryRun: dryRun, + }) + stopSpin() + + if installErr != nil { + return deps.Out.Err(installErr) + } + + deps.Out.WithTextRenderer(renderInstall) + return deps.Out.OK(result, nil) + }, + } + + cmd.Flags().BoolVarP(&global, "global", "g", false, "Install to global skill store (~/.everme/skills)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be installed without doing it") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompts") + cmd.Flags().BoolVar(&noPrompt, "no-prompt", false, "Skip all interactive prompts (same as --yes)") + return cmd +} + +// runInstallInteractive implements the interactive install flow: +// 1. Resolve skill name from hub (shows spinner) +// 2. Scope TUI (project vs global) +// 3. Install with spinner → per-link success output +func runInstallInteractive(cmd *cobra.Command, deps *cmdctx.Deps, idOrName string, projectRoot string) error { + ctx := cmd.Context() + + // Step 1: resolve skill name for display. + hub := buildHubClient(deps) + stopSpin := startSpinner(fmt.Sprintf("Looking up %s…", idOrName)) + detail, err := hub.GetSkill(ctx, idOrName) + stopSpin() + if err != nil { + return deps.Out.Err(err) + } + skillName := detail.Name + if skillName == "" { + skillName = idOrName + } + + fmt.Println() + + // Step 2: Scope TUI. + global, ok := tui.RunScopeSelect(projectRoot) + if !ok { + fmt.Fprintln(os.Stderr, "cancelled") + return nil + } + + // Step 3: Build service and install. + svc := buildServiceDirect(deps, global, projectRoot) + + fmt.Println() + stopSpin = startSpinner(fmt.Sprintf("Downloading %s…", skillName)) + result, installErr := svc.Install(ctx, idOrName, skill.InstallOpts{ + Global: global, + }) + stopSpin() + + if installErr != nil { + return deps.Out.Err(installErr) + } + + fmt.Printf("✓ installed %s\n", result.Name) + fmt.Printf(" id: %s\n", result.SkillID) + for _, a := range result.LinkedAgents { + fmt.Printf(" active in: %s\n", a) + } + fmt.Println() + fmt.Println("Restart your agent to activate. Install complete!") + return nil +} + +func renderInstall(w io.Writer, data interface{}) error { + r, ok := data.(*skill.InstallResult) + if !ok { + _, err := fmt.Fprintln(w, "(no install result)") + return err + } + if r.DryRun { + fmt.Fprintf(w, "[dry-run] would install %s (%s)\n", r.Name, r.SkillID) + for _, a := range r.LinkedAgents { + fmt.Fprintf(w, " → %s\n", a) + } + return nil + } + fmt.Fprintf(w, "✓ installed %s\n", r.Name) + fmt.Fprintf(w, " id: %s\n", r.SkillID) + for _, a := range r.LinkedAgents { + fmt.Fprintf(w, " active in: %s\n", a) + } + fmt.Fprintf(w, "\nRestart your agent to activate. Install complete!\n") + return nil +} diff --git a/cli/cmd/skill/list.go b/cli/cmd/skill/list.go new file mode 100644 index 0000000..fff69e7 --- /dev/null +++ b/cli/cmd/skill/list.go @@ -0,0 +1,118 @@ +package skill + +import ( + "fmt" + "io" + "os" + "strconv" + + "github.com/spf13/cobra" + + "evercli/internal/cmdctx" + "evercli/internal/output" + "evercli/internal/skill" +) + +type skillListData struct { + Skills []skill.InstalledSkill `json:"skills"` + Scope string `json:"scope"` +} + +func newListCmd() *cobra.Command { + var ( + global bool + storage bool + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List installed skills", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + projectRoot, _ := os.Getwd() + isGlobal := global + + svc, _, err := buildService(cmd.Context(), deps, isGlobal, projectRoot) + if err != nil { + return deps.Out.Err(err) + } + if svc == nil { + return nil + } + + allSkills, err := svc.List(cmd.Context()) + if err != nil { + return deps.Out.Err(err) + } + + scope := "project" + if isGlobal { + scope = "global" + } + + var skills []skill.InstalledSkill + if storage { + // --storage: show everything in central store regardless of links. + skills = allSkills + scope = "storage" + } else { + // Default: only show skills linked in the current scope. + for _, sk := range allSkills { + if len(sk.LinkedAgents) > 0 { + skills = append(skills, sk) + } + } + } + + deps.Out.WithTextRenderer(renderList) + return deps.Out.OK(&skillListData{Skills: skills, Scope: scope}, &output.Meta{Count: len(skills)}) + }, + } + + cmd.Flags().BoolVarP(&global, "global", "g", false, "Force global scope (overrides auto-detection)") + cmd.Flags().BoolVarP(&storage, "storage", "s", false, "Show all skills in central store (~/.everme/skills/)") + return cmd +} + +func renderList(w io.Writer, data interface{}) error { + d, ok := data.(*skillListData) + if !ok { + _, err := fmt.Fprintln(w, "(no skills)") + return err + } + if len(d.Skills) == 0 { + var msg string + switch d.Scope { + case "project": + msg = "No skills in this project. Run `evercli skill install ` to add one." + case "storage": + msg = "No skills in central store. Run `evercli skill install ` to install one." + default: + msg = "No skills installed. Run `evercli skill browse` to find skills." + } + _, err := fmt.Fprintln(w, msg) + return err + } + + for _, sk := range d.Skills { + fmt.Fprintln(w, sk.Name) + } + + fmt.Fprintf(w, "\n%d skill(s)\n", len(d.Skills)) + return nil +} + +// termWidth returns the current terminal width, with a fallback to 120. +func termWidth() int { + if cols := os.Getenv("COLUMNS"); cols != "" { + if w, err := strconv.Atoi(cols); err == nil && w > 0 { + return w + } + } + return 120 +} diff --git a/cli/cmd/skill/mcp.go b/cli/cmd/skill/mcp.go new file mode 100644 index 0000000..344629b --- /dev/null +++ b/cli/cmd/skill/mcp.go @@ -0,0 +1,133 @@ +package skill + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "evercli/internal/cmdctx" + "evercli/internal/credential" + "evercli/internal/output" +) + +// mcpEndpointPath is the path on the everme backend that serves hub skills via MCP. +const mcpEndpointPath = "/mcp/skills" + +func newMCPCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Configure and show the cloud MCP endpoint for skills", + Long: `The EverMe cloud MCP endpoint exposes the full skill hub (70k+ skills) +as MCP tools (search_skills, get_skill) so your AI agent can discover +and recommend skills during a session. + +The endpoint is hub-based and does not require local installation.`, + } + cmd.AddCommand(newMCPShowCmd(), newMCPSetupCmd()) + return cmd +} + +func newMCPShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Print the cloud MCP endpoint URL", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + token, err := deps.CredPrv.Get(cmd.Context(), credential.AgentToken()) + if err != nil { + return deps.Out.Err(output.NotLoggedIn()) + } + + endpoint := deps.Config.APIBaseURL + mcpEndpointPath + "?token=" + token + type mcpData struct { + Endpoint string `json:"endpoint"` + Note string `json:"note"` + } + return deps.Out.OK(&mcpData{ + Endpoint: endpoint, + Note: "Add this URL as an MCP server in your agent. Run `evercli skill mcp setup` to configure Claude Code automatically.", + }, nil) + }, + } +} + +func newMCPSetupCmd() *cobra.Command { + return &cobra.Command{ + Use: "setup", + Short: "Write the cloud MCP endpoint into Claude Code's .mcp.json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + token, err := deps.CredPrv.Get(cmd.Context(), credential.AgentToken()) + if err != nil { + return deps.Out.Err(output.NotLoggedIn()) + } + + endpoint := deps.Config.APIBaseURL + mcpEndpointPath + "?token=" + token + if err := writeMCPConfig(endpoint); err != nil { + return deps.Out.Err(err) + } + + type setupResult struct { + Endpoint string `json:"endpoint"` + Config string `json:"configPath"` + } + mcpPath := claudeMCPConfigPath() + return deps.Out.OK(&setupResult{Endpoint: endpoint, Config: mcpPath}, nil) + }, + } +} + +// claudeMCPConfigPath returns ~/.claude/.mcp.json. +func claudeMCPConfigPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".claude", ".mcp.json") +} + +// writeMCPConfig upserts the everme-skills entry in ~/.claude/.mcp.json. +func writeMCPConfig(endpoint string) error { + path := claudeMCPConfigPath() + + raw, err := os.ReadFile(path) + var config map[string]interface{} + if err == nil { + _ = json.Unmarshal(raw, &config) + } + if config == nil { + config = map[string]interface{}{} + } + + mcpServers, _ := config["mcpServers"].(map[string]interface{}) + if mcpServers == nil { + mcpServers = map[string]interface{}{} + } + mcpServers["everme-skills"] = map[string]interface{}{ + "url": endpoint, + } + config["mcpServers"] = mcpServers + + out, err := json.MarshalIndent(config, "", " ") + if err != nil { + return output.Internal(fmt.Errorf("marshal mcp config: %w", err)) + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return output.IOErr(filepath.Dir(path), "mkdir", err) + } + if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { + return output.IOErr(path, "write", err) + } + return nil +} diff --git a/cli/cmd/skill/remove.go b/cli/cmd/skill/remove.go new file mode 100644 index 0000000..9a27a96 --- /dev/null +++ b/cli/cmd/skill/remove.go @@ -0,0 +1,240 @@ +package skill + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "evercli/internal/cmdctx" + "evercli/internal/output" + "evercli/internal/skill" + "evercli/internal/skill/tui" +) + +type skillRemoveResult struct { + Name string `json:"name"` + UnlinkedFrom []string `json:"unlinkedFrom,omitempty"` + StorageAlso bool `json:"storageDeleted,omitempty"` +} + +func newRemoveCmd() *cobra.Command { + var ( + global bool + yes bool + storage bool + ) + + cmd := &cobra.Command{ + Use: "remove [name]", + Aliases: []string{"rm", "uninstall"}, + Short: "Remove a skill copy from the current scope", + Long: `Remove a skill copy from the current scope (project or global, auto-detected). + +By default, only the copy in the current scope is removed — the skill file +in central store (~/.everme/skills/) is preserved so other projects keep working. + +Use --storage / -s to also delete the central store entry.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + projectRoot, _ := os.Getwd() + isGlobal := global || autoDetectGlobal(projectRoot) + + storeRoot := centralStoreRoot(isGlobal, projectRoot) + + // Build store with appropriate scope targets. + // --storage: cover all scopes so no stale copies remain after central delete. + // default: only current scope copies. + var targets []skill.AgentTarget + if storage { + targets = buildRemoveTargets(projectRoot) + } else { + targets = buildAgentTargets(defaultSkillAgents, isGlobal, projectRoot) + } + store := skill.NewStore(storeRoot, targets) + hub := buildHubClient(deps) + svc := skill.NewService(hub, store, nil) + + // Load installed skills (filtered to current scope). + allSkills, err := svc.List(cmd.Context()) + if err != nil { + return deps.Out.Err(err) + } + var scopedSkills []skill.InstalledSkill + for _, sk := range allSkills { + if len(sk.LinkedAgents) > 0 { + scopedSkills = append(scopedSkills, sk) + } + } + + isTTY := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) + formatFlag, _ := cmd.Root().PersistentFlags().GetString("format") + interactive := isTTY && formatFlag == "" + + var names []string + fromTUI := false + + if len(args) == 0 { + if !interactive { + return deps.Out.Err(fmt.Errorf("skill name required in non-interactive mode")) + } + if len(scopedSkills) == 0 { + scopeLabel := "this project" + if isGlobal { + scopeLabel = "global scope" + } + fmt.Printf("No skills installed in %s.\n", scopeLabel) + return nil + } + selected, ok := tui.RunSkillSelect(scopedSkills) + if !ok { + fmt.Fprintln(os.Stderr, "cancelled") + return nil + } + if len(selected) == 0 { + fmt.Println("No skills selected.") + return nil + } + names = selected + fromTUI = true + } else { + names = []string{args[0]} + } + + // Build lookup maps: scopedMap for skills linked in current scope, + // allMap for central store existence checks. + allMap := make(map[string]skill.InstalledSkill, len(allSkills)) + for _, sk := range allSkills { + allMap[sk.Name] = sk + } + scopedMap := make(map[string]skill.InstalledSkill, len(scopedSkills)) + for _, sk := range scopedSkills { + scopedMap[sk.Name] = sk + } + + // lookupSkill finds a skill by name, preferring current scope. + // Returns (skill, inScope, found). + lookupSkill := func(name string) (skill.InstalledSkill, bool, bool) { + if sk, ok := scopedMap[name]; ok { + return sk, true, true + } + if sk, ok := allMap[name]; ok { + return sk, false, true + } + return skill.InstalledSkill{}, false, false + } + + // For single named skill in TTY, ask confirmation. + if !fromTUI && interactive && !yes { + sk, inScope, found := lookupSkill(names[0]) + if !found { + return deps.Out.Err(output.NotFound("skill", names[0])) + } + if !inScope && !storage { + scopeLabel := "this project" + if isGlobal { + scopeLabel = "global scope" + } + return deps.Out.Err(fmt.Errorf("skill %q is not installed in %s\n hint: run `evercli skill list -s` to see all installed skills", names[0], scopeLabel)) + } + _ = sk + action := "remove" + fmt.Printf("%s %q? [Y/n] ", action, names[0]) + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + line = strings.ToLower(strings.TrimSpace(line)) + if line == "n" { + fmt.Println("cancelled") + return nil + } + } + + // Process each selected skill. + var results []*skillRemoveResult + var firstErr error + for _, name := range names { + sk, inScope, found := lookupSkill(name) + if !found { + if firstErr == nil { + firstErr = output.NotFound("skill", name) + } + continue + } + if !inScope && !storage { + if firstErr == nil { + scopeLabel := "this project" + if isGlobal { + scopeLabel = "global scope" + } + firstErr = fmt.Errorf("skill %q is not installed in %s (use -s to remove from storage)", name, scopeLabel) + } + continue + } + var opErr error + if storage { + opErr = svc.Remove(cmd.Context(), name) + } else { + opErr = svc.Unlink(cmd.Context(), name) + } + if opErr != nil { + if firstErr == nil { + firstErr = opErr + } + continue + } + results = append(results, &skillRemoveResult{ + Name: name, + UnlinkedFrom: sk.LinkedAgents, + StorageAlso: storage, + }) + } + + if len(results) == 0 && firstErr != nil { + return deps.Out.Err(firstErr) + } + + // Multi-skill (from TUI): print directly. + if fromTUI || len(results) > 1 { + for _, r := range results { + renderRemoveSingle(os.Stdout, r) + } + return nil + } + + // Single skill: use standard output envelope. + deps.Out.WithTextRenderer(renderRemove) + return deps.Out.OK(results[0], nil) + }, + } + + cmd.Flags().BoolVarP(&global, "global", "g", false, "Force global scope (overrides auto-detection)") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().BoolVarP(&storage, "storage", "s", false, "Also delete skill from central store (~/.everme/skills/)") + return cmd +} + +func renderRemove(w io.Writer, data interface{}) error { + r, ok := data.(*skillRemoveResult) + if !ok { + _, err := fmt.Fprintln(w, "skill removed") + return err + } + renderRemoveSingle(w, r) + return nil +} + +func renderRemoveSingle(w io.Writer, r *skillRemoveResult) { + action := "removed" + fmt.Fprintf(w, "✓ %s %s\n", action, r.Name) + for _, a := range r.UnlinkedFrom { + fmt.Fprintf(w, " removed from: %s\n", a) + } +} diff --git a/cli/cmd/skill/skill.go b/cli/cmd/skill/skill.go new file mode 100644 index 0000000..5e6dc9b --- /dev/null +++ b/cli/cmd/skill/skill.go @@ -0,0 +1,198 @@ +// Package skill implements the `evercli skill` subcommand family. +package skill + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + iauth "evercli/internal/auth" + "evercli/internal/cmdctx" + "evercli/internal/core" + "evercli/internal/credential" + "evercli/internal/skill" +) + +// defaultSkillAgents are the always-active agent targets for skill install/remove. +// Skills are always linked to both the universal dir (.agents/skills/) and Claude Code (.claude/skills/). +var defaultSkillAgents = []string{"claude-code", "universal"} + +// New returns the `evercli skill` parent command with all subcommands attached. +func New() *cobra.Command { + cmd := &cobra.Command{ + Use: "skill", + Short: "Browse, install, and manage EverMe skills", + Long: `Browse the EverMe skill hub (70k+ community skills with quality scores), +install them into your agents, and manage your local skill library.`, + } + cmd.AddCommand( + newBrowseCmd(), + newInfoCmd(), + newInstallCmd(), + newListCmd(), + newRemoveCmd(), + newUpdateCmd(), + newConfigCmd(), + newMCPCmd(), + ) + return cmd +} + +// buildService constructs a skill.Service from the command's deps. +// It shows a login nudge when the user is not logged in and the prompt is pending. +func buildService(ctx context.Context, deps *cmdctx.Deps, global bool, projectRoot string) (*skill.Service, *core.Config, error) { + cfg := deps.Config + skillCfg := &cfg.Skill + + // Show login nudge only when the user is not yet logged in. + if !isLoggedIn(deps) { + fuCfg := &skill.SkillFirstUseConfig{ + LoginPrompt: skillCfg.LoginPrompt, + } + result, ok := skill.RunFirstUsePrompts(fuCfg) + if !ok { + return nil, cfg, nil + } + + if result.LoginAction != "" { + switch result.LoginAction { + case "login": + runDeviceLogin(ctx, deps) + skillCfg.LoginPrompt = "dismissed" + case "snooze": + skillCfg.LoginPrompt = "snoozed:" + skill.SnoozeTimestamp() + case "dismiss": + skillCfg.LoginPrompt = "dismissed" + } + _ = cfg.SaveSkillConfig() // best-effort + } + } + + // Always install to both universal (.agents/skills/) and claude-code (.claude/skills/). + agents := buildAgentTargets(defaultSkillAgents, global, projectRoot) + storeRoot := centralStoreRoot(global, projectRoot) + store := skill.NewStore(storeRoot, agents) + + hub := skill.NewHubClient(skillCfg.HubBaseURL, "evercli/"+deps.Build.Version) + + var syncClient *skill.EvermeSync + if isLoggedIn(deps) { + syncClient = skill.NewEvermeSync(cfg.APIBaseURL, deps.CredPrv, "evercli/"+deps.Build.Version) + } + + svc := skill.NewService(hub, store, syncClient) + return svc, cfg, nil +} + +// runDeviceLogin runs the blocking Device Flow and prints progress to stderr. +// Errors are printed but do not abort the skill command — the user can retry with `evercli auth login`. +func runDeviceLogin(ctx context.Context, deps *cmdctx.Deps) { + fmt.Fprintln(os.Stderr) + authSvc := iauth.NewService(deps.Client, deps.CredPrv, deps.Config.Paths) + res, err := authSvc.Login(ctx, iauth.LoginOptions{ + ClientName: "EverCli", + ClientVersion: deps.Build.Version, + OnDeviceStarted: func(verificationURL, userCode string, expiresInSec int) { + fmt.Fprintf(os.Stderr, "→ Open %s\n Enter code: %s (expires in %ds)\n Waiting for approval...\n", + verificationURL, userCode, expiresInSec) + }, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "✗ Login failed: %v\n Run `evercli auth login` to try again.\n\n", err) + return + } + if res.Status == "approved" { + fmt.Fprintf(os.Stderr, "✓ Logged in as %s\n\n", res.Email) + } +} + +// buildHubClient builds just the hub client (for browse/info which don't need store). +func buildHubClient(deps *cmdctx.Deps) skill.HubClient { + return skill.NewHubClient(deps.Config.Skill.HubBaseURL, "evercli/"+deps.Build.Version) +} + +// buildAgentTargets maps agent names to AgentTarget structs for the given scope. +func buildAgentTargets(agents []string, global bool, projectRoot string) []skill.AgentTarget { + var targets []skill.AgentTarget + for _, name := range agents { + ka, ok := skill.AgentByName(name) + if !ok { + continue + } + var dir string + if global || projectRoot == "" { + dir = ka.GlobalSkillsDir() + } else { + dir = skill.ProjectSkillsDir(name, projectRoot) + if dir == "" { + dir = ka.GlobalSkillsDir() + } + } + if dir != "" { + targets = append(targets, skill.AgentTarget{Name: name, SkillsDir: dir}) + } + } + return targets +} + +// buildRemoveTargets returns agent targets covering both global and project scopes, +// so that removing a skill cleans up all copies regardless of where they were created. +func buildRemoveTargets(projectRoot string) []skill.AgentTarget { + seen := map[string]bool{} + var targets []skill.AgentTarget + for _, scope := range []bool{true, false} { + for _, t := range buildAgentTargets(defaultSkillAgents, scope, projectRoot) { + if !seen[t.SkillsDir] { + seen[t.SkillsDir] = true + targets = append(targets, t) + } + } + } + return targets +} + +// centralStoreRoot always returns ~/.everme/skills regardless of scope. +// Scope only controls where agent copies are placed, not where skills are stored. +func centralStoreRoot(_ bool, _ string) string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".everme", "skills") +} + +// autoDetectGlobal returns true (global scope) when the CWD shows no signs of +// being an agent project — i.e. none of .claude / .cursor / .codex / .agents exist. +func autoDetectGlobal(projectRoot string) bool { + for _, marker := range []string{".claude", ".cursor", ".codex", ".agents"} { + if _, err := os.Stat(filepath.Join(projectRoot, marker)); err == nil { + return false + } + } + return true +} + +// buildServiceDirect builds a skill.Service without running any prompts. +// Used by the interactive install flow after scope has been selected. +func buildServiceDirect(deps *cmdctx.Deps, global bool, projectRoot string) *skill.Service { + cfg := deps.Config + skillCfg := &cfg.Skill + targets := buildAgentTargets(defaultSkillAgents, global, projectRoot) + storeRoot := centralStoreRoot(global, projectRoot) + store := skill.NewStore(storeRoot, targets) + hub := skill.NewHubClient(skillCfg.HubBaseURL, "evercli/"+deps.Build.Version) + var syncClient *skill.EvermeSync + if isLoggedIn(deps) { + syncClient = skill.NewEvermeSync(cfg.APIBaseURL, deps.CredPrv, "evercli/"+deps.Build.Version) + } + return skill.NewService(hub, store, syncClient) +} + +// isLoggedIn returns true when the credential provider can supply an API key. +func isLoggedIn(deps *cmdctx.Deps) bool { + if deps.CredPrv == nil { + return false + } + _, err := deps.CredPrv.Get(context.Background(), credential.APIKey()) + return err == nil +} diff --git a/cli/cmd/skill/skill_render_test.go b/cli/cmd/skill/skill_render_test.go new file mode 100644 index 0000000..af55cf2 --- /dev/null +++ b/cli/cmd/skill/skill_render_test.go @@ -0,0 +1,139 @@ +package skill + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "evercli/internal/skill" +) + +// ---- renderInstall -------------------------------------------------------- + +func TestRenderInstall_Normal(t *testing.T) { + r := &skill.InstallResult{ + Name: "code-reviewer", + SkillID: "awesome:user/code-reviewer", + Version: "deadbeef", + Central: "/home/u/.everme/skills/code-reviewer", + LinkedAgents: []string{"claude-code", "cursor"}, + } + var buf bytes.Buffer + require.NoError(t, renderInstall(&buf, r)) + out := buf.String() + + assert.Contains(t, out, "✓ installed code-reviewer") + assert.Contains(t, out, "awesome:user/code-reviewer") + assert.Contains(t, out, "claude-code") + assert.Contains(t, out, "cursor") + assert.Contains(t, out, "Restart your agent to activate") +} + +func TestRenderInstall_DryRun(t *testing.T) { + r := &skill.InstallResult{ + Name: "pr-summary", + SkillID: "awesome:user/pr-summary", + LinkedAgents: []string{"claude-code"}, + DryRun: true, + } + var buf bytes.Buffer + require.NoError(t, renderInstall(&buf, r)) + out := buf.String() + + assert.Contains(t, out, "[dry-run]") + assert.Contains(t, out, "pr-summary") + assert.NotContains(t, out, "✓ installed", "dry-run must not show the success mark") +} + +// ---- renderList ----------------------------------------------------------- + +func TestRenderList_Empty(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, renderList(&buf, &skillListData{})) + assert.Contains(t, buf.String(), "No skills installed") +} + +func TestRenderList_WithItems(t *testing.T) { + data := &skillListData{ + Skills: []skill.InstalledSkill{ + {Name: "code-reviewer", Description: "AI code review", LinkedAgents: []string{"claude-code"}}, + {Name: "pr-summary", Description: "Summarise PRs", LinkedAgents: []string{"cursor"}}, + }, + } + var buf bytes.Buffer + require.NoError(t, renderList(&buf, data)) + out := buf.String() + + assert.Contains(t, out, "code-reviewer") + assert.Contains(t, out, "pr-summary") + assert.Contains(t, out, "2 skill(s)") +} + +// ---- renderUpdate --------------------------------------------------------- + +func TestRenderUpdate_Mixed(t *testing.T) { + r := &skill.UpdateReport{ + Updated: []string{"code-reviewer"}, + UpToDate: []string{"pr-summary"}, + Failed: []string{"broken-skill"}, + } + var buf bytes.Buffer + require.NoError(t, renderUpdate(&buf, r)) + out := buf.String() + + assert.Contains(t, out, "✓ updated code-reviewer") + assert.Contains(t, out, "— up-to-date pr-summary") + assert.Contains(t, out, "✗ failed broken-skill") +} + +func TestRenderUpdate_AllUpToDate(t *testing.T) { + r := &skill.UpdateReport{UpToDate: []string{"a", "b"}} + var buf bytes.Buffer + require.NoError(t, renderUpdate(&buf, r)) + assert.Contains(t, buf.String(), "All skills are up to date.") +} + +// ---- renderRemove --------------------------------------------------------- + +func TestRenderRemove_Unlink(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, renderRemove(&buf, &skillRemoveResult{Name: "code-reviewer", StorageAlso: false})) + assert.Contains(t, buf.String(), "✓ removed code-reviewer") +} + +func TestRenderRemove_Storage(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, renderRemove(&buf, &skillRemoveResult{Name: "code-reviewer", StorageAlso: true})) + assert.Contains(t, buf.String(), "✓ removed code-reviewer") +} + +// ---- renderInfo ----------------------------------------------------------- + +func TestRenderInfo_FullFields(t *testing.T) { + d := &skill.SkillDetail{ + SkillSummary: skill.SkillSummary{ + Name: "code-reviewer", + SkillID: "awesome:user/code-reviewer", + Category: "coding", + QualityScore: 0.92, + InstallCount: 12300, + Tags: []string{"review", "quality"}, + }, + SkillMD: "# Code Reviewer\nThis skill reviews your code.", + } + var buf bytes.Buffer + require.NoError(t, renderInfo(&buf, d)) + out := buf.String() + + assert.Contains(t, out, "code-reviewer") + assert.Contains(t, out, "awesome:user/code-reviewer") + assert.Contains(t, out, "coding") + assert.Contains(t, out, "0.92") + assert.Contains(t, out, "review, quality") + assert.Contains(t, out, "Code Reviewer") + // Agent Install Prompt block must appear + assert.Contains(t, out, "evercli skill install") + assert.Contains(t, out, "Agent Install Prompt") +} diff --git a/cli/cmd/skill/spinner.go b/cli/cmd/skill/spinner.go new file mode 100644 index 0000000..000cbdd --- /dev/null +++ b/cli/cmd/skill/spinner.go @@ -0,0 +1,36 @@ +package skill + +import ( + "fmt" + "os" + "time" +) + +var spinnerFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} + +// startSpinner writes an animated spinner to stderr. +// Call the returned stop function to clear the spinner line when done. +func startSpinner(msg string) func() { + done := make(chan struct{}) + stopped := make(chan struct{}) + go func() { + defer close(stopped) + i := 0 + t := time.NewTicker(80 * time.Millisecond) + defer t.Stop() + for { + select { + case <-done: + fmt.Fprintf(os.Stderr, "\r\033[2K") // clear current line + return + case <-t.C: + fmt.Fprintf(os.Stderr, "\r %s %s", spinnerFrames[i%len(spinnerFrames)], msg) + i++ + } + } + }() + return func() { + close(done) + <-stopped + } +} diff --git a/cli/cmd/skill/update.go b/cli/cmd/skill/update.go new file mode 100644 index 0000000..3ba8125 --- /dev/null +++ b/cli/cmd/skill/update.go @@ -0,0 +1,133 @@ +package skill + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "evercli/internal/cmdctx" + "evercli/internal/output" + "evercli/internal/skill" +) + +func newUpdateCmd() *cobra.Command { + var ( + global bool + dryRun bool + ) + + cmd := &cobra.Command{ + Use: "update [name...]", + Short: "Update installed skills (all if no names given)", + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + projectRoot, _ := os.Getwd() + svc, _, err := buildService(cmd.Context(), deps, global, projectRoot) + if err != nil { + return deps.Out.Err(err) + } + if svc == nil { + return nil + } + + // Resolve names. + names := args + if len(names) == 0 { + skills, err := svc.List(cmd.Context()) + if err != nil { + return deps.Out.Err(err) + } + for _, s := range skills { + names = append(names, s.Name) + } + } + + // Dry-run: just show what would be checked. + if dryRun { + if len(names) == 0 { + fmt.Println("[dry-run] no skills installed") + return nil + } + fmt.Printf("[dry-run] would check %d skill(s) for updates:\n", len(names)) + for _, n := range names { + fmt.Printf(" → %s\n", n) + } + return nil + } + + total := len(names) + isTTY := isattyStdout() + + // Progress: show spinner on stderr for multi-skill updates. + var stopSpin func() + if isTTY && total > 0 { + stopSpin = startSpinner(fmt.Sprintf("Checking %d skill(s)…", total)) + } + + report, err := svc.Update(cmd.Context(), names...) + if stopSpin != nil { + stopSpin() + } + + if err != nil { + return deps.Out.Err(err) + } + + deps.Out.WithTextRenderer(renderUpdate) + return deps.Out.OK(report, &output.Meta{Count: len(report.Updated) + len(report.UpToDate) + len(report.Failed)}) + }, + } + + cmd.Flags().BoolVarP(&global, "global", "g", false, "Update skills in the global store") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be updated without installing") + return cmd +} + +func renderUpdate(w io.Writer, data interface{}) error { + r, ok := data.(*skill.UpdateReport) + if !ok { + _, err := fmt.Fprintln(w, "(no update report)") + return err + } + for _, name := range r.Updated { + fmt.Fprintf(w, "✓ updated %s\n", name) + } + for _, name := range r.UpToDate { + fmt.Fprintf(w, "— up-to-date %s\n", name) + } + if len(r.FailedDetails) > 0 { + for _, f := range r.FailedDetails { + fmt.Fprintf(w, "✗ failed %s (%s)\n", f.Name, f.Reason) + } + } else { + for _, name := range r.Failed { + fmt.Fprintf(w, "✗ failed %s\n", name) + } + } + + if len(r.Updated) == 0 && len(r.Failed) == 0 { + fmt.Fprintln(w, "\nAll skills are up to date.") + } + if len(r.Updated) > 0 { + fmt.Fprintln(w, "\nRestart your agent to apply updates.") + } + if len(r.Failed) > 0 { + fmt.Fprintf(w, "\n%d skill(s) failed — check connectivity and try again.\n", len(r.Failed)) + } + return nil +} + +// isattyStdout reports whether stdout is an interactive terminal. +func isattyStdout() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} diff --git a/cli/go.mod b/cli/go.mod index 5f2a936..c708361 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -4,6 +4,9 @@ go 1.25.0 require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/denisbrodbeck/machineid v1.0.1 github.com/mattn/go-isatty v0.0.20 github.com/pelletier/go-toml/v2 v2.3.1 @@ -16,13 +19,30 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -30,8 +50,9 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index 5eacdc3..d2af500 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,11 +1,37 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -20,16 +46,30 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -53,6 +93,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= @@ -61,8 +103,9 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/cli/internal/auth/device_start_req_test.go b/cli/internal/auth/device_start_req_test.go new file mode 100644 index 0000000..b6056f2 --- /dev/null +++ b/cli/internal/auth/device_start_req_test.go @@ -0,0 +1,34 @@ +package auth + +import "testing" + +// A locally built evercli can end up with a version string longer than the +// server's client_version varchar(32) column (ECA incident: git describe +// picked up the server's own release tag). deviceStartReq must clamp before +// the request ever reaches the wire, so the server never sees SQLSTATE 22001. +func TestDeviceStartReq_TruncatesOversizedClientVersion(t *testing.T) { + req := deviceStartReq(LoginOptions{ + ClientVersion: "everme-server_release-20260803_v5", + }) + if len(req.ClientVersion) > 32 { + t.Fatalf("ClientVersion = %q (%d bytes), want <=32 bytes", req.ClientVersion, len(req.ClientVersion)) + } + want := "everme-server_release-20260803_v" + if req.ClientVersion != want { + t.Fatalf("ClientVersion = %q, want %q", req.ClientVersion, want) + } +} + +func TestDeviceStartReq_ShortClientVersionUnchanged(t *testing.T) { + req := deviceStartReq(LoginOptions{ClientVersion: "1.2.3"}) + if req.ClientVersion != "1.2.3" { + t.Fatalf("ClientVersion = %q, want unchanged %q", req.ClientVersion, "1.2.3") + } +} + +func TestDeviceStartReq_EmptyClientVersionDefaultsToDev(t *testing.T) { + req := deviceStartReq(LoginOptions{}) + if req.ClientVersion != "dev" { + t.Fatalf("ClientVersion = %q, want default %q", req.ClientVersion, "dev") + } +} diff --git a/cli/internal/auth/service.go b/cli/internal/auth/service.go index 5878fd2..734958c 100644 --- a/cli/internal/auth/service.go +++ b/cli/internal/auth/service.go @@ -12,6 +12,7 @@ import ( "evercli/internal/logger" "evercli/internal/machineid" "evercli/internal/output" + "evercli/internal/runctx" "evercli/internal/validate" ) @@ -143,6 +144,7 @@ func (s *Service) loginAPIKey(ctx context.Context, key string) (*LoginResult, er logger.L().Warnw("loginAPIKey: ensureSelfAgent failed (uploads will require manual re-auth)", "err", err.Error(), ) + s.discardStaleAgentToken(ctx, "loginAPIKey") } return &LoginResult{ Status: "approved", @@ -176,6 +178,23 @@ func (s *Service) ensureSelfAgent(ctx context.Context) error { return nil } +// discardStaleAgentToken removes any cached evt after a failed +// ensureSelfAgent. On an account switch the cached evt belonged to the +// PREVIOUS account; leaving it in place would let a later `import run` +// silently upload under the old account (ECA-689). Deleting it forces a +// loud NotLoggedIn on the next upload so the user re-runs `auth login` +// instead. Best-effort: ErrNotFound (nothing to drop) and ErrReadOnly +// (EnvProvider — evt supplied out-of-band) are both fine. +func (s *Service) discardStaleAgentToken(ctx context.Context, where string) { + if err := s.cred.Delete(ctx, credential.AgentToken()); err != nil && + !errors.Is(err, credential.ErrNotFound) && !errors.Is(err, credential.ErrReadOnly) { + logger.L().Warnw("discard stale evt failed; a later import may target the previous account", + "where", where, + "err", err.Error(), + ) + } +} + func (s *Service) loginDeviceStartOnly(ctx context.Context, opts LoginOptions) (*LoginResult, error) { resp, err := s.cli.DeviceStart(ctx, deviceStartReq(opts)) if err != nil { @@ -251,12 +270,20 @@ func (s *Service) bestEffortDeleteSession(reason string) { // loginDeviceBlocking runs DeviceStart, then polls DeviceToken every // resp.Interval seconds until approved or the server-issued deadline -// passes. Honors ctx cancellation throughout. +// passes. Honors genuine ctx cancellation throughout. // -// We extend ctx beyond --timeout to match the server's expiresIn so the -// global 60s default doesn't kill a flow that legitimately waits for the -// user to click in their browser. +// The global --timeout (60s default) reaches us as a parent-context +// deadline (cmdctx.BuildDeps wraps cmd.Context with it). This flow waits +// on a human clicking "approve" in their browser, so that 60s default +// would kill it long before the server-issued expiresIn (~5 min) elapses +// — the displayed "expires in 300s" then contradicts the real timeout +// (ECA-686). We detach the inherited deadline up front and let the +// server's expiresIn govern instead; each network call below keeps its +// own bounded sub-timeout, and Ctrl-C still aborts promptly. func (s *Service) loginDeviceBlocking(ctx context.Context, opts LoginOptions) (*LoginResult, error) { + ctx, baseCancel := detachInheritedDeadline(ctx) + defer baseCancel() + // Drop any stale --no-wait session left from a previous run so the // blocking flow doesn't accidentally inherit a dead deviceCode if a // later resume command attempts to read this file. @@ -355,6 +382,7 @@ func (s *Service) completeApproved(ctx context.Context, token *client.DeviceToke logger.L().Warnw("completeApproved: ensureSelfAgent failed (uploads will require manual re-auth)", "err", err.Error(), ) + s.discardStaleAgentToken(ctx, "completeApproved") } return &LoginResult{ @@ -367,6 +395,25 @@ func (s *Service) completeApproved(ctx context.Context, token *client.DeviceToke }, nil } +// detachInheritedDeadline returns a child that drops the inherited +// --timeout deadline but still cancels on a genuine SIGINT / user abort. +// Used by the blocking Device Flow so the global --timeout default +// doesn't clip the human-approval wait, while Ctrl-C still aborts +// promptly (ECA-686). +// +// We branch off the un-deadlined cancellation source (the signal context +// cmdctx stashes via runctx) rather than the deadline-bearing parent. +// This matters precisely after the inherited deadline elapses: a +// context's Err freezes to DeadlineExceeded on the FIRST done, so a +// cancel arriving later is never observable through the parent again — +// watching parent would hang the flow until the server expiresIn. The +// un-deadlined source carries no deadline to freeze, so a post-deadline +// Ctrl-C still propagates. When no source was stashed (timeout=0, tests), +// runctx.BaseContext returns parent and behaviour is unchanged. +func detachInheritedDeadline(parent context.Context) (context.Context, context.CancelFunc) { + return context.WithCancel(runctx.BaseContext(parent)) +} + func deviceStartReq(opts LoginOptions) client.DeviceStartReq { name := opts.ClientName if name == "" { @@ -376,6 +423,7 @@ func deviceStartReq(opts LoginOptions) client.DeviceStartReq { if ver == "" { ver = "dev" } + ver = core.TruncateClientVersion(ver) const platform = "evercli" return client.DeviceStartReq{ ClientName: name, diff --git a/cli/internal/auth/service_test.go b/cli/internal/auth/service_test.go index 4b943e1..80646aa 100644 --- a/cli/internal/auth/service_test.go +++ b/cli/internal/auth/service_test.go @@ -16,6 +16,7 @@ import ( "evercli/internal/credential" "evercli/internal/httpmock" "evercli/internal/output" + "evercli/internal/runctx" ) // authFixture wires a Service against httpmock + tmp paths + mem cred. @@ -190,6 +191,149 @@ func TestLogin_DeviceCode_ExpiredAuthError(t *testing.T) { assert.Equal(t, output.TypeAuth, ce.Type) } +// ---- blocking Device Flow deadline (ECA-686) ------------------------ + +// The global --timeout (60s default) is delivered to the service as a +// parent-context deadline. The blocking Device Flow waits on a human in +// their browser, so it must NOT be clipped by that inherited deadline — +// the server-issued expiresIn (e.g. 300s / 5 min) governs instead. +// +// We prove the deadline is fully detached by handing the service a parent +// whose deadline has ALREADY elapsed: the buggy code threads that dead +// context straight into DeviceStart / the poll loop and aborts instantly; +// the fixed code strips the inherited deadline and completes approval. +func TestLogin_DeviceBlocking_IgnoresInheritedDeadline(t *testing.T) { + f := newAuthFixture(t) + f.srv.HandleEnvelope("POST /auth/device", client.DeviceStartResp{ + DeviceCode: "dc_x", + UserCode: "ABCD-EFGH", + VerificationURL: "https://everme.evermind.ai/auth/device?code=ABCD-EFGH", + ExpiresIn: 300, + Interval: 1, + }) + f.srv.HandleEnvelope("POST /auth/token", client.DeviceTokenResp{ + Status: "approved", + APIKey: "emk_0123456789abcdef0123456789abcdef", + APIKeyPrefix: "emk_a1b2", + IsNewKey: true, + Scopes: []string{"mem:read"}, + }) + f.srv.HandleEnvelope("POST /auth/login", client.LoginResp{ + AccountID: "acct_xyz", Email: "user@example.com", APIKeyPrefix: "emk_a1b2", + }) + f.srv.HandleEnvelope("POST /agents", client.RegisterAgentResp{AgentToken: "evt_new"}) + + // Mirror cmdctx wiring: an un-deadlined source (alive) with an + // already-elapsed --timeout layered on top and the source stashed — + // i.e. the 60s --timeout wrap has elapsed during the human-approval + // wait but no SIGINT has fired. + base := context.Background() + ctx, cancel := context.WithDeadline(base, time.Now().Add(-time.Second)) + defer cancel() + ctx = runctx.WithBaseContext(ctx, base) + + res, err := f.service.Login(ctx, auth.LoginOptions{}) // blocking flavor + require.NoError(t, err, "an elapsed inherited deadline must not abort the blocking flow") + assert.Equal(t, "approved", res.Status) +} + +// A genuine cancellation (SIGINT / user abort) MUST still abort the +// blocking flow — detaching the deadline must not also drop cancellation. +func TestLogin_DeviceBlocking_PropagatesCancellation(t *testing.T) { + f := newAuthFixture(t) + f.srv.HandleEnvelope("POST /auth/device", client.DeviceStartResp{ + DeviceCode: "dc_x", UserCode: "ABCD-EFGH", ExpiresIn: 300, Interval: 1, + }) + // Would approve if reached — but a cancelled parent must prevent it. + f.srv.HandleEnvelope("POST /auth/token", client.DeviceTokenResp{ + Status: "approved", + APIKey: "emk_0123456789abcdef0123456789abcdef", + APIKeyPrefix: "emk_a1b2", + }) + f.srv.HandleEnvelope("POST /auth/login", client.LoginResp{AccountID: "acct_xyz"}) + f.srv.HandleEnvelope("POST /agents", client.RegisterAgentResp{AgentToken: "evt_new"}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // user already aborted before the flow runs + + res, err := f.service.Login(ctx, auth.LoginOptions{}) + require.Error(t, err, "a cancelled parent must abort the blocking flow, not detach into a long wait") + assert.Nil(t, res) +} + +// Detaching the inherited --timeout (ECA-686) must not cost prompt Ctrl-C. +// The regression: once the inherited deadline has ELAPSED, the parent's +// Err freezes to DeadlineExceeded, so a cancel arriving afterwards is +// invisible through the parent and the flow hangs until the server-issued +// expiresIn. The fix tracks the un-deadlined cancellation source that +// cmdctx stashes via runctx.WithBaseContext, so a genuine post-deadline +// Ctrl-C still aborts immediately. +func TestLogin_DeviceBlocking_CancelAfterInheritedDeadlineElapsed(t *testing.T) { + f := newAuthFixture(t) + f.srv.HandleEnvelope("POST /auth/device", client.DeviceStartResp{ + DeviceCode: "dc_x", UserCode: "ABCD-EFGH", ExpiresIn: 1, Interval: 5, + }) + // Poll always returns pending so only ctx cancellation (or the server + // expiresIn) can end the flow. + f.srv.HandleEnvelope("POST /auth/token", client.DeviceTokenResp{Status: "pending"}) + + // Mirror the cmdctx wiring: a cancellable signal ctx (the un-deadlined + // cancellation source) with the global --timeout layered on top, and + // the signal ctx stashed so the device flow can reach it. + base, cancelBase := context.WithCancel(context.Background()) + defer cancelBase() + parent, cancelTimeout := context.WithTimeout(base, 50*time.Millisecond) + defer cancelTimeout() + parent = runctx.WithBaseContext(parent, base) + + // Genuine Ctrl-C arrives AFTER the 50ms inherited deadline has elapsed, + // but well before the 1s server expiresIn. + time.AfterFunc(150*time.Millisecond, cancelBase) + + start := time.Now() + res, err := f.service.Login(parent, auth.LoginOptions{}) + elapsed := time.Since(start) + + assert.Nil(t, res) + assert.ErrorIs(t, err, context.Canceled, + "a Ctrl-C after the inherited --timeout elapsed must abort as cancellation, not time out on the server deadline") + assert.Less(t, elapsed, 800*time.Millisecond, + "abort must be prompt on cancel, not wait out the ~1s server expiresIn") +} + +// ---- evt hygiene on account switch (ECA-689) ------------------------ + +// ensureSelfAgent failure is intentionally non-fatal (login still +// succeeds for status/me), but it MUST NOT leave a stale evt behind: if +// the user switched EverMe accounts, the cached evt belonged to the OLD +// account and a later `import run` would silently upload under it. On +// failure we delete the stale evt so import fails loudly (NotLoggedIn) +// and the user re-runs `auth login` instead of misrouting data. +func TestLogin_APIKey_EnsureSelfAgentFailure_DeletesStaleEvt(t *testing.T) { + f := newAuthFixture(t) + ctx := context.Background() + + // Pre-seed an evt from a previous (old-account) login. + require.NoError(t, f.cred.Set(ctx, credential.AgentToken(), "evt_old_account")) + + f.srv.HandleEnvelope("POST /auth/login", client.LoginResp{ + AccountID: "acct_new", Email: "new@example.com", APIKeyPrefix: "emk_a1b2", + }) + // ensureSelfAgent's RegisterAgent call fails. + f.srv.HandleEnvelopeError("POST /agents", 50000, "ErrInternal") + + res, err := f.service.Login(ctx, auth.LoginOptions{ + APIKey: "emk_0123456789abcdef0123456789abcdef", + }) + require.NoError(t, err, "ensureSelfAgent failure stays non-fatal for login") + assert.Equal(t, "approved", res.Status) + + // The stale evt must be gone, not silently retained. + _, err = f.cred.Get(ctx, credential.AgentToken()) + assert.ErrorIs(t, err, credential.ErrNotFound, + "stale evt from the previous account must be deleted when re-registration fails") +} + // ---- Logout ---------------------------------------------------------- func TestLogout_ClearsAllThreeArtifacts(t *testing.T) { diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index 6f3ae94..b768cfb 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -30,12 +30,10 @@ type Client interface { ListAgents(ctx context.Context, filter AgentFilter) ([]Agent, error) RegisterAgent(ctx context.Context, req RegisterAgentReq) (*RegisterAgentResp, error) + DisconnectAgent(ctx context.Context, agentID string) error // --- Memory / Records -------------------------------------------- - Presign(ctx context.Context, req PresignReq) (*PresignResp, error) - CreateRecord(ctx context.Context, req CreateRecordReq) (*CreateRecordResp, error) - // --- Transport tuning ------------------------------------------- // SetUserAgent overrides the default User-Agent header. Production @@ -138,64 +136,3 @@ type RegisterAgentResp struct { TokenPrefix string `json:"tokenPrefix"` SourceID string `json:"sourceId,omitempty"` } - -// PresignReq is POST /mem/uploads/presign. fileName + contentType + -// sizeBytes + contentHash all required by backend; missing any → 400. -type PresignReq struct { - FileName string `json:"fileName"` - ContentType string `json:"contentType"` - SizeBytes int64 `json:"sizeBytes"` - ContentHash string `json:"contentHash"` -} - -// PresignResp matches PresignUploadResponse: formFields (not "fields"), -// expiresAt is RFC3339 string (not time.Time so we can pass it back into -// a checkpoint without time-zone reformatting drift). -type PresignResp struct { - ObjectKey string `json:"objectKey"` - UploadURL string `json:"uploadUrl"` - FormFields map[string]string `json:"formFields"` - MaxSize int64 `json:"maxSize"` - ExpiresAt string `json:"expiresAt"` -} - -// CreateRecordReq is POST /mem/sources. Backend requires title / -// objectKey / sizeBytes / contentHash; everything else optional. -// agent affiliation comes from the evt-bound auth context — the -// sourceId field is gone after the source/record merge. -type CreateRecordReq struct { - ObjectKey string `json:"objectKey"` - Title string `json:"title"` - SizeBytes int64 `json:"sizeBytes"` - ContentHash string `json:"contentHash"` - ContentType string `json:"contentType,omitempty"` - RawFormat string `json:"rawFormat,omitempty"` - ObjectETag string `json:"objectETag,omitempty"` - Tags []string `json:"tags,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - Summary string `json:"summary,omitempty"` - DocumentKey string `json:"documentKey,omitempty"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` - // OriginPlatform attributes the row to a specific platform - // regardless of which evt did the write. `evercli import run - // claude-code` sets this to "claude-code" so cold-start imports - // surface under Claude Code in the UI instead of EverCli. - OriginPlatform string `json:"originPlatform,omitempty"` -} - -// CreateRecordResp is the unified Source DTO. agentId replaces the -// old sourceId field after the merge. -type CreateRecordResp struct { - ID string `json:"id"` - AgentID string `json:"agentId"` - Title string `json:"title"` - ObjectKey string `json:"objectKey"` - SizeBytes int64 `json:"sizeBytes"` - ContentHash string `json:"contentHash"` - DocumentKey string `json:"documentKey"` - CreatedAt string `json:"createdAt"` -} - -// RecordID is a convenience alias for the canonical id field — earlier -// code referenced "RecordID" before we aligned with the backend's "id". -func (r *CreateRecordResp) RecordID() string { return r.ID } diff --git a/cli/internal/client/http_client.go b/cli/internal/client/http_client.go index 4f69dab..6c08639 100644 --- a/cli/internal/client/http_client.go +++ b/cli/internal/client/http_client.go @@ -201,7 +201,7 @@ func (c *httpClient) do( evt, err := c.cred.Get(ctx, credential.AgentToken()) if err != nil { if errors.Is(err, credential.ErrNotFound) { - return output.NotLoggedIn() + return output.UploadTokenMissing() } return output.Internal(fmt.Errorf("read agent credential: %w", err)) } @@ -496,20 +496,9 @@ func (c *httpClient) RegisterAgent(ctx context.Context, req RegisterAgentReq) (* return &resp, nil } -// (DisconnectAgent retired with `evercli plugin uninstall`.) - -func (c *httpClient) Presign(ctx context.Context, req PresignReq) (*PresignResp, error) { - var resp PresignResp - if err := c.do(ctx, http.MethodPost, "/mem/uploads/presign", nil, req, &resp, authAgent); err != nil { - return nil, err - } - return &resp, nil -} - -func (c *httpClient) CreateRecord(ctx context.Context, req CreateRecordReq) (*CreateRecordResp, error) { - var resp CreateRecordResp - if err := c.do(ctx, http.MethodPost, "/mem/sources", nil, req, &resp, authAgent); err != nil { - return nil, err - } - return &resp, nil +func (c *httpClient) DisconnectAgent(ctx context.Context, agentID string) error { + body := struct { + AgentID string `json:"agentId"` + }{AgentID: agentID} + return c.do(ctx, http.MethodPost, "/agents/disconnect", nil, body, nil, authBearer) } diff --git a/cli/internal/client/http_client_test.go b/cli/internal/client/http_client_test.go index 9f213aa..bf0fcf3 100644 --- a/cli/internal/client/http_client_test.go +++ b/cli/internal/client/http_client_test.go @@ -87,6 +87,21 @@ func TestListAgents_HappyAttachesAuthorization(t *testing.T) { assert.True(t, strings.HasPrefix(auth, "Bearer emk_"), "Bearer header must be set, got %q", auth) } +func TestDisconnectAgent_PostsAgentIDWithEMKAuthorization(t *testing.T) { + srv, cli := newTestClient(t) + srv.HandleEnvelope("POST /agents/disconnect", map[string]bool{"ok": true}) + + err := cli.DisconnectAgent(context.Background(), "agt_disconnect") + require.NoError(t, err) + + request := srv.LastRequest("POST /agents/disconnect") + require.NotNil(t, request) + assert.True(t, strings.HasPrefix(request.Authorization, "Bearer emk_")) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(request.Body, &body)) + assert.Equal(t, map[string]interface{}{"agentId": "agt_disconnect"}, body) +} + // (Me / DisconnectAgent tests retired with the slimming pass.) // ---- Auth-space errno classification -------------------------------- diff --git a/cli/internal/cmdctx/deps.go b/cli/internal/cmdctx/deps.go index 68707b4..98621fd 100644 --- a/cli/internal/cmdctx/deps.go +++ b/cli/internal/cmdctx/deps.go @@ -10,6 +10,7 @@ import ( "evercli/internal/credential" "evercli/internal/logger" "evercli/internal/output" + "evercli/internal/runctx" ) // Deps is the bag of dependencies injected into every command's RunE. @@ -93,7 +94,13 @@ func BuildDeps(cmd *cobra.Command) (*Deps, error) { // ctx (telemetry flush, log Sync hooked to ctx) saw // context.Canceled. We now run prev() first and cancel after. if g.Timeout > 0 && cmd != nil { - ctx, cancel := context.WithTimeout(cmd.Context(), g.Timeout) + // Stash the un-deadlined source (signal context) BEFORE layering + // the deadline, so long-blocking flows (Device Flow) can detach + // the --timeout yet still observe a genuine post-deadline SIGINT. + // See internal/runctx and auth.detachInheritedDeadline (ECA-686). + base := cmd.Context() + ctx, cancel := context.WithTimeout(base, g.Timeout) + ctx = runctx.WithBaseContext(ctx, base) cmd.SetContext(ctx) registerTimeoutCancel(cmd, cancel) } diff --git a/cli/internal/cmdctx/flags.go b/cli/internal/cmdctx/flags.go index f5359d2..5ba9137 100644 --- a/cli/internal/cmdctx/flags.go +++ b/cli/internal/cmdctx/flags.go @@ -15,7 +15,7 @@ import ( ) // GlobalFlags is the persistent flag set shared by every subcommand -// (docs/contracts.md). The shape is part of the +// (AGENTS.md "Output contract is sacred"). The shape is part of the // AI-Agent ABI and cannot be reshaped without a deprecation cycle. type GlobalFlags struct { Format string diff --git a/cli/internal/core/config.go b/cli/internal/core/config.go index a55ec65..e84d237 100644 --- a/cli/internal/core/config.go +++ b/cli/internal/core/config.go @@ -33,6 +33,30 @@ type Config struct { APIBaseURL string `valid:"required,url"` Timeout time.Duration `valid:"-"` Paths *Paths `valid:"-"` + Skill SkillConfig `valid:"-"` +} + +// SkillConfig holds settings for the `evercli skill` subcommand family. +// +// HubBaseURL is intentionally not documented for end users — it exists +// for dev/debug overrides only. +// TODO: future multi-hub-source selection (everme skillhub / github / custom). +type SkillConfig struct { + // HubBaseURL is the base URL of the skill-hub-base service. + // Default: "https://skillhub.evermind.ai". Override via env EVERCLI_SKILL_HUB_BASE_URL. + HubBaseURL string + + // Agents is the list of agent platforms (e.g. ["claude-code","cursor"]) + // that skills are copied into after install. + // Populated during first-use agent selection. + Agents []string + + // LoginPrompt tracks whether the user has been shown the everme login + // nudge on first use. + // "pending" — not yet shown; show on next skill command + // "snoozed:" — user chose "remind later"; re-show after that time + // "dismissed" — user chose "don't ask again"; never show again + LoginPrompt string } // LoadConfig resolves Paths, then loads config.yaml (if present) merged @@ -55,6 +79,8 @@ func LoadConfig(configPath string) (*Config, error) { v.SetDefault("api_base_url", "https://api.everme.evermind.ai") v.SetDefault("timeout", "60s") + v.SetDefault("skill.hub_base_url", "https://skillhub.evermind.ai") + v.SetDefault("skill.login_prompt", "pending") if configPath == "" { configPath = paths.ConfigFile() @@ -80,6 +106,11 @@ func LoadConfig(configPath string) (*Config, error) { APIBaseURL: strings.TrimSpace(v.GetString("api_base_url")), Timeout: timeout, Paths: paths, + Skill: SkillConfig{ + HubBaseURL: strings.TrimSpace(v.GetString("skill.hub_base_url")), + Agents: v.GetStringSlice("skill.agents"), + LoginPrompt: v.GetString("skill.login_prompt"), + }, } if err := cfg.Validate(); err != nil { return nil, err @@ -123,3 +154,27 @@ func (c *Config) EnsureDirs() error { // ConfigPath returns the file viper would read for this Config. Useful // for `evercli config show --format text` and doctor diagnostics. func (c *Config) ConfigPath() string { return filepath.Join(c.Paths.ConfigDir, "config.yaml") } + +// SaveSkillConfig persists the current Skill section back to config.yaml. +// It reads the existing file, merges the skill block, and rewrites atomically. +// Other top-level keys (api_base_url, timeout) are left untouched. +func (c *Config) SaveSkillConfig() error { + v := viper.New() + v.SetConfigFile(c.ConfigPath()) + // Best-effort: load existing config if present; ignore missing-file errors. + if err := v.ReadInConfig(); err != nil { + var nf viper.ConfigFileNotFoundError + if !errors.As(err, &nf) && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read config for update: %w", err) + } + } + + v.Set("skill.hub_base_url", c.Skill.HubBaseURL) + v.Set("skill.agents", c.Skill.Agents) + v.Set("skill.login_prompt", c.Skill.LoginPrompt) + + if err := os.MkdirAll(c.Paths.ConfigDir, 0o700); err != nil { + return fmt.Errorf("ensure config dir: %w", err) + } + return v.WriteConfigAs(c.ConfigPath()) +} diff --git a/cli/internal/core/hermes.go b/cli/internal/core/hermes.go new file mode 100644 index 0000000..cab74eb --- /dev/null +++ b/cli/internal/core/hermes.go @@ -0,0 +1,58 @@ +package core + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// HermesCommand resolves the `hermes` CLI binary name/path. EVERCLI_HERMES_CMD +// lets tests substitute a fake; otherwise the binary is found on PATH. +func HermesCommand() string { + if v := os.Getenv("EVERCLI_HERMES_CMD"); v != "" { + return v + } + return "hermes" +} + +// HermesHome resolves the Hermes home directory using the priority chain +// mandated by Hermes maintainers: callers MUST NOT hard-guess `~/.hermes` +// when a user has overridden the location. Order: +// +// 1. EVERCLI_HERMES_CONFIG_DIR — test / advanced override; wins outright. +// 2. HERMES_HOME — Hermes's own well-known env var. +// 3. `hermes config path` — authoritative source from the installed CLI. +// 4. $HOME/.hermes — last-resort fallback. +func HermesHome() (string, error) { + if v := os.Getenv("EVERCLI_HERMES_CONFIG_DIR"); v != "" { + return v, nil + } + if v := os.Getenv("HERMES_HOME"); v != "" { + return v, nil + } + if p, ok := probeHermesConfigPath(); ok { + return filepath.Dir(p), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve hermes home: %w", err) + } + return filepath.Join(home, ".hermes"), nil +} + +// probeHermesConfigPath runs `hermes config path` and returns the trimmed +// stdout when it succeeds (one absolute file path). Best-effort: any failure +// returns ("", false) so the caller falls through to the next priority level. +func probeHermesConfigPath() (string, bool) { + out, err := exec.Command(HermesCommand(), "config", "path").Output() + if err != nil { + return "", false + } + p := strings.TrimSpace(string(out)) + if p == "" || !filepath.IsAbs(p) { + return "", false + } + return p, true +} diff --git a/cli/internal/core/hermes_test.go b/cli/internal/core/hermes_test.go new file mode 100644 index 0000000..4d8b4f7 --- /dev/null +++ b/cli/internal/core/hermes_test.go @@ -0,0 +1,53 @@ +package core + +import ( + "path/filepath" + "testing" +) + +func TestHermesHome_ConfigDirOverrideWins(t *testing.T) { + t.Setenv("EVERCLI_HERMES_CONFIG_DIR", "/tmp/pinned-home") + t.Setenv("HERMES_HOME", "/tmp/other") + got, err := HermesHome() + if err != nil { + t.Fatal(err) + } + if got != "/tmp/pinned-home" { + t.Fatalf("EVERCLI_HERMES_CONFIG_DIR must win, got %q", got) + } +} + +func TestHermesHome_HermesHomeEnv(t *testing.T) { + t.Setenv("EVERCLI_HERMES_CONFIG_DIR", "") + t.Setenv("HERMES_HOME", "/tmp/hh") + got, err := HermesHome() + if err != nil { + t.Fatal(err) + } + if got != "/tmp/hh" { + t.Fatalf("HERMES_HOME should resolve, got %q", got) + } +} + +func TestHermesHome_FallbackToDotHermes(t *testing.T) { + t.Setenv("EVERCLI_HERMES_CONFIG_DIR", "") + t.Setenv("HERMES_HOME", "") + // EVERCLI_HERMES_CMD points to a missing binary so `hermes config path` + // fails and we fall through to $HOME/.hermes. + t.Setenv("EVERCLI_HERMES_CMD", "definitely-not-a-real-binary-xyz") + t.Setenv("HOME", "/tmp/fakehome") + got, err := HermesHome() + if err != nil { + t.Fatal(err) + } + if got != filepath.Join("/tmp/fakehome", ".hermes") { + t.Fatalf("fallback should be $HOME/.hermes, got %q", got) + } +} + +func TestHermesCommand_EnvOverride(t *testing.T) { + t.Setenv("EVERCLI_HERMES_CMD", "/opt/hermes") + if got := HermesCommand(); got != "/opt/hermes" { + t.Fatalf("expected override, got %q", got) + } +} diff --git a/cli/internal/core/version.go b/cli/internal/core/version.go new file mode 100644 index 0000000..28e4cfe --- /dev/null +++ b/cli/internal/core/version.go @@ -0,0 +1,14 @@ +package core + +// TruncateClientVersion clamps s to at most 32 bytes. It mirrors the +// server's client_version varchar(32) column: a raw SQLSTATE 22001 +// ("value too long") bubbling up from the server as an opaque errno is +// harder to diagnose than the CLI simply never sending an oversized value. +// Version strings are ASCII (git describe output), so a byte-safe slice is +// equivalent to a rune-safe one here. +func TruncateClientVersion(s string) string { + if len(s) <= 32 { + return s + } + return s[:32] +} diff --git a/cli/internal/core/version_test.go b/cli/internal/core/version_test.go new file mode 100644 index 0000000..6086784 --- /dev/null +++ b/cli/internal/core/version_test.go @@ -0,0 +1,51 @@ +package core + +import "testing" + +func TestTruncateClientVersion(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + // Reproduces the incident: a local dev build's git-describe + // picked up the server's tag, producing a client_version longer + // than the server's varchar(32) column. + name: "incident string (33 chars) truncates to 32", + in: "everme-server_release-20260803_v5", + want: "everme-server_release-20260803_v", + }, + { + name: "37-char input truncates to 32", + in: "0123456789012345678901234567890123456", + want: "01234567890123456789012345678901", + }, + { + name: "short input passes through unchanged", + in: "dev", + want: "dev", + }, + { + name: "exactly 32 chars passes through unchanged", + in: "12345678901234567890123456789012", + want: "12345678901234567890123456789012", + }, + { + name: "empty input passes through unchanged", + in: "", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := TruncateClientVersion(tc.in) + if got != tc.want { + t.Fatalf("TruncateClientVersion(%q) = %q, want %q", tc.in, got, tc.want) + } + if len(got) > 32 { + t.Fatalf("TruncateClientVersion(%q) returned %d bytes, want <=32", tc.in, len(got)) + } + }) + } +} diff --git a/cli/internal/doctor/doctor.go b/cli/internal/doctor/doctor.go index 775f6b5..e2cd7d0 100644 --- a/cli/internal/doctor/doctor.go +++ b/cli/internal/doctor/doctor.go @@ -89,6 +89,7 @@ func Run(ctx context.Context, d Deps) *Report { credBackendCheck{prv: d.CredPrv}, credReadableCheck{prv: d.CredPrv}, claudeCodeMcpVisibleCheck{}, + claudeCodePluginVersionCheck{}, } results := make([]Result, len(checks)) @@ -205,7 +206,7 @@ func (c credBackendCheck) Run(_ context.Context) Result { // `claude plugin install` exits 0 (plugin registered, hooks work), but // the plugin's MCP server is gated by a separate user-consent step // (`/mcp` inside Claude Code → enabledMcpjsonServers). Without this -// check, the symptom — manual tool calls like everme_search not +// check, the symptom — manual tool calls like mem_search not // appearing — looks like a backend issue and takes far longer to // localize than it should. // @@ -243,6 +244,47 @@ func (claudeCodeMcpVisibleCheck) Run(ctx context.Context) Result { return r } +// claudeCodePluginVersionCheck compares the plugin version Claude Code +// has cached against the version of the payload on disk. This is the +// second silent-failure mode of the same shape as the MCP one above: +// `claude plugin install` exits 0 with "already installed" and keeps the +// previous cache directory, so a freshly upgraded npm package can sit +// unused for weeks while Claude Code loads the old plugin — with no error +// anywhere. evercli now runs the update verb at install time; this check +// catches a host that drifted for any other reason (manual install, +// interrupted update, restart that never happened). +// +// SevWarning, not Critical: a stale plugin is degraded, not broken. +type claudeCodePluginVersionCheck struct{} + +func (claudeCodePluginVersionCheck) Run(ctx context.Context) Result { + r := Result{Name: "plugin.claude-code.version-current", Severity: SevWarning} + cached, available, err := plugin.ClaudePluginVersionDrift(ctx) + if err != nil { + r.Message = "could not compare claude-code plugin versions: " + err.Error() + r.HintCmd = "claude plugin list" + return r + } + // Nothing to compare: no host, no payload on disk, plugin not + // installed, or a source we can't read without a network fetch. + // evercli is host-agnostic — absence is not a failure. + if cached == "" || available == "" { + r.Severity = SevInfo + r.OK = true + r.Message = "no comparable claude-code plugin payload; skipped" + return r + } + r.Detail = map[string]interface{}{"cached": cached, "available": available} + if cached != available { + r.Message = "Claude Code has plugin " + cached + " cached but " + available + " is on disk" + r.HintCmd = "claude plugin update everme@everme" + return r + } + r.OK = true + r.Message = "claude-code plugin up to date (" + cached + ")" + return r +} + type credReadableCheck struct{ prv credential.Provider } func (c credReadableCheck) Run(ctx context.Context) Result { diff --git a/cli/internal/doctor/doctor_test.go b/cli/internal/doctor/doctor_test.go index 5ef4621..a9527ae 100644 --- a/cli/internal/doctor/doctor_test.go +++ b/cli/internal/doctor/doctor_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "testing" "time" @@ -43,6 +44,11 @@ func TestRun_NetworkAndCredHappyPath(t *testing.T) { // branch so the test result is independent of whether the dev // machine running the suite happens to have `claude` on PATH. t.Setenv("EVERCLI_CLAUDE_CMD", "/nonexistent/claude-for-doctor-test") + // Same reason for the version-drift check: point it at a payload + // directory that doesn't exist so it takes the "nothing to compare" + // branch instead of reading whatever @everme/claude-code the dev + // machine happens to have globally installed. + t.Setenv("EVERCLI_CLAUDE_PLUGIN_SOURCE", filepath.Join(tmp, "no-such-payload")) paths := &core.Paths{ConfigDir: tmp, DataDir: tmp, CacheDir: tmp} cfg := &core.Config{APIBaseURL: srv.URL, Paths: paths, Timeout: 5 * time.Second} @@ -52,7 +58,7 @@ func TestRun_NetworkAndCredHappyPath(t *testing.T) { rep := Run(context.Background(), Deps{Config: cfg, CredPrv: prv}) require.NotNil(t, rep) - require.Len(t, rep.Checks, 5, "doctor runs: network.healthz, network.readyz, credential.backend, credential.readable, plugin.claude-code.mcp-visible") + require.Len(t, rep.Checks, 6, "doctor runs: network.healthz, network.readyz, credential.backend, credential.readable, plugin.claude-code.mcp-visible, plugin.claude-code.version-current") assert.Equal(t, "network.everme-api", rep.Checks[0].Name) assert.True(t, rep.Checks[0].OK) @@ -65,6 +71,9 @@ func TestRun_NetworkAndCredHappyPath(t *testing.T) { assert.Equal(t, "plugin.claude-code.mcp-visible", rep.Checks[4].Name) assert.True(t, rep.Checks[4].OK, "with no claude CLI present the check degrades to SevInfo OK") assert.Equal(t, SevInfo, rep.Checks[4].Severity) + assert.Equal(t, "plugin.claude-code.version-current", rep.Checks[5].Name) + assert.True(t, rep.Checks[5].OK, "with no readable payload the check degrades to SevInfo OK") + assert.Equal(t, SevInfo, rep.Checks[5].Severity) assert.Zero(t, rep.Summary.CriticalFailed) } diff --git a/cli/internal/httpmock/server.go b/cli/internal/httpmock/server.go index e98e74e..8e41c40 100644 --- a/cli/internal/httpmock/server.go +++ b/cli/internal/httpmock/server.go @@ -29,6 +29,8 @@ type Server struct { // captured holds the most recent request per route so tests can // assert on headers / body without bookkeeping at the call site. + // Guarded by mu: handlers write it from server goroutines while the + // test goroutine reads it through LastRequest. mu sync.Mutex captured map[string]*RecordedRequest } diff --git a/cli/internal/importer/checkpoint.go b/cli/internal/importer/checkpoint.go deleted file mode 100644 index a98e877..0000000 --- a/cli/internal/importer/checkpoint.go +++ /dev/null @@ -1,124 +0,0 @@ -package importer - -import ( - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - "time" -) - -// Checkpoint is the resume artifact written between pipeline steps so -// `import run --resume` can pick up where a previous attempt left off. -// -// The merged body is NOT persisted; --resume re-runs scanner+merger -// because Merge is deterministic given the same scan inputs. Replaying -// is cheaper than persisting a multi-MB file alongside an emk-bearing -// machine. -// -// Step semantics: -// -// "presigned" → Presign succeeded; have UploadURL + ObjectKey. -// "uploaded" → S3 POST succeeded; objectKey is durable. -// "recorded" → CreateRecord succeeded (terminal; we delete the -// checkpoint here so it shouldn't normally be on disk). -type Checkpoint struct { - Platform PlatformID `json:"platform"` - Step string `json:"step"` - IdempotencyKey string `json:"idempotencyKey"` - DocumentKey string `json:"documentKey"` - ContentHash string `json:"contentHash"` - SizeBytes int64 `json:"sizeBytes"` - FileCount int `json:"fileCount"` - ObjectKey string `json:"objectKey,omitempty"` - UploadURL string `json:"uploadUrl,omitempty"` - UploadFields map[string]string `json:"uploadFields,omitempty"` - UploadURLExpiresAt time.Time `json:"uploadUrlExpiresAt,omitempty"` - SourceID string `json:"sourceId,omitempty"` - CreatedAt time.Time `json:"createdAt"` -} - -// CheckpointPath returns the canonical on-disk location for a -// platform's checkpoint. Lives under cacheDir (XDG_CACHE_HOME) so a -// `doctor --cleanup` pass can prune stale ones. -func CheckpointPath(cacheDir string, p PlatformID) string { - return filepath.Join(cacheDir, "import-checkpoint-"+string(p)+".json") -} - -// LoadCheckpoint returns (nil, nil) when the file is missing. -func LoadCheckpoint(path string) (*Checkpoint, error) { - raw, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, nil - } - return nil, err - } - var c Checkpoint - if err := json.Unmarshal(raw, &c); err != nil { - return nil, err - } - return &c, nil -} - -// SaveCheckpoint atomically persists the checkpoint at 0600. We use -// the same O_CREATE|O_EXCL + fsync + rename + dir-fsync pattern as the -// auth-side savers so a crash mid-write can't leave a torn or zero-byte -// checkpoint that LoadCheckpoint would silently accept on next launch. -func SaveCheckpoint(path string, c *Checkpoint) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return err - } - raw, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err - } - tmp := path + ".tmp" - _ = os.Remove(tmp) - f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return err - } - cleanup := func() { _ = os.Remove(tmp) } - if _, err := f.Write(raw); err != nil { - _ = f.Close() - cleanup() - return err - } - if err := f.Sync(); err != nil { - _ = f.Close() - cleanup() - return err - } - if err := f.Close(); err != nil { - cleanup() - return err - } - if err := os.Rename(tmp, path); err != nil { - cleanup() - return err - } - if dir, dirErr := os.Open(filepath.Dir(path)); dirErr == nil { - _ = dir.Sync() - _ = dir.Close() - } - return nil -} - -// DeleteCheckpoint is idempotent. -func DeleteCheckpoint(path string) error { - if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { - return err - } - return nil -} - -// UploadURLValid is true when the presigned URL has not yet expired. -// Conservatively returns false if expires-at is the zero value. -func (c *Checkpoint) UploadURLValid(now time.Time) bool { - if c.UploadURLExpiresAt.IsZero() { - return false - } - return now.Before(c.UploadURLExpiresAt) -} diff --git a/cli/internal/importer/conversation/attribution_test.go b/cli/internal/importer/conversation/attribution_test.go new file mode 100644 index 0000000..857c437 --- /dev/null +++ b/cli/internal/importer/conversation/attribution_test.go @@ -0,0 +1,107 @@ +package conversation + +import ( + "os" + "path/filepath" + "testing" +) + +// FIX 3 — ownerForMarkdownPath maps a path under each agent home dir. +func TestOwnerForMarkdownPath(t *testing.T) { + home := t.TempDir() + claude := filepath.Join(home, ".claude") + codex := filepath.Join(home, ".codex") + hermes := filepath.Join(home, ".hermes") + openclaw := filepath.Join(home, ".openclaw") + t.Setenv("CLAUDE_CONFIG_DIR", claude) + t.Setenv("CODEX_HOME", codex) + t.Setenv("OPENCLAW_CONFIG_DIR", openclaw) + // hermes honors HOME default; override its dir via HOME. + t.Setenv("HOME", home) + + cases := []struct { + path string + want PlatformID + }{ + {filepath.Join(claude, "notes", "a.md"), PlatformClaudeCode}, + {filepath.Join(codex, "x.md"), PlatformCodex}, + {filepath.Join(hermes, "y.md"), PlatformHermes}, + {filepath.Join(openclaw, "z.md"), PlatformOpenClaw}, + {filepath.Join(home, "Documents", "elsewhere.md"), ""}, + } + for _, c := range cases { + if got := ownerForMarkdownPath(c.path); got != c.want { + t.Errorf("ownerForMarkdownPath(%q) = %q, want %q", c.path, got, c.want) + } + } +} + +// FIX 3 — AttributionPlatform returns owner for owned md, platform otherwise. +func TestAttributionPlatform(t *testing.T) { + if got := AttributionPlatform(Item{Platform: PlatformMarkdown, OwnerPlatform: PlatformClaudeCode}); got != PlatformClaudeCode { + t.Errorf("owned md should attribute to owner, got %q", got) + } + if got := AttributionPlatform(Item{Platform: PlatformMarkdown}); got != PlatformMarkdown { + t.Errorf("ownerless md should stay markdown, got %q", got) + } + if got := AttributionPlatform(Item{Platform: PlatformCodex}); got != PlatformCodex { + t.Errorf("non-md should return its own platform, got %q", got) + } +} + +// FIX 3 — scanning an md under an agent dir sets OwnerPlatform and includes it. +func TestMarkdownScanSetsOwnerPlatform(t *testing.T) { + home := t.TempDir() + claude := filepath.Join(home, ".claude") + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_CONFIG_DIR", claude) + t.Setenv("HOME", home) + + mdPath := filepath.Join(claude, "owned.md") + if err := os.WriteFile(mdPath, []byte("# note\nhello"), 0o644); err != nil { + t.Fatal(err) + } + + sc := NewMarkdownScanner() + items, err := sc.Scan([]string{claude}) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].OwnerPlatform != PlatformClaudeCode { + t.Fatalf("OwnerPlatform should be claude-code, got %q", items[0].OwnerPlatform) + } +} + +func TestMarkdownScanUnknownDirHasNoOwner(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(home, ".claude")) + t.Setenv("CODEX_HOME", filepath.Join(home, ".codex")) + t.Setenv("OPENCLAW_CONFIG_DIR", filepath.Join(home, ".openclaw")) + + other := filepath.Join(home, "Documents") + if err := os.MkdirAll(other, 0o755); err != nil { + t.Fatal(err) + } + mdPath := filepath.Join(other, "loose.md") + if err := os.WriteFile(mdPath, []byte("loose note"), 0o644); err != nil { + t.Fatal(err) + } + + sc := NewMarkdownScanner() + items, err := sc.Scan([]string{other}) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].OwnerPlatform != "" { + t.Fatalf("md outside agent dirs must have empty OwnerPlatform, got %q", items[0].OwnerPlatform) + } +} diff --git a/cli/internal/importer/conversation/batch.go b/cli/internal/importer/conversation/batch.go new file mode 100644 index 0000000..cf3ab11 --- /dev/null +++ b/cli/internal/importer/conversation/batch.go @@ -0,0 +1,51 @@ +package conversation + +import "encoding/json" + +// maxAgentBatchBytes bounds the size of a single agent-memory POST. A whole +// session is split into batches under this budget and uploaded as multiple +// add calls under the same conversationId. EverOS rejects oversized agent +// payloads (its boundary detector is tuned for ~32K-token batches), so an +// unbounded single POST of a large session fails upstream. 64 KiB maps to +// well under that token budget and stays below empirically-passing sizes. +const maxAgentBatchBytes = 64 * 1024 + +// messageBytes is the marshaled JSON size of one message — the unit the batch +// budget accounts in. +func messageBytes(m AgentMemoryMessage) int { + b, err := json.Marshal(m) + if err != nil { + return 0 + } + return len(b) +} + +// batchMessagesByBytes splits msgs into ordered batches whose per-message byte +// sizes sum to at most budget. Order is preserved and every message appears +// exactly once. A single message larger than budget occupies its own batch +// (never dropped, never split). budget <= 0 means no limit (one batch). +func batchMessagesByBytes(msgs []AgentMemoryMessage, budget int) [][]AgentMemoryMessage { + if len(msgs) == 0 { + return nil + } + if budget <= 0 { + return [][]AgentMemoryMessage{msgs} + } + var batches [][]AgentMemoryMessage + var cur []AgentMemoryMessage + curBytes := 0 + for _, m := range msgs { + mb := messageBytes(m) + if len(cur) > 0 && curBytes+mb > budget { + batches = append(batches, cur) + cur = nil + curBytes = 0 + } + cur = append(cur, m) + curBytes += mb + } + if len(cur) > 0 { + batches = append(batches, cur) + } + return batches +} diff --git a/cli/internal/importer/conversation/batch_test.go b/cli/internal/importer/conversation/batch_test.go new file mode 100644 index 0000000..de03c99 --- /dev/null +++ b/cli/internal/importer/conversation/batch_test.go @@ -0,0 +1,78 @@ +package conversation + +import ( + "strings" + "testing" +) + +func TestBatchMessagesByBytesEmpty(t *testing.T) { + if got := batchMessagesByBytes(nil, 1000); len(got) != 0 { + t.Fatalf("empty input must yield no batches, got %d", len(got)) + } +} + +func TestBatchMessagesByBytesSingleBatchWhenUnderBudget(t *testing.T) { + msgs := []AgentMemoryMessage{ + {Role: "user", Timestamp: 1, Content: "hi"}, + {Role: "assistant", Timestamp: 2, Content: "hello"}, + } + got := batchMessagesByBytes(msgs, 64*1024) + if len(got) != 1 || len(got[0]) != 2 { + t.Fatalf("small set must be one batch of 2, got %d batches", len(got)) + } +} + +func TestBatchMessagesByBytesSplitsOnBudgetPreservingOrderAndCompleteness(t *testing.T) { + mk := func(role string, n int) AgentMemoryMessage { + return AgentMemoryMessage{Role: role, Timestamp: int64(n), Content: strings.Repeat("x", 4000)} + } + msgs := []AgentMemoryMessage{mk("a", 1), mk("b", 2), mk("c", 3), mk("d", 4), mk("e", 5)} + budget := 9000 // ~2 of the ~4KB messages per batch + + got := batchMessagesByBytes(msgs, budget) + if len(got) < 2 { + t.Fatalf("oversized set must split into >1 batch, got %d", len(got)) + } + + // Completeness + order: flattening must equal the input exactly. + var flat []AgentMemoryMessage + for _, b := range got { + if len(b) == 0 { + t.Fatalf("no empty batches allowed") + } + flat = append(flat, b...) + } + if len(flat) != len(msgs) { + t.Fatalf("lost/duplicated messages: %d vs %d", len(flat), len(msgs)) + } + for i := range flat { + if flat[i].Timestamp != msgs[i].Timestamp { + t.Fatalf("order not preserved at %d", i) + } + } + + // Each multi-message batch must stay within budget. + for i, b := range got { + if len(b) > 1 { + sum := 0 + for _, m := range b { + sum += messageBytes(m) + } + if sum > budget { + t.Fatalf("batch %d exceeds budget: %d > %d", i, sum, budget) + } + } + } +} + +func TestBatchMessagesByBytesOversizedSingleMessageGoesAlone(t *testing.T) { + huge := AgentMemoryMessage{Role: "tool", Timestamp: 1, ToolCallID: "c", Content: strings.Repeat("x", 50000)} + small := AgentMemoryMessage{Role: "user", Timestamp: 2, Content: "ok"} + got := batchMessagesByBytes([]AgentMemoryMessage{huge, small}, 9000) + if len(got) != 2 { + t.Fatalf("oversized message must occupy its own batch: got %d batches", len(got)) + } + if len(got[0]) != 1 || got[0][0].ToolCallID != "c" { + t.Fatalf("first batch must hold the oversized message alone") + } +} diff --git a/cli/internal/importer/conversation/claude_code.go b/cli/internal/importer/conversation/claude_code.go new file mode 100644 index 0000000..8d8bc92 --- /dev/null +++ b/cli/internal/importer/conversation/claude_code.go @@ -0,0 +1,405 @@ +package conversation + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// ClaudeCodeScanner parses Claude Code JSONL session files. +// Each line is a JSON object wrapping a "message" field with role/content. +// user content blocks: text → user message, tool_result → role=tool message. +// assistant content blocks: text → assistant message, tool_use → toolCalls[]. +type ClaudeCodeScanner struct{} + +var _ Scanner = (*ClaudeCodeScanner)(nil) + +func NewClaudeCodeScanner() *ClaudeCodeScanner { return &ClaudeCodeScanner{} } + +func (s *ClaudeCodeScanner) Platform() PlatformID { return PlatformClaudeCode } + +func (s *ClaudeCodeScanner) Scan(roots []string) ([]Item, error) { + var items []Item + for _, root := range roots { + if _, err := os.Stat(root); os.IsNotExist(err) { + continue + } + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + info, err := d.Info() + if err != nil || info == nil { + return nil // skip unreadable entry + } + item := Item{ + Platform: PlatformClaudeCode, + Path: path, + Status: "ready", + SizeBytes: info.Size(), + UpdatedAt: info.ModTime().UTC().Format(time.RFC3339), + } + items = append(items, item) + return nil + }) + if err != nil { + return nil, err + } + } + return items, nil +} + +func (s *ClaudeCodeScanner) Read(item Item) (*Conversation, error) { + f, err := os.Open(item.Path) + if err != nil { + return nil, err + } + defer f.Close() + + conv := &Conversation{Item: item} + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 64*1024*1024) + // Use file mtime as deterministic fallback base — never time.Now() (breaks idempotency). + var fallbackBase int64 + if fi, err := os.Stat(item.Path); err == nil { + fallbackBase = fi.ModTime().UnixMilli() + } else { + fallbackBase = time.Unix(0, 0).UnixMilli() + } + lineNum := 0 + originID := "" + + const maxRunes = 8000 + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + lineNum++ + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: json decode error: %v", lineNum, err)) + continue + } + + // Extract sessionId for originID + if originID == "" { + if sid, ok := ev["sessionId"].(string); ok && sid != "" { + originID = sid + } + } + + // Claude Code embeds the turn inside ev["message"] + inner := objectMapCC(ev["message"]) + role := stringFieldCC(inner, "role") + if role == "" { + role = stringFieldCC(ev, "role") + } + if role == "" { + role = stringFieldCC(ev, "type") + } + content, ok := inner["content"] + if !ok { + content = ev["content"] + } + ts := normalizeTimestampCC(firstPresentCC(ev["timestamp"], inner["timestamp"]), fallbackBase+int64(lineNum)) + + switch role { + case "user": + // Extract tool_result blocks first + toolMsgs, dropped := toolResultsFromContentCC(content, ts, maxRunes) + for range dropped { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: tool_result missing tool_use_id, dropped", lineNum)) + } + conv.Messages = append(conv.Messages, toolMsgs...) + // Then extract text + if text := textFromContentCC(content, maxRunes); text != "" { + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "user", + Timestamp: ts, + Content: Redact(text), + }) + } + case "assistant": + m, calls := assistantMessageFromContentCC(content, ts, maxRunes) + if m != nil { + conv.Messages = append(conv.Messages, *m) + _ = calls + } + default: + if role != "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: unknown role %q, skipped", lineNum, role)) + } + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + + setStartedAtFromMessages(conv) + conv.ID = ConversationID(PlatformClaudeCode, originID, item.Path) + return conv, nil +} + +func assistantMessageFromContentCC(content any, timestamp int64, maxRunes int) (*AgentMemoryMessage, int) { + if s, ok := content.(string); ok { + s = truncateRunesCC(strings.TrimSpace(s), maxRunes) + if s == "" { + return nil, 0 + } + return &AgentMemoryMessage{Role: "assistant", Timestamp: timestamp, Content: Redact(s)}, 0 + } + blocks, ok := content.([]any) + if !ok { + return nil, 0 + } + var textParts []string + var calls []AgentMemoryToolCall + for i, raw := range blocks { + b := objectMapCC(raw) + if b == nil { + continue + } + switch stringFieldCC(b, "type") { + case "text": + if text := stringFieldCC(b, "text"); text != "" { + textParts = append(textParts, truncateRunesCC(text, maxRunes)) + } + case "tool_use", "toolCall": + args, ok := b["input"] + if !ok { + args = b["arguments"] + } + id := firstNonEmptyCC(stringFieldCC(b, "id"), stringFieldCC(b, "tool_use_id"), fmt.Sprintf("claude_tool_%d_%d", timestamp, i)) + name := firstNonEmptyCC(stringFieldCC(b, "name"), "unknown") + calls = append(calls, AgentMemoryToolCall{ + ID: id, + Type: "function", + Name: name, + Arguments: Redact(argumentsStringCC(args)), + }) + } + } + m := AgentMemoryMessage{Role: "assistant", Timestamp: timestamp} + if text := strings.TrimSpace(strings.Join(textParts, "\n\n")); text != "" { + m.Content = Redact(text) + } + if len(calls) > 0 { + m.ToolCalls = calls + } + if m.Content == nil && len(m.ToolCalls) == 0 { + return nil, 0 + } + return &m, len(calls) +} + +func toolResultsFromContentCC(content any, timestamp int64, maxRunes int) ([]AgentMemoryMessage, int) { + blocks, ok := content.([]any) + if !ok { + return nil, 0 + } + var out []AgentMemoryMessage + dropped := 0 + for _, raw := range blocks { + b := objectMapCC(raw) + if b == nil || stringFieldCC(b, "type") != "tool_result" { + continue + } + toolCallID := firstNonEmptyCC(stringFieldCC(b, "tool_use_id"), stringFieldCC(b, "toolCallId"), stringFieldCC(b, "tool_call_id")) + if toolCallID == "" { + dropped++ + continue + } + text := contentTextCC(b["content"], maxRunes) + if text == "" { + text = "tool result" + } + out = append(out, AgentMemoryMessage{Role: "tool", Timestamp: timestamp, ToolCallID: toolCallID, Content: Redact(text)}) + } + return out, dropped +} + +func textFromContentCC(content any, maxRunes int) string { + if s, ok := content.(string); ok { + return truncateRunesCC(strings.TrimSpace(s), maxRunes) + } + blocks, ok := content.([]any) + if !ok { + return "" + } + parts := make([]string, 0, len(blocks)) + for _, raw := range blocks { + if s, ok := raw.(string); ok { + parts = append(parts, s) + continue + } + b := objectMapCC(raw) + if b == nil || stringFieldCC(b, "type") != "text" { + continue + } + if text := stringFieldCC(b, "text"); text != "" { + parts = append(parts, text) + } + } + return truncateRunesCC(strings.TrimSpace(strings.Join(parts, "\n")), maxRunes) +} + +func contentTextCC(v any, maxRunes int) string { + switch x := v.(type) { + case nil: + return "" + case string: + return truncateRunesCC(strings.TrimSpace(x), maxRunes) + case []any: + parts := make([]string, 0, len(x)) + for _, item := range x { + if s, ok := item.(string); ok { + parts = append(parts, s) + continue + } + m := objectMapCC(item) + if m != nil && stringFieldCC(m, "type") == "text" && stringFieldCC(m, "text") != "" { + parts = append(parts, stringFieldCC(m, "text")) + continue + } + } + return truncateRunesCC(strings.TrimSpace(strings.Join(parts, "\n")), maxRunes) + default: + b, _ := json.Marshal(v) + return truncateRunesCC(string(b), maxRunes) + } +} + +func objectMapCC(v any) map[string]any { + m, _ := v.(map[string]any) + return m +} + +func stringFieldCC(m map[string]any, key string) string { + if m == nil { + return "" + } + if s, ok := m[key].(string); ok { + return s + } + return "" +} + +func firstPresentCC(vals ...any) any { + for _, v := range vals { + if v != nil { + return v + } + } + return nil +} + +func firstNonEmptyCC(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func normalizeTimestampCC(v any, fallback int64) int64 { + switch x := v.(type) { + case float64: + if x > 10_000_000_000 { + return int64(x) + } + if x > 0 { + return int64(x * 1000) + } + case int64: + if x > 10_000_000_000 { + return x + } + if x > 0 { + return x * 1000 + } + case string: + if t, err := time.Parse(time.RFC3339Nano, x); err == nil { + return t.UnixMilli() + } + if t, err := time.Parse(time.RFC3339, x); err == nil { + return t.UnixMilli() + } + } + return fallback +} + +func argumentsStringCC(v any) string { + if s, ok := v.(string); ok { + return s + } + if v == nil { + return "{}" + } + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return string(b) +} + +// setStartedAtFromMessages sets conv.Item.StartedAt to the earliest message +// timestamp (RFC3339, UTC) found in the parsed conversation. Timestamps are +// epoch milliseconds. No-op if there are no messages with a positive ts. +func setStartedAtFromMessages(conv *Conversation) { + var earliest int64 + for _, m := range conv.Messages { + if m.Timestamp <= 0 { + continue + } + if earliest == 0 || m.Timestamp < earliest { + earliest = m.Timestamp + } + } + if earliest == 0 { + return + } + conv.Item.StartedAt = time.UnixMilli(earliest).UTC().Format(time.RFC3339) +} + +// truncateRunesCC truncates s to at most max runes (the server rejects message +// content over its rune cap). It keeps the HEAD and TAIL with a middle marker +// (head_ratio 0.7) instead of head-only clipping: the case/skill extractor +// mines tool results and final responses for findings that often live at the +// END of a long message (final result, exit status, root-cause line), so the +// tail must survive. Mirrors the server extractor's _truncate_text(0.7). +func truncateRunesCC(s string, max int) string { + if max <= 0 { + return s + } + r := []rune(s) + if len(r) <= max { + return s + } + const headRatio = 0.7 + const trimTmpl = "\n[... trimmed %d runes by evercli import ...]\n" + // Reserve marker space using len(r) as an upper bound on the trimmed + // count, so the real marker (fewer digits) can only be shorter — the + // final string is therefore guaranteed <= max runes. + markerBudget := len([]rune(fmt.Sprintf(trimTmpl, len(r)))) + budget := max - markerBudget + if budget < 1 { + // Cap too small to fit head+tail+marker; fall back to a plain head clip. + return string(r[:max]) + } + head := int(float64(budget) * headRatio) + tail := budget - head + trimmed := len(r) - head - tail + marker := fmt.Sprintf(trimTmpl, trimmed) + return string(r[:head]) + marker + string(r[len(r)-tail:]) +} diff --git a/cli/internal/importer/conversation/claude_code_test.go b/cli/internal/importer/conversation/claude_code_test.go new file mode 100644 index 0000000..2cdd8f1 --- /dev/null +++ b/cli/internal/importer/conversation/claude_code_test.go @@ -0,0 +1,91 @@ +package conversation + +import ( + "os" + "strings" + "testing" +) + +func TestClaudeCodeParseCounts(t *testing.T) { + sc := NewClaudeCodeScanner() + conv, err := sc.Read(Item{Platform: PlatformClaudeCode, Path: "testdata/claude_code_sample.jsonl"}) + if err != nil { + t.Fatal(err) + } + var toolCalls, toolResults int + for _, m := range conv.Messages { + toolCalls += len(m.ToolCalls) + if m.Role == "tool" { + toolResults++ + } + } + // fixture has 2 tool_use blocks and 2 tool_result blocks + if toolCalls == 0 || toolResults == 0 { + t.Fatalf("expected tool trajectory, got calls=%d results=%d", toolCalls, toolResults) + } + if conv.ID == "" { + t.Fatal("conversationId must be set") + } + // expected: 2 tool calls, 2 tool results + if toolCalls != 2 { + t.Fatalf("expected 2 toolCalls, got %d", toolCalls) + } + if toolResults != 2 { + t.Fatalf("expected 2 toolResults, got %d", toolResults) + } + // fixture: 1 user + 1 assistant(tool_use) + 1 tool + 1 assistant(tool_use) + 1 tool + 1 user + 1 assistant = 7 + if len(conv.Messages) != 7 { + t.Fatalf("expected 7 total messages, got %d", len(conv.Messages)) + } +} + +func TestClaudeCodeToolArgRedaction(t *testing.T) { + // Build a single-line JSONL fixture with a secret in the tool_use input. + line := `{"type":"say","timestamp":1749000001000,"sessionId":"sess-redact-001","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_redact","name":"Bash","input":{"cmd":"curl -H 'Authorization: Bearer sk-redactme0123456789ABCDEF' https://api.example.com"}}]}}` + dir := t.TempDir() + path := dir + "/redact_test.jsonl" + if err := os.WriteFile(path, []byte(line+"\n"), 0o644); err != nil { + t.Fatal(err) + } + sc := NewClaudeCodeScanner() + conv, err := sc.Read(Item{Platform: PlatformClaudeCode, Path: path}) + if err != nil { + t.Fatal(err) + } + if len(conv.Messages) == 0 { + t.Fatal("expected at least one message") + } + var found bool + for _, m := range conv.Messages { + for _, tc := range m.ToolCalls { + found = true + if strings.Contains(tc.Arguments, "sk-redactme") { + t.Fatalf("raw secret not redacted in Arguments: %q", tc.Arguments) + } + if !strings.Contains(tc.Arguments, "[redacted]") { + t.Fatalf("expected [redacted] in Arguments, got: %q", tc.Arguments) + } + } + } + if !found { + t.Fatal("no tool calls found in parsed messages") + } +} + +func TestClaudeCodeScannerPlatform(t *testing.T) { + sc := NewClaudeCodeScanner() + if sc.Platform() != PlatformClaudeCode { + t.Fatalf("expected %s, got %s", PlatformClaudeCode, sc.Platform()) + } +} + +func TestClaudeCodeScanMissingDir(t *testing.T) { + sc := NewClaudeCodeScanner() + items, err := sc.Scan([]string{"/no/such/dir/ever"}) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("missing dir should yield 0 items, got %d", len(items)) + } +} diff --git a/cli/internal/importer/conversation/clock.go b/cli/internal/importer/conversation/clock.go new file mode 100644 index 0000000..1383bfd --- /dev/null +++ b/cli/internal/importer/conversation/clock.go @@ -0,0 +1,5 @@ +package conversation + +import "time" + +func nowISO() string { return time.Now().UTC().Format(time.RFC3339) } diff --git a/cli/internal/importer/conversation/codex.go b/cli/internal/importer/conversation/codex.go new file mode 100644 index 0000000..5fcd4d5 --- /dev/null +++ b/cli/internal/importer/conversation/codex.go @@ -0,0 +1,301 @@ +package conversation + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// CodexScanner parses Codex JSONL session files. +// Each line is a JSON object with fields: type, timestamp, payload. +// +// type=response_item carries the conversation: payload.type = +// message | function_call | function_call_output | custom_tool_call | +// custom_tool_call_output | web_search_call are imported, reasoning is +// dropped, anything else warns. +// type=event_msg is the UI event stream — see codexKnownEventMsgTypes. +// type=session_meta and type=turn_context are skipped. +type CodexScanner struct{} + +// codexKnownEventMsgTypes are the event_msg payload types we have seen on +// real rollouts and deliberately drop. They are either display-only +// (agent_message, task_started) or a second rendering of a response_item +// we already import: on a 44-session home, 97.3% of mcp_tool_call_end +// call ids also appeared as a response_item function_call, so importing +// them too would double-write the same tool round-trip. +var codexKnownEventMsgTypes = map[string]bool{ + "agent_message": true, + "agent_reasoning": true, + "context_compacted": true, + "mcp_tool_call_begin": true, + "mcp_tool_call_end": true, + "patch_apply_begin": true, + "patch_apply_end": true, + "reasoning": true, + "task_complete": true, + "task_started": true, + "thread_goal_updated": true, + "thread_rolled_back": true, + "thread_settings_applied": true, + "token_count": true, + "turn_aborted": true, + "user_message": true, + "web_search_begin": true, + "web_search_end": true, +} + +var _ Scanner = (*CodexScanner)(nil) + +func NewCodexScanner() *CodexScanner { return &CodexScanner{} } + +func (s *CodexScanner) Platform() PlatformID { return PlatformCodex } + +func (s *CodexScanner) Scan(roots []string) ([]Item, error) { + var items []Item + for _, root := range roots { + if _, err := os.Stat(root); os.IsNotExist(err) { + continue + } + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + // OpenClaw trajectory files also end in .jsonl; they belong to the + // OpenClaw scanner and must never be parsed as Codex sessions. + if strings.HasSuffix(path, ".trajectory.jsonl") { + return nil + } + info, err := d.Info() + if err != nil || info == nil { + return nil // skip unreadable entry + } + item := Item{ + Platform: PlatformCodex, + Path: path, + Status: "ready", + SizeBytes: info.Size(), + UpdatedAt: info.ModTime().UTC().Format(time.RFC3339), + } + items = append(items, item) + return nil + }) + if err != nil { + return nil, err + } + } + return items, nil +} + +func (s *CodexScanner) Read(item Item) (*Conversation, error) { + f, err := os.Open(item.Path) + if err != nil { + return nil, err + } + defer f.Close() + + conv := &Conversation{Item: item} + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 64*1024*1024) + // Use file mtime as deterministic fallback base — never time.Now() (breaks idempotency). + var fallbackBase int64 + if fi, err := os.Stat(item.Path); err == nil { + fallbackBase = fi.ModTime().UnixMilli() + } else { + fallbackBase = time.Unix(0, 0).UnixMilli() + } + lineNum := 0 + + const maxRunes = 8000 + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + lineNum++ + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: json decode error: %v", lineNum, err)) + continue + } + + ts := normalizeTimestampCC(ev["timestamp"], fallbackBase+int64(lineNum)) + topType := stringFieldCC(ev, "type") + payload := objectMapCC(ev["payload"]) + + // Skip meta/context events + if topType == "session_meta" || topType == "turn_context" { + continue + } + + // event_msg is Codex's UI event stream. Every type we know about + // either duplicates a response_item or is pure display, so none of + // them produce messages — but an unrecognised one is schema drift + // we want to hear about rather than discard in silence. + if topType == "event_msg" { + if payload != nil { + if pt := stringFieldCC(payload, "type"); pt != "" && !codexKnownEventMsgTypes[pt] { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: unknown event_msg payload type %q, skipped", lineNum, pt)) + } + } + continue + } + + if topType != "response_item" || payload == nil { + continue + } + + switch stringFieldCC(payload, "type") { + case "message": + m, ok := codexMessageFromPayload(payload, ts, maxRunes) + if !ok { + continue + } + conv.Messages = append(conv.Messages, m) + case "function_call": + m := codexToolCallFromPayload(payload, ts) + conv.Messages = append(conv.Messages, m) + case "custom_tool_call": + // Codex's sandboxed `exec` tool. Same call_id pairing as + // function_call; the arguments arrive as a plain `input` string. + conv.Messages = append(conv.Messages, codexCustomToolCallFromPayload(payload, ts)) + case "function_call_output", "custom_tool_call_output": + callID := stringFieldCC(payload, "call_id") + if callID == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: %s missing call_id, dropped", lineNum, stringFieldCC(payload, "type"))) + continue + } + // function_call_output carries a plain string; custom_tool_call_output + // carries the same typed content blocks a message does. + text := truncateRunesCC(strings.TrimSpace(stringFieldCC(payload, "output")), maxRunes) + if text == "" { + text = codexTextFromContent(payload["output"], maxRunes) + } + if text == "" { + text = "tool result" + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "tool", + Timestamp: ts, + ToolCallID: callID, + Content: Redact(text), + }) + case "web_search_call": + // Self-contained: Codex records the search action but no + // call_id and no paired output, so this is a tool call with a + // deterministic synthetic id and no tool result to match. + conv.Messages = append(conv.Messages, codexWebSearchFromPayload(payload, ts, lineNum)) + case "reasoning": + // skip + default: + pt := stringFieldCC(payload, "type") + if pt != "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: unknown response_item payload type %q, skipped", lineNum, pt)) + } + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + + setStartedAtFromMessages(conv) + conv.ID = ConversationID(PlatformCodex, "", item.Path) + return conv, nil +} + +func codexMessageFromPayload(payload map[string]any, ts int64, maxRunes int) (AgentMemoryMessage, bool) { + role := stringFieldCC(payload, "role") + // developer role maps to user for memory purposes; skip other roles + switch role { + case "user", "developer": + role = "user" + case "assistant": + // ok + default: + return AgentMemoryMessage{}, false + } + text := codexTextFromContent(payload["content"], maxRunes) + if text == "" { + return AgentMemoryMessage{}, false + } + return AgentMemoryMessage{Role: role, Timestamp: ts, Content: Redact(text)}, true +} + +func codexToolCallFromPayload(payload map[string]any, ts int64) AgentMemoryMessage { + callID := firstNonEmptyCC(stringFieldCC(payload, "call_id"), fmt.Sprintf("codex_tool_%d", ts)) + return AgentMemoryMessage{ + Role: "assistant", + Timestamp: ts, + ToolCalls: []AgentMemoryToolCall{{ + ID: callID, + Type: "function", + Name: firstNonEmptyCC(stringFieldCC(payload, "name"), "unknown"), + Arguments: Redact(argumentsStringCC(payload["arguments"])), + }}, + } +} + +func codexCustomToolCallFromPayload(payload map[string]any, ts int64) AgentMemoryMessage { + callID := firstNonEmptyCC(stringFieldCC(payload, "call_id"), fmt.Sprintf("codex_custom_tool_%d", ts)) + return AgentMemoryMessage{ + Role: "assistant", + Timestamp: ts, + ToolCalls: []AgentMemoryToolCall{{ + ID: callID, + Type: "function", + Name: firstNonEmptyCC(stringFieldCC(payload, "name"), "unknown"), + Arguments: Redact(argumentsStringCC(payload["input"])), + }}, + } +} + +func codexWebSearchFromPayload(payload map[string]any, ts int64, lineNum int) AgentMemoryMessage { + return AgentMemoryMessage{ + Role: "assistant", + Timestamp: ts, + ToolCalls: []AgentMemoryToolCall{{ + ID: fmt.Sprintf("codex_web_search_%d_%d", ts, lineNum), + // The action object holds the query (or the page for + // open_page / find_in_page), so keep it whole. + Type: "function", + Name: "web_search", + Arguments: Redact(argumentsStringCC(payload["action"])), + }}, + } +} + +func codexTextFromContent(content any, maxRunes int) string { + switch x := content.(type) { + case string: + return truncateRunesCC(strings.TrimSpace(x), maxRunes) + case []any: + parts := make([]string, 0, len(x)) + for _, raw := range x { + if s, ok := raw.(string); ok { + parts = append(parts, s) + continue + } + b := objectMapCC(raw) + if b == nil { + continue + } + switch stringFieldCC(b, "type") { + case "input_text", "output_text", "text": + if text := stringFieldCC(b, "text"); text != "" { + parts = append(parts, text) + } + } + } + return truncateRunesCC(strings.TrimSpace(strings.Join(parts, "\n")), maxRunes) + default: + return "" + } +} diff --git a/cli/internal/importer/conversation/codex_test.go b/cli/internal/importer/conversation/codex_test.go new file mode 100644 index 0000000..eccc691 --- /dev/null +++ b/cli/internal/importer/conversation/codex_test.go @@ -0,0 +1,193 @@ +package conversation + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCodexParseCounts(t *testing.T) { + sc := NewCodexScanner() + conv, err := sc.Read(Item{Platform: PlatformCodex, Path: "testdata/codex_sample.jsonl"}) + if err != nil { + t.Fatal(err) + } + var toolCalls, toolResults int + for _, m := range conv.Messages { + toolCalls += len(m.ToolCalls) + if m.Role == "tool" { + toolResults++ + } + } + // fixture has 2 function_call and 2 function_call_output + if toolCalls == 0 || toolResults == 0 { + t.Fatalf("expected tool trajectory, got calls=%d results=%d", toolCalls, toolResults) + } + if conv.ID == "" { + t.Fatal("conversationId must be set") + } + if toolCalls != 2 { + t.Fatalf("expected 2 toolCalls, got %d", toolCalls) + } + if toolResults != 2 { + t.Fatalf("expected 2 toolResults, got %d", toolResults) + } + // fixture: 2 user + 2 assistant(func_call) + 2 tool + 2 assistant(text) = 8 + if len(conv.Messages) != 8 { + t.Fatalf("expected 8 total messages, got %d", len(conv.Messages)) + } +} + +func TestCodexScannerPlatform(t *testing.T) { + sc := NewCodexScanner() + if sc.Platform() != PlatformCodex { + t.Fatalf("expected %s, got %s", PlatformCodex, sc.Platform()) + } +} + +func TestCodexScanIgnoresTrajectoryFiles(t *testing.T) { + dir := t.TempDir() + // An OpenClaw trajectory file lives under a dir Codex might also walk. + traj := filepath.Join(dir, "sess.trajectory.jsonl") + if err := os.WriteFile(traj, []byte(`{"type":"session.started"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + // A genuine codex session in the same dir must still be picked up. + codex := filepath.Join(dir, "rollout.jsonl") + if err := os.WriteFile(codex, []byte(`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"hi"}]}}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + sc := NewCodexScanner() + items, err := sc.Scan([]string{dir}) + if err != nil { + t.Fatal(err) + } + for _, it := range items { + if strings.HasSuffix(it.Path, ".trajectory.jsonl") { + t.Fatalf("codex scanner must not claim OpenClaw trajectory file: %s", it.Path) + } + } + if len(items) != 1 || !strings.HasSuffix(items[0].Path, "rollout.jsonl") { + t.Fatalf("expected only the genuine codex session, got %d items: %+v", len(items), items) + } +} + +func TestCodexScanMissingDir(t *testing.T) { + sc := NewCodexScanner() + items, err := sc.Scan([]string{"/no/such/dir/codex"}) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("missing dir should yield 0 items, got %d", len(items)) + } +} + +// TestCodexKeepsNonFunctionToolFamilies is the regression for the +// 2026-08-17 review item 1.2.2. The reader recognised exactly four +// response_item payload types, so every tool family Codex has added +// since - custom_tool_call (the `exec` sandbox tool) and web_search_call +// - was counted as "unknown" and dropped. On a real 44-session home that +// silently lost 183 paired custom tool round-trips and 106 searches. +func TestCodexKeepsNonFunctionToolFamilies(t *testing.T) { + lines := []string{ + `{"type":"response_item","timestamp":"2026-08-01T00:00:00Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"list the repo"}]}}`, + `{"type":"response_item","timestamp":"2026-08-01T00:00:01Z","payload":{"type":"custom_tool_call","id":"ctc_1","call_id":"call_exec_1","name":"exec","input":"ls -la"}}`, + `{"type":"response_item","timestamp":"2026-08-01T00:00:02Z","payload":{"type":"custom_tool_call_output","id":"ctco_1","call_id":"call_exec_1","output":[{"type":"input_text","text":"AGENTS.md"}]}}`, + `{"type":"response_item","timestamp":"2026-08-01T00:00:03Z","payload":{"type":"web_search_call","status":"completed","action":{"type":"search","query":"codex rollout schema"}}}`, + } + path := filepath.Join(t.TempDir(), "rollout.jsonl") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + conv, err := NewCodexScanner().Read(Item{Platform: PlatformCodex, Path: path}) + if err != nil { + t.Fatal(err) + } + + var call *AgentMemoryToolCall + var search *AgentMemoryToolCall + var result *AgentMemoryMessage + for i := range conv.Messages { + m := &conv.Messages[i] + if m.Role == "tool" && m.ToolCallID == "call_exec_1" { + result = m + } + for j := range m.ToolCalls { + switch m.ToolCalls[j].Name { + case "exec": + call = &m.ToolCalls[j] + case "web_search": + search = &m.ToolCalls[j] + } + } + } + + if call == nil { + t.Fatal("custom_tool_call must become an assistant tool call") + } + if call.ID != "call_exec_1" { + t.Fatalf("custom tool call id: want call_exec_1, got %q", call.ID) + } + if !strings.Contains(call.Arguments, "ls -la") { + t.Fatalf("custom tool call arguments must carry the input, got %q", call.Arguments) + } + if result == nil { + t.Fatal("custom_tool_call_output must become a tool message paired by call_id") + } + if s, _ := result.Content.(string); !strings.Contains(s, "AGENTS.md") { + t.Fatalf("custom tool result content: got %q", result.Content) + } + if search == nil { + t.Fatal("web_search_call must become an assistant tool call") + } + if !strings.Contains(search.Arguments, "codex rollout schema") { + t.Fatalf("web_search arguments must carry the query, got %q", search.Arguments) + } + for _, w := range conv.Warnings { + if strings.Contains(w, "unknown") { + t.Fatalf("no payload type in this fixture is unknown, got warning %q", w) + } + } +} + +// TestCodexEventMsgSkipsKnownAndWarnsOnDrift: event_msg was dropped as a +// whole class without a word, so a new Codex event type could carry +// content we never noticed we were missing. Known types stay silent +// (mcp_tool_call_end and web_search_end duplicate the response_item +// records - 97.3% of mcp call ids on a real home also appear as +// function_call, so mapping them would double-write); anything else +// leaves a warning that scan --detail can surface. +func TestCodexEventMsgSkipsKnownAndWarnsOnDrift(t *testing.T) { + lines := []string{ + `{"type":"response_item","timestamp":"2026-08-01T00:00:00Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}`, + `{"type":"event_msg","timestamp":"2026-08-01T00:00:01Z","payload":{"type":"token_count","info":{}}}`, + `{"type":"event_msg","timestamp":"2026-08-01T00:00:02Z","payload":{"type":"mcp_tool_call_end","call_id":"call_dup_1"}}`, + `{"type":"event_msg","timestamp":"2026-08-01T00:00:03Z","payload":{"type":"web_search_end"}}`, + `{"type":"event_msg","timestamp":"2026-08-01T00:00:04Z","payload":{"type":"brand_new_event"}}`, + } + path := filepath.Join(t.TempDir(), "rollout.jsonl") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + conv, err := NewCodexScanner().Read(Item{Platform: PlatformCodex, Path: path}) + if err != nil { + t.Fatal(err) + } + if len(conv.Messages) != 1 { + t.Fatalf("event_msg records must not become messages, got %d", len(conv.Messages)) + } + joined := strings.Join(conv.Warnings, "\n") + if !strings.Contains(joined, "brand_new_event") { + t.Fatalf("an unrecognised event_msg type must warn, got %q", joined) + } + for _, known := range []string{"token_count", "mcp_tool_call_end", "web_search_end"} { + if strings.Contains(joined, known) { + t.Fatalf("%s is a known skip and must stay silent, got %q", known, joined) + } + } +} diff --git a/cli/internal/importer/conversation/convid.go b/cli/internal/importer/conversation/convid.go new file mode 100644 index 0000000..48c23e9 --- /dev/null +++ b/cli/internal/importer/conversation/convid.go @@ -0,0 +1,38 @@ +package conversation + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +// ConversationID is derived deterministically from origin session id (or +// path hash when origin is empty). It intentionally does NOT include any +// content hash, so a re-send lands on the same session (idempotent at the +// addressing layer). Format: import--. +func ConversationID(platform PlatformID, originID, path string) string { + base := strings.TrimSpace(originID) + if base == "" { + sum := sha256.Sum256([]byte(path)) + base = hex.EncodeToString(sum[:])[:12] + } + return sanitizeConvID("import-" + string(platform) + "-" + base) +} + +func sanitizeConvID(raw string) string { + var b strings.Builder + lastDash := false + for _, r := range raw { + ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' + if ok { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} diff --git a/cli/internal/importer/conversation/convid_test.go b/cli/internal/importer/conversation/convid_test.go new file mode 100644 index 0000000..3da804e --- /dev/null +++ b/cli/internal/importer/conversation/convid_test.go @@ -0,0 +1,20 @@ +package conversation + +import "testing" + +func TestConversationIDStableOnOrigin(t *testing.T) { + a := ConversationID(PlatformClaudeCode, "sess-123", "/x/y.jsonl") + b := ConversationID(PlatformClaudeCode, "sess-123", "/x/y.jsonl") + if a != b || a == "" { + t.Fatalf("must be stable & non-empty: %q %q", a, b) + } + // no origin id -> falls back to path hash, still stable + c := ConversationID(PlatformCodex, "", "/a/b.jsonl") + d := ConversationID(PlatformCodex, "", "/a/b.jsonl") + if c != d || c == "" { + t.Fatalf("path-hash fallback must be stable: %q %q", c, d) + } + if a == c { + t.Fatalf("different inputs must differ") + } +} diff --git a/cli/internal/importer/conversation/e2e_test.go b/cli/internal/importer/conversation/e2e_test.go new file mode 100644 index 0000000..bbe2908 --- /dev/null +++ b/cli/internal/importer/conversation/e2e_test.go @@ -0,0 +1,325 @@ +package conversation + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// bffUploader is a real Uploader pointing at an httptest server +// We test with the real Uploader (not a fake) for the e2e test. + +type e2eCapture struct { + mu sync.Mutex + requests []e2eReq +} + +type e2eReq struct { + Auth string + ConversationID string + Flush bool + Sync bool + MessageCount int +} + +func (c *e2eCapture) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload struct { + ConversationID string `json:"conversationId"` + Messages []AgentMemoryMessage `json:"messages"` + Flush bool `json:"flush"` + Sync bool `json:"sync"` + } + json.Unmarshal(body, &payload) + c.mu.Lock() + c.requests = append(c.requests, e2eReq{ + Auth: r.Header.Get("Authorization"), + ConversationID: payload.ConversationID, + Flush: payload.Flush, + Sync: payload.Sync, + MessageCount: len(payload.Messages), + }) + c.mu.Unlock() + w.WriteHeader(202) + w.Write([]byte(`{"status":0,"requestId":"r1","result":{"sessionId":"s1","status":"queued","messageCount":1,"flushed":false}}`)) + } +} + +// backdatedTestdata copies the committed testdata/ fixtures into a fresh temp +// dir and backdates their mtimes by an hour. The active-session filter (FIX 1) +// excludes files whose mtime is within activeSessionWindow of now; on a fresh +// checkout the committed fixtures get a checkout-time mtime (~now), which would +// otherwise make them disappear from these e2e scans. Copying + backdating +// keeps the tests deterministic regardless of how the repo was obtained. +func backdatedTestdata(t *testing.T) string { + t.Helper() + src, _ := filepath.Abs("testdata") + dst := t.TempDir() + entries, err := os.ReadDir(src) + if err != nil { + t.Fatal(err) + } + old := time.Now().Add(-1 * time.Hour) + for _, e := range entries { + if e.IsDir() { + continue + } + b, err := os.ReadFile(filepath.Join(src, e.Name())) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(dst, e.Name()) + if err := os.WriteFile(p, b, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + return dst +} + +// TestE2EExcludeSkipsUpload proves a per-item exclusion (run --exclude ) +// keeps the excluded path out of the upload set: its conversationId never +// reaches the fake BFF. +func TestE2EExcludeSkipsUpload(t *testing.T) { + cap := &e2eCapture{} + srv := httptest.NewServer(cap.handler()) + defer srv.Close() + + tdDir := backdatedTestdata(t) + roots := map[PlatformID][]string{PlatformCodex: {tdDir}} + + dir := t.TempDir() + svc := NewService(ServiceDeps{ + Registry: DefaultRegistry(), + Roots: roots, + StatePath: dir + "/state.json", + Uploader: NewUploader(srv.URL, srv.Client()), + EvtResolver: func(p PlatformID) (string, error) { return "evt_" + string(p), nil }, + }) + + rep, err := svc.Scan([]PlatformID{PlatformCodex}) + if err != nil { + t.Fatalf("scan failed: %v", err) + } + if len(rep.Items) == 0 { + t.Fatal("expected codex fixtures") + } + + // Exclude the first scanned path. Mirror the run command's filter: drop any + // item whose Path matches the exclusion before uploading. + excludePath := rep.Items[0].Path + excludedID := "" + for _, item := range rep.Items { + sc := DefaultRegistry().ScannerFor(item.Platform) + conv, err := sc.Read(item) + if err != nil { + t.Fatalf("read %s: %v", item.Path, err) + } + if item.Path == excludePath { + excludedID = conv.ID + continue // excluded — never uploaded + } + if _, err := svc.RunOne(context.Background(), conv, RunOpts{Consented: true}); err != nil { + t.Fatalf("RunOne %s: %v", item.Path, err) + } + } + + if excludedID == "" { + t.Fatal("could not determine excluded conversationId") + } + for _, req := range cap.requests { + if req.ConversationID == excludedID { + t.Fatalf("excluded path was uploaded (convID=%s)", excludedID) + } + } +} + +// TestE2EDryRunTouchesNoNetworkOrState pins the dry-run contract (spec §10): +// a dry-run performs a Scan only — it never calls the uploader and never writes +// the state file. This mirrors the run command's dry-run early-return, which +// returns before constructing the uploader/state and before any RunOne. +func TestE2EDryRunTouchesNoNetworkOrState(t *testing.T) { + cap := &e2eCapture{} + srv := httptest.NewServer(cap.handler()) + defer srv.Close() + + tdDir := backdatedTestdata(t) + roots := map[PlatformID][]string{PlatformCodex: {tdDir}} + + dir := t.TempDir() + statePath := dir + "/state.json" + fake := &fakeUploader{} + svc := NewService(ServiceDeps{ + Registry: DefaultRegistry(), + Roots: roots, + StatePath: statePath, + Uploader: fake, + EvtResolver: func(p PlatformID) (string, error) { return "evt_" + string(p), nil }, + }) + + // Dry-run == scan only; no RunOne is invoked. + rep, err := svc.Scan([]PlatformID{PlatformCodex}) + if err != nil { + t.Fatalf("scan failed: %v", err) + } + if len(rep.Items) == 0 { + t.Fatal("expected codex fixtures for a meaningful dry-run") + } + + if fake.calls != 0 { + t.Fatalf("dry-run must not upload, got %d calls", fake.calls) + } + if len(cap.requests) != 0 { + t.Fatalf("dry-run must not POST to the BFF, got %d requests", len(cap.requests)) + } + if _, err := os.Stat(statePath); !os.IsNotExist(err) { + t.Fatalf("dry-run must not create the state file at %s (stat err=%v)", statePath, err) + } +} + +func TestE2EScanThenRunWithRealFixtures(t *testing.T) { + cap := &e2eCapture{} + srv := httptest.NewServer(cap.handler()) + defer srv.Close() + + // Use a backdated copy of testdata as roots for all platforms so the + // active-session filter (FIX 1) does not exclude freshly-checked-out files. + tdDir := backdatedTestdata(t) + + roots := map[PlatformID][]string{ + PlatformClaudeCode: {tdDir}, + PlatformCodex: {tdDir}, + PlatformHermes: {tdDir}, + PlatformOpenClaw: {tdDir}, + PlatformMarkdown: {tdDir}, + } + + uploader := NewUploader(srv.URL, srv.Client()) + + evtCalls := map[PlatformID]int{} + var evtMu sync.Mutex + evtResolver := func(p PlatformID) (string, error) { + evtMu.Lock() + evtCalls[p]++ + evtMu.Unlock() + return "evt_" + string(p), nil + } + + dir := t.TempDir() + svc := NewService(ServiceDeps{ + Registry: DefaultRegistry(), + Roots: roots, + StatePath: dir + "/state.json", + Uploader: uploader, + EvtResolver: evtResolver, + }) + + // (a) Scan + RunOne for each item — each uploads once with right evt + rep, err := svc.Scan([]PlatformID{PlatformClaudeCode, PlatformCodex, PlatformHermes, PlatformOpenClaw, PlatformMarkdown}) + if err != nil { + t.Fatalf("scan failed: %v", err) + } + if len(rep.Items) == 0 { + t.Fatal("e2e: expected items from testdata, got none") + } + + // wantEvt maps each conversation ID to the Authorization header it must + // carry — the conversation's own platform evt, proving per-platform binding. + wantEvt := map[string]string{} + for _, item := range rep.Items { + sc := DefaultRegistry().ScannerFor(item.Platform) + if sc == nil { + t.Fatalf("no scanner for %s", item.Platform) + } + conv, err := sc.Read(item) + if err != nil { + t.Fatalf("read %s: %v", item.Path, err) + } + wantEvt[conv.ID] = "Bearer evt_" + string(item.Platform) + res, err := svc.RunOne(context.Background(), conv, RunOpts{Consented: true}) + if err != nil { + t.Fatalf("RunOne %s: %v", item.Path, err) + } + if res.Skipped { + t.Fatalf("first run must not skip: %s", item.Path) + } + } + + // Verify each upload used the correct platform evt. Every testdata + // fixture fits in a single batch, so each upload is both the leading and + // the final batch of its session: sync:true and flush:true. + for _, req := range cap.requests { + if !req.Sync { + t.Errorf("sync must be true for all uploads, got false for convID=%s", req.ConversationID) + } + if !req.Flush { + t.Errorf("single-batch upload must flush, got false for convID=%s", req.ConversationID) + } + want, ok := wantEvt[req.ConversationID] + if !ok { + t.Errorf("upload for unexpected convID=%s", req.ConversationID) + continue + } + if req.Auth != want { + t.Errorf("convID=%s used evt %q, want %q (per-platform binding broken)", req.ConversationID, req.Auth, want) + } + } + if evtCalls[PlatformClaudeCode] == 0 { + t.Error("evt resolver was never consulted for claude-code") + } + if len(cap.requests) == 0 { + t.Fatal("e2e: no uploads happened") + } + firstRunUploads := len(cap.requests) + + // (b) Second run — all items should be skipped + for _, item := range rep.Items { + sc := DefaultRegistry().ScannerFor(item.Platform) + conv, _ := sc.Read(item) + res, err := svc.RunOne(context.Background(), conv, RunOpts{Consented: true}) + if err != nil { + t.Fatalf("second RunOne %s: %v", item.Path, err) + } + if !res.Skipped { + t.Fatalf("second run must skip submitted path: %s", item.Path) + } + } + if len(cap.requests) != firstRunUploads { + t.Fatalf("second run must not upload: got %d extra uploads", len(cap.requests)-firstRunUploads) + } + + // (c) No-consent run — uploads nothing (use a fresh service pointing at same testdata, different state) + dir2 := t.TempDir() + cap2 := &e2eCapture{} + srv2 := httptest.NewServer(cap2.handler()) + defer srv2.Close() + up2 := NewUploader(srv2.URL, srv2.Client()) + svc2 := NewService(ServiceDeps{ + Registry: DefaultRegistry(), + Roots: roots, + StatePath: dir2 + "/state.json", + Uploader: up2, + EvtResolver: evtResolver, + }) + for _, item := range rep.Items { + sc := DefaultRegistry().ScannerFor(item.Platform) + conv, _ := sc.Read(item) + _, err := svc2.RunOne(context.Background(), conv, RunOpts{Consented: false}) + if err == nil { + t.Fatalf("no-consent run must return error for %s", item.Path) + } + } + if len(cap2.requests) != 0 { + t.Fatalf("no-consent run must not upload, got %d uploads", len(cap2.requests)) + } +} diff --git a/cli/internal/importer/conversation/evt.go b/cli/internal/importer/conversation/evt.go new file mode 100644 index 0000000..5cbcc1e --- /dev/null +++ b/cli/internal/importer/conversation/evt.go @@ -0,0 +1,245 @@ +package conversation + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +// openClawPluginID is the OpenClaw manifest plugin id under which the +// installer writes the EverMe per-agent config. +// keep in sync with cli/internal/plugin/openclaw.go OpenClawPluginID +const openClawPluginID = "@everme/openclaw" + +// platformEnvFile maps a platform to its plugin config env file (where +// plugin install wrote EVERME_AGENT_TOKEN). Honors per-tool home env first. +// Only Claude Code and Hermes write a dotenv-style everme.env; Codex and +// OpenClaw write structured config (see ResolveEvt). +func platformEnvFile(p PlatformID) (string, bool) { + home, _ := os.UserHomeDir() + switch p { + case PlatformClaudeCode: + base := envOr("CLAUDE_CONFIG_DIR", home+"/.claude") + return base + "/everme.env", true + case PlatformHermes: + return home + "/.hermes/everme.env", true + case PlatformKimicode: + base := envOr("KIMI_CODE_HOME", filepath.Join(home, ".kimi-code")) + return filepath.Join(base, "everme.env"), true + default: + // Codex / OpenClaw resolve from structured config; markdown has no + // platform agent. Callers handle these in ResolveEvt. + return "", false + } +} + +// codexConfigPath returns $CODEX_HOME/config.toml (default ~/.codex/config.toml). +func codexConfigPath() string { + home, _ := os.UserHomeDir() + base := envOr("CODEX_HOME", filepath.Join(home, ".codex")) + return filepath.Join(base, "config.toml") +} + +// resolveCodexEvt reads EVERME_AGENT_TOKEN from config.toml under +// [mcp_servers.everme.env]. The Codex plugin installer writes here, not to a +// dotenv file — keep in sync with cli/internal/plugin/codex.go +// (codexMcpEntryName="everme", key "EVERME_AGENT_TOKEN"). +func resolveCodexEvt() (string, error) { + path := codexConfigPath() + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read codex config %s: %w", path, err) + } + var cfg struct { + McpServers map[string]struct { + Env map[string]string `toml:"env"` + } `toml:"mcp_servers"` + } + if err := toml.Unmarshal(raw, &cfg); err != nil { + return "", fmt.Errorf("parse codex config %s: %w", path, err) + } + entry, ok := cfg.McpServers["everme"] + if !ok { + return "", fmt.Errorf("codex config %s has no [mcp_servers.everme] entry", path) + } + tok := strings.TrimSpace(entry.Env["EVERME_AGENT_TOKEN"]) + if tok == "" { + return "", fmt.Errorf("EVERME_AGENT_TOKEN is empty in [mcp_servers.everme.env] of %s", path) + } + return tok, nil +} + +// openClawConfigPath returns $OPENCLAW_CONFIG_DIR/openclaw.json +// (default ~/.openclaw/openclaw.json). +func openClawConfigPath() string { + if dir := os.Getenv("OPENCLAW_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "openclaw.json") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".openclaw", "openclaw.json") +} + +// resolveOpenClawEvt reads the agent token from openclaw.json at +// plugins.entries["@everme/openclaw"].config.agentToken. The OpenClaw plugin +// installer writes here, not to a dotenv file — keep in sync with +// cli/internal/plugin/openclaw.go. +func resolveOpenClawEvt() (string, error) { + path := openClawConfigPath() + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read openclaw config %s: %w", path, err) + } + var cfg struct { + Plugins struct { + Entries map[string]struct { + Config struct { + AgentToken string `json:"agentToken"` + } `json:"config"` + } `json:"entries"` + } `json:"plugins"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + return "", fmt.Errorf("parse openclaw config %s: %w", path, err) + } + entry, ok := cfg.Plugins.Entries[openClawPluginID] + if !ok { + return "", fmt.Errorf("openclaw config %s has no plugins.entries[%q]", path, openClawPluginID) + } + tok := strings.TrimSpace(entry.Config.AgentToken) + if tok == "" { + return "", fmt.Errorf("agentToken is empty in plugins.entries[%q].config of %s", openClawPluginID, path) + } + return tok, nil +} + +// resolveRavenEvt reads the agent token from ~/.raven/config.json at +// plugins.config["everme-memory"].agent_token (snake_case: Raven hands +// the dict to the plugin factory verbatim). The Raven plugin installer +// writes here, not to a dotenv file — keep in sync with +// cli/internal/plugin/raven.go. +func resolveRavenEvt() (string, error) { + home, _ := os.UserHomeDir() + base := envOr("EVERCLI_RAVEN_CONFIG_DIR", filepath.Join(home, ".raven")) + path := filepath.Join(base, "config.json") + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read raven config %s: %w", path, err) + } + var cfg struct { + Plugins struct { + Config map[string]struct { + AgentToken string `json:"agent_token"` + } `json:"config"` + } `json:"plugins"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + return "", fmt.Errorf("parse raven config %s: %w", path, err) + } + entry, ok := cfg.Plugins.Config[ravenPluginID] + if !ok { + return "", fmt.Errorf("raven config %s has no plugins.config[%q]", path, ravenPluginID) + } + tok := strings.TrimSpace(entry.AgentToken) + if tok == "" { + return "", fmt.Errorf("agent_token is empty in plugins.config[%q] of %s", ravenPluginID, path) + } + return tok, nil +} + +// workBuddyConfigPath returns $EVERCLI_WORKBUDDY_CONFIG_DIR/mcp.json +// (default ~/.workbuddy/mcp.json) - mirrors DefaultRoots(PlatformWorkBuddy) +// in registry.go and cli/internal/plugin/workbuddy.go workBuddyConfigDir. +func workBuddyConfigPath() string { + home, _ := os.UserHomeDir() + base := envOr("EVERCLI_WORKBUDDY_CONFIG_DIR", filepath.Join(home, ".workbuddy")) + return filepath.Join(base, "mcp.json") +} + +// resolveWorkBuddyEvt reads the agent token from ~/.workbuddy/mcp.json at +// mcpServers["everme-memory"].env.EVERME_AGENT_TOKEN. Unlike Claude +// Code/Hermes, the WorkBuddy plugin installer does not write a dotenv +// everme.env - it embeds the credential straight into the generic MCP +// server entry that launches memory-mcp, same as every other MCP host +// wired through the shared writer - keep in sync with +// cli/internal/plugin/mcp.go buildEntry / mcpEntryName. +func resolveWorkBuddyEvt() (string, error) { + path := workBuddyConfigPath() + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read workbuddy config %s: %w", path, err) + } + var cfg struct { + McpServers map[string]struct { + Env map[string]string `json:"env"` + } `json:"mcpServers"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + return "", fmt.Errorf("parse workbuddy config %s: %w", path, err) + } + entry, ok := cfg.McpServers["everme-memory"] + if !ok { + return "", fmt.Errorf(`workbuddy config %s has no mcpServers["everme-memory"] entry`, path) + } + tok := strings.TrimSpace(entry.Env["EVERME_AGENT_TOKEN"]) + if tok == "" { + return "", fmt.Errorf(`EVERME_AGENT_TOKEN is empty in mcpServers["everme-memory"].env of %s`, path) + } + return tok, nil +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// readAgentTokenFromEnvFile extracts EVERME_AGENT_TOKEN from a dotenv-style file. +func readAgentTokenFromEnvFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + line = strings.TrimPrefix(line, "export ") // Fix 4: tolerate `export VAR=` prefix + if v, ok := strings.CutPrefix(line, "EVERME_AGENT_TOKEN="); ok { + tok := strings.Trim(strings.TrimSpace(v), `"'`) + if tok == "" { + return "", fmt.Errorf("EVERME_AGENT_TOKEN is empty in %s", path) // Fix 3 + } + return tok, nil + } + } + if err := sc.Err(); err != nil { // Fix 1: check scanner error + return "", err + } + return "", fmt.Errorf("EVERME_AGENT_TOKEN not found in %s", path) +} + +// ResolveEvt returns the target platform's agent token, or an error the +// caller surfaces (that platform is skipped, others continue — spec OQ1). +func ResolveEvt(p PlatformID) (string, error) { + switch p { + case PlatformCodex: + return resolveCodexEvt() + case PlatformOpenClaw: + return resolveOpenClawEvt() + case PlatformRaven: + return resolveRavenEvt() + case PlatformWorkBuddy: + return resolveWorkBuddyEvt() + } + path, ok := platformEnvFile(p) + if !ok { + return "", fmt.Errorf("platform %s has no agent token", p) + } + return readAgentTokenFromEnvFile(path) +} diff --git a/cli/internal/importer/conversation/evt_sources_test.go b/cli/internal/importer/conversation/evt_sources_test.go new file mode 100644 index 0000000..c4d9535 --- /dev/null +++ b/cli/internal/importer/conversation/evt_sources_test.go @@ -0,0 +1,93 @@ +package conversation + +import ( + "os" + "path/filepath" + "testing" +) + +// FIX 1 — Codex evt read from config.toml [mcp_servers.everme.env]. +func TestResolveEvtCodexFromConfigToml(t *testing.T) { + dir := t.TempDir() + cfg := `[mcp_servers.everme.env] +EVERME_AGENT_TOKEN = "evt_codex123" +` + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CODEX_HOME", dir) + + got, err := ResolveEvt(PlatformCodex) + if err != nil { + t.Fatal(err) + } + if got != "evt_codex123" { + t.Fatalf("got %q, want evt_codex123", got) + } +} + +func TestResolveEvtCodexMissingTable(t *testing.T) { + dir := t.TempDir() + // config.toml exists but has no mcp_servers.everme.env table. + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("model = \"gpt\"\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CODEX_HOME", dir) + + if _, err := ResolveEvt(PlatformCodex); err == nil { + t.Fatal("expected a clear error when the everme env table is absent") + } +} + +func TestResolveEvtCodexMissingFile(t *testing.T) { + t.Setenv("CODEX_HOME", t.TempDir()) + if _, err := ResolveEvt(PlatformCodex); err == nil { + t.Fatal("expected an error when config.toml is missing") + } +} + +// FIX 2 — OpenClaw evt read from openclaw.json plugins.entries[id].config.agentToken. +func TestResolveEvtOpenClawFromJSON(t *testing.T) { + dir := t.TempDir() + js := `{ + "plugins": { + "entries": { + "@everme/openclaw": { + "enabled": true, + "config": { "agentToken": "evt_oc456" } + } + } + } +}` + if err := os.WriteFile(filepath.Join(dir, "openclaw.json"), []byte(js), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("OPENCLAW_CONFIG_DIR", dir) + + got, err := ResolveEvt(PlatformOpenClaw) + if err != nil { + t.Fatal(err) + } + if got != "evt_oc456" { + t.Fatalf("got %q, want evt_oc456", got) + } +} + +func TestResolveEvtOpenClawMissingEntry(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "openclaw.json"), []byte(`{"plugins":{"entries":{}}}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("OPENCLAW_CONFIG_DIR", dir) + + if _, err := ResolveEvt(PlatformOpenClaw); err == nil { + t.Fatal("expected error when the everme entry is absent") + } +} + +func TestResolveEvtOpenClawMissingFile(t *testing.T) { + t.Setenv("OPENCLAW_CONFIG_DIR", t.TempDir()) + if _, err := ResolveEvt(PlatformOpenClaw); err == nil { + t.Fatal("expected error when openclaw.json is missing") + } +} diff --git a/cli/internal/importer/conversation/evt_test.go b/cli/internal/importer/conversation/evt_test.go new file mode 100644 index 0000000..6ae1498 --- /dev/null +++ b/cli/internal/importer/conversation/evt_test.go @@ -0,0 +1,96 @@ +package conversation + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveEvtFromEnvFile(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "everme.env") + os.WriteFile(f, []byte("EVERME_AGENT_ID=agt_x\nEVERME_AGENT_TOKEN=evt_target123\n"), 0o600) + got, err := readAgentTokenFromEnvFile(f) + if err != nil { + t.Fatal(err) + } + if got != "evt_target123" { + t.Fatalf("got %q", got) + } +} + +func TestResolveEvtMissing(t *testing.T) { + _, err := readAgentTokenFromEnvFile(filepath.Join(t.TempDir(), "nope.env")) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestResolveEvtEmptyValue(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "everme.env") + os.WriteFile(f, []byte("EVERME_AGENT_TOKEN=\n"), 0o600) + _, err := readAgentTokenFromEnvFile(f) + if err == nil { + t.Fatal("expected error for empty token value") + } +} + +func TestResolveEvtExportPrefix(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "everme.env") + os.WriteFile(f, []byte("export EVERME_AGENT_TOKEN=evt_x\n"), 0o600) + got, err := readAgentTokenFromEnvFile(f) + if err != nil { + t.Fatal(err) + } + if got != "evt_x" { + t.Fatalf("got %q, want %q", got, "evt_x") + } +} + +func TestResolveWorkBuddyEvt(t *testing.T) { + dir := t.TempDir() + t.Setenv("EVERCLI_WORKBUDDY_CONFIG_DIR", dir) + cfg := `{"mcpServers":{"everme-memory":{"command":"npx","args":["-y","@everme/memory-mcp@latest"],"env":{"EVERME_API_BASE":"https://api.everme.evermind.ai","EVERME_AGENT_ID":"agt_x","EVERME_AGENT_TOKEN":"evt_workbuddy123"}}}}` + if err := os.WriteFile(filepath.Join(dir, "mcp.json"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + got, err := resolveWorkBuddyEvt() + if err != nil { + t.Fatal(err) + } + if got != "evt_workbuddy123" { + t.Fatalf("got %q, want %q", got, "evt_workbuddy123") + } +} + +func TestResolveWorkBuddyEvtMissingEntry(t *testing.T) { + dir := t.TempDir() + t.Setenv("EVERCLI_WORKBUDDY_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "mcp.json"), []byte(`{"mcpServers":{}}`), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := resolveWorkBuddyEvt(); err == nil { + t.Fatal("expected error when the everme-memory entry is absent") + } +} + +func TestResolveWorkBuddyEvtViaResolveEvt(t *testing.T) { + dir := t.TempDir() + t.Setenv("EVERCLI_WORKBUDDY_CONFIG_DIR", dir) + cfg := `{"mcpServers":{"everme-memory":{"env":{"EVERME_AGENT_TOKEN":"evt_via_dispatch"}}}}` + if err := os.WriteFile(filepath.Join(dir, "mcp.json"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + got, err := ResolveEvt(PlatformWorkBuddy) + if err != nil { + t.Fatal(err) + } + if got != "evt_via_dispatch" { + t.Fatalf("got %q, want %q", got, "evt_via_dispatch") + } +} diff --git a/cli/internal/importer/conversation/hermes.go b/cli/internal/importer/conversation/hermes.go new file mode 100644 index 0000000..58a2698 --- /dev/null +++ b/cli/internal/importer/conversation/hermes.go @@ -0,0 +1,255 @@ +package conversation + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// HermesScanner parses Hermes session JSON files. +// Root object has: session_id, messages[]. +// messages[].role: user | assistant | tool | tool_result. +// assistant messages may have tool_calls[]. +// tool messages have tool_call_id. +// system_prompt is skipped. +type HermesScanner struct{} + +var _ Scanner = (*HermesScanner)(nil) + +func NewHermesScanner() *HermesScanner { return &HermesScanner{} } + +func (s *HermesScanner) Platform() PlatformID { return PlatformHermes } + +func (s *HermesScanner) Scan(roots []string) ([]Item, error) { + var items []Item + for _, root := range roots { + if _, err := os.Stat(root); os.IsNotExist(err) { + continue + } + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".json") { + return nil + } + info, err := d.Info() + if err != nil || info == nil { + return nil // skip unreadable entry + } + item := Item{ + Platform: PlatformHermes, + Path: path, + Status: "ready", + SizeBytes: info.Size(), + UpdatedAt: info.ModTime().UTC().Format(time.RFC3339), + } + items = append(items, item) + return nil + }) + if err != nil { + return nil, err + } + } + return items, nil +} + +func (s *HermesScanner) Read(item Item) (*Conversation, error) { + raw, err := os.ReadFile(item.Path) + if err != nil { + return nil, err + } + var root any + if err := json.Unmarshal(raw, &root); err != nil { + return nil, fmt.Errorf("json decode: %w", err) + } + + conv := &Conversation{Item: item} + var sessionID string + var rawMessages []any + + if obj := objectMapCC(root); obj != nil { + sessionID = stringFieldCC(obj, "session_id") + if arr, ok := obj["messages"].([]any); ok { + rawMessages = arr + } + } else if arr, ok := root.([]any); ok { + rawMessages = arr + } + if rawMessages == nil { + return nil, fmt.Errorf("no messages array found in %s", item.Path) + } + + // Set originID on the item from session_id + conv.Item.OriginID = sessionID + + const maxRunes = 8000 + // Use file mtime as deterministic fallback base — never time.Now() (breaks idempotency). + var fallbackBase int64 + if fi, err := os.Stat(item.Path); err == nil { + fallbackBase = fi.ModTime().UnixMilli() + } else { + fallbackBase = time.Unix(0, 0).UnixMilli() + } + + for i, rawMsg := range rawMessages { + m := objectMapCC(rawMsg) + if m == nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("msg[%d]: not an object, skipped", i)) + continue + } + role := stringFieldCC(m, "role") + ts := normalizeTimestampCC(m["timestamp"], fallbackBase+int64(i)) + + switch role { + case "system", "system_prompt": + // skip system prompts + case "user": + text := hermesContentText(m["content"], maxRunes) + if text == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("msg[%d]: user message with no text, skipped", i)) + continue + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "user", + Timestamp: ts, + Content: Redact(text), + }) + case "assistant": + msg, calls := hermesAssistantMessage(m, ts, maxRunes) + if msg == nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("msg[%d]: assistant message with no content, skipped", i)) + continue + } + conv.Messages = append(conv.Messages, *msg) + _ = calls + case "tool", "tool_result": + toolCallID := firstNonEmptyCC(stringFieldCC(m, "tool_call_id"), stringFieldCC(m, "toolCallId")) + if toolCallID == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("msg[%d]: tool message missing tool_call_id, dropped", i)) + continue + } + text := hermesContentText(m["content"], maxRunes) + if text == "" { + text = "tool result" + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "tool", + Timestamp: ts, + ToolCallID: toolCallID, + Content: Redact(text), + }) + default: + if role != "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("msg[%d]: unknown role %q, skipped", i, role)) + } + } + } + + setStartedAtFromMessages(conv) + conv.ID = ConversationID(PlatformHermes, sessionID, item.Path) + return conv, nil +} + +func hermesAssistantMessage(m map[string]any, ts int64, maxRunes int) (*AgentMemoryMessage, int) { + text := hermesContentText(m["content"], maxRunes) + calls := hermesToolCalls(m, ts) + + msg := AgentMemoryMessage{Role: "assistant", Timestamp: ts} + if text != "" { + msg.Content = Redact(text) + } + if len(calls) > 0 { + msg.ToolCalls = calls + } + if msg.Content == nil && len(msg.ToolCalls) == 0 { + return nil, 0 + } + return &msg, len(calls) +} + +func hermesToolCalls(m map[string]any, ts int64) []AgentMemoryToolCall { + var rawCalls []any + for _, key := range []string{"tool_calls", "toolCalls"} { + if arr, ok := m[key].([]any); ok { + rawCalls = arr + break + } + } + if rawCalls == nil { + return nil + } + var calls []AgentMemoryToolCall + for j, raw := range rawCalls { + b := objectMapCC(raw) + if b == nil { + continue + } + fn := objectMapCC(b["function"]) + name := stringFieldCC(fn, "name") + var args any + if fn != nil { + args = fn["arguments"] + } + if name == "" { + name = stringFieldCC(b, "name") + args = b["arguments"] + } + id := firstNonEmptyCC( + stringFieldCC(b, "id"), + stringFieldCC(b, "call_id"), + fmt.Sprintf("hermes_tool_%d_%d", ts, j), + ) + calls = append(calls, AgentMemoryToolCall{ + ID: id, + Type: "function", + Name: firstNonEmptyCC(name, "unknown"), + Arguments: Redact(argumentsStringCC(args)), + }) + } + return calls +} + +func hermesContentText(v any, maxRunes int) string { + switch x := v.(type) { + case nil: + return "" + case string: + return truncateRunesCC(strings.TrimSpace(x), maxRunes) + case []any: + parts := make([]string, 0, len(x)) + for _, item := range x { + if s, ok := item.(string); ok { + parts = append(parts, s) + continue + } + mm := objectMapCC(item) + if mm == nil { + continue + } + if text := stringFieldCC(mm, "text"); text != "" { + parts = append(parts, text) + continue + } + if text := stringFieldCC(mm, "content"); text != "" { + parts = append(parts, text) + } + } + return truncateRunesCC(strings.TrimSpace(strings.Join(parts, "\n")), maxRunes) + case map[string]any: + if text := stringFieldCC(x, "text"); text != "" { + return truncateRunesCC(strings.TrimSpace(text), maxRunes) + } + if text := stringFieldCC(x, "content"); text != "" { + return truncateRunesCC(strings.TrimSpace(text), maxRunes) + } + b, _ := json.Marshal(x) + return truncateRunesCC(string(b), maxRunes) + default: + b, _ := json.Marshal(v) + return truncateRunesCC(string(b), maxRunes) + } +} diff --git a/cli/internal/importer/conversation/hermes_materialize.go b/cli/internal/importer/conversation/hermes_materialize.go new file mode 100644 index 0000000..5e1b91d --- /dev/null +++ b/cli/internal/importer/conversation/hermes_materialize.go @@ -0,0 +1,195 @@ +package conversation + +import ( + "bufio" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "evercli/internal/core" +) + +// splitHermesExport consumes a `hermes sessions export` JSONL stream (one +// session object per line) and writes one file per ended session into destDir. +// It enforces the cold-start invariants: +// - skip sessions with no ended_at (still in-flight; provider owns them) +// - when until != "" (YYYY-MM-DD), skip sessions whose ended_at is on/after it +// - copy "id" -> "session_id" so HermesScanner.Read sets OriginID (idempotency) +// - filename = sha256(session_id) so a hostile id cannot escape destDir +// - file mtime = ended_at so isActiveSession's 5-min window lets it through +func splitHermesExport(r io.Reader, destDir, until string) (int, error) { + var untilT time.Time + if until != "" { + t, err := time.Parse("2006-01-02", until) + if err != nil { + return 0, fmt.Errorf("invalid --until %q (want YYYY-MM-DD): %w", until, err) + } + untilT = t + } + + br := bufio.NewReader(r) + count := 0 + for { + line, readErr := br.ReadBytes('\n') + if len(line) > 0 { + wrote, err := writeHermesSession(line, destDir, until != "", untilT) + if err != nil { + return count, err + } + if wrote { + count++ + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return count, fmt.Errorf("read export stream: %w", readErr) + } + } + return count, nil +} + +func writeHermesSession(line []byte, destDir string, hasUntil bool, untilT time.Time) (bool, error) { + var obj map[string]any + if err := json.Unmarshal(line, &obj); err != nil { + // Blank lines / trailing newline yield empty input; ignore non-JSON. + return false, nil + } + id, _ := obj["id"].(string) + if id == "" { + return false, nil + } + endedRaw, ok := obj["ended_at"] + if !ok || endedRaw == nil { + return false, nil // in-flight + } + endedSec, ok := toEpochSeconds(endedRaw) + if !ok { + return false, nil // unparseable -> treat as in-flight, skip + } + endedT := time.Unix(endedSec, 0).UTC() + if hasUntil && !endedT.Before(untilT) { + return false, nil // ended_at >= until -> after cold-start window + } + + obj["session_id"] = id + out, err := json.Marshal(obj) + if err != nil { + return false, fmt.Errorf("marshal session %q: %w", id, err) + } + name := fmt.Sprintf("%x.json", sha256.Sum256([]byte(id))) + path := filepath.Join(destDir, name) + if err := os.WriteFile(path, out, 0600); err != nil { + return false, fmt.Errorf("write session %q: %w", id, err) + } + if err := os.Chtimes(path, endedT, endedT); err != nil { + return false, fmt.Errorf("set mtime for %q: %w", id, err) + } + return true, nil +} + +// toEpochSeconds reads a JSON number (float seconds) or numeric string. +func toEpochSeconds(v any) (int64, bool) { + switch x := v.(type) { + case float64: + return int64(x), true + case json.Number: + if f, err := x.Float64(); err == nil { + return int64(f), true + } + } + return 0, false +} + +const hermesTmpSubdir = "everme-hermes-import" + +// HermesMaterialization is a temp dir of per-session JSON files exported from +// the live state.db, ready for HermesScanner. The caller owns its lifecycle +// and MUST call Cleanup() after the scan/read/upload phase completes. +type HermesMaterialization struct { + Dir string + SessionCount int +} + +// Cleanup removes the temp dir. Best-effort and idempotent. +func (m *HermesMaterialization) Cleanup() { + if m == nil || m.Dir == "" { + return + } + _ = os.RemoveAll(m.Dir) +} + +// MaterializeHermes exports the live Hermes session DB to a deterministic temp +// dir of per-session JSON files. until (YYYY-MM-DD, optional) bounds the import +// to sessions that ended before it. Returns an error when the session DB is +// absent or the hermes CLI cannot run. +func MaterializeHermes(until string) (*HermesMaterialization, error) { + home, err := core.HermesHome() + if err != nil { + return nil, err + } + dbPath := filepath.Join(home, "state.db") + if _, err := os.Stat(dbPath); err != nil { + return nil, fmt.Errorf("no hermes session db at %s (set HERMES_HOME or run hermes first)", dbPath) + } + if _, err := exec.LookPath(core.HermesCommand()); err != nil { + return nil, fmt.Errorf("hermes CLI not found (%s): %w", core.HermesCommand(), err) + } + + dir := filepath.Join(os.TempDir(), hermesTmpSubdir) + // Clear any stale residue from a prior crashed run so the scanner never + // picks up old sessions, then recreate private (0700 matters on Linux /tmp). + if err := os.RemoveAll(dir); err != nil { + return nil, fmt.Errorf("clear temp dir: %w", err) + } + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("create temp dir: %w", err) + } + + jsonlPath := filepath.Join(dir, "export.jsonl") + out, err := exec.Command(core.HermesCommand(), "sessions", "export", jsonlPath).CombinedOutput() + if err != nil { + _ = os.RemoveAll(dir) + return nil, fmt.Errorf("hermes sessions export failed: %v: %s", err, string(out)) + } + + f, err := os.Open(jsonlPath) + if err != nil { + _ = os.RemoveAll(dir) + return nil, fmt.Errorf("open export: %w", err) + } + count, splitErr := splitHermesExport(f, dir, until) + _ = f.Close() + _ = os.Remove(jsonlPath) // drop the intermediate JSONL; keep only per-session files + if splitErr != nil { + _ = os.RemoveAll(dir) + return nil, splitErr + } + return &HermesMaterialization{Dir: dir, SessionCount: count}, nil +} + +// ShouldBridgeHermes reports whether the Hermes DB bridge should run: hermes +// is in the requested platform set AND the user did not pin a custom JSON dir +// via --path hermes= (which explicitly opts into the old file scanner). +func ShouldBridgeHermes(platformIDs []PlatformID, customRoots map[PlatformID][]string) bool { + inScope := false + for _, p := range platformIDs { + if p == PlatformHermes { + inScope = true + break + } + } + if !inScope { + return false + } + if _, overridden := customRoots[PlatformHermes]; overridden { + return false + } + return true +} diff --git a/cli/internal/importer/conversation/hermes_materialize_test.go b/cli/internal/importer/conversation/hermes_materialize_test.go new file mode 100644 index 0000000..7832865 --- /dev/null +++ b/cli/internal/importer/conversation/hermes_materialize_test.go @@ -0,0 +1,224 @@ +package conversation + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// epoch seconds for 2026-06-10 and 2026-06-20 (UTC midnight) used as ended_at. +// Derived via time.Date so the names match the dates (hand-written epoch +// literals were off by ~4 days, which silently defeated the on-boundary test). +var ( + ended0610 = time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC).Unix() + ended0615 = time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC).Unix() // == --until boundary + ended0620 = time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC).Unix() +) + +func readSplitFile(t *testing.T, dir, id string) map[string]any { + t.Helper() + name := fmt.Sprintf("%x.json", sha256.Sum256([]byte(id))) + b, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("expected file for id %q: %v", id, err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + return m +} + +func TestSplitHermesExport_MapsIdToSessionIdAndCounts(t *testing.T) { + dir := t.TempDir() + jsonl := fmt.Sprintf(`{"id":"sess-A","ended_at":%d,"messages":[{"role":"user","content":"hi"}]}`+"\n", ended0610) + n, err := splitHermesExport(strings.NewReader(jsonl), dir, "") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("want 1 session written, got %d", n) + } + m := readSplitFile(t, dir, "sess-A") + if m["session_id"] != "sess-A" { + t.Fatalf("session_id must be set from id, got %v", m["session_id"]) + } +} + +func TestSplitHermesExport_SkipsInFlight(t *testing.T) { + dir := t.TempDir() + // ended_at omitted -> in-flight -> skipped, even with old started_at. + jsonl := `{"id":"live","started_at":1700000000,"messages":[{"role":"user","content":"x"}]}` + "\n" + n, err := splitHermesExport(strings.NewReader(jsonl), dir, "") + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("in-flight session must be skipped, wrote %d", n) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Fatalf("no files should be written, got %d", len(entries)) + } +} + +func TestSplitHermesExport_UntilUpperBound(t *testing.T) { + dir := t.TempDir() + jsonl := fmt.Sprintf( + `{"id":"old","ended_at":%d,"messages":[{"role":"user","content":"a"}]}`+"\n"+ + `{"id":"new","ended_at":%d,"messages":[{"role":"user","content":"b"}]}`+"\n", + ended0610, ended0620) + // until = 2026-06-15: keep only ended_at < that (old), drop new. + n, err := splitHermesExport(strings.NewReader(jsonl), dir, "2026-06-15") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("until upper bound should keep 1, got %d", n) + } + readSplitFile(t, dir, "old") // present + newName := fmt.Sprintf("%x.json", sha256.Sum256([]byte("new"))) + if _, err := os.Stat(filepath.Join(dir, newName)); !os.IsNotExist(err) { + t.Fatal("session after until must be dropped") + } +} + +func TestSplitHermesExport_MaliciousIdStaysInDir(t *testing.T) { + dir := t.TempDir() + jsonl := fmt.Sprintf(`{"id":"../../etc/passwd","ended_at":%d,"messages":[{"role":"user","content":"x"}]}`+"\n", ended0610) + n, err := splitHermesExport(strings.NewReader(jsonl), dir, "") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("want 1, got %d", n) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatalf("exactly one file expected inside destDir, got %d", len(entries)) + } + // sha256 hex name => no path separators, stays in destDir. + if strings.ContainsAny(entries[0].Name(), "/\\") { + t.Fatalf("filename must be path-safe, got %q", entries[0].Name()) + } +} + +func TestSplitHermesExport_SetsMtimeToEndedAt(t *testing.T) { + dir := t.TempDir() + jsonl := fmt.Sprintf(`{"id":"sess-A","ended_at":%d,"messages":[{"role":"user","content":"hi"}]}`+"\n", ended0610) + if _, err := splitHermesExport(strings.NewReader(jsonl), dir, ""); err != nil { + t.Fatal(err) + } + name := fmt.Sprintf("%x.json", sha256.Sum256([]byte("sess-A"))) + fi, err := os.Stat(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + if got := fi.ModTime().UTC(); got != time.Unix(ended0610, 0).UTC() { + t.Fatalf("mtime should equal ended_at, got %v", got) + } +} + +func TestSplitHermesExport_UntilExactBoundaryDropped(t *testing.T) { + dir := t.TempDir() + // ended_at exactly == until -> must be dropped (strict upper bound: keep only < until). + jsonl := fmt.Sprintf(`{"id":"exact","ended_at":%d,"messages":[{"role":"user","content":"x"}]}`+"\n", ended0615) + n, err := splitHermesExport(strings.NewReader(jsonl), dir, "2026-06-15") + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("on-boundary session must be dropped, got %d", n) + } +} + +func TestShouldBridgeHermes(t *testing.T) { + hermesOnly := []PlatformID{PlatformHermes} + mixed := []PlatformID{PlatformClaudeCode, PlatformHermes} + noHermes := []PlatformID{PlatformClaudeCode} + + if !ShouldBridgeHermes(hermesOnly, nil) { + t.Fatal("hermes in scope, no override -> should bridge") + } + if !ShouldBridgeHermes(mixed, map[PlatformID][]string{PlatformCodex: {"/x"}}) { + t.Fatal("hermes in scope, unrelated override -> should bridge") + } + if ShouldBridgeHermes(noHermes, nil) { + t.Fatal("hermes not in scope -> no bridge") + } + if ShouldBridgeHermes(hermesOnly, map[PlatformID][]string{PlatformHermes: {"/custom"}}) { + t.Fatal("--path hermes= override -> bypass bridge") + } +} + +func TestMaterializeHermes_NoStateDB(t *testing.T) { + home := t.TempDir() // empty, no state.db + t.Setenv("EVERCLI_HERMES_CONFIG_DIR", home) + t.Setenv("EVERCLI_HERMES_CMD", "/bin/echo") // binary exists but db doesn't + _, err := MaterializeHermes("") + if err == nil { + t.Fatal("expected error when state.db is absent") + } +} + +func TestMaterializeHermes_NoBinary(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "state.db"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("EVERCLI_HERMES_CONFIG_DIR", home) + t.Setenv("EVERCLI_HERMES_CMD", "definitely-not-a-real-binary-xyz") + _, err := MaterializeHermes("") + if err == nil { + t.Fatal("expected error when hermes binary is not on PATH") + } +} + +func TestMaterializeHermes_HappyPathWithFakeExport(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "state.db"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + // Fake `hermes`: ignores args except the last (output path) and writes one + // JSONL line there. `hermes sessions export ` => last arg is . + fake := filepath.Join(t.TempDir(), "fake-hermes.sh") + script := "#!/bin/sh\n" + + "for last; do :; done\n" + + `printf '{"id":"s1","ended_at":1781395200,"messages":[{"role":"user","content":"hi"}]}\n' > "$last"` + "\n" + if err := os.WriteFile(fake, []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("EVERCLI_HERMES_CONFIG_DIR", home) + t.Setenv("EVERCLI_HERMES_CMD", fake) + + m, err := MaterializeHermes("") + if err != nil { + t.Fatal(err) + } + defer m.Cleanup() + if m.SessionCount != 1 { + t.Fatalf("want 1 session materialized, got %d", m.SessionCount) + } + // HermesScanner should now parse the temp dir end to end. + items, err := NewHermesScanner().Scan([]string{m.Dir}) + if err != nil || len(items) != 1 { + t.Fatalf("scanner should find 1 item, got %d err=%v", len(items), err) + } + conv, err := NewHermesScanner().Read(items[0]) + if err != nil { + t.Fatal(err) + } + if conv.Item.OriginID != "s1" { + t.Fatalf("OriginID must come from session_id, got %q", conv.Item.OriginID) + } + + m.Cleanup() + if _, err := os.Stat(m.Dir); !os.IsNotExist(err) { + t.Fatal("Cleanup must remove the temp dir") + } +} diff --git a/cli/internal/importer/conversation/hermes_test.go b/cli/internal/importer/conversation/hermes_test.go new file mode 100644 index 0000000..9982265 --- /dev/null +++ b/cli/internal/importer/conversation/hermes_test.go @@ -0,0 +1,65 @@ +package conversation + +import "testing" + +func TestHermesParseCounts(t *testing.T) { + sc := NewHermesScanner() + conv, err := sc.Read(Item{Platform: PlatformHermes, Path: "testdata/hermes_sample.json"}) + if err != nil { + t.Fatal(err) + } + var toolCalls, toolResults int + for _, m := range conv.Messages { + toolCalls += len(m.ToolCalls) + if m.Role == "tool" { + toolResults++ + } + } + // fixture has 2 assistant messages with tool_calls and 2 tool messages + if toolCalls == 0 || toolResults == 0 { + t.Fatalf("expected tool trajectory, got calls=%d results=%d", toolCalls, toolResults) + } + if conv.ID == "" { + t.Fatal("conversationId must be set") + } + if toolCalls != 2 { + t.Fatalf("expected 2 toolCalls, got %d", toolCalls) + } + if toolResults != 2 { + t.Fatalf("expected 2 toolResults, got %d", toolResults) + } + // fixture: 2 user + 2 assistant(tool_calls) + 2 tool + 1 assistant(text) = 7 + if len(conv.Messages) != 7 { + t.Fatalf("expected 7 total messages, got %d", len(conv.Messages)) + } +} + +func TestHermesScannerPlatform(t *testing.T) { + sc := NewHermesScanner() + if sc.Platform() != PlatformHermes { + t.Fatalf("expected %s, got %s", PlatformHermes, sc.Platform()) + } +} + +func TestHermesScanMissingDir(t *testing.T) { + sc := NewHermesScanner() + items, err := sc.Scan([]string{"/no/such/dir/hermes"}) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("missing dir should yield 0 items, got %d", len(items)) + } +} + +func TestHermesSessionIDInConvID(t *testing.T) { + sc := NewHermesScanner() + conv, err := sc.Read(Item{Platform: PlatformHermes, Path: "testdata/hermes_sample.json"}) + if err != nil { + t.Fatal(err) + } + // session_id from fixture is "hermes-sess-001" + if conv.Item.OriginID != "hermes-sess-001" { + t.Fatalf("expected OriginID=hermes-sess-001, got %q", conv.Item.OriginID) + } +} diff --git a/cli/internal/importer/conversation/kimicode.go b/cli/internal/importer/conversation/kimicode.go new file mode 100644 index 0000000..9030476 --- /dev/null +++ b/cli/internal/importer/conversation/kimicode.go @@ -0,0 +1,284 @@ +package conversation + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// KimicodeScanner parses Kimi Code (~/.kimi-code) wire.jsonl session +// transcripts. Each session's main-agent transcript lives at +// sessions//session_/agents/main/wire.jsonl and is an +// append-only JSON-Lines event stream (timestamps in epoch ms under "time"). +// +// Event mapping (confirmed from real local sessions): +// - context.append_message, message.role=user, origin.kind != "injection" +// -> user message (turn.prompt is ignored; it duplicates this). +// - context.append_loop_event, event.type=content.part, part.type=="text" +// -> assistant text, aggregated per turnId/step, flushed at step.end. +// "think" parts are dropped. +// - message.toolCalls (when non-empty) -> assistant tool calls. The on-disk +// tool serialization was not observed in any local session, so this path +// is defensive (see kimicodeToolCalls) and warns on unrecognized shapes. +type KimicodeScanner struct{} + +var _ Scanner = (*KimicodeScanner)(nil) + +func NewKimicodeScanner() *KimicodeScanner { return &KimicodeScanner{} } + +func (s *KimicodeScanner) Platform() PlatformID { return PlatformKimicode } + +func (s *KimicodeScanner) Scan(roots []string) ([]Item, error) { + var items []Item + for _, root := range roots { + if _, err := os.Stat(root); os.IsNotExist(err) { + continue + } + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + // Every agent transcript under a session: agents//wire.jsonl + // — the main agent AND each subagent. A session that delegates work + // keeps that tool activity in the subagents' own wire.jsonl, so we + // import them too; each becomes its own conversation, distinguished + // by OriginID (main -> session_, subagent -> session___). + if filepath.Base(path) != "wire.jsonl" { + return nil + } + if filepath.Base(filepath.Dir(filepath.Dir(path))) != "agents" { + return nil + } + info, err := d.Info() + if err != nil || info == nil { + return nil + } + items = append(items, Item{ + Platform: PlatformKimicode, + Path: path, + OriginID: kimicodeSessionID(path), + Status: "ready", + SizeBytes: info.Size(), + UpdatedAt: info.ModTime().UTC().Format(time.RFC3339), + }) + return nil + }) + if err != nil { + return nil, err + } + } + return items, nil +} + +// kimicodeSessionID derives a stable per-transcript origin id from a +// .../session_/agents//wire.jsonl path. The main agent maps to +// "session_"; a subagent maps to "session___" so its +// ConversationID does not collide with the main session's. Returns "" when the +// layout doesn't match (ConversationID then falls back to a path hash). +func kimicodeSessionID(path string) string { + agentID := filepath.Base(filepath.Dir(path)) // "main" or "" + sessionDir := filepath.Dir(filepath.Dir(filepath.Dir(path))) + base := filepath.Base(sessionDir) + if !strings.HasPrefix(base, "session_") { + return "" + } + if agentID == "" || agentID == "main" { + return base + } + return base + "__" + agentID +} + +func (s *KimicodeScanner) Read(item Item) (*Conversation, error) { + f, err := os.Open(item.Path) + if err != nil { + return nil, err + } + defer f.Close() + + conv := &Conversation{Item: item} + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 1024*1024), 64*1024*1024) + + // Deterministic fallback base — never time.Now() (breaks idempotency). + var fallbackBase int64 + if fi, err := os.Stat(item.Path); err == nil { + fallbackBase = fi.ModTime().UnixMilli() + } else { + fallbackBase = time.Unix(0, 0).UnixMilli() + } + + const maxRunes = 8000 + + // assistant text aggregator keyed by turnId+step + type aggKey struct { + turn string + step float64 + } + pending := map[aggKey]*strings.Builder{} + pendingTS := map[aggKey]int64{} + flush := func(k aggKey) { + b := pending[k] + if b == nil { + return + } + text := truncateRunesCC(strings.TrimSpace(b.String()), maxRunes) + if text != "" { + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "assistant", + Timestamp: pendingTS[k], + Content: Redact(text), + }) + } + delete(pending, k) + delete(pendingTS, k) + } + + lineNum := 0 + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + lineNum++ + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: json decode error: %v", lineNum, err)) + continue + } + ts := normalizeTimestampCC(ev["time"], fallbackBase+int64(lineNum)) + + switch stringFieldCC(ev, "type") { + case "context.append_message": + msg := objectMapCC(ev["message"]) + if msg == nil { + continue + } + // Only genuine user input is captured here; assistant text + tool + // calls/results arrive via context.append_loop_event. Keep role=user + // with origin.kind=="user" (or no origin); drop every system-injected + // pseudo-user message: injection (permission notices), skill_activation + // (injected skill text), and any other non-user origin. + if stringFieldCC(msg, "role") != "user" { + continue + } + if origin := objectMapCC(msg["origin"]); origin != nil { + if kind := stringFieldCC(origin, "kind"); kind != "" && kind != "user" { + continue + } + } + text := kimicodeTextFromContent(msg["content"], maxRunes) + if text == "" { + continue + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "user", + Timestamp: ts, + Content: Redact(text), + }) + + case "context.append_loop_event": + event := objectMapCC(ev["event"]) + if event == nil { + continue + } + k := aggKey{turn: stringFieldCC(event, "turnId")} + if st, ok := event["step"].(float64); ok { + k.step = st + } + switch stringFieldCC(event, "type") { + case "content.part": + part := objectMapCC(event["part"]) + if part == nil || stringFieldCC(part, "type") != "text" { + continue // drop "think" and non-text parts + } + if pending[k] == nil { + pending[k] = &strings.Builder{} + pendingTS[k] = ts + } + pending[k].WriteString(stringFieldCC(part, "text")) + case "tool.call": + // Flush any assistant preamble text for this step first so order + // is text -> tool call -> tool result. + flush(k) + name := stringFieldCC(event, "name") + if name == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: tool.call missing name, skipped", lineNum)) + continue + } + id := firstNonEmptyCC(stringFieldCC(event, "toolCallId"), stringFieldCC(event, "uuid"), fmt.Sprintf("kimicode_tool_%d", lineNum)) + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "assistant", + Timestamp: ts, + ToolCalls: []AgentMemoryToolCall{{ + ID: id, + Type: "function", + Name: name, + Arguments: Redact(argumentsStringCC(event["args"])), + }}, + }) + case "tool.result": + id := firstNonEmptyCC(stringFieldCC(event, "toolCallId"), stringFieldCC(event, "parentUuid")) + if id == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: tool.result missing toolCallId, skipped", lineNum)) + continue + } + out := "" + if r := objectMapCC(event["result"]); r != nil { + out = stringFieldCC(r, "output") + } + out = truncateRunesCC(strings.TrimSpace(out), maxRunes) + if out == "" { + out = "tool result" + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "tool", + Timestamp: ts, + ToolCallID: id, + Content: Redact(out), + }) + case "step.end": + flush(k) + } + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + // Flush any steps that never emitted an explicit step.end. + for k := range pending { + flush(k) + } + + setStartedAtFromMessages(conv) + conv.ID = ConversationID(PlatformKimicode, item.OriginID, item.Path) + return conv, nil +} + +// kimicodeTextFromContent joins text blocks from a message.content array +// ([]{type:"text", text:"..."}), or returns a plain string as-is. +func kimicodeTextFromContent(content any, maxRunes int) string { + switch x := content.(type) { + case string: + return truncateRunesCC(strings.TrimSpace(x), maxRunes) + case []any: + parts := make([]string, 0, len(x)) + for _, raw := range x { + b := objectMapCC(raw) + if b == nil { + continue + } + if stringFieldCC(b, "type") == "text" { + if t := stringFieldCC(b, "text"); t != "" { + parts = append(parts, t) + } + } + } + return truncateRunesCC(strings.TrimSpace(strings.Join(parts, "\n")), maxRunes) + default: + return "" + } +} diff --git a/cli/internal/importer/conversation/kimicode_test.go b/cli/internal/importer/conversation/kimicode_test.go new file mode 100644 index 0000000..d147930 --- /dev/null +++ b/cli/internal/importer/conversation/kimicode_test.go @@ -0,0 +1,167 @@ +package conversation + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestKimicodeScannerPlatform(t *testing.T) { + if NewKimicodeScanner().Platform() != PlatformKimicode { + t.Fatalf("expected %s", PlatformKimicode) + } +} + +func TestKimicodeScanMissingDir(t *testing.T) { + items, err := NewKimicodeScanner().Scan([]string{"/no/such/dir/kc"}) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("missing dir should yield 0 items, got %d", len(items)) + } +} + +func TestKimicodeScanDiscoversMainAndSubagents(t *testing.T) { + dir := t.TempDir() + sess := filepath.Join(dir, "sessions", "wd_proj_abc123", "session_uuid-1") + mustWrite(t, filepath.Join(sess, "agents", "main", "wire.jsonl"), `{"type":"metadata"}`+"\n") + mustWrite(t, filepath.Join(sess, "agents", "sub-9", "wire.jsonl"), `{"type":"metadata"}`+"\n") + + items, err := NewKimicodeScanner().Scan([]string{filepath.Join(dir, "sessions")}) + if err != nil { + t.Fatal(err) + } + // Both the main agent and the subagent transcript are discovered. + if len(items) != 2 { + t.Fatalf("expected 2 items (main + subagent), got %d: %+v", len(items), items) + } + origins := map[string]bool{} + for _, it := range items { + origins[it.OriginID] = true + if !strings.HasSuffix(it.Path, filepath.Join("wire.jsonl")) { + t.Fatalf("unexpected path %s", it.Path) + } + } + // Distinct origin ids so ConversationIDs don't collide. + if !origins["session_uuid-1"] { + t.Fatalf("missing main origin session_uuid-1; got %v", origins) + } + if !origins["session_uuid-1__sub-9"] { + t.Fatalf("missing subagent origin session_uuid-1__sub-9; got %v", origins) + } +} + +func TestKimicodeParseCounts(t *testing.T) { + conv, err := NewKimicodeScanner().Read(Item{ + Platform: PlatformKimicode, + Path: "testdata/kimicode_sample.jsonl", + OriginID: "session_uuid-1", + }) + if err != nil { + t.Fatal(err) + } + var users, assistantText, toolCalls, toolResults int + for _, m := range conv.Messages { + switch m.Role { + case "user": + users++ + case "assistant": + if len(m.ToolCalls) > 0 { + toolCalls += len(m.ToolCalls) + } else if m.Content != nil { + assistantText++ + } + case "tool": + toolResults++ + } + } + // 3 real user messages ("list the files", "thanks", "fetch the homepage"); + // injection + skill_activation pseudo-user messages filtered out. + if users != 3 { + t.Fatalf("expected 3 user messages, got %d", users) + } + // 2 aggregated assistant texts ("Here are the files:", "Let me fetch it."), + // with "think" parts dropped. + if assistantText != 2 { + t.Fatalf("expected 2 assistant text messages, got %d", assistantText) + } + if toolCalls != 1 || toolResults != 1 { + t.Fatalf("expected 1 tool call + 1 tool result, got calls=%d results=%d", toolCalls, toolResults) + } + if got := firstAssistantText(conv); got != "Here are the files:" { + t.Fatalf("assistant text = %q (think part must be dropped)", got) + } + if conv.ID != "import-kimicode-session_uuid-1" { + t.Fatalf("conversationId = %q", conv.ID) + } +} + +func TestKimicodeToolCallOrderAndShape(t *testing.T) { + conv, err := NewKimicodeScanner().Read(Item{ + Platform: PlatformKimicode, + Path: "testdata/kimicode_sample.jsonl", + OriginID: "s", + }) + if err != nil { + t.Fatal(err) + } + // Find the FetchURL tool call and assert its shape + that its preamble text + // ("Let me fetch it.") precedes it and the tool result follows it. + callIdx, resultIdx, preambleIdx := -1, -1, -1 + for i, m := range conv.Messages { + if m.Role == "assistant" { + if s, ok := m.Content.(string); ok && s == "Let me fetch it." { + preambleIdx = i + } + for _, tc := range m.ToolCalls { + if tc.Name == "FetchURL" { + callIdx = i + if tc.Arguments == "" || tc.ID != "call_abc" || tc.Type != "function" { + t.Fatalf("bad tool call shape: %+v", tc) + } + } + } + } + if m.Role == "tool" && m.ToolCallID == "call_abc" { + resultIdx = i + if s, _ := m.Content.(string); s != "the page body" { + t.Fatalf("tool result content = %v", m.Content) + } + } + } + if preambleIdx < 0 || callIdx < 0 || resultIdx < 0 { + t.Fatalf("missing message: preamble=%d call=%d result=%d", preambleIdx, callIdx, resultIdx) + } + if !(preambleIdx < callIdx && callIdx < resultIdx) { + t.Fatalf("expected order preamble 0 && len(items) > limit { + return items[:limit] + } + return items +} + +// SortItemsNewestFirst orders items by session date descending so --limit N +// selects the N most recent sessions rather than the first N in scan order. +// Dates are RFC3339-ish strings; lexicographic compare matches chronology. +func SortItemsNewestFirst(items []Item) []Item { + out := make([]Item, len(items)) + copy(out, items) + sort.SliceStable(out, func(i, j int) bool { + return itemSortDate(out[i]) > itemSortDate(out[j]) + }) + return out +} + +func itemSortDate(it Item) string { + if it.StartedAt != "" { + return it.StartedAt + } + return it.UpdatedAt +} diff --git a/cli/internal/importer/conversation/limit_test.go b/cli/internal/importer/conversation/limit_test.go new file mode 100644 index 0000000..3a45c21 --- /dev/null +++ b/cli/internal/importer/conversation/limit_test.go @@ -0,0 +1,44 @@ +package conversation + +import "testing" + +func TestLimitItems(t *testing.T) { + items := []Item{{Path: "a"}, {Path: "b"}, {Path: "c"}} + + // limit <= 0 means unlimited: return all. + if got := LimitItems(items, 0); len(got) != 3 { + t.Fatalf("limit 0 must return all 3, got %d", len(got)) + } + if got := LimitItems(items, -1); len(got) != 3 { + t.Fatalf("negative limit must return all 3, got %d", len(got)) + } + + // limit larger than len: return all. + if got := LimitItems(items, 10); len(got) != 3 { + t.Fatalf("limit > len must return all 3, got %d", len(got)) + } + + // limit smaller than len: return first N, in order. + got := LimitItems(items, 2) + if len(got) != 2 || got[0].Path != "a" || got[1].Path != "b" { + t.Fatalf("limit 2 must return first two [a b], got %v", got) + } +} + +func TestSortItemsNewestFirst(t *testing.T) { + items := []Item{ + {Path: "a", StartedAt: "2026-08-01T00:00:00Z"}, + {Path: "b", UpdatedAt: "2026-08-05T00:00:00Z"}, // no StartedAt → falls back + {Path: "c", StartedAt: "2026-08-03T00:00:00Z"}, + } + got := SortItemsNewestFirst(items) + want := []string{"b", "c", "a"} + for i, w := range want { + if got[i].Path != w { + t.Fatalf("order[%d]=%s, want %s", i, got[i].Path, w) + } + } + if items[0].Path != "a" { + t.Fatal("input slice must not be mutated") + } +} diff --git a/cli/internal/importer/conversation/markdown.go b/cli/internal/importer/conversation/markdown.go new file mode 100644 index 0000000..f60cf69 --- /dev/null +++ b/cli/internal/importer/conversation/markdown.go @@ -0,0 +1,234 @@ +package conversation + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// markdownChunkBudget is the per-message rune budget when an oversize file is +// split across multiple messages. It equals the server's MaxMessageContentRunes +// so every emitted message passes per-message validation. No message-count cap +// is needed here: the Uploader batches these messages into <=maxAgentBatchBytes +// (64 KiB) POSTs under one conversationId, which is the real per-request object +// limit. So any file admitted at scan time is uploaded in full, with zero loss. +const markdownChunkBudget = 8000 + +// markdownMaxFileBytes is the scan-time guard: a single .md larger than this is +// skipped rather than read. It is NOT the per-request object limit — that is +// maxAgentBatchBytes (64 KiB), enforced downstream by the Uploader, which slices +// the messages this scanner emits into multiple POSTs. +const markdownMaxFileBytes = 1 << 20 // 1 MB + +// markdownBlacklistDirs are directory names pruned during any markdown walk. +// Agent home dirs and project trees embed installed-software docs (dependency +// READMEs, build output) that must never be swept into memory. +var markdownBlacklistDirs = map[string]struct{}{ + ".git": {}, ".hg": {}, ".svn": {}, + "node_modules": {}, ".venv": {}, "venv": {}, ".env": {}, + "__pycache__": {}, ".tox": {}, ".mypy_cache": {}, ".pytest_cache": {}, + "dist": {}, "build": {}, "target": {}, "vendor": {}, "coverage": {}, + ".next": {}, ".nuxt": {}, ".gradle": {}, ".idea": {}, ".vscode": {}, +} + +// markdownZone is a curated scan region inside an agent home dir: depth-1 +// *.md directly under personaDir (persona / identity / memory-index files) +// plus a recursive *.md walk of treeDir (the agent's memory or projects log). +// Anything outside these zones — plugin caches, vendored skills, marketplace +// docs — is intentionally never scanned. +type markdownZone struct { + personaDir string + treeDir string +} + +// markdownZonesForHome maps an agent home dir to its curated zones. Returns +// false when home is not a recognized agent home (e.g. a user-supplied +// --path override), in which case the caller recursive-walks it directly +// (still pruned by the blacklist + size cap). +func markdownZonesForHome(home string) (markdownZone, bool) { + home = filepath.Clean(home) + for p, dir := range agentHomeDirs() { + if filepath.Clean(dir) != home { + continue + } + switch p { + case PlatformClaudeCode: + return markdownZone{personaDir: home, treeDir: filepath.Join(home, "projects")}, true + case PlatformOpenClaw: + ws := filepath.Join(home, "workspace") + return markdownZone{personaDir: ws, treeDir: filepath.Join(ws, "memory")}, true + case PlatformCodex: + return markdownZone{personaDir: home, treeDir: filepath.Join(home, "memories")}, true + case PlatformHermes: + return markdownZone{personaDir: home, treeDir: filepath.Join(home, "memories")}, true + } + } + return markdownZone{}, false +} + +// MarkdownScanner turns each .md file into a conversation of role=user +// messages. It redacts the whole text, then splits it into one or more messages +// of at most markdownChunkBudget runes each (no content is dropped), all sharing +// the file mtime as their base timestamp. +type MarkdownScanner struct{} + +var _ Scanner = (*MarkdownScanner)(nil) + +func NewMarkdownScanner() *MarkdownScanner { return &MarkdownScanner{} } + +func (s *MarkdownScanner) Platform() PlatformID { return PlatformMarkdown } + +func (s *MarkdownScanner) Scan(roots []string) ([]Item, error) { + var items []Item + seen := map[string]struct{}{} + add := func(path string, info os.FileInfo) { + if info.Size() > markdownMaxFileBytes { + return + } + if _, dup := seen[path]; dup { + return + } + seen[path] = struct{}{} + items = append(items, Item{ + Platform: PlatformMarkdown, + Path: path, + Status: "ready", + SizeBytes: info.Size(), + UpdatedAt: info.ModTime().UTC().Format(time.RFC3339), + OwnerPlatform: ownerForMarkdownPath(path), + }) + } + + for _, root := range roots { + if zone, ok := markdownZonesForHome(root); ok { + scanMarkdownDepth1(zone.personaDir, add) + scanMarkdownTree(zone.treeDir, add) + continue + } + // Custom --path override: recursive walk, still pruned + capped. + scanMarkdownTree(root, add) + } + return items, nil +} + +// scanMarkdownDepth1 collects *.md directly under dir (no recursion). Missing +// dir and symlinked entries are skipped silently. +func scanMarkdownDepth1(dir string, add func(string, os.FileInfo)) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.IsDir() || e.Type()&os.ModeSymlink != 0 { + continue + } + if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") { + continue + } + info, err := e.Info() + if err != nil || info == nil { + continue + } + add(filepath.Join(dir, e.Name()), info) + } +} + +// scanMarkdownTree recursively collects *.md under root, pruning blacklisted +// directories and not following symlinks. Missing root yields nothing. +func scanMarkdownTree(root string, add func(string, os.FileInfo)) { + if _, err := os.Stat(root); os.IsNotExist(err) { + return + } + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if path != root { + if _, blocked := markdownBlacklistDirs[strings.ToLower(d.Name())]; blocked { + return filepath.SkipDir + } + } + return nil + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + if !strings.HasSuffix(strings.ToLower(path), ".md") { + return nil + } + info, err := d.Info() + if err != nil || info == nil { + return nil + } + add(path, info) + return nil + }) +} + +func (s *MarkdownScanner) Read(item Item) (*Conversation, error) { + raw, err := os.ReadFile(item.Path) + if err != nil { + return nil, err + } + + // Get file mtime for timestamp + fi, err := os.Stat(item.Path) + if err != nil { + return nil, err + } + ts := fi.ModTime().UnixMilli() + + // Redact the FULL text BEFORE splitting so a credential can't straddle a + // chunk boundary and slip past redaction. + text := Redact(string(raw)) + + conv := &Conversation{Item: item} + // Split into as many messages as the content needs — no message-count cap. + // The scan-time whole-file size limit (markdownMaxFileBytes) already bounds + // the message count, so an admitted file is uploaded in full with no loss. + chunks := splitRunes(text, markdownChunkBudget) + if len(chunks) > 1 { + conv.Warnings = append(conv.Warnings, + fmt.Sprintf("content split into %d messages", len(chunks))) + } + + for i, c := range chunks { + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "user", + Timestamp: ts + int64(i), // same mtime; +i keeps order stable if sorted by ts + Content: c, + }) + } + conv.ID = ConversationID(PlatformMarkdown, "", item.Path) + return conv, nil +} + +// splitRunes splits text into chunks of at most budget runes, preferring to +// break at the last '\n' in the back 20% of a chunk so sections stay whole. A +// stretch with no newline in range is hard-split at the budget. +func splitRunes(text string, budget int) []string { + runes := []rune(text) + if len(runes) <= budget { + return []string{text} + } + var chunks []string + for len(runes) > budget { + cut := budget + lo := budget * 4 / 5 + for i := budget - 1; i >= lo; i-- { + if runes[i] == '\n' { + cut = i + 1 // keep the newline in this chunk + break + } + } + chunks = append(chunks, string(runes[:cut])) + runes = runes[cut:] + } + if len(runes) > 0 { + chunks = append(chunks, string(runes)) + } + return chunks +} diff --git a/cli/internal/importer/conversation/markdown_test.go b/cli/internal/importer/conversation/markdown_test.go new file mode 100644 index 0000000..8a3503f --- /dev/null +++ b/cli/internal/importer/conversation/markdown_test.go @@ -0,0 +1,308 @@ +package conversation + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func mustWriteMD(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestMarkdownScanCuratedZonesOnly pins the cold-start markdown scope to the +// curated zones (depth-1 persona files + the agent's memory/projects subtree) +// instead of recursively sweeping the whole agent home dir. Agent home dirs +// are full of installed-software docs (plugin SKILL.md, dependency README.md, +// caches) that must never be uploaded to memory. +func TestMarkdownScanCuratedZonesOnly(t *testing.T) { + claudeHome := t.TempDir() + t.Setenv("CLAUDE_CONFIG_DIR", claudeHome) + + // Curated content — MUST be picked up. + mustWriteMD(t, filepath.Join(claudeHome, "CLAUDE.md"), "persona memory") + mustWriteMD(t, filepath.Join(claudeHome, "projects", "proj1", "notes.md"), "project notes") + + // Tooling / dependency docs — MUST NOT be picked up. + mustWriteMD(t, filepath.Join(claudeHome, "plugins", "mkt", "SKILL.md"), "plugin skill doc") + mustWriteMD(t, filepath.Join(claudeHome, "cache", "x", "README.md"), "third-party readme") + mustWriteMD(t, filepath.Join(claudeHome, "projects", "proj1", "node_modules", "pkg", "README.md"), "dep readme") + + sc := NewMarkdownScanner() + items, err := sc.Scan([]string{claudeHome}) + if err != nil { + t.Fatal(err) + } + + got := map[string]bool{} + for _, it := range items { + got[it.Path] = true + } + for _, w := range []string{ + filepath.Join(claudeHome, "CLAUDE.md"), + filepath.Join(claudeHome, "projects", "proj1", "notes.md"), + } { + if !got[w] { + t.Errorf("expected curated file in scan, missing: %s", w) + } + } + for _, w := range []string{ + filepath.Join(claudeHome, "plugins", "mkt", "SKILL.md"), + filepath.Join(claudeHome, "cache", "x", "README.md"), + filepath.Join(claudeHome, "projects", "proj1", "node_modules", "pkg", "README.md"), + } { + if got[w] { + t.Errorf("tooling/dependency doc must NOT be scanned: %s", w) + } + } + if len(items) != 2 { + t.Errorf("expected exactly 2 curated items, got %d", len(items)) + } +} + +func TestMarkdownToSingleUserMessage(t *testing.T) { + sc := NewMarkdownScanner() + conv, err := sc.Read(Item{Platform: PlatformMarkdown, Path: "testdata/sample.md"}) + if err != nil { + t.Fatal(err) + } + if len(conv.Messages) != 1 || conv.Messages[0].Role != "user" { + t.Fatalf("md must become one user message, got %+v", conv.Messages) + } + if conv.ID == "" { + t.Fatal("conversationId must be set") + } +} + +func TestMarkdownTimestampFromMtime(t *testing.T) { + sc := NewMarkdownScanner() + conv, err := sc.Read(Item{Platform: PlatformMarkdown, Path: "testdata/sample.md"}) + if err != nil { + t.Fatal(err) + } + if conv.Messages[0].Timestamp == 0 { + t.Fatal("timestamp must be set from file mtime") + } +} + +// joinContents concatenates the string Content of every message, in order. +func joinContents(t *testing.T, conv *Conversation) string { + t.Helper() + var b strings.Builder + for i, m := range conv.Messages { + s, ok := m.Content.(string) + if !ok { + t.Fatalf("message %d content not a string: %T", i, m.Content) + } + b.WriteString(s) + } + return b.String() +} + +// TestMarkdownSplitsOversizeIntoMessages: a file over the per-message cap is +// split into multiple user messages in the SAME Messages array, each within +// the cap, with NO content loss. +func TestMarkdownSplitsOversizeIntoMessages(t *testing.T) { + dir := t.TempDir() + path := dir + "/big.md" + content := strings.Repeat("a", 9000) // no newlines -> hard split at budget + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + sc := NewMarkdownScanner() + conv, err := sc.Read(Item{Platform: PlatformMarkdown, Path: path}) + if err != nil { + t.Fatal(err) + } + if len(conv.Messages) != 2 { + t.Fatalf("9000 runes should split into 2 messages, got %d", len(conv.Messages)) + } + for i, m := range conv.Messages { + if m.Role != "user" { + t.Errorf("message %d role = %q, want user", i, m.Role) + } + s := m.Content.(string) + if n := len([]rune(s)); n > markdownChunkBudget { + t.Errorf("message %d has %d runes, exceeds cap %d", i, n, markdownChunkBudget) + } + } + if got := joinContents(t, conv); got != content { + t.Fatalf("content lost in split: joined len %d, want %d", len([]rune(got)), len([]rune(content))) + } + if conv.Messages[1].Timestamp <= conv.Messages[0].Timestamp { + t.Errorf("split message timestamps must be strictly increasing: %d then %d", + conv.Messages[0].Timestamp, conv.Messages[1].Timestamp) + } +} + +// TestMarkdownSplitPrefersNewlineBoundary: the cut point favors the last +// newline in the back of a chunk so sections stay whole. +func TestMarkdownSplitPrefersNewlineBoundary(t *testing.T) { + dir := t.TempDir() + path := dir + "/lines.md" + // Newline at rune index 7900 (within the back 20% of an 8000 budget). + content := strings.Repeat("a", 7900) + "\n" + strings.Repeat("b", 2000) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + sc := NewMarkdownScanner() + conv, err := sc.Read(Item{Platform: PlatformMarkdown, Path: path}) + if err != nil { + t.Fatal(err) + } + if len(conv.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(conv.Messages)) + } + first := conv.Messages[0].Content.(string) + if !strings.HasSuffix(first, "\n") { + t.Errorf("first chunk should end at the newline boundary, got tail %q", first[len(first)-5:]) + } + if strings.Contains(first, "b") { + t.Errorf("first chunk leaked content from past the newline boundary") + } +} + +// TestMarkdownLargeFileFullyPreservedAcrossMessages: there is no message-count +// cap. A file admitted by the scan-time size limit is uploaded in full, split +// into as many messages as it needs, with zero content loss and every message +// within the per-message budget. +func TestMarkdownLargeFileFullyPreservedAcrossMessages(t *testing.T) { + dir := t.TempDir() + path := dir + "/huge.md" + // Far more than any fixed chunk-count cap would have allowed. + const n = 100*markdownChunkBudget + 123 + content := strings.Repeat("a", n) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + sc := NewMarkdownScanner() + conv, err := sc.Read(Item{Platform: PlatformMarkdown, Path: path}) + if err != nil { + t.Fatal(err) + } + wantChunks := (n + markdownChunkBudget - 1) / markdownChunkBudget // ceil + if len(conv.Messages) != wantChunks { + t.Fatalf("expected %d messages (no count cap), got %d", wantChunks, len(conv.Messages)) + } + for i, m := range conv.Messages { + if c := len([]rune(m.Content.(string))); c > markdownChunkBudget { + t.Errorf("message %d has %d runes, exceeds budget %d", i, c, markdownChunkBudget) + } + } + if got := joinContents(t, conv); got != content { + t.Fatalf("content lost: joined %d runes, want %d", len([]rune(got)), n) + } +} + +// TestMarkdownOver64KBSplitsIntoMultipleBoundedPosts proves the end-to-end +// composition for a file far larger than the per-POST object limit: Read splits +// it into <=budget-rune messages, and the uploader's byte batcher then groups +// those into multiple POSTs each within maxAgentBatchBytes (64 KiB) — same +// conversationId, order preserved, zero content loss. +func TestMarkdownOver64KBSplitsIntoMultipleBoundedPosts(t *testing.T) { + dir := t.TempDir() + path := dir + "/over64k.md" + const runes = 200_000 // ~200 KB ASCII, ~3x the 64 KiB POST budget + content := strings.Repeat("a", runes) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + conv, err := NewMarkdownScanner().Read(Item{Platform: PlatformMarkdown, Path: path}) + if err != nil { + t.Fatal(err) + } + + // Layer 1: every message is within the server's per-message rune cap. + for i, m := range conv.Messages { + if n := len([]rune(m.Content.(string))); n > markdownChunkBudget { + t.Fatalf("message %d: %d runes exceeds per-message cap %d", i, n, markdownChunkBudget) + } + } + + // Layer 2: the uploader batches those messages into multiple POSTs, each + // within the 64 KiB object limit, with no single message stranded oversize. + batches := batchMessagesByBytes(conv.Messages, maxAgentBatchBytes) + if len(batches) < 2 { + t.Fatalf("a >64 KiB file must span multiple POSTs, got %d batch(es)", len(batches)) + } + var seen int + for bi, batch := range batches { + total := 0 + for _, m := range batch { + total += messageBytes(m) + seen++ + } + if total > maxAgentBatchBytes { + t.Errorf("batch %d is %d bytes, exceeds object limit %d", bi, total, maxAgentBatchBytes) + } + } + if seen != len(conv.Messages) { + t.Fatalf("batching lost messages: %d batched vs %d total", seen, len(conv.Messages)) + } + + // Zero content loss across the whole split-and-batch pipeline. + if got := joinContents(t, conv); got != content { + t.Fatalf("content lost: joined %d runes, want %d", len([]rune(got)), runes) + } +} + +// TestMarkdownSecretStraddlingChunkBoundaryRedacted: redaction runs on the +// full text BEFORE splitting, so a credential straddling a chunk boundary is +// still removed (redact-after-split would leak the two halves). +func TestMarkdownSecretStraddlingChunkBoundaryRedacted(t *testing.T) { + dir := t.TempDir() + path := dir + "/straddle.md" + secret := "evt_" + strings.Repeat("b", 20) // matches evt_[A-Za-z0-9_-]{8,} + // Place the secret so it spans the first budget boundary (rune 8000). + content := strings.Repeat("a", markdownChunkBudget-5) + secret + strings.Repeat("c", 1000) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + sc := NewMarkdownScanner() + conv, err := sc.Read(Item{Platform: PlatformMarkdown, Path: path}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(joinContents(t, conv), secret) { + t.Fatalf("secret straddling chunk boundary was not redacted") + } +} + +func TestMarkdownRedact(t *testing.T) { + dir := t.TempDir() + path := dir + "/secret.md" + content := "My token is evt_0123456789abcdef and my key sk-ABCDEF0123456789ABCDEF" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + sc := NewMarkdownScanner() + conv, err := sc.Read(Item{Platform: PlatformMarkdown, Path: path}) + if err != nil { + t.Fatal(err) + } + text, ok := conv.Messages[0].Content.(string) + if !ok { + t.Fatal("content must be string") + } + if strings.Contains(text, "evt_0123456789abcdef") || strings.Contains(text, "sk-ABCDEF") { + t.Fatalf("secrets not redacted in: %q", text) + } +} + +func TestMarkdownScanMissingDir(t *testing.T) { + sc := NewMarkdownScanner() + items, err := sc.Scan([]string{"/no/such/dir/markdown"}) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("missing dir should yield 0 items, got %d", len(items)) + } +} diff --git a/cli/internal/importer/conversation/openclaw.go b/cli/internal/importer/conversation/openclaw.go new file mode 100644 index 0000000..8bf6d73 --- /dev/null +++ b/cli/internal/importer/conversation/openclaw.go @@ -0,0 +1,266 @@ +package conversation + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// OpenClawScanner parses OpenClaw trajectory JSONL files. +// +// OpenClaw trajectory format (traceSchema: "openclaw-trajectory"): +// Each line is a JSON event with top-level fields: +// +// type, ts, sessionId, data +// +// Relevant event types: +// - model.completed: data.messagesSnapshot — the full conversation up to that turn. +// We use the LAST model.completed event's snapshot to get the complete conversation. +// - session.started, session.ended, trace.metadata, context.compiled, +// prompt.submitted, trace.artifacts — metadata only, skipped. +// +// messagesSnapshot items: +// +// role: "user" | "assistant" | "toolResult" +// For user: content is []{"type":"text","text":"..."} +// For assistant: content is []{"type":"text","text":"..."} | []{"type":"toolCall","id":"...","name":"...","arguments":{}} +// For toolResult: toolCallId, content is []{"type":"text","text":"..."} +// +// The .trajectory-path.json file is a metadata pointer (session file path); not parsed here. +type OpenClawScanner struct{} + +var _ Scanner = (*OpenClawScanner)(nil) + +func NewOpenClawScanner() *OpenClawScanner { return &OpenClawScanner{} } + +func (s *OpenClawScanner) Platform() PlatformID { return PlatformOpenClaw } + +func (s *OpenClawScanner) Scan(roots []string) ([]Item, error) { + var items []Item + for _, root := range roots { + if _, err := os.Stat(root); os.IsNotExist(err) { + continue + } + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".trajectory.jsonl") { + return nil + } + info, err := d.Info() + if err != nil || info == nil { + return nil // skip unreadable entry + } + item := Item{ + Platform: PlatformOpenClaw, + Path: path, + Status: "ready", + SizeBytes: info.Size(), + UpdatedAt: info.ModTime().UTC().Format(time.RFC3339), + } + items = append(items, item) + return nil + }) + if err != nil { + return nil, err + } + } + return items, nil +} + +func (s *OpenClawScanner) Read(item Item) (*Conversation, error) { + f, err := os.Open(item.Path) + if err != nil { + return nil, err + } + defer f.Close() + + conv := &Conversation{Item: item} + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 64*1024*1024) + lineNum := 0 + + const maxRunes = 8000 + + // We use the last model.completed snapshot as the canonical conversation. + // All prior snapshots are intermediate states (OpenClaw appends full snapshots + // after each turn, so the last one is the most complete). + var lastSnapshot []any + var sessionID string + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + lineNum++ + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: json decode error: %v", lineNum, err)) + continue + } + + if sessionID == "" { + if sid := stringFieldCC(ev, "sessionId"); sid != "" { + sessionID = sid + } + } + + evType := stringFieldCC(ev, "type") + if evType != "model.completed" { + continue + } + + data := objectMapCC(ev["data"]) + if data == nil { + continue + } + if snap, ok := data["messagesSnapshot"].([]any); ok && len(snap) > 0 { + lastSnapshot = snap + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + + if lastSnapshot == nil { + conv.Warnings = append(conv.Warnings, "no model.completed snapshot found") + conv.ID = ConversationID(PlatformOpenClaw, sessionID, item.Path) + return conv, nil + } + + // Parse the snapshot into messages. + // Use file mtime as deterministic fallback base — never time.Now() (breaks idempotency). + var fallbackBase int64 + if fi, err := os.Stat(item.Path); err == nil { + fallbackBase = fi.ModTime().UnixMilli() + } else { + fallbackBase = time.Unix(0, 0).UnixMilli() + } + for i, raw := range lastSnapshot { + m := objectMapCC(raw) + if m == nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("snapshot[%d]: not an object, skipped", i)) + continue + } + role := stringFieldCC(m, "role") + // OpenClaw uses "toolResult" (camelCase) for tool results + ts := normalizeTimestampCC(m["timestamp"], fallbackBase+int64(i)) + + switch role { + case "user": + text := openClawContentText(m["content"], maxRunes) + if text == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("snapshot[%d]: user message with no text, skipped", i)) + continue + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "user", + Timestamp: ts, + Content: Redact(text), + }) + case "assistant": + msg := openClawAssistantMessage(m, ts, maxRunes, i) + if msg == nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("snapshot[%d]: assistant message with no content, skipped", i)) + continue + } + conv.Messages = append(conv.Messages, *msg) + case "toolResult": + toolCallID := firstNonEmptyCC(stringFieldCC(m, "toolCallId"), stringFieldCC(m, "tool_call_id")) + if toolCallID == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("snapshot[%d]: toolResult missing toolCallId, dropped", i)) + continue + } + text := openClawContentText(m["content"], maxRunes) + if text == "" { + text = "tool result" + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "tool", + Timestamp: ts, + ToolCallID: toolCallID, + Content: Redact(text), + }) + default: + if role != "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("snapshot[%d]: unknown role %q, skipped", i, role)) + } + } + } + + setStartedAtFromMessages(conv) + conv.ID = ConversationID(PlatformOpenClaw, sessionID, item.Path) + return conv, nil +} + +func openClawAssistantMessage(m map[string]any, ts int64, maxRunes, idx int) *AgentMemoryMessage { + content, ok := m["content"].([]any) + if !ok { + return nil + } + var textParts []string + var calls []AgentMemoryToolCall + for j, raw := range content { + b := objectMapCC(raw) + if b == nil { + continue + } + switch stringFieldCC(b, "type") { + case "text": + if text := stringFieldCC(b, "text"); text != "" { + textParts = append(textParts, truncateRunesCC(text, maxRunes)) + } + case "toolCall": + id := firstNonEmptyCC(stringFieldCC(b, "id"), fmt.Sprintf("oc_tool_%d_%d_%d", ts, idx, j)) + calls = append(calls, AgentMemoryToolCall{ + ID: id, + Type: "function", + Name: firstNonEmptyCC(stringFieldCC(b, "name"), "unknown"), + Arguments: Redact(argumentsStringCC(b["arguments"])), + }) + } + } + msg := AgentMemoryMessage{Role: "assistant", Timestamp: ts} + if text := strings.TrimSpace(strings.Join(textParts, "\n\n")); text != "" { + msg.Content = Redact(text) + } + if len(calls) > 0 { + msg.ToolCalls = calls + } + if msg.Content == nil && len(msg.ToolCalls) == 0 { + return nil + } + return &msg +} + +func openClawContentText(v any, maxRunes int) string { + switch x := v.(type) { + case string: + return truncateRunesCC(strings.TrimSpace(x), maxRunes) + case []any: + parts := make([]string, 0, len(x)) + for _, item := range x { + if s, ok := item.(string); ok { + parts = append(parts, s) + continue + } + b := objectMapCC(item) + if b == nil { + continue + } + if text := stringFieldCC(b, "text"); text != "" { + parts = append(parts, text) + } + } + return truncateRunesCC(strings.TrimSpace(strings.Join(parts, "\n")), maxRunes) + default: + return "" + } +} diff --git a/cli/internal/importer/conversation/openclaw_test.go b/cli/internal/importer/conversation/openclaw_test.go new file mode 100644 index 0000000..1b252a9 --- /dev/null +++ b/cli/internal/importer/conversation/openclaw_test.go @@ -0,0 +1,95 @@ +package conversation + +import ( + "os" + "testing" +) + +func TestOpenClawParseCounts(t *testing.T) { + sc := NewOpenClawScanner() + conv, err := sc.Read(Item{Platform: PlatformOpenClaw, Path: "testdata/openclaw_sample.trajectory.jsonl"}) + if err != nil { + t.Fatal(err) + } + var toolCalls, toolResults int + for _, m := range conv.Messages { + toolCalls += len(m.ToolCalls) + if m.Role == "tool" { + toolResults++ + } + } + // fixture has 2 toolCall blocks and 2 toolResult messages in snapshot + if toolCalls == 0 || toolResults == 0 { + t.Fatalf("expected tool trajectory, got calls=%d results=%d", toolCalls, toolResults) + } + if conv.ID == "" { + t.Fatal("conversationId must be set") + } + if toolCalls != 2 { + t.Fatalf("expected 2 toolCalls, got %d", toolCalls) + } + if toolResults != 2 { + t.Fatalf("expected 2 toolResults, got %d", toolResults) + } + // fixture snapshot: 1 user + 2 assistant(toolCall) + 2 toolResult + 1 assistant(text) = 6 + if len(conv.Messages) != 6 { + t.Fatalf("expected 6 total messages, got %d", len(conv.Messages)) + } +} + +// TestOpenClawNoModelCompleted verifies that a file with only session.started/session.ended +// (no model.completed event) returns a non-nil conv with a warning and no error. +func TestOpenClawNoModelCompleted(t *testing.T) { + fixture := `{"type":"session.started","ts":"2026-06-01T10:00:00.000Z","sessionId":"sess-empty-001"} +{"type":"session.ended","ts":"2026-06-01T10:01:00.000Z","sessionId":"sess-empty-001"} +` + dir := t.TempDir() + path := dir + "/empty.trajectory.jsonl" + if err := os.WriteFile(path, []byte(fixture), 0o644); err != nil { + t.Fatal(err) + } + sc := NewOpenClawScanner() + conv, err := sc.Read(Item{Platform: PlatformOpenClaw, Path: path}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if conv == nil { + t.Fatal("expected non-nil conv") + } + if len(conv.Messages) != 0 { + t.Fatalf("expected 0 messages for snapshot-less file, got %d", len(conv.Messages)) + } + if len(conv.Warnings) == 0 { + t.Fatal("expected at least one warning about missing model.completed") + } +} + +func TestOpenClawScannerPlatform(t *testing.T) { + sc := NewOpenClawScanner() + if sc.Platform() != PlatformOpenClaw { + t.Fatalf("expected %s, got %s", PlatformOpenClaw, sc.Platform()) + } +} + +func TestOpenClawScanMissingDir(t *testing.T) { + sc := NewOpenClawScanner() + items, err := sc.Scan([]string{"/no/such/dir/openclaw"}) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("missing dir should yield 0 items, got %d", len(items)) + } +} + +func TestOpenClawSessionIDFromFile(t *testing.T) { + sc := NewOpenClawScanner() + conv, err := sc.Read(Item{Platform: PlatformOpenClaw, Path: "testdata/openclaw_sample.trajectory.jsonl"}) + if err != nil { + t.Fatal(err) + } + // conv.ID should embed the session ID from the fixture (sess-oc-001) + if conv.ID == "" { + t.Fatal("conversationId must be set") + } +} diff --git a/cli/internal/importer/conversation/platforms.go b/cli/internal/importer/conversation/platforms.go new file mode 100644 index 0000000..4b38427 --- /dev/null +++ b/cli/internal/importer/conversation/platforms.go @@ -0,0 +1,85 @@ +package conversation + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// knownPlatforms is the canonical set of platform names accepted on the +// command line and in DefaultRegistry. +var knownPlatforms = map[PlatformID]struct{}{ + PlatformClaudeCode: {}, + PlatformCodex: {}, + PlatformHermes: {}, + PlatformOpenClaw: {}, + PlatformMarkdown: {}, + PlatformKimicode: {}, + PlatformRaven: {}, + PlatformWorkBuddy: {}, +} + +// IsKnownPlatform reports whether p is one of the supported platform names. +func IsKnownPlatform(p PlatformID) bool { + _, ok := knownPlatforms[p] + return ok +} + +// ParsePlatforms trims and validates the requested platform names, returning +// an error naming the first unknown one. Callers surface this as an +// invalid-argument error. +func ParsePlatforms(names []string) ([]PlatformID, error) { + ids := make([]PlatformID, 0, len(names)) + for _, n := range names { + p := PlatformID(strings.TrimSpace(n)) + if !IsKnownPlatform(p) { + return nil, fmt.Errorf("unknown platform %q (known: claude-code, codex, hermes, openclaw, markdown, kimicode, raven, workbuddy)", n) + } + ids = append(ids, p) + } + return ids, nil +} + +// agentHomeDirs returns the resolved home directory for each agent platform, +// honoring the same env overrides as DefaultRoots / platformEnvFile. Used to +// attribute a markdown file to the agent whose folder contains it. +func agentHomeDirs() map[PlatformID]string { + home, _ := os.UserHomeDir() + return map[PlatformID]string{ + PlatformClaudeCode: envOr("CLAUDE_CONFIG_DIR", filepath.Join(home, ".claude")), + PlatformCodex: envOr("CODEX_HOME", filepath.Join(home, ".codex")), + PlatformHermes: filepath.Join(home, ".hermes"), + PlatformOpenClaw: envOr("OPENCLAW_CONFIG_DIR", filepath.Join(home, ".openclaw")), + } +} + +// ownerForMarkdownPath returns the agent platform whose home dir contains the +// given path (prefix match), or "" if the path is under none of them. +func ownerForMarkdownPath(path string) PlatformID { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + abs = filepath.Clean(abs) + for p, dir := range agentHomeDirs() { + dir = filepath.Clean(dir) + if abs == dir { + return p + } + if strings.HasPrefix(abs, dir+string(os.PathSeparator)) { + return p + } + } + return "" +} + +// AttributionPlatform returns the platform whose evt should be used to upload +// the item: for an owned markdown file, its owning agent; otherwise the item's +// own platform. +func AttributionPlatform(item Item) PlatformID { + if item.Platform == PlatformMarkdown && item.OwnerPlatform != "" { + return item.OwnerPlatform + } + return item.Platform +} diff --git a/cli/internal/importer/conversation/platforms_test.go b/cli/internal/importer/conversation/platforms_test.go new file mode 100644 index 0000000..5d11a76 --- /dev/null +++ b/cli/internal/importer/conversation/platforms_test.go @@ -0,0 +1,44 @@ +package conversation + +import "testing" + +// FIX 5 — IsKnownPlatform / ParsePlatforms reject unknown names. +func TestIsKnownPlatform(t *testing.T) { + for _, p := range []PlatformID{PlatformClaudeCode, PlatformCodex, PlatformHermes, PlatformOpenClaw, PlatformMarkdown} { + if !IsKnownPlatform(p) { + t.Errorf("%q should be known", p) + } + } + if IsKnownPlatform("nope") { + t.Error("nope must not be known") + } +} + +func TestParsePlatforms(t *testing.T) { + ids, err := ParsePlatforms([]string{" claude-code ", "codex"}) + if err != nil { + t.Fatal(err) + } + if len(ids) != 2 || ids[0] != PlatformClaudeCode || ids[1] != PlatformCodex { + t.Fatalf("got %v", ids) + } + + if _, err := ParsePlatforms([]string{"claude-code", "nope"}); err == nil { + t.Fatal("unknown platform name must error") + } +} + +func TestKimicodeKnownAndRoots(t *testing.T) { + if !IsKnownPlatform(PlatformKimicode) { + t.Fatal("kimicode must be a known platform") + } + t.Setenv("KIMI_CODE_HOME", "/tmp/kc-test") + roots := DefaultRoots(PlatformKimicode) + if len(roots) != 1 || roots[0] != "/tmp/kc-test/sessions" { + t.Fatalf("unexpected roots: %v", roots) + } + path, ok := platformEnvFile(PlatformKimicode) + if !ok || path != "/tmp/kc-test/everme.env" { + t.Fatalf("unexpected env file: %q ok=%v", path, ok) + } +} diff --git a/cli/internal/importer/conversation/raven.go b/cli/internal/importer/conversation/raven.go new file mode 100644 index 0000000..422df43 --- /dev/null +++ b/cli/internal/importer/conversation/raven.go @@ -0,0 +1,259 @@ +package conversation + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// ravenPluginID is the Raven plugin id under which the installer writes +// the EverMe per-agent config (plugins.config["everme-memory"]). +// keep in sync with cli/internal/plugin/raven.go RavenPluginID +const ravenPluginID = "everme-memory" + +// RavenScanner parses Raven (~/.raven/workspace/sessions) session +// transcripts. Each session lives at sessions//.jsonl +// (chat_id = "YYYYMMDD_HHMMSS_xxxxxx", sortable) and is an append-only +// JSON-Lines stream written by raven/session/manager.py: +// +// - metadata records ({"_type": "metadata", ...}) are re-appended on +// every save — skipped here (multiple occurrences per file). +// - message records are the AgentLoop's OpenAI-style dicts serialized +// verbatim: {"role", "content", "timestamp", ...} with assistant +// messages carrying "tool_calls" ([{id, type, function: {name, +// arguments}}]) and tool results carrying "tool_call_id". +// - "timestamp" is datetime.now().isoformat() — a NAIVE local-time +// ISO string without offset, so RFC3339 parsing fails on it; see +// normalizeTimestampRaven. +type RavenScanner struct{} + +var _ Scanner = (*RavenScanner)(nil) + +func NewRavenScanner() *RavenScanner { return &RavenScanner{} } + +func (s *RavenScanner) Platform() PlatformID { return PlatformRaven } + +func (s *RavenScanner) Scan(roots []string) ([]Item, error) { + var items []Item + for _, root := range roots { + if _, err := os.Stat(root); os.IsNotExist(err) { + continue + } + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + info, err := d.Info() + if err != nil || info == nil { + return nil + } + items = append(items, Item{ + Platform: PlatformRaven, + Path: path, + OriginID: ravenSessionID(path), + Status: "ready", + SizeBytes: info.Size(), + UpdatedAt: info.ModTime().UTC().Format(time.RFC3339), + }) + return nil + }) + if err != nil { + return nil, err + } + } + return items, nil +} + +// ravenSessionID derives the session key (":") from a +// .../sessions//.jsonl path — the same key Raven uses +// internally (session.key), so re-imports of a renamed/moved workspace +// stay idempotent. Returns "" when the layout doesn't match +// (ConversationID then falls back to a path hash). +func ravenSessionID(path string) string { + chatID := strings.TrimSuffix(filepath.Base(path), ".jsonl") + channel := filepath.Base(filepath.Dir(path)) + if chatID == "" || channel == "" || channel == "." || channel == string(filepath.Separator) { + return "" + } + return channel + ":" + chatID +} + +func (s *RavenScanner) Read(item Item) (*Conversation, error) { + f, err := os.Open(item.Path) + if err != nil { + return nil, err + } + defer f.Close() + + conv := &Conversation{Item: item} + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 1024*1024), 64*1024*1024) + + // Deterministic fallback base — never time.Now() (breaks idempotency). + var fallbackBase int64 + if fi, err := os.Stat(item.Path); err == nil { + fallbackBase = fi.ModTime().UnixMilli() + } else { + fallbackBase = time.Unix(0, 0).UnixMilli() + } + + const maxRunes = 8000 + + lineNum := 0 + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + lineNum++ + var rec map[string]any + if err := json.Unmarshal([]byte(line), &rec); err != nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: json decode error: %v", lineNum, err)) + continue + } + if stringFieldCC(rec, "_type") == "metadata" { + continue // re-appended on every save; not a message + } + ts := normalizeTimestampRaven(rec["timestamp"], fallbackBase+int64(lineNum)) + + switch stringFieldCC(rec, "role") { + case "user": + text := ravenTextFromContent(rec["content"], maxRunes) + if text == "" { + continue + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "user", + Timestamp: ts, + Content: Redact(text), + }) + + case "assistant": + text := ravenTextFromContent(rec["content"], maxRunes) + toolCalls := ravenToolCalls(rec["tool_calls"], lineNum, conv) + if text == "" && len(toolCalls) == 0 { + continue + } + msg := AgentMemoryMessage{ + Role: "assistant", + Timestamp: ts, + ToolCalls: toolCalls, + } + if text != "" { + msg.Content = Redact(text) + } + conv.Messages = append(conv.Messages, msg) + + case "tool": + id := firstNonEmptyCC(stringFieldCC(rec, "tool_call_id"), stringFieldCC(rec, "toolCallId")) + if id == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: tool message missing tool_call_id, skipped", lineNum)) + continue + } + out := truncateRunesCC(strings.TrimSpace(ravenTextFromContent(rec["content"], maxRunes)), maxRunes) + if out == "" { + out = "tool result" + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "tool", + Timestamp: ts, + ToolCallID: id, + Content: Redact(out), + }) + + default: + // "system" (and anything unknown) is dropped: the BFF accepts + // user/assistant/tool only. + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + + setStartedAtFromMessages(conv) + conv.ID = ConversationID(PlatformRaven, item.OriginID, item.Path) + return conv, nil +} + +// ravenToolCalls converts an OpenAI-style tool_calls array +// ([{id, type, function: {name, arguments}}]; arguments is a JSON +// string, but objects are tolerated) into the BFF DTO shape. Flat +// {id, name, arguments} entries without a "function" wrapper are +// tolerated too. Malformed entries warn and are skipped. +func ravenToolCalls(v any, lineNum int, conv *Conversation) []AgentMemoryToolCall { + arr, _ := v.([]any) + if len(arr) == 0 { + return nil + } + out := make([]AgentMemoryToolCall, 0, len(arr)) + for _, raw := range arr { + tc := objectMapCC(raw) + if tc == nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: non-object tool_call entry, skipped", lineNum)) + continue + } + fn := objectMapCC(tc["function"]) + if fn == nil { + fn = tc // flat shape fallback + } + name := stringFieldCC(fn, "name") + if name == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: tool_call missing function.name, skipped", lineNum)) + continue + } + out = append(out, AgentMemoryToolCall{ + ID: firstNonEmptyCC(stringFieldCC(tc, "id"), fmt.Sprintf("raven_tool_%d", lineNum)), + Type: "function", + Name: name, + Arguments: Redact(argumentsStringCC(fn["arguments"])), + }) + } + return out +} + +// ravenTextFromContent joins text blocks from a message.content that is +// either a plain string or a multimodal parts array +// ([]{type:"text", text:"..."}); non-text parts are dropped. +func ravenTextFromContent(content any, maxRunes int) string { + switch x := content.(type) { + case string: + return truncateRunesCC(strings.TrimSpace(x), maxRunes) + case []any: + parts := make([]string, 0, len(x)) + for _, raw := range x { + b := objectMapCC(raw) + if b == nil { + continue + } + if stringFieldCC(b, "type") == "text" { + if t := stringFieldCC(b, "text"); t != "" { + parts = append(parts, t) + } + } + } + return truncateRunesCC(strings.TrimSpace(strings.Join(parts, "\n")), maxRunes) + default: + return "" + } +} + +// normalizeTimestampRaven handles Raven's naive local-time ISO strings +// (datetime.now().isoformat() — no offset, so RFC3339 parsing fails), +// then defers to normalizeTimestampCC for offset-carrying strings and +// epoch numbers. Naive timestamps are interpreted in the machine's +// local zone — the same zone that wrote them. +func normalizeTimestampRaven(v any, fallback int64) int64 { + if s, ok := v.(string); ok { + if t, err := time.ParseInLocation("2006-01-02T15:04:05.999999999", s, time.Local); err == nil { + return t.UnixMilli() + } + } + return normalizeTimestampCC(v, fallback) +} diff --git a/cli/internal/importer/conversation/raven_test.go b/cli/internal/importer/conversation/raven_test.go new file mode 100644 index 0000000..d0cbf94 --- /dev/null +++ b/cli/internal/importer/conversation/raven_test.go @@ -0,0 +1,193 @@ +package conversation + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRavenScannerPlatform(t *testing.T) { + if NewRavenScanner().Platform() != PlatformRaven { + t.Fatalf("expected %s", PlatformRaven) + } +} + +func TestRavenScanMissingDir(t *testing.T) { + items, err := NewRavenScanner().Scan([]string{"/no/such/dir/raven"}) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("missing dir should yield 0 items, got %d", len(items)) + } +} + +func TestRavenScanDiscoversChannelSessions(t *testing.T) { + dir := t.TempDir() + sessions := filepath.Join(dir, "workspace", "sessions") + mustWrite(t, filepath.Join(sessions, "cli", "20260703_101500_ab12cd.jsonl"), "{}\n") + mustWrite(t, filepath.Join(sessions, "telegram", "20260702_090000_ff00aa.jsonl"), "{}\n") + mustWrite(t, filepath.Join(sessions, "cli", "notes.txt"), "ignored\n") + + items, err := NewRavenScanner().Scan([]string{sessions}) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d: %+v", len(items), items) + } + origins := map[string]bool{} + for _, it := range items { + origins[it.OriginID] = true + } + // OriginID is Raven's own session key ":" so + // re-imports stay idempotent across workspace moves. + if !origins["cli:20260703_101500_ab12cd"] { + t.Fatalf("missing cli origin; got %v", origins) + } + if !origins["telegram:20260702_090000_ff00aa"] { + t.Fatalf("missing telegram origin; got %v", origins) + } +} + +func TestRavenParseSample(t *testing.T) { + conv, err := NewRavenScanner().Read(Item{ + Platform: PlatformRaven, + Path: "testdata/raven_sample.jsonl", + OriginID: "cli:20260703_101500_ab12cd", + }) + if err != nil { + t.Fatal(err) + } + + var users, assistants, toolCalls, toolResults int + for _, m := range conv.Messages { + switch m.Role { + case "user": + users++ + case "assistant": + assistants++ + toolCalls += len(m.ToolCalls) + case "tool": + toolResults++ + } + } + // 2 user messages (plain string + multimodal text parts joined, + // image part dropped); the system message is dropped. + if users != 2 { + t.Fatalf("expected 2 user messages, got %d", users) + } + // 3 assistant messages: text+tool_call combined, plain text, parts text. + if assistants != 3 { + t.Fatalf("expected 3 assistant messages, got %d", assistants) + } + if toolCalls != 1 { + t.Fatalf("expected 1 tool call, got %d", toolCalls) + } + // 1 valid tool result; the orphan without tool_call_id is skipped. + if toolResults != 1 { + t.Fatalf("expected 1 tool result, got %d", toolResults) + } + + // Warnings: orphan tool result + non-JSON trailing line. + if len(conv.Warnings) != 2 { + t.Fatalf("expected 2 warnings, got %d: %v", len(conv.Warnings), conv.Warnings) + } + + // Metadata records (appended twice by Raven's save) never become messages. + for _, m := range conv.Messages { + if s, ok := m.Content.(string); ok && strings.Contains(s, "_type") { + t.Fatalf("metadata leaked into messages: %q", s) + } + } + + // tool_calls: function wrapper unwrapped, arguments stay a JSON string. + var sawCall bool + for _, m := range conv.Messages { + for _, tc := range m.ToolCalls { + sawCall = true + if tc.ID != "call_001" || tc.Name != "run_shell" { + t.Fatalf("unexpected tool call %+v", tc) + } + if !strings.Contains(tc.Arguments, `"cmd"`) { + t.Fatalf("arguments not preserved: %q", tc.Arguments) + } + } + } + if !sawCall { + t.Fatal("tool call not parsed") + } + + // The combined assistant message keeps its preamble text alongside the call. + if conv.Messages[1].Role != "assistant" || conv.Messages[1].Content != "Let me check." { + t.Fatalf("expected combined text+tool_call assistant message, got %+v", conv.Messages[1]) + } + + // Multimodal user content joins text parts and drops non-text parts. + last := conv.Messages[len(conv.Messages)-2] + if last.Role != "user" || last.Content != "thanks,\nsummarize them" { + t.Fatalf("multimodal user content mishandled: %+v", last) + } + + if conv.ID == "" || conv.Item.StartedAt == "" { + t.Fatalf("conversation id / startedAt not set: %+v", conv.Item) + } +} + +func TestRavenNaiveTimestampParsedInLocalZone(t *testing.T) { + got := normalizeTimestampRaven("2026-07-03T10:15:01.500000", 42) + want := time.Date(2026, 7, 3, 10, 15, 1, 500_000_000, time.Local).UnixMilli() + if got != want { + t.Fatalf("naive ISO parse: got %d want %d", got, want) + } + // Offset-carrying strings and epoch numbers defer to the shared helper. + if normalizeTimestampRaven("2026-07-03T10:15:01Z", 42) != time.Date(2026, 7, 3, 10, 15, 1, 0, time.UTC).UnixMilli() { + t.Fatal("RFC3339 fallback broken") + } + if normalizeTimestampRaven(float64(1751501762), 42) != 1751501762000 { + t.Fatal("epoch-seconds fallback broken") + } + if normalizeTimestampRaven(nil, 42) != 42 { + t.Fatal("fallback broken") + } +} + +func TestRavenSessionID(t *testing.T) { + if got := ravenSessionID(filepath.Join("x", "sessions", "cli", "20260703_101500_ab12cd.jsonl")); got != "cli:20260703_101500_ab12cd" { + t.Fatalf("got %q", got) + } +} + +func TestRavenResolveEvt(t *testing.T) { + dir := t.TempDir() + t.Setenv("EVERCLI_RAVEN_CONFIG_DIR", dir) + + // Missing config file. + if _, err := resolveRavenEvt(); err == nil { + t.Fatal("expected error for missing config") + } + + // Entry present with snake_case token. + cfg := `{"memory":{"backend":"everme"},"plugins":{"config":{"everme-memory":{"agent_token":"evt_tok123","agent_id":"agt_1"}}}}` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + tok, err := resolveRavenEvt() + if err != nil { + t.Fatal(err) + } + if tok != "evt_tok123" { + t.Fatalf("got %q", tok) + } + + // Empty token is an explicit error, not "". + cfg = `{"plugins":{"config":{"everme-memory":{"agent_token":" "}}}}` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + if _, err := resolveRavenEvt(); err == nil { + t.Fatal("expected error for empty token") + } +} diff --git a/cli/internal/importer/conversation/redact.go b/cli/internal/importer/conversation/redact.go new file mode 100644 index 0000000..8ab5bf9 --- /dev/null +++ b/cli/internal/importer/conversation/redact.go @@ -0,0 +1,27 @@ +package conversation + +import "regexp" + +// redactors covers common credential shapes (spec §7.3). Not exhaustive — +// the preview must warn users to self-check (spec §7.0). +var redactors = []*regexp.Regexp{ + regexp.MustCompile(`sk-[A-Za-z0-9_-]{16,}`), + regexp.MustCompile(`evt_[A-Za-z0-9_-]{8,}`), + regexp.MustCompile(`emk_[A-Za-z0-9_-]{8,}`), + regexp.MustCompile(`ghp_[A-Za-z0-9]{20,}`), + regexp.MustCompile(`AKIA[0-9A-Z]{16}`), // AWS access key id + regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._=\-]{10,}`), // Bearer tokens + regexp.MustCompile(`X-Amz-Signature=[A-Za-z0-9%]+`), // S3 signed URL + // PEM private key blocks (RSA/EC/OPENSSH/PKCS8 etc.). DOTALL so the + // base64 body spanning many lines is captured in one match. + regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`), +} + +// Redact replaces known credential patterns with [redacted]. Applied to +// every message's textual content before upload. +func Redact(s string) string { + for _, re := range redactors { + s = re.ReplaceAllString(s, "[redacted]") + } + return s +} diff --git a/cli/internal/importer/conversation/redact_test.go b/cli/internal/importer/conversation/redact_test.go new file mode 100644 index 0000000..c3f1e09 --- /dev/null +++ b/cli/internal/importer/conversation/redact_test.go @@ -0,0 +1,84 @@ +package conversation + +import ( + "strings" + "testing" +) + +func TestRedactCommonSecrets(t *testing.T) { + cases := []string{ + "key sk-ABCDEF0123456789ABCDEF here", + "token evt_0123456789abcdef", + "emk_0123456789abcdef", + "Authorization: Bearer abcdef.ghijkl.mnopqr", + "ghp_0123456789abcdefghij0123456789abcdef", + } + for _, in := range cases { + out := Redact(in) + if strings.Contains(out, "sk-ABCDEF") || strings.Contains(out, "evt_0123") || + strings.Contains(out, "emk_0123") || strings.Contains(out, "ghp_0123") || + strings.Contains(out, "abcdef.ghijkl") { + t.Fatalf("secret not redacted: %q -> %q", in, out) + } + if !strings.Contains(out, "[redacted]") { + t.Fatalf("expected marker in %q", out) + } + } +} + +func TestRedactBroadenedPatterns(t *testing.T) { + tests := []struct { + name string + input string + mustNot []string // substrings that must NOT appear in output + }{ + { + name: "sk-ant key with hyphens and underscores", + input: "key sk-ant-api03-abcdefABCDEF0123456789_- here", + mustNot: []string{"sk-ant-api03-abcdefABCDEF0123456789"}, + }, + { + name: "Bearer token with base64 padding", + input: "Authorization: Bearer dG9rZW49MTIzNDU2Nzg5MA==", + mustNot: []string{"dG9rZW49MTIzNDU2Nzg5MA==", "=="}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := Redact(tt.input) + if !strings.Contains(out, "[redacted]") { + t.Fatalf("expected [redacted] marker in output %q", out) + } + for _, bad := range tt.mustNot { + if strings.Contains(out, bad) { + t.Fatalf("secret fragment %q still present in output %q", bad, out) + } + } + }) + } +} + +func TestRedactPEMPrivateKeyBlock(t *testing.T) { + cases := []string{ + "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAabc123\nDEF456ghi789\n-----END RSA PRIVATE KEY-----", + "prefix\n-----BEGIN PRIVATE KEY-----\nbase64lines\n-----END PRIVATE KEY-----\nsuffix", + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk\n-----END OPENSSH PRIVATE KEY-----", + } + for _, in := range cases { + out := Redact(in) + if strings.Contains(out, "PRIVATE KEY") || strings.Contains(out, "MIIEpAIB") || + strings.Contains(out, "base64lines") || strings.Contains(out, "b3BlbnNzaC1rZXk") { + t.Fatalf("PEM private key block not redacted: %q -> %q", in, out) + } + if !strings.Contains(out, "[redacted]") { + t.Fatalf("expected [redacted] marker in %q", out) + } + } +} + +func TestRedactLeavesPlainText(t *testing.T) { + in := "the cat sat on the mat" + if Redact(in) != in { + t.Fatalf("plain text must be unchanged") + } +} diff --git a/cli/internal/importer/conversation/registry.go b/cli/internal/importer/conversation/registry.go new file mode 100644 index 0000000..34ccf59 --- /dev/null +++ b/cli/internal/importer/conversation/registry.go @@ -0,0 +1,91 @@ +package conversation + +import ( + "os" + "path/filepath" +) + +// Registry holds all platform scanners. +type Registry struct { + scanners []Scanner +} + +// DefaultRegistry returns a registry with every platform scanner registered. +func DefaultRegistry() *Registry { + return &Registry{ + scanners: []Scanner{ + NewClaudeCodeScanner(), + NewCodexScanner(), + NewHermesScanner(), + NewOpenClawScanner(), + NewMarkdownScanner(), + NewKimicodeScanner(), + NewRavenScanner(), + NewWorkBuddyScanner(), + }, + } +} + +// Scanners returns the list of registered scanners. +func (r *Registry) Scanners() []Scanner { + return r.scanners +} + +// ScannerFor returns the scanner for a given platform, or nil if not found. +func (r *Registry) ScannerFor(p PlatformID) Scanner { + for _, sc := range r.scanners { + if sc.Platform() == p { + return sc + } + } + return nil +} + +// DefaultRoots returns the OS/env-resolved default scan roots for the given +// platform. Honors CLAUDE_CONFIG_DIR, CODEX_HOME per platform. +func DefaultRoots(p PlatformID) []string { + home, _ := os.UserHomeDir() + switch p { + case PlatformClaudeCode: + base := envOr("CLAUDE_CONFIG_DIR", filepath.Join(home, ".claude")) + return []string{filepath.Join(base, "projects")} + case PlatformCodex: + base := envOr("CODEX_HOME", filepath.Join(home, ".codex")) + return []string{filepath.Join(base, "sessions")} + case PlatformHermes: + return []string{filepath.Join(home, ".hermes", "sessions")} + case PlatformOpenClaw: + // The agents dir, not agents/main/sessions: OpenClaw keeps one + // folder per agent and the scanner already walks recursively for + // *.trajectory.jsonl, so anchoring on "main" made every other + // agent's history invisible. + base := envOr("OPENCLAW_CONFIG_DIR", filepath.Join(home, ".openclaw")) + return []string{filepath.Join(base, "agents")} + case PlatformKimicode: + base := envOr("KIMI_CODE_HOME", filepath.Join(home, ".kimi-code")) + return []string{filepath.Join(base, "sessions")} + case PlatformRaven: + // Raven hardcodes ~/.raven (raven/config/loader.py); the env + // override is evercli's own test escape hatch, mirroring + // cli/internal/plugin/raven.go RavenHome. + base := envOr("EVERCLI_RAVEN_CONFIG_DIR", filepath.Join(home, ".raven")) + return []string{filepath.Join(base, "workspace", "sessions")} + case PlatformWorkBuddy: + // Mirrors cli/internal/plugin/workbuddy.go workBuddyConfigDir - same + // env var name, so the two doors into ~/.workbuddy agree. + base := envOr("EVERCLI_WORKBUDDY_CONFIG_DIR", filepath.Join(home, ".workbuddy")) + return []string{filepath.Join(base, "projects")} + case PlatformMarkdown: + // A markdown file's owning agent is the agent whose local folder + // contains it; scan the agent home dirs (not generic doc dirs) so md + // can be attributed and uploaded under that agent's evt. + return []string{ + envOr("CLAUDE_CONFIG_DIR", filepath.Join(home, ".claude")), + envOr("CODEX_HOME", filepath.Join(home, ".codex")), + filepath.Join(home, ".hermes"), + envOr("OPENCLAW_CONFIG_DIR", filepath.Join(home, ".openclaw")), + } + default: + return nil + } +} diff --git a/cli/internal/importer/conversation/registry_test.go b/cli/internal/importer/conversation/registry_test.go new file mode 100644 index 0000000..c31ff2a --- /dev/null +++ b/cli/internal/importer/conversation/registry_test.go @@ -0,0 +1,96 @@ +package conversation + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRegistryListsAllScanners(t *testing.T) { + r := DefaultRegistry() + got := map[PlatformID]bool{} + for _, sc := range r.Scanners() { + got[sc.Platform()] = true + } + for _, p := range []PlatformID{PlatformClaudeCode, PlatformCodex, PlatformHermes, PlatformOpenClaw, PlatformMarkdown, PlatformWorkBuddy} { + if !got[p] { + t.Fatalf("registry missing %s", p) + } + } +} + +func TestDefaultRootsHonorEnv(t *testing.T) { + t.Setenv("CODEX_HOME", "/custom/codex") + roots := DefaultRoots(PlatformCodex) + found := false + for _, r := range roots { + if r == "/custom/codex/sessions" { + found = true + } + } + if !found { + t.Fatalf("CODEX_HOME not honored: %v", roots) + } +} + +func TestDefaultRootsHonorClaudeConfigDir(t *testing.T) { + t.Setenv("CLAUDE_CONFIG_DIR", "/custom/claude") + roots := DefaultRoots(PlatformClaudeCode) + found := false + for _, r := range roots { + if r == "/custom/claude/projects" { + found = true + } + } + if !found { + t.Fatalf("CLAUDE_CONFIG_DIR not honored: %v", roots) + } +} + +// TestOpenClawRootCoversEveryAgent is the regression for the 2026-08-17 +// review item 1.2.1.2. The default root was hardcoded to +// ~/.openclaw/agents/main/sessions, so a user running more than one +// OpenClaw agent had every non-main agent's history silently invisible +// to scan and run. +func TestOpenClawRootCoversEveryAgent(t *testing.T) { + t.Setenv("OPENCLAW_CONFIG_DIR", "/custom/openclaw") + roots := DefaultRoots(PlatformOpenClaw) + if len(roots) != 1 || roots[0] != "/custom/openclaw/agents" { + t.Fatalf("openclaw root must be the agents dir so every agent is walked, got %v", roots) + } +} + +// TestOpenClawScanFindsNonMainAgents proves the root change end to end: +// the scanner already walks recursively, it was only ever pointed at one +// agent's folder. +func TestOpenClawScanFindsNonMainAgents(t *testing.T) { + home := t.TempDir() + for _, agent := range []string{"main", "research"} { + dir := filepath.Join(home, "agents", agent, "sessions") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := `{"type":"model.completed","sessionId":"s-` + agent + `","data":{"messagesSnapshot":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}}` + "\n" + if err := os.WriteFile(filepath.Join(dir, agent+".trajectory.jsonl"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("OPENCLAW_CONFIG_DIR", home) + + items, err := NewOpenClawScanner().Scan(DefaultRoots(PlatformOpenClaw)) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatalf("expected one item per agent, got %d: %v", len(items), items) + } + seen := map[string]bool{} + for _, it := range items { + seen[filepath.Base(it.Path)] = true + } + for _, want := range []string{"main.trajectory.jsonl", "research.trajectory.jsonl"} { + if !seen[want] { + t.Fatalf("missing %s in scan results: %v", want, seen) + } + } +} diff --git a/cli/internal/importer/conversation/service.go b/cli/internal/importer/conversation/service.go new file mode 100644 index 0000000..35ffe91 --- /dev/null +++ b/cli/internal/importer/conversation/service.go @@ -0,0 +1,366 @@ +package conversation + +import ( + "context" + "fmt" + "os" + "time" +) + +// activeSessionWindow is how recent a file's mtime must be for it to be +// treated as the user's currently-open ("active") session. Such files are +// excluded from import because the live plugin already captures them — including +// them here would double-ingest the same conversation. +const activeSessionWindow = 5 * time.Minute + +// StatusExtractionPending is the reported status when a session's data is on +// the server but extraction could not be triggered (mirrors the server's +// v1.AgentMemoryStatusExtractionPending). A --force re-run retries the flush. +const StatusExtractionPending = "extraction_pending" + +// UploaderIface is the upload interface so a fake can substitute in tests. +type UploaderIface interface { + Upload(ctx context.Context, evt string, conv *Conversation) (string, error) + // UploadAsync sends every batch without sync or flush (fire-and-forget + // adds); extraction is triggered later via FlushSession. + UploadAsync(ctx context.Context, evt string, conv *Conversation) (string, error) + // FlushSession sends a flush-only request for an already-uploaded session. + FlushSession(ctx context.Context, evt, conversationID string) (string, error) +} + +// ScanReport is the result of a Scan call. +type ScanReport struct { + Items []Item `json:"items"` + NotFound map[PlatformID]string `json:"notFound,omitempty"` // platform → hint string + DriftWarnings []string `json:"driftWarnings,omitempty"` // dir exists but 0 parseable items + SkippedActive []string `json:"skippedActive,omitempty"` // file paths excluded as active sessions +} + +// ServiceDeps holds external dependencies for Service. +type ServiceDeps struct { + // Registry is the scanner registry. Defaults to DefaultRegistry() if nil. + Registry *Registry + // Roots overrides the default scan roots per platform. If a platform is + // not listed, DefaultRoots(platform) is used. + Roots map[PlatformID][]string + // StatePath is the path to the state JSON file. Only needed for Run. + StatePath string + // StateScope pins ledger entries to one account + environment; see + // StateScope(). Only needed for Run. + StateScope string + // Uploader is used by RunOne to POST conversations. If nil, RunOne returns + // an error. + Uploader UploaderIface + // EvtResolver resolves the per-platform agent token. Defaults to ResolveEvt + // if nil. + EvtResolver func(PlatformID) (string, error) +} + +// RunOpts controls the behaviour of RunOne. +type RunOpts struct { + Consented bool + Force bool + // Async uploads via the fire-and-forget add path (no sync, no flush). + // The caller is responsible for issuing FlushOne per session after the + // whole run's adds are sent — deferring the flush is what keeps it from + // racing async adds that have not landed upstream yet. + Async bool +} + +// RunResult is the per-conversation outcome of RunOne. +type RunResult struct { + Status string + Skipped bool + SkipReason string +} + +// Service orchestrates scan and run operations. +type Service struct { + deps ServiceDeps + state *State // lazily loaded +} + +// NewService creates a new Service with the given deps. +func NewService(deps ServiceDeps) *Service { + if deps.Registry == nil { + deps.Registry = DefaultRegistry() + } + return &Service{deps: deps} +} + +// EnsureStateLoaded loads and caches the state, exposing it to callers that +// want to surface a corruption-recovery notice (State.RecoveredFrom) before +// the run loop begins. RunOne reuses the same cached state. +func (s *Service) EnsureStateLoaded() (*State, error) { + return s.loadState() +} + +// loadState loads (or returns the already-loaded) state from StatePath. +func (s *Service) loadState() (*State, error) { + if s.state != nil { + return s.state, nil + } + st, err := LoadState(s.deps.StatePath, s.deps.StateScope) + if err != nil { + return nil, err + } + s.state = st + return st, nil +} + +// stateKey returns the idempotency key for a conversation, incorporating +// both platform and path so that the same file scanned by two different +// platform scanners does not collide in the state store. +func stateKey(conv *Conversation) string { + return ItemStateKey(conv.Item) +} + +// RunOne uploads a single conversation, respecting consent and idempotency. +func (s *Service) RunOne(ctx context.Context, conv *Conversation, opts RunOpts) (*RunResult, error) { + if !opts.Consented { + return nil, fmt.Errorf("user consent required; run 'evercli import conversations run' interactively") + } + + st, err := s.loadState() + if err != nil { + return nil, fmt.Errorf("load state: %w", err) + } + + key := stateKey(conv) + + if st.ShouldSkip(key) && !opts.Force { + return &RunResult{Skipped: true, SkipReason: "already submitted"}, nil + } + + evtResolver := s.deps.EvtResolver + if evtResolver == nil { + evtResolver = ResolveEvt + } + + attrib := AttributionPlatform(conv.Item) + evt, err := evtResolver(attrib) + if err != nil { + st.MarkFailed(key, err.Error()) + _ = st.Save() + return nil, fmt.Errorf("resolve evt for %s: %w", attrib, err) + } + + if s.deps.Uploader == nil { + return nil, fmt.Errorf("no uploader configured") + } + + upload := s.deps.Uploader.Upload + if opts.Async { + upload = s.deps.Uploader.UploadAsync + } + status, err := upload(ctx, evt, conv) + if err != nil { + st.MarkFailed(key, err.Error()) + _ = st.Save() + return nil, fmt.Errorf("upload %s: %w", conv.Item.Path, err) + } + + // Async adds are only half the session: extraction still needs the + // deferred flush. Marking submitted here would let a run interrupted + // before its flush phase leave the session queued-but-never-flushed and + // idempotently skipped forever — FlushOne marks instead. + if !opts.Async { + st.MarkSubmitted(key, conv.ID) + if err := st.Save(); err != nil { + return nil, fmt.Errorf("save state: %w", err) + } + } + + return &RunResult{Status: status}, nil +} + +// FlushOne triggers extraction for a session whose adds were sent via the +// async path. A "no_extraction" answer usually means the async adds had not +// landed upstream when the flush arrived, so it retries exactly once after +// retryDelay; if the retry still reports no_extraction the session is +// surfaced as extraction_pending — the data is on the server, a later +// --force re-run retries the flush (same recovery as the sync path). +func (s *Service) FlushOne(ctx context.Context, conv *Conversation, retryDelay time.Duration) (*RunResult, error) { + if s.deps.Uploader == nil { + return nil, fmt.Errorf("no uploader configured") + } + evtResolver := s.deps.EvtResolver + if evtResolver == nil { + evtResolver = ResolveEvt + } + evt, err := evtResolver(AttributionPlatform(conv.Item)) + if err != nil { + return nil, fmt.Errorf("resolve evt for %s: %w", AttributionPlatform(conv.Item), err) + } + status, err := s.deps.Uploader.FlushSession(ctx, evt, conv.ID) + if err != nil { + return nil, fmt.Errorf("flush %s: %w", conv.Item.Path, err) + } + if status == "no_extraction" { + select { + case <-time.After(retryDelay): + case <-ctx.Done(): + return nil, ctx.Err() + } + status, err = s.deps.Uploader.FlushSession(ctx, evt, conv.ID) + if err != nil { + return nil, fmt.Errorf("flush retry %s: %w", conv.Item.Path, err) + } + if status == "no_extraction" { + status = StatusExtractionPending + } + } + + // The flush completes an async session, so submitted is recorded here + // (extraction_pending included — the data is on the server and a --force + // re-run retries the flush, matching the sync path's semantics). A flush + // that errored out entirely returns above without marking, so a plain + // re-run re-sends the session (the server lands it on the same session id). + st, err := s.loadState() + if err != nil { + return nil, fmt.Errorf("load state: %w", err) + } + st.MarkSubmitted(stateKey(conv), conv.ID) + if err := st.Save(); err != nil { + return nil, fmt.Errorf("save state: %w", err) + } + return &RunResult{Status: status}, nil +} + +// roots returns the effective scan roots for a platform. +func (s *Service) roots(p PlatformID) []string { + if r, ok := s.deps.Roots[p]; ok { + return r + } + return DefaultRoots(p) +} + +// Scan runs all requested platforms' scanners and returns a ScanReport. +// Missing dirs produce NotFound entries (not an error). Dirs that exist +// but yield 0 parseable items produce DriftWarnings. +func (s *Service) Scan(platforms []PlatformID) (*ScanReport, error) { + rep := &ScanReport{ + NotFound: map[PlatformID]string{}, + } + + for _, p := range platforms { + sc := s.deps.Registry.ScannerFor(p) + if sc == nil { + rep.DriftWarnings = append(rep.DriftWarnings, + fmt.Sprintf("no scanner registered for platform %s", p)) + continue + } + + roots := s.roots(p) + + // Check if all roots are missing + allMissing := true + anyExists := false + for _, root := range roots { + if _, err := os.Stat(root); err == nil { + anyExists = true + allMissing = false + break + } + } + + if allMissing { + hint := fmt.Sprintf("directory not found for %s (checked: %v); "+ + "use --path to specify a custom location or set %s", + p, roots, envHintFor(p)) + rep.NotFound[p] = hint + continue + } + + items, err := sc.Scan(roots) + if err != nil { + return nil, fmt.Errorf("scan %s: %w", p, err) + } + + if anyExists && len(items) == 0 { + rep.DriftWarnings = append(rep.DriftWarnings, + fmt.Sprintf("platform %s: directory exists but no parseable sessions found "+ + "(directory layout may have changed)", p)) + } + + // Populate message/toolcall counts via a best-effort Read pass. + for i := range items { + // A markdown file not under any known agent folder cannot be + // attributed to an evt; mark it unsupported (excluded below). + if items[i].Platform == PlatformMarkdown && items[i].OwnerPlatform == "" { + items[i].Status = "unsupported" + items[i].SkipReason = "markdown file is not under a known agent folder; cannot attribute" + continue + } + if conv, rerr := sc.Read(items[i]); rerr == nil && conv != nil { + items[i].MessageCount = len(conv.Messages) + // Read derives StartedAt from the earliest message timestamp; + // surface it so the preview date reflects the session, not mtime. + if conv.Item.StartedAt != "" { + items[i].StartedAt = conv.Item.StartedAt + } + tc, tr := 0, 0 + for _, m := range conv.Messages { + tc += len(m.ToolCalls) + if m.Role == "tool" { + tr++ + } + } + items[i].ToolCallCount = tc + items[i].ToolResultCount = tr + // The server rejects empty messages ("messages must contain + // 1-500 items"); a 0-message parse (e.g. wrong-platform file) + // must never be offered for upload. + if items[i].MessageCount == 0 && items[i].Status != "unsupported" { + items[i].Status = "unsupported" + items[i].SkipReason = "no parseable messages" + } + } else if rerr != nil { + items[i].Status = "unsupported" + items[i].SkipReason = "parse failed during scan" + } + } + + // Exclude any item whose underlying file is still being written (mtime + // within activeSessionWindow) — the live plugin already captures it. + // Also exclude items marked unsupported above so they are never offered + // for upload nor counted in the consentable set. + now := time.Now() + for _, it := range items { + if it.Status == "unsupported" { + continue + } + if isActiveSession(it.Path, now) { + rep.SkippedActive = append(rep.SkippedActive, it.Path) + continue + } + rep.Items = append(rep.Items, it) + } + } + + return rep, nil +} + +// isActiveSession reports whether the file at path was modified within +// activeSessionWindow of now (i.e. likely still being written). Unstattable +// files are treated as not-active so they still surface in the preview. +func isActiveSession(path string, now time.Time) bool { + fi, err := os.Stat(path) + if err != nil { + return false + } + return now.Sub(fi.ModTime()) < activeSessionWindow +} + +// envHintFor returns an env var name that can override the default root +// for the given platform. +func envHintFor(p PlatformID) string { + switch p { + case PlatformClaudeCode: + return "CLAUDE_CONFIG_DIR" + case PlatformCodex: + return "CODEX_HOME" + default: + return "the corresponding home environment variable" + } +} diff --git a/cli/internal/importer/conversation/service_async_test.go b/cli/internal/importer/conversation/service_async_test.go new file mode 100644 index 0000000..b614a42 --- /dev/null +++ b/cli/internal/importer/conversation/service_async_test.go @@ -0,0 +1,143 @@ +package conversation + +import ( + "context" + "testing" +) + +// fakeAsyncUploader records the order of async adds and flushes so tests can +// assert the two-phase contract (all adds before any flush is the cmd layer's +// job; per-call routing and retry are the service's). +type fakeAsyncUploader struct { + fakeUploader + asyncCalls int + flushCalls int + flushConvIDs []string + flushStatuses []string // consumed per FlushSession call; last repeats + flushErr error +} + +func (f *fakeAsyncUploader) UploadAsync(_ context.Context, evt string, _ *Conversation) (string, error) { + if f.err != nil { + return "", f.err + } + f.asyncCalls++ + f.lastEvt = evt + return "queued", nil +} + +func (f *fakeAsyncUploader) FlushSession(_ context.Context, _ string, conversationID string) (string, error) { + if f.flushErr != nil { + return "", f.flushErr + } + i := f.flushCalls + f.flushCalls++ + f.flushConvIDs = append(f.flushConvIDs, conversationID) + if i >= len(f.flushStatuses) { + i = len(f.flushStatuses) - 1 + } + return f.flushStatuses[i], nil +} + +func TestRunOneAsyncUploadsWithoutFlushAndMarksSubmitted(t *testing.T) { + dir := t.TempDir() + fake := &fakeAsyncUploader{} + svc := NewService(ServiceDeps{ + Registry: DefaultRegistry(), + StatePath: dir + "/state.json", + Uploader: fake, + EvtResolver: func(p PlatformID) (string, error) { return "evt_" + string(p), nil }, + }) + conv := &Conversation{ + ID: "import-claude-code-async", + Item: Item{Platform: PlatformClaudeCode, Path: "/x/async.jsonl"}, + Messages: []AgentMemoryMessage{{Role: "user", Timestamp: 1, Content: "hi"}}, + } + + res, err := svc.RunOne(t.Context(), conv, RunOpts{Consented: true, Async: true}) + if err != nil { + t.Fatal(err) + } + if fake.asyncCalls != 1 || fake.calls != 0 { + t.Fatalf("async mode must use UploadAsync only: async=%d sync=%d", fake.asyncCalls, fake.calls) + } + if res.Status != "queued" { + t.Fatalf("status=%q", res.Status) + } + // The async ACK must NOT mark submitted yet: a run interrupted between + // the add phase and the flush phase would otherwise leave the session + // queued-but-never-flushed AND idempotently skipped on every re-run — + // extraction silently never happens. Submitted is FlushOne's job. + res2, err := svc.RunOne(t.Context(), conv, RunOpts{Consented: true, Async: true}) + if err != nil { + t.Fatal(err) + } + if res2.Skipped { + t.Fatal("un-flushed async session must not be idempotently skipped") + } + + fake.flushStatuses = []string{"extracted"} + if _, err := svc.FlushOne(t.Context(), conv, 0); err != nil { + t.Fatal(err) + } + // Only the flush completes the session: now the re-run must skip. + res3, err := svc.RunOne(t.Context(), conv, RunOpts{Consented: true, Async: true}) + if err != nil { + t.Fatal(err) + } + if !res3.Skipped { + t.Fatal("flushed session must skip on re-run") + } +} + +func TestFlushOneRetriesOnceOnNoExtraction(t *testing.T) { + dir := t.TempDir() + fake := &fakeAsyncUploader{flushStatuses: []string{"no_extraction", "extracted"}} + svc := NewService(ServiceDeps{ + Registry: DefaultRegistry(), + StatePath: dir + "/state.json", + Uploader: fake, + EvtResolver: func(p PlatformID) (string, error) { return "evt_" + string(p), nil }, + }) + conv := &Conversation{ + ID: "import-claude-code-async", + Item: Item{Platform: PlatformClaudeCode, Path: "/x/async.jsonl"}, + } + + res, err := svc.FlushOne(t.Context(), conv, 0) // 0 retry delay in tests + if err != nil { + t.Fatal(err) + } + if fake.flushCalls != 2 { + t.Fatalf("no_extraction must trigger exactly one retry, got %d calls", fake.flushCalls) + } + if res.Status != "extracted" { + t.Fatalf("status=%q", res.Status) + } +} + +func TestFlushOneReportsExtractionPendingWhenRetryStillFails(t *testing.T) { + dir := t.TempDir() + fake := &fakeAsyncUploader{flushStatuses: []string{"no_extraction", "no_extraction"}} + svc := NewService(ServiceDeps{ + Registry: DefaultRegistry(), + StatePath: dir + "/state.json", + Uploader: fake, + EvtResolver: func(p PlatformID) (string, error) { return "evt_" + string(p), nil }, + }) + conv := &Conversation{ + ID: "import-claude-code-async", + Item: Item{Platform: PlatformClaudeCode, Path: "/x/async.jsonl"}, + } + + res, err := svc.FlushOne(t.Context(), conv, 0) + if err != nil { + t.Fatal(err) + } + if fake.flushCalls != 2 { + t.Fatalf("expected exactly 2 flush attempts, got %d", fake.flushCalls) + } + if res.Status != "extraction_pending" { + t.Fatalf("persistent no_extraction must report extraction_pending, got %q", res.Status) + } +} diff --git a/cli/internal/importer/conversation/service_run_test.go b/cli/internal/importer/conversation/service_run_test.go new file mode 100644 index 0000000..13123e6 --- /dev/null +++ b/cli/internal/importer/conversation/service_run_test.go @@ -0,0 +1,73 @@ +package conversation + +import ( + "context" + "fmt" + "testing" +) + +type fakeUploader struct { + calls int + lastEvt string + err error +} + +func (f *fakeUploader) Upload(_ context.Context, evt string, _ *Conversation) (string, error) { + if f.err != nil { + return "", f.err + } + f.calls++ + f.lastEvt = evt + return "queued", nil +} + +// Async-path stubs: sync-path tests must never reach these. +func (f *fakeUploader) UploadAsync(context.Context, string, *Conversation) (string, error) { + return "", fmt.Errorf("unexpected UploadAsync call in sync-path test") +} + +func (f *fakeUploader) FlushSession(context.Context, string, string) (string, error) { + return "", fmt.Errorf("unexpected FlushSession call in sync-path test") +} + +func TestRunSkipsSubmittedAndRequiresConsent(t *testing.T) { + dir := t.TempDir() + fake := &fakeUploader{} + svc := NewService(ServiceDeps{ + Registry: DefaultRegistry(), + StatePath: dir + "/state.json", + Uploader: fake, + EvtResolver: func(p PlatformID) (string, error) { return "evt_" + string(p), nil }, + }) + conv := &Conversation{ + ID: "import-claude-code-x", + Item: Item{Platform: PlatformClaudeCode, Path: "/x/a.jsonl"}, + Messages: []AgentMemoryMessage{{Role: "user", Timestamp: 1, Content: "hi"}}, + } + + // no consent -> refuse, no upload + if _, err := svc.RunOne(t.Context(), conv, RunOpts{Consented: false}); err == nil { + t.Fatal("must refuse without consent") + } + if fake.calls != 0 { + t.Fatal("must not upload without consent") + } + // consented -> upload with target evt, mark submitted + if _, err := svc.RunOne(t.Context(), conv, RunOpts{Consented: true}); err != nil { + t.Fatal(err) + } + if fake.calls != 1 || fake.lastEvt != "evt_claude-code" { + t.Fatalf("calls=%d evt=%q", fake.calls, fake.lastEvt) + } + // second run -> skipped (submitted) + res, err := svc.RunOne(t.Context(), conv, RunOpts{Consented: true}) + if err != nil { + t.Fatal(err) + } + if !res.Skipped { + t.Fatal("submitted path must skip on re-run") + } + if fake.calls != 1 { + t.Fatal("submitted path must not re-upload") + } +} diff --git a/cli/internal/importer/conversation/service_scan_test.go b/cli/internal/importer/conversation/service_scan_test.go new file mode 100644 index 0000000..40cc8f9 --- /dev/null +++ b/cli/internal/importer/conversation/service_scan_test.go @@ -0,0 +1,193 @@ +package conversation + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// backdate sets a file's mtime an hour into the past so the active-session +// filter (mtime < activeSessionWindow) does not exclude test fixtures that +// are written fresh during the test. +func backdate(t *testing.T, path string) { + t.Helper() + old := time.Now().Add(-1 * time.Hour) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } +} + +func TestScanSkipsActiveSession(t *testing.T) { + dir := t.TempDir() + fixture := `{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"hi"}]}}` + + // Fresh file (mtime ~now) — should be treated as the active session. + active := filepath.Join(dir, "active.jsonl") + if err := os.WriteFile(active, []byte(fixture+"\n"), 0o644); err != nil { + t.Fatal(err) + } + // Old file (backdated) — should be included. + old := filepath.Join(dir, "old.jsonl") + if err := os.WriteFile(old, []byte(fixture+"\n"), 0o644); err != nil { + t.Fatal(err) + } + oldTime := time.Now().Add(-1 * time.Hour) + if err := os.Chtimes(old, oldTime, oldTime); err != nil { + t.Fatal(err) + } + + svc := NewService(ServiceDeps{ + Roots: map[PlatformID][]string{PlatformCodex: {dir}}, + Registry: DefaultRegistry(), + }) + rep, err := svc.Scan([]PlatformID{PlatformCodex}) + if err != nil { + t.Fatal(err) + } + + for _, it := range rep.Items { + if it.Path == active { + t.Fatalf("active (fresh-mtime) session must be excluded from Items: %s", active) + } + } + foundOld := false + for _, it := range rep.Items { + if it.Path == old { + foundOld = true + } + } + if !foundOld { + t.Fatal("old session must be included in Items") + } + foundActive := false + for _, p := range rep.SkippedActive { + if p == active { + foundActive = true + } + } + if !foundActive { + t.Fatalf("active session path must appear in SkippedActive, got %v", rep.SkippedActive) + } +} + +func TestScanMissingDirIsNotFatalAndAnnounced(t *testing.T) { + svc := NewService(ServiceDeps{Roots: map[PlatformID][]string{PlatformCodex: {"/no/such/dir"}}, Registry: DefaultRegistry()}) + rep, err := svc.Scan([]PlatformID{PlatformCodex}) + if err != nil { + t.Fatal(err) + } + if len(rep.Items) != 0 { + t.Fatal("missing dir -> 0 items") + } + if rep.NotFound[PlatformCodex] == "" { + t.Fatal("missing dir must produce an explicit not-found notice") + } +} + +func TestScanDirExistsButEmpty(t *testing.T) { + dir := t.TempDir() + svc := NewService(ServiceDeps{ + Roots: map[PlatformID][]string{PlatformCodex: {dir}}, + Registry: DefaultRegistry(), + }) + rep, err := svc.Scan([]PlatformID{PlatformCodex}) + if err != nil { + t.Fatal(err) + } + // dir exists but 0 parseable files -> drift warning + if len(rep.DriftWarnings) == 0 { + t.Fatal("empty dir must produce a drift warning") + } +} + +func TestScanReturnsItemsWithPathAndDate(t *testing.T) { + dir := t.TempDir() + // write a valid codex JSONL fixture + fixture := `{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"hello"}],"created_at":1748736000}}` + f := filepath.Join(dir, "sess_abc.jsonl") + if err := os.WriteFile(f, []byte(fixture+"\n"), 0o644); err != nil { + t.Fatal(err) + } + backdate(t, f) + svc := NewService(ServiceDeps{ + Roots: map[PlatformID][]string{PlatformCodex: {dir}}, + Registry: DefaultRegistry(), + }) + rep, err := svc.Scan([]PlatformID{PlatformCodex}) + if err != nil { + t.Fatal(err) + } + if len(rep.Items) == 0 { + t.Fatal("expected at least 1 item") + } + item := rep.Items[0] + if item.Path == "" { + t.Fatal("item must have path") + } + if item.UpdatedAt == "" { + t.Fatal("item must have UpdatedAt date") + } + if item.MessageCount <= 0 { + t.Fatalf("expected MessageCount > 0, got %d", item.MessageCount) + } +} + +// FIX 4 — a file the scanner parses to 0 messages must be marked unsupported +// and excluded from Items (the server rejects empty messages). +func TestScanExcludesZeroMessageItems(t *testing.T) { + dir := t.TempDir() + // Valid JSONL the codex scanner reads without error but yields 0 messages + // (no message payloads at all). + fixture := `{"type":"response_item","payload":{"type":"reasoning","summary":[]}}` + f := filepath.Join(dir, "empty.jsonl") + if err := os.WriteFile(f, []byte(fixture+"\n"), 0o644); err != nil { + t.Fatal(err) + } + backdate(t, f) + svc := NewService(ServiceDeps{ + Roots: map[PlatformID][]string{PlatformCodex: {dir}}, + Registry: DefaultRegistry(), + }) + rep, err := svc.Scan([]PlatformID{PlatformCodex}) + if err != nil { + t.Fatal(err) + } + for _, it := range rep.Items { + if it.Path == f { + t.Fatalf("0-message item must be excluded from Items, got %+v", it) + } + } +} + +func TestScanPopulatesStartedAtFromEarliestMessage(t *testing.T) { + dir := t.TempDir() + // Two messages: the earlier one (2026-05-01) must drive StartedAt even + // though it appears second in the file. + lines := `{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"later"}]},"timestamp":"2026-05-02T10:00:00Z"} +{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"earlier"}]},"timestamp":"2026-05-01T08:00:00Z"} +` + f := filepath.Join(dir, "sess_started.jsonl") + if err := os.WriteFile(f, []byte(lines), 0o644); err != nil { + t.Fatal(err) + } + backdate(t, f) + svc := NewService(ServiceDeps{ + Roots: map[PlatformID][]string{PlatformCodex: {dir}}, + Registry: DefaultRegistry(), + }) + rep, err := svc.Scan([]PlatformID{PlatformCodex}) + if err != nil { + t.Fatal(err) + } + if len(rep.Items) == 0 { + t.Fatal("expected an item") + } + got := rep.Items[0].StartedAt + if got == "" { + t.Fatal("StartedAt must be populated from earliest message timestamp") + } + if got[:10] != "2026-05-01" { + t.Fatalf("StartedAt should reflect earliest message (2026-05-01), got %q", got) + } +} diff --git a/cli/internal/importer/conversation/since.go b/cli/internal/importer/conversation/since.go new file mode 100644 index 0000000..f5abff0 --- /dev/null +++ b/cli/internal/importer/conversation/since.go @@ -0,0 +1,37 @@ +package conversation + +import ( + "fmt" + "time" +) + +// ValidateSince checks that since is empty (no filter) or a YYYY-MM-DD date. +func ValidateSince(since string) error { + if since == "" { + return nil + } + if _, err := time.Parse("2006-01-02", since); err != nil { + return fmt.Errorf("--since must be YYYY-MM-DD, got %q", since) + } + return nil +} + +// FilterItemsSince keeps items whose date (StartedAt, falling back to +// UpdatedAt) is on or after since (YYYY-MM-DD). An empty since returns items +// unchanged. Items without a usable date are dropped when filtering. +func FilterItemsSince(items []Item, since string) []Item { + if since == "" { + return items + } + filtered := items[:0] + for _, item := range items { + date := item.StartedAt + if date == "" { + date = item.UpdatedAt + } + if len(date) >= 10 && date[:10] >= since { + filtered = append(filtered, item) + } + } + return filtered +} diff --git a/cli/internal/importer/conversation/since_test.go b/cli/internal/importer/conversation/since_test.go new file mode 100644 index 0000000..5cb359a --- /dev/null +++ b/cli/internal/importer/conversation/since_test.go @@ -0,0 +1,52 @@ +package conversation + +import "testing" + +func TestValidateSince(t *testing.T) { + if err := ValidateSince(""); err != nil { + t.Fatalf("empty since must be valid (no filter): %v", err) + } + if err := ValidateSince("2026-06-01"); err != nil { + t.Fatalf("YYYY-MM-DD must be valid: %v", err) + } + if err := ValidateSince("2026/06/01"); err == nil { + t.Fatalf("non YYYY-MM-DD must error") + } + if err := ValidateSince("garbage"); err == nil { + t.Fatalf("garbage must error") + } +} + +func TestFilterItemsSince(t *testing.T) { + items := []Item{ + {Path: "old", StartedAt: "2026-05-01T10:00:00Z"}, + {Path: "boundary", StartedAt: "2026-06-01T00:00:00Z"}, + {Path: "new", StartedAt: "2026-06-15T09:00:00Z"}, + {Path: "fallback", StartedAt: "", UpdatedAt: "2026-06-10T00:00:00Z"}, + {Path: "nodate", StartedAt: "", UpdatedAt: ""}, + } + + // Empty since: no filtering, returns all. + if got := FilterItemsSince(items, ""); len(got) != len(items) { + t.Fatalf("empty since must return all %d, got %d", len(items), len(got)) + } + + got := FilterItemsSince(items, "2026-06-01") + gotPaths := map[string]bool{} + for _, it := range got { + gotPaths[it.Path] = true + } + // boundary is inclusive; old is dropped; fallback uses UpdatedAt; nodate dropped. + want := []string{"boundary", "new", "fallback"} + if len(got) != len(want) { + t.Fatalf("want %v, got %d items: %v", want, len(got), gotPaths) + } + for _, w := range want { + if !gotPaths[w] { + t.Fatalf("expected %q to be kept; got %v", w, gotPaths) + } + } + if gotPaths["old"] || gotPaths["nodate"] { + t.Fatalf("old/nodate must be dropped; got %v", gotPaths) + } +} diff --git a/cli/internal/importer/conversation/state.go b/cli/internal/importer/conversation/state.go new file mode 100644 index 0000000..e6d5262 --- /dev/null +++ b/cli/internal/importer/conversation/state.go @@ -0,0 +1,198 @@ +package conversation + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" +) + +type entry struct { + ConversationID string `json:"conversationId,omitempty"` + Status string `json:"status"` // submitted | failed + SentAt string `json:"sentAt,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +// State is the path-keyed idempotency record. status=submitted means the +// POST was accepted (2xx/queued) — NOT that extraction succeeded (importer +// is add-only and does not poll). Re-runs skip submitted paths; failed are +// retryable. See spec §5.2. +// +// Entries are keyed "|:". The scope pins an entry +// to the account and environment that uploaded it: the ledger is a single +// file under DataDir, so without it, logging into another account (or +// pointing at another api_base) inherited the previous identity's +// "already submitted" marks and silently skipped sessions that identity +// had never uploaded. +type State struct { + path string + scope string + Entries map[string]entry `json:"entries"` + // RecoveredFrom is set when LoadState found an unparseable file, backed it + // up to this path, and started fresh. Not serialized. Callers surface a + // one-time warning so the user knows sessions may re-upload. + RecoveredFrom string `json:"-"` + // AdoptedLegacyEntries counts pre-scope entries this load claimed for + // the current identity. Not serialized; callers report it once. + AdoptedLegacyEntries int `json:"-"` +} + +// scopeLen is the hex width of a StateScope digest. +const scopeLen = 12 + +// StateScope derives the ledger scope for an identity. It is a digest, +// not the raw values: the ledger is plain JSON on disk and neither the +// account id nor the gateway host belongs in it. +// +// An empty accountID (not logged in yet) still yields a stable scope for +// the environment — falling back to "no scope" would restore exactly the +// cross-identity bleed this exists to stop. +func StateScope(apiBase, accountID string) string { + sum := sha256.Sum256([]byte(apiBase + "\n" + accountID)) + return hex.EncodeToString(sum[:])[:scopeLen] +} + +// normalizeScope guarantees every State has a well-formed scope. Keys +// are recognised as scoped by their fixed-width hex head, so a caller +// that supplies no scope must still get one — otherwise its own entries +// would look like legacy keys on the next load and be re-adopted +// forever. +func normalizeScope(scope string) string { + if isScopedKey(scope + "|") { + return scope + } + return StateScope("", scope) +} + +// scoped returns the on-disk key for a caller-supplied item key. +func (s *State) scoped(key string) string { + return s.scope + "|" + key +} + +// isScopedKey reports whether k already carries a scope prefix. Legacy +// keys are ":"; a path may contain "|" but never before +// the platform prefix, so a 12-hex-char head followed by "|" is decisive. +func isScopedKey(k string) bool { + if len(k) < scopeLen+1 || k[scopeLen] != '|' { + return false + } + for i := 0; i < scopeLen; i++ { + c := k[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func LoadState(path, scope string) (*State, error) { + scope = normalizeScope(scope) + s := &State{path: path, scope: scope, Entries: map[string]entry{}} + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, err + } + if err := json.Unmarshal(b, s); err != nil { + // A corrupt/unparseable state file must not brick every import. Back + // the bad file up and start fresh: worst case we re-evaluate sessions + // (possibly re-uploading), which is far better than failing every + // item with a cryptic JSON error. The backup lets the user inspect it. + backup := path + ".corrupt" + if renameErr := os.Rename(path, backup); renameErr != nil { + // If we cannot even move the bad file aside, surface the original + // parse error rather than silently looping on it. + return nil, err + } + return &State{path: path, scope: scope, Entries: map[string]entry{}, RecoveredFrom: backup}, nil + } + if s.Entries == nil { + s.Entries = map[string]entry{} + } + s.path = path + s.scope = scope + s.adoptLegacyEntries() + return s, nil +} + +// adoptLegacyEntries re-keys pre-scope entries onto the current identity. +// +// Adopting is the conservative choice: the alternative — dropping them — +// re-uploads every previously imported session and duplicates it upstream +// (the importer is add-only). A wrongly adopted entry only costs a skip, +// which `--force` (or an interactive run's re-import prompt) undoes. +func (s *State) adoptLegacyEntries() { + for k, v := range s.Entries { + if isScopedKey(k) { + continue + } + delete(s.Entries, k) + s.Entries[s.scoped(k)] = v + s.AdoptedLegacyEntries++ + } +} + +// ItemStateKey returns the idempotency key for an Item, incorporating both +// platform and path so that the same file scanned by two different platform +// scanners does not collide in the state store. This is the same derivation +// stateKey (service.go) uses for a *Conversation — keep them in sync; callers +// outside this package (e.g. cmd/imports annotating a scan preview) must use +// this exported form rather than re-deriving the key themselves. +func ItemStateKey(item Item) string { + return string(item.Platform) + ":" + item.Path +} + +func (s *State) ShouldSkip(key string) bool { + return s.Entries[s.scoped(key)].Status == "submitted" +} + +func (s *State) MarkSubmitted(key, convID string) { + s.Entries[s.scoped(key)] = entry{ConversationID: convID, Status: "submitted", SentAt: nowISO()} +} + +func (s *State) MarkFailed(key, errMsg string) { + s.Entries[s.scoped(key)] = entry{Status: "failed", LastError: errMsg} +} + +func (s *State) Save() error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + // Write to a UNIQUE temp file (not a fixed ".tmp"): two concurrent + // `import run` processes would otherwise open+truncate the same temp inode + // and interleave their writes, leaving a corrupt double-object file. A + // per-writer temp + atomic rename makes each save independent (last wins). + f, err := os.CreateTemp(dir, filepath.Base(s.path)+".tmp-*") + if err != nil { + return err + } + tmp := f.Name() + if _, err := f.Write(b); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Chmod(0o644); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, s.path); err != nil { + os.Remove(tmp) + return err + } + return nil +} diff --git a/cli/internal/importer/conversation/state_concurrency_test.go b/cli/internal/importer/conversation/state_concurrency_test.go new file mode 100644 index 0000000..36de60a --- /dev/null +++ b/cli/internal/importer/conversation/state_concurrency_test.go @@ -0,0 +1,36 @@ +package conversation + +import ( + "fmt" + "strings" + "sync" + "testing" +) + +// Reproduction: many concurrent Save() to the same path share a fixed +// ".tmp" temp file. Two writers open+truncate the same inode and write +// different-length content from offset 0; the longer writer's tail can survive +// past the shorter writer's end → a corrupt, double-object file. +func TestStateSaveConcurrentNoCorruption(t *testing.T) { + dir := t.TempDir() + path := dir + "/state.json" + const writers = 40 + var wg sync.WaitGroup + for i := 0; i < writers; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + s := &State{path: path, Entries: map[string]entry{}} + // vary content length per writer so overwrite-without-truncate shows + for j := 0; j <= n; j++ { + s.Entries[fmt.Sprintf("k-%03d-%s", j, strings.Repeat("x", n))] = + entry{Status: "submitted", ConversationID: strings.Repeat("y", n)} + } + _ = s.Save() + }(i) + } + wg.Wait() + if _, err := LoadState(path, ""); err != nil { + t.Fatalf("state file corrupted by concurrent Save: %v", err) + } +} diff --git a/cli/internal/importer/conversation/state_test.go b/cli/internal/importer/conversation/state_test.go new file mode 100644 index 0000000..dc3e908 --- /dev/null +++ b/cli/internal/importer/conversation/state_test.go @@ -0,0 +1,187 @@ +package conversation + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A corrupt/unparseable state file must not brick every import. LoadState +// backs the bad file up to .corrupt and returns a fresh empty state +// (recording the backup path), rather than returning a hard error that the +// run loop would surface on every single session. +func TestLoadStateRecoversFromCorrupt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + // Mimic the real corruption: two concatenated JSON objects. + corrupt := `{"entries":{"a":{"status":"failed"}}}{"entries":{"b":{}}}` + if err := os.WriteFile(path, []byte(corrupt), 0o644); err != nil { + t.Fatal(err) + } + + s, err := LoadState(path, "") + if err != nil { + t.Fatalf("LoadState must recover from a corrupt file, got error: %v", err) + } + if len(s.Entries) != 0 { + t.Fatalf("recovered state must start empty, got %d entries", len(s.Entries)) + } + if s.RecoveredFrom == "" { + t.Fatal("recovered state must record the backup path in RecoveredFrom") + } + if _, statErr := os.Stat(s.RecoveredFrom); statErr != nil { + t.Fatalf("corrupt file must be preserved at backup path %s: %v", s.RecoveredFrom, statErr) + } + got, _ := os.ReadFile(s.RecoveredFrom) + if string(got) != corrupt { + t.Fatalf("backup must contain the original corrupt bytes") + } + // A subsequent save+reload round-trips cleanly. + s.MarkSubmitted("/x/a.jsonl", "cid") + if err := s.Save(); err != nil { + t.Fatal(err) + } + if s2, err := LoadState(path, ""); err != nil || !s2.ShouldSkip("/x/a.jsonl") { + t.Fatalf("post-recovery save must round-trip: err=%v", err) + } +} + +func TestStateSubmittedSkips(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + s, err := LoadState(path, "") + if err != nil { + t.Fatal(err) + } + if s.ShouldSkip("/x/a.jsonl") { + t.Fatal("fresh path must not skip") + } + s.MarkSubmitted("/x/a.jsonl", "import-claude-code-aaa") + if err := s.Save(); err != nil { + t.Fatal(err) + } + // reload from disk + s2, _ := LoadState(path, "") + if !s2.ShouldSkip("/x/a.jsonl") { + t.Fatal("submitted path must skip after reload") + } + // failed path is retryable (not skipped) + s2.MarkFailed("/x/b.jsonl", "net error") + if s2.ShouldSkip("/x/b.jsonl") { + t.Fatal("failed path must be retryable") + } +} + +// TestStateScopeSeparatesAccountsAndEnvironments is the regression for +// the 2026-08-17 review item 1.2.3. The ledger key was platform:path and +// the file is a single global one under DataDir, so switching account or +// api_base made the new identity inherit the old one's "already +// submitted" marks: every session it had never uploaded was silently +// skipped, and the fresh account's memory started out incomplete. +func TestStateScopeSeparatesAccountsAndEnvironments(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + item := Item{Platform: PlatformCodex, Path: "/sessions/a.jsonl"} + key := ItemStateKey(item) + + first, err := LoadState(path, StateScope("https://api.everme.evermind.ai", "acc_one")) + if err != nil { + t.Fatal(err) + } + first.MarkSubmitted(key, "conv-1") + if err := first.Save(); err != nil { + t.Fatal(err) + } + + sameAccount, err := LoadState(path, StateScope("https://api.everme.evermind.ai", "acc_one")) + if err != nil { + t.Fatal(err) + } + if !sameAccount.ShouldSkip(key) { + t.Fatal("the account that uploaded it must still skip it") + } + + otherAccount, err := LoadState(path, StateScope("https://api.everme.evermind.ai", "acc_two")) + if err != nil { + t.Fatal(err) + } + if otherAccount.ShouldSkip(key) { + t.Fatal("a different account has never uploaded this session") + } + + otherEnv, err := LoadState(path, StateScope("https://api.dev.everme.evermind.ai", "acc_one")) + if err != nil { + t.Fatal(err) + } + if otherEnv.ShouldSkip(key) { + t.Fatal("the same account on another environment has never uploaded this session") + } +} + +// TestStateScopeDoesNotLeakIdentity: the ledger sits in a plain JSON file, +// so the scope must be a digest rather than the account id or api base. +func TestStateScopeDoesNotLeakIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + st, err := LoadState(path, StateScope("https://api.everme.evermind.ai", "acc_secret")) + if err != nil { + t.Fatal(err) + } + st.MarkSubmitted(ItemStateKey(Item{Platform: PlatformCodex, Path: "/a.jsonl"}), "cid") + if err := st.Save(); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"acc_secret", "everme.evermind.ai"} { + if strings.Contains(string(raw), secret) { + t.Fatalf("state file must not carry %q verbatim: %s", secret, raw) + } + } +} + +// TestLoadStateAdoptsLegacyEntries: ledgers written before scoping have +// unscoped keys. Adopt them for whoever is logged in now and say so +// once. Dropping them instead would re-upload every past session and +// duplicate it upstream, which is worse than a skip the user can undo +// with --reimport. +func TestLoadStateAdoptsLegacyEntries(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + legacy := `{"entries":{"codex:/sessions/a.jsonl":{"status":"submitted","conversationId":"cid"}}}` + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + + scope := StateScope("https://api.everme.evermind.ai", "acc_one") + st, err := LoadState(path, scope) + if err != nil { + t.Fatal(err) + } + if st.AdoptedLegacyEntries != 1 { + t.Fatalf("expected 1 adopted legacy entry, got %d", st.AdoptedLegacyEntries) + } + key := ItemStateKey(Item{Platform: PlatformCodex, Path: "/sessions/a.jsonl"}) + if !st.ShouldSkip(key) { + t.Fatal("an adopted legacy entry must still count as submitted") + } + if err := st.Save(); err != nil { + t.Fatal(err) + } + // Re-loading the migrated file adopts nothing more, and another + // identity is unaffected by the adoption. + again, err := LoadState(path, scope) + if err != nil { + t.Fatal(err) + } + if again.AdoptedLegacyEntries != 0 { + t.Fatalf("migration must run once, got %d", again.AdoptedLegacyEntries) + } + other, err := LoadState(path, StateScope("https://api.everme.evermind.ai", "acc_two")) + if err != nil { + t.Fatal(err) + } + if other.ShouldSkip(key) { + t.Fatal("adoption must not hand the entry to every identity") + } +} diff --git a/cli/internal/importer/conversation/testdata/claude_code_sample.jsonl b/cli/internal/importer/conversation/testdata/claude_code_sample.jsonl new file mode 100644 index 0000000..6df6246 --- /dev/null +++ b/cli/internal/importer/conversation/testdata/claude_code_sample.jsonl @@ -0,0 +1,6 @@ +{"parentUuid":null,"isSidechain":false,"type":"say","uuid":"uuid-001","timestamp":1749000001000,"sessionId":"sess-cc-001","version":"1.0.0","message":{"role":"user","content":[{"type":"text","text":"hello, run ls for me"}]}} +{"parentUuid":"uuid-001","isSidechain":false,"type":"say","uuid":"uuid-002","timestamp":1749000002000,"sessionId":"sess-cc-001","version":"1.0.0","message":{"role":"assistant","content":[{"type":"text","text":"Sure, let me run ls."},{"type":"tool_use","id":"toolu_abc001","name":"Bash","input":{"command":"ls"}}]}} +{"parentUuid":"uuid-002","isSidechain":false,"type":"say","uuid":"uuid-003","timestamp":1749000003000,"sessionId":"sess-cc-001","version":"1.0.0","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_abc001","content":"file1.txt\nfile2.go","is_error":false}]}} +{"parentUuid":"uuid-003","isSidechain":false,"type":"say","uuid":"uuid-004","timestamp":1749000004000,"sessionId":"sess-cc-001","version":"1.0.0","message":{"role":"assistant","content":[{"type":"text","text":"Here are the files: file1.txt and file2.go"},{"type":"tool_use","id":"toolu_abc002","name":"Read","input":{"file_path":"file1.txt"}}]}} +{"parentUuid":"uuid-004","isSidechain":false,"type":"say","uuid":"uuid-005","timestamp":1749000005000,"sessionId":"sess-cc-001","version":"1.0.0","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_abc002","content":"hello world","is_error":false},{"type":"text","text":"ok great"}]}} +{"parentUuid":"uuid-005","isSidechain":false,"type":"say","uuid":"uuid-006","timestamp":1749000006000,"sessionId":"sess-cc-001","version":"1.0.0","message":{"role":"assistant","content":[{"type":"text","text":"Done! The file contains: hello world"}]}} diff --git a/cli/internal/importer/conversation/testdata/codex_sample.jsonl b/cli/internal/importer/conversation/testdata/codex_sample.jsonl new file mode 100644 index 0000000..3ae5c40 --- /dev/null +++ b/cli/internal/importer/conversation/testdata/codex_sample.jsonl @@ -0,0 +1,10 @@ +{"timestamp":1749001000000,"type":"session_meta","payload":{}} +{"timestamp":1749001001000,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"run ls and show me the output"}]}} +{"timestamp":1749001002000,"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_abc001","arguments":"{\"cmd\":\"ls\"}"}} +{"timestamp":1749001003000,"type":"response_item","payload":{"type":"function_call_output","call_id":"call_abc001","output":"file1.txt\nfile2.go\n"}} +{"timestamp":1749001004000,"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"The directory contains: file1.txt and file2.go"}]}} +{"timestamp":1749001005000,"type":"event_msg","payload":{"type":"agent_reasoning","text":"thinking..."}} +{"timestamp":1749001006000,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"now read file1.txt"}]}} +{"timestamp":1749001007000,"type":"response_item","payload":{"type":"function_call","name":"read_file","call_id":"call_abc002","arguments":"{\"path\":\"file1.txt\"}"}} +{"timestamp":1749001008000,"type":"response_item","payload":{"type":"function_call_output","call_id":"call_abc002","output":"hello world"}} +{"timestamp":1749001009000,"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"The file contains: hello world"}]}} diff --git a/cli/internal/importer/conversation/testdata/hermes_sample.json b/cli/internal/importer/conversation/testdata/hermes_sample.json new file mode 100644 index 0000000..78053a2 --- /dev/null +++ b/cli/internal/importer/conversation/testdata/hermes_sample.json @@ -0,0 +1,66 @@ +{ + "session_id": "hermes-sess-001", + "model": "claude-opus-4-8", + "session_start": "2026-06-01T10:00:00Z", + "last_updated": "2026-06-01T10:05:00Z", + "system_prompt": "You are a helpful assistant.", + "messages": [ + { + "role": "user", + "content": "hello, can you run ls for me?", + "timestamp": 1748775600000 + }, + { + "role": "assistant", + "content": "Sure, let me run ls.", + "tool_calls": [ + { + "id": "toolu_h001", + "type": "function", + "function": { + "name": "bash", + "arguments": "{\"cmd\":\"ls\"}" + } + } + ], + "timestamp": 1748775601000 + }, + { + "role": "tool", + "tool_call_id": "toolu_h001", + "content": "file1.txt\nfile2.go", + "timestamp": 1748775602000 + }, + { + "role": "user", + "content": "great, now read file1.txt", + "timestamp": 1748775603000 + }, + { + "role": "assistant", + "content": "Reading the file now.", + "tool_calls": [ + { + "id": "toolu_h002", + "type": "function", + "function": { + "name": "read_file", + "arguments": "{\"path\":\"file1.txt\"}" + } + } + ], + "timestamp": 1748775604000 + }, + { + "role": "tool", + "tool_call_id": "toolu_h002", + "content": "hello world", + "timestamp": 1748775605000 + }, + { + "role": "assistant", + "content": "The file contains: hello world", + "timestamp": 1748775606000 + } + ] +} diff --git a/cli/internal/importer/conversation/testdata/kimicode_sample.jsonl b/cli/internal/importer/conversation/testdata/kimicode_sample.jsonl new file mode 100644 index 0000000..62c8fe4 --- /dev/null +++ b/cli/internal/importer/conversation/testdata/kimicode_sample.jsonl @@ -0,0 +1,17 @@ +{"type":"metadata","protocol_version":"1.4","created_at":1782718958000,"time":1782718958000} +{"type":"turn.prompt","input":[{"type":"text","text":"list the files"}],"origin":{"kind":"user"},"time":1782718958820} +{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"list the files"}],"toolCalls":[],"origin":{"kind":"user"}},"time":1782718958820} +{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"auto mode"}],"toolCalls":[],"origin":{"kind":"injection","variant":"permission_mode"}},"time":1782718958821} +{"type":"context.append_loop_event","event":{"type":"step.begin","uuid":"u1","turnId":"0","step":1},"time":1782718959000} +{"type":"context.append_loop_event","event":{"type":"content.part","uuid":"p1","turnId":"0","step":1,"part":{"type":"think","think":"deciding to list"}},"time":1782718959100} +{"type":"context.append_loop_event","event":{"type":"content.part","uuid":"p2","turnId":"0","step":1,"part":{"type":"text","text":"Here are the files:"}},"time":1782718959200} +{"type":"context.append_loop_event","event":{"type":"step.end","uuid":"u1","turnId":"0","step":1,"finishReason":"end_turn"},"time":1782718959300} +{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"thanks"}],"toolCalls":[],"origin":{"kind":"user"}},"time":1782718960000} +{"type":"turn.prompt","input":[{"type":"text","text":"fetch the homepage"}],"origin":{"kind":"user"},"time":1782718961000} +{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"fetch the homepage"}],"toolCalls":[],"origin":{"kind":"user"}},"time":1782718961000} +{"type":"context.append_loop_event","event":{"type":"step.begin","uuid":"u2","turnId":"1","step":1},"time":1782718961100} +{"type":"context.append_loop_event","event":{"type":"content.part","uuid":"p3","turnId":"1","step":1,"part":{"type":"text","text":"Let me fetch it."}},"time":1782718961200} +{"type":"context.append_loop_event","event":{"type":"tool.call","uuid":"call_abc","turnId":"1","step":1,"stepUuid":"s2","toolCallId":"call_abc","name":"FetchURL","args":{"url":"https://example.com/"},"description":"Fetching"},"time":1782718961300} +{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"Skill loaded inline."}],"toolCalls":[],"origin":{"kind":"skill_activation"}},"time":1782718961350} +{"type":"context.append_loop_event","event":{"type":"tool.result","parentUuid":"call_abc","toolCallId":"call_abc","result":{"output":"the page body"}},"time":1782718961400} +{"type":"context.append_loop_event","event":{"type":"step.end","uuid":"u2","turnId":"1","step":1,"finishReason":"end_turn"},"time":1782718961500} diff --git a/cli/internal/importer/conversation/testdata/openclaw_sample.trajectory.jsonl b/cli/internal/importer/conversation/testdata/openclaw_sample.trajectory.jsonl new file mode 100644 index 0000000..0eb64a2 --- /dev/null +++ b/cli/internal/importer/conversation/testdata/openclaw_sample.trajectory.jsonl @@ -0,0 +1,3 @@ +{"traceSchema":"openclaw-trajectory","schemaVersion":1,"traceId":"sess-oc-001","source":"runtime","type":"session.started","ts":"2026-06-01T10:00:00.000Z","seq":1,"sessionId":"sess-oc-001","sessionKey":"agent:main:run:sess-oc-001","runId":"sess-oc-001"} +{"traceSchema":"openclaw-trajectory","schemaVersion":1,"traceId":"sess-oc-001","source":"runtime","type":"model.completed","ts":"2026-06-01T10:00:05.000Z","seq":2,"sessionId":"sess-oc-001","sessionKey":"agent:main:run:sess-oc-001","runId":"sess-oc-001","data":{"assistantTexts":["Let me check the files for you."],"messagesSnapshot":[{"role":"user","content":[{"type":"text","text":"hello, list the files for me"}],"timestamp":1748775600000},{"role":"assistant","content":[{"type":"text","text":"Let me check the files for you."},{"type":"toolCall","id":"toolu_oc001","name":"read","arguments":{"path":"/workspace/state.json"}}],"timestamp":1748775601000},{"role":"toolResult","toolCallId":"toolu_oc001","toolName":"read","content":[{"type":"text","text":"file1.txt\nfile2.go"}],"isError":false,"timestamp":1748775602000},{"role":"assistant","content":[{"type":"text","text":"The workspace contains file1.txt and file2.go. Let me read the first file."},{"type":"toolCall","id":"toolu_oc002","name":"read","arguments":{"path":"/workspace/file1.txt"}}],"timestamp":1748775603000},{"role":"toolResult","toolCallId":"toolu_oc002","toolName":"read","content":[{"type":"text","text":"hello world"}],"isError":false,"timestamp":1748775604000},{"role":"assistant","content":[{"type":"text","text":"The file contains: hello world"}],"timestamp":1748775605000}]}} +{"traceSchema":"openclaw-trajectory","schemaVersion":1,"traceId":"sess-oc-001","source":"runtime","type":"session.ended","ts":"2026-06-01T10:01:00.000Z","seq":3,"sessionId":"sess-oc-001","sessionKey":"agent:main:run:sess-oc-001","runId":"sess-oc-001"} diff --git a/cli/internal/importer/conversation/testdata/raven_sample.jsonl b/cli/internal/importer/conversation/testdata/raven_sample.jsonl new file mode 100644 index 0000000..016716d --- /dev/null +++ b/cli/internal/importer/conversation/testdata/raven_sample.jsonl @@ -0,0 +1,11 @@ +{"_type":"metadata","key":"cli:20260703_101500_ab12cd","created_at":"2026-07-03T10:15:00.123456","updated_at":"2026-07-03T10:15:00.123456","metadata":{"source":null,"channel":"cli","chat_id":"20260703_101500_ab12cd","title":null,"parent_session_id":null},"last_consolidated":0,"pending_clarification":null} +{"role":"system","content":"You are Raven, a helpful agent.","timestamp":"2026-07-03T10:15:00.200000"} +{"role":"user","content":"list the files in /tmp/demo","timestamp":"2026-07-03T10:15:01.000000"} +{"role":"assistant","content":"Let me check.","tool_calls":[{"id":"call_001","type":"function","function":{"name":"run_shell","arguments":"{\"cmd\":\"ls /tmp/demo\"}"}}],"timestamp":"2026-07-03T10:15:02.000000"} +{"role":"tool","tool_call_id":"call_001","content":"a.txt\nb.txt","timestamp":"2026-07-03T10:15:03.000000"} +{"role":"assistant","content":"There are two files: a.txt and b.txt.","timestamp":"2026-07-03T10:15:04.000000"} +{"_type":"metadata","key":"cli:20260703_101500_ab12cd","created_at":"2026-07-03T10:15:00.123456","updated_at":"2026-07-03T10:16:00.654321","metadata":{"source":null,"channel":"cli","chat_id":"20260703_101500_ab12cd","title":"demo","parent_session_id":null},"last_consolidated":0,"pending_clarification":null} +{"role":"user","content":[{"type":"text","text":"thanks,"},{"type":"text","text":"summarize them"},{"type":"image","path":"/tmp/x.png"}],"timestamp":"2026-07-03T10:16:01.000000"} +{"role":"assistant","content":[{"type":"text","text":"Both files are empty placeholders."}],"timestamp":1751501762} +{"role":"tool","content":"orphan result without id","timestamp":"2026-07-03T10:16:03.000000"} +not json at all diff --git a/cli/internal/importer/conversation/testdata/sample.md b/cli/internal/importer/conversation/testdata/sample.md new file mode 100644 index 0000000..4cb596a --- /dev/null +++ b/cli/internal/importer/conversation/testdata/sample.md @@ -0,0 +1,11 @@ +# My Notes + +This is a test markdown document for the importer. + +## Section 1 + +Here is some content about a project. + +## Section 2 + +More content here with details about implementation. diff --git a/cli/internal/importer/conversation/testdata/workbuddy_sample.jsonl b/cli/internal/importer/conversation/testdata/workbuddy_sample.jsonl new file mode 100644 index 0000000..25fa8e9 --- /dev/null +++ b/cli/internal/importer/conversation/testdata/workbuddy_sample.jsonl @@ -0,0 +1,12 @@ +{"id":"m0","timestamp":1755100000000,"type":"message","role":"user","status":"completed","content":[{"type":"input_text","text":"\n\nOS Version: darwin\n\n\nSOUL.md / IDENTITY.md boilerplate the app injects into every session.\n\n\n\n2026-08-14T10:30:00Z\n\n\n\nunrelated skill/mcp usage boilerplate\n\n\nwhat's for dinner tonight?\n\n"}],"sessionId":"wb-test-session-1","cwd":"/Users/admin/WorkBuddy/2026-08-14-10-30-18"} +{"id":"m1","parentId":"m0","timestamp":1755100001000,"type":"reasoning","providerData":{"model":"deepseek-v4-flash"},"content":[],"rawContent":[{"type":"reasoning_text","text":"internal chain of thought"}],"sessionId":"wb-test-session-1"} +{"id":"m2","parentId":"m1","timestamp":1755100002000,"type":"message","role":"user","status":"completed","content":[{"type":"input_text","text":"hi, what's 2+2?"}],"sessionId":"wb-test-session-1"} +{"id":"m3","parentId":"m2","timestamp":1755100003000,"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"2+2 is 4."}],"sessionId":"wb-test-session-1"} +{"id":"m4","parentId":"m3","timestamp":1755100004000,"type":"function_call","name":"Bash","callId":"call_1","arguments":"{\"cmd\":\"ls\"}","sessionId":"wb-test-session-1"} +{"id":"m5","parentId":"m4","timestamp":1755100005000,"type":"function_call_result","name":"Bash","callId":"call_1","status":"completed","output":{"type":"text","text":"file1.txt\nfile2.go"},"sessionId":"wb-test-session-1"} +{"id":"m6","timestamp":1755100006000,"type":"file-history-snapshot","isSnapshotUpdate":false,"snapshot":{"messageId":"m5","trackedFileBackups":{}}} +{"id":"m7","parentId":"m5","timestamp":1755100007000,"type":"message","role":"user","status":"completed","content":[{"type":"input_text","text":"\nunrelated boilerplate the host injects, no user_query tag here\nwhat is the weather today?"}],"sessionId":"wb-test-session-1"} +{"id":"m8","parentId":"m7","timestamp":1755100008000,"type":"message","role":"user","status":"completed","content":[{"type":"input_text","text":"old stuff from before compactionomittedcontinue the compaction test please"}],"logicalParentId":"m7","sessionId":"wb-test-session-1"} +{"id":"m9","parentId":"m8","timestamp":1755100009000,"type":"message","role":"user","status":"completed","content":[{"type":"input_text","text":"\n\n\n2026-08-15T09:00:00Z\n\n\n\nunrelated reminder text\n\n\nwhat did I do yesterday?\n\n"}],"sessionId":"wb-test-session-1"} +{"id":"m10","parentId":"m9","timestamp":1755100010000,"type":"message","role":"assistant","status":"completed","providerData":{"agent":"compact","isCompactInternal":true},"content":[{"type":"output_text","text":"internal compaction recap, not shown to the user"}],"sessionId":"wb-test-session-1"} +{"timestamp":1755100011000,"type":"ai-title","aiTitle":"dinner and yesterday's work","sessionId":"wb-test-session-1"} diff --git a/cli/internal/importer/conversation/truncate_test.go b/cli/internal/importer/conversation/truncate_test.go new file mode 100644 index 0000000..94e7dc4 --- /dev/null +++ b/cli/internal/importer/conversation/truncate_test.go @@ -0,0 +1,78 @@ +package conversation + +import ( + "os" + "strings" + "testing" + "unicode/utf8" +) + +// The server rejects any message whose content exceeds 8000 runes +// (utf8.RuneCountInString(s) > 8000 → HTTP 400). Truncation must therefore +// produce output whose TOTAL rune count (content slice + marker) is ≤ the +// cap — reserving room for the marker, not appending it on top of a full +// cap-sized slice (which overshoots to cap+markerLen and gets rejected). +func TestTruncateRunesCCStaysWithinCapIncludingMarker(t *testing.T) { + const cap = 8000 + in := strings.Repeat("世", 10000) // 10k multibyte runes, well over cap + out := truncateRunesCC(in, cap) + if n := utf8.RuneCountInString(out); n > cap { + t.Fatalf("truncated output must be <= %d runes (server cap), got %d", cap, n) + } + if !strings.Contains(out, "evercli import") { + t.Fatal("truncated output must keep the truncation marker") + } +} + +// Truncation keeps HEAD and TAIL (middle-out, head_ratio 0.7) — mirroring the +// server extractor's _truncate_text, so the start (command/intent) AND the end +// (final result/conclusion, which the case extractor mines) both survive into +// the <=cap payload instead of head-only clipping that drops the tail. +func TestTruncateRunesCCKeepsHeadAndTail(t *testing.T) { + const cap = 8000 + const headMark = "HEAD_SENTINEL_BEGIN" + const tailMark = "TAIL_SENTINEL_END" + in := headMark + strings.Repeat("x", 25000) + tailMark + + out := truncateRunesCC(in, cap) + + if n := utf8.RuneCountInString(out); n > cap { + t.Fatalf("output must be <= %d runes, got %d", cap, n) + } + if !strings.Contains(out, headMark) { + t.Errorf("head must be preserved") + } + if !strings.Contains(out, tailMark) { + t.Errorf("tail must be preserved (head+tail truncation, not head-only)") + } + if !strings.Contains(out, "trimmed") { + t.Errorf("expected a middle-trim marker") + } + // head_ratio 0.7 → head segment materially larger than tail segment. + mid := strings.Index(out, "trimmed") + if mid <= 0 { + t.Fatal("marker not found") + } + headLen := utf8.RuneCountInString(out[:mid]) + tailLen := utf8.RuneCountInString(out[mid:]) + if headLen <= tailLen { + t.Errorf("head_ratio 0.7 should make head longer than tail, got head=%d tail=%d", headLen, tailLen) + } +} + +// Markdown uses its own inline truncation; it has the same cap obligation. +func TestMarkdownReadStaysWithinCap(t *testing.T) { + dir := t.TempDir() + path := dir + "/big.md" + if err := os.WriteFile(path, []byte(strings.Repeat("世", 10000)), 0o644); err != nil { + t.Fatal(err) + } + conv, err := NewMarkdownScanner().Read(Item{Platform: PlatformMarkdown, Path: path}) + if err != nil { + t.Fatal(err) + } + s, _ := conv.Messages[0].Content.(string) + if n := utf8.RuneCountInString(s); n > markdownChunkBudget { + t.Fatalf("first markdown message must be <= %d runes, got %d", markdownChunkBudget, n) + } +} diff --git a/cli/internal/importer/conversation/types.go b/cli/internal/importer/conversation/types.go new file mode 100644 index 0000000..0b452f7 --- /dev/null +++ b/cli/internal/importer/conversation/types.go @@ -0,0 +1,71 @@ +// Package conversation imports local agent sessions and md into EverMe +// agent-memory. +package conversation + +type PlatformID string + +const ( + PlatformClaudeCode PlatformID = "claude-code" + PlatformCodex PlatformID = "codex" + PlatformHermes PlatformID = "hermes" + PlatformOpenClaw PlatformID = "openclaw" + PlatformMarkdown PlatformID = "markdown" + PlatformKimicode PlatformID = "kimicode" + PlatformRaven PlatformID = "raven" + PlatformWorkBuddy PlatformID = "workbuddy" +) + +// AgentMemoryToolCall mirrors the BFF /mem/agent-memory camelCase DTO. +type AgentMemoryToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// AgentMemoryMessage mirrors the BFF /mem/agent-memory message shape. +type AgentMemoryMessage struct { + Role string `json:"role"` + Timestamp int64 `json:"timestamp"` + Content any `json:"content,omitempty"` + ToolCalls []AgentMemoryToolCall `json:"toolCalls,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` +} + +// Item is one discovered session/doc, surfaced in scan preview. +type Item struct { + Platform PlatformID `json:"platform"` + Path string `json:"path"` + OriginID string `json:"originId,omitempty"` + StartedAt string `json:"startedAt,omitempty"` // ISO date for preview + UpdatedAt string `json:"updatedAt,omitempty"` + MessageCount int `json:"messageCount"` + ToolCallCount int `json:"toolCallCount"` + ToolResultCount int `json:"toolResultCount"` + SizeBytes int64 `json:"sizeBytes"` + Status string `json:"status"` // ready | unsupported | skipped | submitted (annotated by scan/run from local idempotency state) + SkipReason string `json:"skipReason,omitempty"` + // OwnerPlatform: for markdown items, the agent whose folder owns this + // file; its evt is used for upload. Empty when the file is not under any + // known agent folder. + OwnerPlatform PlatformID `json:"ownerPlatform,omitempty"` +} + +// Conversation is a parsed session ready to upload. +type Conversation struct { + Item Item `json:"item"` + ID string `json:"conversationId"` + Messages []AgentMemoryMessage `json:"messages"` + Warnings []string `json:"warnings,omitempty"` +} + +// Scanner discovers and parses one platform's sessions. +type Scanner interface { + Platform() PlatformID + // Scan returns discovered items (path + date + counts), never errors on + // a missing dir (returns nil items + a not-found note via Item.Status). + Scan(roots []string) ([]Item, error) + // Read parses one item into a Conversation. Tolerant: unknown lines -> + // warnings, not fatal. + Read(item Item) (*Conversation, error) +} diff --git a/cli/internal/importer/conversation/types_test.go b/cli/internal/importer/conversation/types_test.go new file mode 100644 index 0000000..c190abb --- /dev/null +++ b/cli/internal/importer/conversation/types_test.go @@ -0,0 +1,11 @@ +package conversation + +import "testing" + +func TestPlatformIDKnown(t *testing.T) { + for _, p := range []PlatformID{PlatformClaudeCode, PlatformCodex, PlatformHermes, PlatformOpenClaw, PlatformMarkdown} { + if p == "" { + t.Fatalf("empty platform id") + } + } +} diff --git a/cli/internal/importer/conversation/uploader.go b/cli/internal/importer/conversation/uploader.go new file mode 100644 index 0000000..20ddaf5 --- /dev/null +++ b/cli/internal/importer/conversation/uploader.go @@ -0,0 +1,146 @@ +package conversation + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// Uploader writes a Conversation to the BFF agent-memory endpoint using a +// caller-supplied target-platform evt. It is local (does NOT use the shared +// client, which hardcodes evercli's evt). All batches are sent with sync:true +// so leading batches are applied before the final one is processed; only the +// last batch of a session sets flush:true, which requests extraction over the +// whole session once every prior batch has landed. +type Uploader struct { + baseURL string + hc *http.Client + // maxBatchBytes bounds a single POST; a session larger than this is split + // into multiple add calls under the same conversationId (EverOS rejects + // oversized agent payloads). 0 falls back to maxAgentBatchBytes. + maxBatchBytes int +} + +func NewUploader(baseURL string, hc *http.Client) *Uploader { + if hc == nil { + hc = http.DefaultClient + } + return &Uploader{baseURL: baseURL, hc: hc, maxBatchBytes: maxAgentBatchBytes} +} + +type agentMemoryReq struct { + ConversationID string `json:"conversationId"` + Messages []AgentMemoryMessage `json:"messages"` + Flush bool `json:"flush"` + // Sync forces the synchronous add path on leading batches so the final + // flushing batch can see every earlier batch (v1.AgentMemoryRequest.Sync). + Sync bool `json:"sync,omitempty"` +} + +// Upload POSTs the conversation; returns the upstream status string +// (queued/accepted/success/...). Any 2xx counts as "submitted". A session +// whose messages exceed maxBatchBytes is split into multiple add calls under +// the same conversationId (EverOS rejects oversized agent payloads). All +// batches must succeed; the status of the last batch is returned. A mid-way +// failure leaves earlier batches submitted — a retry re-sends the whole +// session (the server is not idempotent), same as the single-POST path. +func (u *Uploader) Upload(ctx context.Context, targetEvt string, conv *Conversation) (string, error) { + batches := batchMessagesByBytes(conv.Messages, u.maxBatchBytes) + if len(batches) == 0 { + return "", fmt.Errorf("conversation %s has no messages to upload", conv.ID) + } + var status string + for i, batch := range batches { + final := i == len(batches)-1 + s, err := u.postBatch(ctx, targetEvt, conv.ID, batch, final, true) + if err != nil { + if len(batches) > 1 { + return "", fmt.Errorf("batch %d/%d: %w", i+1, len(batches), err) + } + return "", err + } + status = s + } + return status, nil +} + +// UploadAsync POSTs the conversation without sync or flush on any batch: the +// server ACKs each add as enqueued ("queued") instead of waiting for the +// upstream write. It deliberately does NOT flush the tail batch — a flush +// racing an async add that has not landed upstream silently drops the batch +// from extraction (the failure the sync path exists to avoid). Callers issue +// the flush later via FlushSession, after the adds have had time to land. +// Batching, ordering, and mid-way failure semantics match Upload. +func (u *Uploader) UploadAsync(ctx context.Context, targetEvt string, conv *Conversation) (string, error) { + batches := batchMessagesByBytes(conv.Messages, u.maxBatchBytes) + if len(batches) == 0 { + return "", fmt.Errorf("conversation %s has no messages to upload", conv.ID) + } + var status string + for i, batch := range batches { + s, err := u.postBatch(ctx, targetEvt, conv.ID, batch, false, false) + if err != nil { + if len(batches) > 1 { + return "", fmt.Errorf("batch %d/%d: %w", i+1, len(batches), err) + } + return "", err + } + status = s + } + return status, nil +} + +// FlushSession POSTs a flush-only request (no messages) for the session, +// asking the server to trigger extraction over everything already added +// under the conversation id. Returns the upstream flush status +// (extracted/no_extraction/...). +func (u *Uploader) FlushSession(ctx context.Context, targetEvt, conversationID string) (string, error) { + return u.postBatch(ctx, targetEvt, conversationID, nil, true, false) +} + +func (u *Uploader) postBatch(ctx context.Context, targetEvt, conversationID string, messages []AgentMemoryMessage, flush, sync bool) (string, error) { + body, err := json.Marshal(agentMemoryReq{ConversationID: conversationID, Messages: messages, Flush: flush, Sync: sync}) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.baseURL+"/api/v1/mem/agent-memory", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+targetEvt) + resp, err := u.hc.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet := Redact(strings.TrimSpace(string(raw))) + return "", fmt.Errorf("agent-memory POST %d: %s", resp.StatusCode, snippet) + } + // The BFF wraps success data under "result"; the root "status" is an + // integer errno (0 = ok), not the async status. The agent-memory result + // carries the real status (e.g. "queued"). See server/pkg/core/core.go + // and v1.AgentMemoryResponse. + var out struct { + Result struct { + Status string `json:"status"` + Flushed bool `json:"flushed"` + } `json:"result"` + } + _ = json.Unmarshal(raw, &out) + status := out.Result.Status + if status == "" { + if out.Result.Flushed { + status = "flushed" + } else { + status = "submitted" // POST accepted; server status absent/unexpected shape + } + } + return status, nil +} diff --git a/cli/internal/importer/conversation/uploader_test.go b/cli/internal/importer/conversation/uploader_test.go new file mode 100644 index 0000000..7e97fe6 --- /dev/null +++ b/cli/internal/importer/conversation/uploader_test.go @@ -0,0 +1,270 @@ +package conversation + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +func TestUploaderUsesTargetEvtAndSingleBatchFlushes(t *testing.T) { + var gotAuth string + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &gotBody) + w.WriteHeader(202) + // Real BFF envelope: root status is an int errno; the async status + // lives in result.status (see server/pkg/core + v1.AgentMemoryResponse). + w.Write([]byte(`{"status":0,"requestId":"r1","result":{"sessionId":"s1","status":"queued","messageCount":1,"flushed":false}}`)) + })) + defer srv.Close() + + up := NewUploader(srv.URL, srv.Client()) + conv := &Conversation{ID: "import-claude-code-x", Messages: []AgentMemoryMessage{{Role: "user", Timestamp: 1, Content: "hi"}}} + status, err := up.Upload(t.Context(), "evt_target123", conv) + if err != nil { + t.Fatal(err) + } + if status != "queued" { + t.Fatalf("status=%q", status) + } + if gotAuth != "Bearer evt_target123" { + t.Fatalf("must use target evt, got %q", gotAuth) + } + // A conversation with a single batch is both the leading and the final + // batch, so it must carry sync:true and flush:true. + if gotBody["flush"] != true { + t.Fatalf("single batch must flush, got %v", gotBody["flush"]) + } + if gotBody["sync"] != true { + t.Fatalf("single batch must be sync, got %v", gotBody["sync"]) + } + if gotBody["conversationId"] != "import-claude-code-x" { + t.Fatalf("conversationId mismatch: %v", gotBody["conversationId"]) + } +} + +func TestUploaderBatchesLargeConversation(t *testing.T) { + var mu sync.Mutex + var reqs []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + var body map[string]any + json.Unmarshal(b, &body) + body["_auth"] = r.Header.Get("Authorization") + mu.Lock() + reqs = append(reqs, body) + mu.Unlock() + w.WriteHeader(202) + w.Write([]byte(`{"status":0,"result":{"status":"queued"}}`)) + })) + defer srv.Close() + + up := NewUploader(srv.URL, srv.Client()) + up.maxBatchBytes = 9000 // force splitting on small messages + + var msgs []AgentMemoryMessage + for i := 0; i < 5; i++ { + msgs = append(msgs, AgentMemoryMessage{Role: "tool", Timestamp: int64(i + 1), ToolCallID: "c", Content: strings.Repeat("x", 4000)}) + } + conv := &Conversation{ID: "import-codex-big", Messages: msgs} + + status, err := up.Upload(t.Context(), "evt_target123", conv) + if err != nil { + t.Fatal(err) + } + if status != "queued" { + t.Fatalf("status=%q", status) + } + + if len(reqs) < 2 { + t.Fatalf("large conversation must be POSTed in >1 batch, got %d", len(reqs)) + } + totalMsgs := 0 + for i, b := range reqs { + final := i == len(reqs)-1 + if b["conversationId"] != "import-codex-big" { + t.Fatalf("batch %d conversationId mismatch: %v", i, b["conversationId"]) + } + wantFlush := final + if b["flush"] != wantFlush { + t.Fatalf("batch %d flush=%v, want %v", i, b["flush"], wantFlush) + } + if b["sync"] != true { + t.Fatalf("batch %d sync must be true, got %v", i, b["sync"]) + } + if b["_auth"] != "Bearer evt_target123" { + t.Fatalf("batch %d must use target evt, got %v", i, b["_auth"]) + } + if arr, ok := b["messages"].([]any); ok { + totalMsgs += len(arr) + } + } + if totalMsgs != len(msgs) { + t.Fatalf("batches must cover all %d messages, got %d", len(msgs), totalMsgs) + } +} + +func TestUploadSyncLeadingFlushFinal(t *testing.T) { + var got []agentMemoryReq + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req agentMemoryReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + got = append(got, req) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":0,"result":{"status":"success","flushed":true}}`) + })) + defer srv.Close() + + u := NewUploader(srv.URL, srv.Client()) + u.maxBatchBytes = 1 // force one batch per message + + conv := &Conversation{ + ID: "conv-1", + Messages: []AgentMemoryMessage{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "world"}, + {Role: "user", Content: "bye"}, + }, + } + status, err := u.Upload(context.Background(), "evt_x", conv) + if err != nil { + t.Fatalf("upload: %v", err) + } + if len(got) != 3 { + t.Fatalf("want 3 batches, got %d", len(got)) + } + for i, req := range got { + final := i == len(got)-1 + if req.Flush != final { + t.Errorf("batch %d: flush=%v, want %v", i, req.Flush, final) + } + if !req.Sync { + t.Errorf("batch %d: sync=false, want true", i) + } + } + if status != "success" { + t.Errorf("status=%q, want success", status) + } +} + +func TestUploadSingleBatchFlushes(t *testing.T) { + var got []agentMemoryReq + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req agentMemoryReq + _ = json.NewDecoder(r.Body).Decode(&req) + got = append(got, req) + fmt.Fprint(w, `{"status":0,"result":{"status":"success","flushed":true}}`) + })) + defer srv.Close() + + u := NewUploader(srv.URL, srv.Client()) + conv := &Conversation{ID: "conv-1", Messages: []AgentMemoryMessage{{Role: "user", Content: "hi"}}} + if _, err := u.Upload(context.Background(), "evt_x", conv); err != nil { + t.Fatalf("upload: %v", err) + } + if len(got) != 1 || !got[0].Flush || !got[0].Sync { + t.Fatalf("single batch must be sync+flush, got %+v", got) + } +} + +func TestUploadAsyncSendsEveryBatchWithoutSyncOrFlush(t *testing.T) { + var mu sync.Mutex + var reqs []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + var body map[string]any + json.Unmarshal(b, &body) + body["_auth"] = r.Header.Get("Authorization") + mu.Lock() + reqs = append(reqs, body) + mu.Unlock() + w.WriteHeader(202) + w.Write([]byte(`{"status":0,"result":{"status":"queued"}}`)) + })) + defer srv.Close() + + up := NewUploader(srv.URL, srv.Client()) + up.maxBatchBytes = 9000 // force splitting on small messages + + var msgs []AgentMemoryMessage + for i := 0; i < 5; i++ { + msgs = append(msgs, AgentMemoryMessage{Role: "tool", Timestamp: int64(i + 1), ToolCallID: "c", Content: strings.Repeat("x", 4000)}) + } + conv := &Conversation{ID: "import-codex-async", Messages: msgs} + + status, err := up.UploadAsync(t.Context(), "evt_target123", conv) + if err != nil { + t.Fatal(err) + } + if status != "queued" { + t.Fatalf("status=%q", status) + } + if len(reqs) < 2 { + t.Fatalf("large conversation must be POSTed in >1 batch, got %d", len(reqs)) + } + totalMsgs := 0 + for i, b := range reqs { + // The async path must never set sync or flush on ANY batch: flush on + // the tail would race the earlier async adds upstream (the silent-drop + // failure the sync path exists to avoid). The flush is issued later + // via FlushSession, after every add has had time to land. + if _, ok := b["sync"]; ok { + t.Fatalf("batch %d must omit sync, got %v", i, b["sync"]) + } + if b["flush"] != false { + t.Fatalf("batch %d flush must be false, got %v", i, b["flush"]) + } + if b["_auth"] != "Bearer evt_target123" { + t.Fatalf("batch %d must use target evt, got %v", i, b["_auth"]) + } + if arr, ok := b["messages"].([]any); ok { + totalMsgs += len(arr) + } + } + if totalMsgs != len(msgs) { + t.Fatalf("batches must cover all %d messages, got %d", len(msgs), totalMsgs) + } +} + +func TestFlushSessionPostsFlushOnlyRequest(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &gotBody) + gotBody["_auth"] = r.Header.Get("Authorization") + w.WriteHeader(200) + w.Write([]byte(`{"status":0,"result":{"sessionId":"s1","status":"extracted","flushed":true}}`)) + })) + defer srv.Close() + + up := NewUploader(srv.URL, srv.Client()) + status, err := up.FlushSession(t.Context(), "evt_target123", "import-codex-async") + if err != nil { + t.Fatal(err) + } + if status != "extracted" { + t.Fatalf("status=%q", status) + } + if gotBody["flush"] != true { + t.Fatalf("flush-only request must set flush, got %v", gotBody["flush"]) + } + if msgs, ok := gotBody["messages"].([]any); ok && len(msgs) != 0 { + t.Fatalf("flush-only request must carry no messages, got %d", len(msgs)) + } + if gotBody["conversationId"] != "import-codex-async" { + t.Fatalf("conversationId mismatch: %v", gotBody["conversationId"]) + } + if gotBody["_auth"] != "Bearer evt_target123" { + t.Fatalf("must use target evt, got %v", gotBody["_auth"]) + } +} diff --git a/cli/internal/importer/conversation/workbuddy.go b/cli/internal/importer/conversation/workbuddy.go new file mode 100644 index 0000000..6d12bf9 --- /dev/null +++ b/cli/internal/importer/conversation/workbuddy.go @@ -0,0 +1,322 @@ +package conversation + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// WorkBuddyScanner parses WorkBuddy JSONL session files. +// +// Each line is a flat JSON object with a top-level "type" field: +// +// message -> role user|assistant turn (content is []{type,text}) +// function_call -> assistant tool call; arguments is a JSON-encoded +// string (re-marshalled through argumentsStringCC) +// function_call_result -> role=tool message, paired to function_call by +// the shared "callId" field (not call_id) +// reasoning -> internal chain-of-thought, skipped +// file-history-snapshot -> editor undo checkpoint, skipped +// ai-title -> auto-generated session title, skipped (no +// Item/Conversation field to hold it yet) +// +// See docs/spec/workbuddy-cold-start-import.md for the full contract this +// mirrors, including why the three text-cleaning rules below exist. +type WorkBuddyScanner struct{} + +var _ Scanner = (*WorkBuddyScanner)(nil) + +func NewWorkBuddyScanner() *WorkBuddyScanner { return &WorkBuddyScanner{} } + +func (s *WorkBuddyScanner) Platform() PlatformID { return PlatformWorkBuddy } + +func (s *WorkBuddyScanner) Scan(roots []string) ([]Item, error) { + var items []Item + for _, root := range roots { + if _, err := os.Stat(root); os.IsNotExist(err) { + continue + } + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + info, err := d.Info() + if err != nil || info == nil { + return nil // skip unreadable entry + } + items = append(items, Item{ + Platform: PlatformWorkBuddy, + Path: path, + Status: "ready", + SizeBytes: info.Size(), + UpdatedAt: info.ModTime().UTC().Format(time.RFC3339), + }) + return nil + }) + if err != nil { + return nil, err + } + } + return items, nil +} + +func (s *WorkBuddyScanner) Read(item Item) (*Conversation, error) { + f, err := os.Open(item.Path) + if err != nil { + return nil, err + } + defer f.Close() + + conv := &Conversation{Item: item} + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 1024*1024) + // A single tool-result line has been observed over 64KB (the default + // bufio.Scanner cap), so this must be raised - see spec §2.4. + scanner.Buffer(buf, 64*1024*1024) + + // Use file mtime as deterministic fallback base — never time.Now() (breaks idempotency). + var fallbackBase int64 + if fi, err := os.Stat(item.Path); err == nil { + fallbackBase = fi.ModTime().UnixMilli() + } else { + fallbackBase = time.Unix(0, 0).UnixMilli() + } + lineNum := 0 + originID := "" + + const maxRunes = 8000 + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + lineNum++ + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: json decode error: %v", lineNum, err)) + continue + } + + // Every record type carries sessionId; grab it from whichever line + // has it first (file-history-snapshot lines do not). + if originID == "" { + if sid, ok := ev["sessionId"].(string); ok && sid != "" { + originID = sid + } + } + + ts := normalizeTimestampCC(ev["timestamp"], fallbackBase+int64(lineNum)) + recType := stringFieldCC(ev, "type") + + switch recType { + case "reasoning", "file-history-snapshot", "ai-title": + // Not conversational content - see package doc comment. + continue + + case "message": + if isWorkBuddyInternalMessage(ev) { + continue + } + m, ok := workBuddyMessageFromRecord(ev, ts, maxRunes) + if !ok { + continue + } + conv.Messages = append(conv.Messages, m) + + case "function_call": + callID := firstNonEmptyCC(stringFieldCC(ev, "callId"), fmt.Sprintf("workbuddy_tool_%d", ts)) + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "assistant", + Timestamp: ts, + ToolCalls: []AgentMemoryToolCall{{ + ID: callID, + Type: "function", + Name: firstNonEmptyCC(stringFieldCC(ev, "name"), "unknown"), + Arguments: Redact(argumentsStringCC(ev["arguments"])), + }}, + }) + + case "function_call_result": + callID := stringFieldCC(ev, "callId") + if callID == "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: function_call_result missing callId, dropped", lineNum)) + continue + } + text := truncateRunesCC(strings.TrimSpace(workBuddyOutputText(ev["output"])), maxRunes) + if text == "" { + text = "tool result" + } + conv.Messages = append(conv.Messages, AgentMemoryMessage{ + Role: "tool", + Timestamp: ts, + ToolCallID: callID, + Content: Redact(text), + }) + + default: + if recType != "" { + conv.Warnings = append(conv.Warnings, fmt.Sprintf("line %d: unknown record type %q, skipped", lineNum, recType)) + } + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + + setStartedAtFromMessages(conv) + conv.ID = ConversationID(PlatformWorkBuddy, originID, item.Path) + return conv, nil +} + +var ( + // is the real text a user typed. It is NOT unique to the + // first message or to a compaction wrapper: a real upload + // against this machine's own WorkBuddy history showed EVERY user turn + // wrapped in with + // /// + // injected ahead of a trailing + // ... that holds what the user actually typed. + // The original assumption (system-reminder = first-message-only + // boilerplate, safe to drop whole) was wrong and silently discarded the + // user's real question on every turn - see docs/spec/workbuddy-cold-start-import.md. + // Extracting unconditionally, whatever wraps it, is both + // simpler and correct for the first message, an ordinary later turn, and + // a compaction turn alike. + workBuddyUserQueryRe = regexp.MustCompile(`(?s)(.*?)`) + // Fallback tag-stripping for the (so far unobserved, but plausible) case + // of a wrapper with no inside - remove the injected + // boilerplate and keep whatever real text is left outside it, rather + // than drop the whole message. + workBuddySystemReminderRe = regexp.MustCompile(`(?s)]*>.*?`) + workBuddyAdditionalDataRe = regexp.MustCompile(`(?s).*?`) + workBuddyWorkingMemoryRe = regexp.MustCompile(`(?s).*?`) +) + +// workBuddyMessageFromRecord turns a "message" record into an +// AgentMemoryMessage, or ok=false when it carries no importable content +// (an empty body, or a wrapper with nothing real left after cleaning). +func workBuddyMessageFromRecord(ev map[string]any, ts int64, maxRunes int) (AgentMemoryMessage, bool) { + role := stringFieldCC(ev, "role") + switch role { + case "user", "assistant": + default: + return AgentMemoryMessage{}, false + } + + text := workBuddyRawTextFromContent(ev["content"]) + if text == "" { + return AgentMemoryMessage{}, false + } + + if role == "user" { + text = workBuddyCleanUserText(text) + if text == "" { + return AgentMemoryMessage{}, false + } + } + + text = truncateRunesCC(text, maxRunes) + return AgentMemoryMessage{Role: role, Timestamp: ts, Content: Redact(text)}, true +} + +// workBuddyCleanUserText extracts the real content of a user message. +// wins whenever present - regardless of what wraps it (the +// synthetic first-message context dump, an ordinary later turn's per-message +// reminder injection, or a compaction wrapper all end in one). +// Only when no is found does it fall back to stripping the +// known boilerplate tags and keeping whatever text is left outside them. +func workBuddyCleanUserText(text string) string { + if m := workBuddyUserQueryRe.FindStringSubmatch(text); m != nil { + return strings.TrimSpace(m[1]) + } + text = workBuddySystemReminderRe.ReplaceAllString(text, "") + text = workBuddyAdditionalDataRe.ReplaceAllString(text, "") + text = workBuddyWorkingMemoryRe.ReplaceAllString(text, "") + return strings.TrimSpace(text) +} + +// workBuddyRawTextFromContent joins a message's content blocks +// ([{type:"input_text"|"output_text",text:"..."}]) into plain text. +// Deliberately NOT truncated here: truncateRunesCC keeps head+tail and drops +// the middle, and a real inside a large wrapper +// could sit anywhere - cleaning must run on the full text before any cap is +// applied (see workBuddyMessageFromRecord). +func workBuddyRawTextFromContent(content any) string { + if s, ok := content.(string); ok { + return strings.TrimSpace(s) + } + blocks, ok := content.([]any) + if !ok { + return "" + } + parts := make([]string, 0, len(blocks)) + for _, raw := range blocks { + b := objectMapCC(raw) + if b == nil { + continue + } + switch stringFieldCC(b, "type") { + case "input_text", "output_text", "text": + if text := stringFieldCC(b, "text"); text != "" { + parts = append(parts, text) + } + } + } + return strings.TrimSpace(strings.Join(parts, "\n")) +} + +// isWorkBuddyInternalMessage reports whether a "message" record is +// WorkBuddy-internal machinery (compaction bookkeeping, teammate relay) +// rather than a turn a user or the assistant actually exchanged - spec §3.3. +func isWorkBuddyInternalMessage(ev map[string]any) bool { + pd := objectMapCC(ev["providerData"]) + if pd == nil { + return false + } + if b, ok := pd["skipRun"].(bool); ok && b { + return true + } + if b, ok := pd["isCompactInternal"].(bool); ok && b { + return true + } + if s, ok := pd["agent"].(string); ok && s == "compact" { + return true + } + if tm := objectMapCC(pd["teammateMessage"]); tm != nil { + if from, ok := tm["from"].(string); ok && from != "" { + return true + } + } + return false +} + +// workBuddyOutputText extracts a function_call_result's rendered text. +// {"type":"text","text":"..."} is the common shape; anything else (e.g. the +// "list" structured-output shape) is JSON-dumped rather than silently +// dropped, matching this package's warn-don't-fail convention. +func workBuddyOutputText(v any) string { + m := objectMapCC(v) + if m == nil { + if s, ok := v.(string); ok { + return s + } + return "" + } + if stringFieldCC(m, "type") == "text" { + return stringFieldCC(m, "text") + } + b, err := json.Marshal(v) + if err != nil { + return "" + } + return string(b) +} diff --git a/cli/internal/importer/conversation/workbuddy_test.go b/cli/internal/importer/conversation/workbuddy_test.go new file mode 100644 index 0000000..4b0ecf4 --- /dev/null +++ b/cli/internal/importer/conversation/workbuddy_test.go @@ -0,0 +1,132 @@ +package conversation + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWorkBuddyParseCounts(t *testing.T) { + sc := NewWorkBuddyScanner() + conv, err := sc.Read(Item{Platform: PlatformWorkBuddy, Path: "testdata/workbuddy_sample.jsonl"}) + if err != nil { + t.Fatal(err) + } + + var toolCalls, toolResults int + var userTexts []string + for _, m := range conv.Messages { + toolCalls += len(m.ToolCalls) + if m.Role == "tool" { + toolResults++ + } + if m.Role == "user" { + if s, ok := m.Content.(string); ok { + userTexts = append(userTexts, s) + } + } + } + + // fixture: reasoning/file-history-snapshot/ai-title skipped, the + // internal compact message dropped. Remaining: 4 cleaned user turns + // (first-message system-reminder, additional_data-only, cb_summary, + // ordinary-later-turn system-reminder), 1 plain assistant turn, 1 + // function_call, 1 function_call_result = 8. + if len(conv.Messages) != 8 { + t.Fatalf("expected 8 messages, got %d: %+v", len(conv.Messages), conv.Messages) + } + if toolCalls != 1 { + t.Fatalf("expected 1 toolCall, got %d", toolCalls) + } + if toolResults != 1 { + t.Fatalf("expected 1 tool result message, got %d", toolResults) + } + if conv.ID == "" { + t.Fatal("conversationId must be set") + } + + for _, text := range userTexts { + if strings.Contains(text, "system-reminder") || strings.Contains(text, "identity_context") { + t.Fatalf("system-reminder wrapper leaked into a user message: %q", text) + } + if strings.Contains(text, "additional_data") || strings.Contains(text, "memory_and_skills_reminder") { + t.Fatalf("boilerplate tag leaked into a user message: %q", text) + } + if strings.Contains(text, "cb_summary") || strings.Contains(text, "previous_assistant_message") { + t.Fatalf("cb_summary wrapper leaked into a user message: %q", text) + } + } + + // The real point of this fixture: the actual question a user typed must + // survive extraction regardless of which wrapper buried it - the first + // message's whole-session context dump, a bare additional_data tag, a + // compaction summary, or an ordinary later turn's own reminder + // injection. A real WorkBuddy upload against this machine's own history + // showed every one of these wrappers in practice; dropping any of them + // whole (the original, wrong assumption) silently loses the user's real + // question every time. + want := []string{ + "what's for dinner tonight?", + "what is the weather today?", + "continue the compaction test please", + "what did I do yesterday?", + } + for _, w := range want { + found := false + for _, text := range userTexts { + if text == w { + found = true + } + } + if !found { + t.Fatalf("expected %q among cleaned user messages, got %v", w, userTexts) + } + } +} + +func TestWorkBuddyScannerPlatform(t *testing.T) { + sc := NewWorkBuddyScanner() + if sc.Platform() != PlatformWorkBuddy { + t.Fatalf("expected %s, got %s", PlatformWorkBuddy, sc.Platform()) + } +} + +func TestWorkBuddyScanMissingDir(t *testing.T) { + sc := NewWorkBuddyScanner() + items, err := sc.Scan([]string{"/no/such/dir/workbuddy"}) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("missing dir should yield 0 items, got %d", len(items)) + } +} + +func TestWorkBuddyScanFindsSessionFiles(t *testing.T) { + dir := t.TempDir() + projectDir := filepath.Join(dir, "Users-admin-WorkBuddy-2026-08-14-10-30-18") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatal(err) + } + sess := filepath.Join(projectDir, "0a8db3b2-7a10-4e75-ab43-92cebbe73857.jsonl") + line := `{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}],"sessionId":"0a8db3b2-7a10-4e75-ab43-92cebbe73857"}` + "\n" + if err := os.WriteFile(sess, []byte(line), 0o644); err != nil { + t.Fatal(err) + } + // The reserved-but-unused /tool-results/ sibling dir must not + // confuse the walk (it shares no extension with anything we'd claim). + toolResults := filepath.Join(projectDir, "0a8db3b2-7a10-4e75-ab43-92cebbe73857", "tool-results") + if err := os.MkdirAll(toolResults, 0o755); err != nil { + t.Fatal(err) + } + + sc := NewWorkBuddyScanner() + items, err := sc.Scan([]string{dir}) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Path != sess { + t.Fatalf("expected exactly the one session file, got %+v", items) + } +} diff --git a/cli/internal/importer/idempotency.go b/cli/internal/importer/idempotency.go deleted file mode 100644 index 6f26cf6..0000000 --- a/cli/internal/importer/idempotency.go +++ /dev/null @@ -1,27 +0,0 @@ -package importer - -import ( - "crypto/rand" - "encoding/hex" - "fmt" -) - -// newIdempotencyKey returns a fresh UUIDv4-shaped string. We don't pull -// in google/uuid for one helper; the format is "xxxxxxxx-xxxx-4xxx-Yxxx-xxxxxxxxxxxx" -// per RFC 4122. Backend treats this opaquely (just needs uniqueness + -// stability across retries of one logical request). -func newIdempotencyKey() string { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - // crypto/rand only fails when the OS RNG is broken — falling - // back to a deterministic value here would silently produce - // idempotency conflicts, so panic. - panic("crypto/rand unavailable: " + err.Error()) - } - // Set version (4) and variant (10) bits per RFC 4122. - b[6] = (b[6] & 0x0f) | 0x40 - b[8] = (b[8] & 0x3f) | 0x80 - hexStr := hex.EncodeToString(b[:]) - return fmt.Sprintf("%s-%s-%s-%s-%s", - hexStr[0:8], hexStr[8:12], hexStr[12:16], hexStr[16:20], hexStr[20:]) -} diff --git a/cli/internal/importer/importer_test.go b/cli/internal/importer/importer_test.go deleted file mode 100644 index fa29866..0000000 --- a/cli/internal/importer/importer_test.go +++ /dev/null @@ -1,460 +0,0 @@ -package importer - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "evercli/internal/client" - "evercli/internal/core" - "evercli/internal/credential" - "evercli/internal/httpmock" - "evercli/internal/output" -) - -// stubScanner walks a tmp directory we control. Lets tests pin the -// scan root without touching $HOME or env vars. -type stubScanner struct { - platform PlatformID - root string -} - -func (s stubScanner) Platform() PlatformID { return s.platform } -func (s stubScanner) Root() (string, error) { return s.root, nil } -func (s stubScanner) Scan(ctx context.Context, ex []string) (*SourceScan, error) { - return scanMarkdownTree(ctx, s.platform, s.root, ex) -} - -// ---- scanner -------------------------------------------------------- - -func TestScan_EmptyRoot_NotAnError(t *testing.T) { - tmp := t.TempDir() - s := stubScanner{platform: PlatformClaudeCode, root: tmp} - res, err := s.Scan(context.Background(), nil) - require.NoError(t, err) - assert.Empty(t, res.Files) - assert.Zero(t, res.TotalBytes) -} - -func TestScan_MissingRoot_NotAnError(t *testing.T) { - s := stubScanner{platform: PlatformClaudeCode, root: "/tmp/definitely-does-not-exist-xyz-12345"} - res, err := s.Scan(context.Background(), nil) - require.NoError(t, err) - assert.Empty(t, res.Files) -} - -func TestScan_PicksUpMarkdown_SortsByRelPath(t *testing.T) { - tmp := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(tmp, "z.md"), []byte("zz"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.md"), []byte("aa"), 0o600)) - require.NoError(t, os.MkdirAll(filepath.Join(tmp, "sub"), 0o700)) - require.NoError(t, os.WriteFile(filepath.Join(tmp, "sub", "m.md"), []byte("mm"), 0o600)) - - s := stubScanner{platform: PlatformClaudeCode, root: tmp} - res, err := s.Scan(context.Background(), nil) - require.NoError(t, err) - require.Len(t, res.Files, 3) - assert.Equal(t, "a.md", res.Files[0].RelPath) - assert.Equal(t, "sub/m.md", filepath.ToSlash(res.Files[1].RelPath)) - assert.Equal(t, "z.md", res.Files[2].RelPath) - assert.EqualValues(t, 6, res.TotalBytes) -} - -func TestScan_SkipsBlacklistedDirs(t *testing.T) { - tmp := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".git"), 0o700)) - require.NoError(t, os.WriteFile(filepath.Join(tmp, ".git", "HEAD.md"), []byte("ref"), 0o600)) - require.NoError(t, os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o700)) - require.NoError(t, os.WriteFile(filepath.Join(tmp, "node_modules", "x.md"), []byte("y"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(tmp, "real.md"), []byte("hello"), 0o600)) - - s := stubScanner{platform: PlatformClaudeCode, root: tmp} - res, err := s.Scan(context.Background(), nil) - require.NoError(t, err) - require.Len(t, res.Files, 1) - assert.Equal(t, "real.md", res.Files[0].RelPath) -} - -func TestScan_LargeFilesSkipped(t *testing.T) { - tmp := t.TempDir() - big := make([]byte, SingleFileLimitBytes+10) - require.NoError(t, os.WriteFile(filepath.Join(tmp, "big.md"), big, 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(tmp, "small.md"), []byte("ok"), 0o600)) - - s := stubScanner{platform: PlatformClaudeCode, root: tmp} - res, err := s.Scan(context.Background(), nil) - require.NoError(t, err) - require.Len(t, res.Files, 1) - assert.Equal(t, "small.md", res.Files[0].RelPath) - require.Len(t, res.SkippedFiles, 1) - assert.Contains(t, res.SkippedFiles[0].Reason, "too large") -} - -// ---- merger --------------------------------------------------------- - -func TestMerge_DocumentKeyStableAcrossInstances(t *testing.T) { - tmp := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.md"), []byte("hello"), 0o600)) - - s := stubScanner{platform: PlatformClaudeCode, root: tmp} - scan, _ := s.Scan(context.Background(), nil) - - m1, err := Merge(scan, MergeOptions{SourceID: "src_x", Now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}) - require.NoError(t, err) - m2, err := Merge(scan, MergeOptions{SourceID: "src_x", Now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}) - require.NoError(t, err) - - assert.Equal(t, m1.DocumentKey, m2.DocumentKey, "documentKey must depend only on (sourceKey, platform)") - assert.NotEqual(t, m1.IdempotencyKey, m2.IdempotencyKey, "idempotencyKey must be fresh per call") -} - -func TestMerge_ContentHashStable_NotAffectedByLineEndings(t *testing.T) { - tmp := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.md"), []byte("line1\nline2\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(tmp, "b.md"), []byte("line1\r\nline2\r\n"), 0o600)) - - scan := &SourceScan{ - Platform: PlatformClaudeCode, - Files: []ScanFile{ - {Path: filepath.Join(tmp, "a.md"), RelPath: "a.md", SizeBytes: 12}, - {Path: filepath.Join(tmp, "b.md"), RelPath: "b.md", SizeBytes: 14}, - }, - } - merged, err := Merge(scan, MergeOptions{Now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}) - require.NoError(t, err) - // Both files should appear with identical normalized content. - count := strings.Count(string(merged.Body), "line1\nline2\n") - assert.Equal(t, 2, count, "CRLF must be normalized to LF before merge") -} - -func TestMerge_FailsOnEmptyScan(t *testing.T) { - _, err := Merge(&SourceScan{Platform: PlatformClaudeCode}, MergeOptions{}) - require.Error(t, err) - ce, ok := output.AsCLIError(err) - require.True(t, ok) - assert.Equal(t, output.TypeInvalidArgs, ce.Type) -} - -// ---- uploader ------------------------------------------------------- - -// s3Server is a tiny fake of the AWS S3 PresignedPOST endpoint. Returns -// 204 on POST, recording received bytes. -type s3Server struct { - *httptest.Server - receivedFile []byte - receivedFields map[string]string - failOnPost bool -} - -func newS3Server(t *testing.T, fail bool) *s3Server { - t.Helper() - s := &s3Server{failOnPost: fail, receivedFields: map[string]string{}} - mux := http.NewServeMux() - mux.HandleFunc("/s3-upload", func(w http.ResponseWriter, r *http.Request) { - if s.failOnPost { - http.Error(w, "policy denied", http.StatusForbidden) - return - } - err := r.ParseMultipartForm(32 << 20) - if err != nil { - http.Error(w, err.Error(), 400) - return - } - for k, v := range r.MultipartForm.Value { - if len(v) > 0 { - s.receivedFields[k] = v[0] - } - } - fhs := r.MultipartForm.File["file"] - if len(fhs) > 0 { - f, err := fhs[0].Open() - if err != nil { - http.Error(w, err.Error(), 400) - return - } - defer f.Close() - buf := make([]byte, fhs[0].Size) - _, _ = f.Read(buf) - s.receivedFile = buf - } - w.WriteHeader(204) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - s.Server = srv - return s -} - -func TestUpload_HappyPath(t *testing.T) { - srv := httpmock.NewServer(t) - mem := credential.NewMem() - require.NoError(t, mem.Set(context.Background(), credential.APIKey(), - "emk_0123456789abcdef0123456789abcdef")) - require.NoError(t, mem.Set(context.Background(), credential.AgentToken(), - "evt_0123456789abcdef0123456789abcdef")) - cli := client.NewWithHTTP(srv.URL(), mem, srv.HTTPClient()) - - s3 := newS3Server(t, false) - uploader := NewUploaderWithHTTP(cli, srv.HTTPClient()) - - srv.HandleEnvelope("POST /mem/uploads/presign", client.PresignResp{ - UploadURL: s3.URL + "/s3-upload", - FormFields: map[string]string{"key": "objects/x", "policy": "..."}, - ObjectKey: "objects/x", - ExpiresAt: time.Now().Add(time.Hour).Format(time.RFC3339), - }) - srv.HandleEnvelope("POST /mem/sources", client.CreateRecordResp{ - ID: "rec_xyz", - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }) - - doc := &MergedDoc{ - Platform: PlatformClaudeCode, FileName: "merge.md", - Body: []byte("hello world"), SizeBytes: 11, - ContentHash: "h", FileCount: 1, - DocumentKey: "doc_x", IdempotencyKey: "idem_x", - } - res, err := uploader.Upload(context.Background(), UploadParams{Doc: doc, SourceID: "src_x"}, &Checkpoint{}) - require.NoError(t, err) - assert.Equal(t, "rec_xyz", res.RecordID) - assert.Equal(t, "objects/x", res.ObjectKey) - assert.Equal(t, "hello world", string(s3.receivedFile)) - assert.Equal(t, "objects/x", s3.receivedFields["key"]) -} - -// TestUpload_SendsOriginPlatform asserts the CLI explicitly sets -// originPlatform on POST /mem/sources matching the target platform. -// Without this, evercli imports would default server-side to the -// caller's own platform (evercli) — surfacing cold-start data as -// "EverCli" rather than the AI agent it actually belongs to. -func TestUpload_SendsOriginPlatform(t *testing.T) { - srv := httpmock.NewServer(t) - mem := credential.NewMem() - require.NoError(t, mem.Set(context.Background(), credential.APIKey(), - "emk_0123456789abcdef0123456789abcdef")) - require.NoError(t, mem.Set(context.Background(), credential.AgentToken(), - "evt_0123456789abcdef0123456789abcdef")) - cli := client.NewWithHTTP(srv.URL(), mem, srv.HTTPClient()) - - s3 := newS3Server(t, false) - uploader := NewUploaderWithHTTP(cli, srv.HTTPClient()) - - srv.HandleEnvelope("POST /mem/uploads/presign", client.PresignResp{ - UploadURL: s3.URL + "/s3-upload", - FormFields: map[string]string{"key": "objects/x"}, - ObjectKey: "objects/x", - ExpiresAt: time.Now().Add(time.Hour).Format(time.RFC3339), - }) - - srv.HandleEnvelope("POST /mem/sources", client.CreateRecordResp{ - ID: "rec_o", CreatedAt: time.Now().UTC().Format(time.RFC3339), - }) - - doc := &MergedDoc{ - Platform: PlatformClaudeCode, FileName: "x.md", - Body: []byte("body"), SizeBytes: 4, - ContentHash: "h", FileCount: 1, - DocumentKey: "doc_x", IdempotencyKey: "idem_x", - } - _, err := uploader.Upload(context.Background(), UploadParams{Doc: doc}, &Checkpoint{}) - require.NoError(t, err, "upload should succeed end-to-end") - - recorded := srv.LastRequest("POST /mem/sources") - require.NotNil(t, recorded, "POST /mem/sources must have been called") - var sent map[string]any - require.NoError(t, json.Unmarshal(recorded.Body, &sent)) - assert.Equal(t, "claude-code", sent["originPlatform"], - "importer must set originPlatform = target platform string (matches PlatformClaudeCode)") -} - -func TestUpload_S3Failure_BecomesUpstream(t *testing.T) { - srv := httpmock.NewServer(t) - mem := credential.NewMem() - require.NoError(t, mem.Set(context.Background(), credential.APIKey(), - "emk_0123456789abcdef0123456789abcdef")) - require.NoError(t, mem.Set(context.Background(), credential.AgentToken(), - "evt_0123456789abcdef0123456789abcdef")) - cli := client.NewWithHTTP(srv.URL(), mem, srv.HTTPClient()) - s3 := newS3Server(t, true) - - uploader := NewUploaderWithHTTP(cli, srv.HTTPClient()) - srv.HandleEnvelope("POST /mem/uploads/presign", client.PresignResp{ - UploadURL: s3.URL + "/s3-upload", - FormFields: map[string]string{"key": "objects/x"}, - ObjectKey: "objects/x", - ExpiresAt: time.Now().Add(time.Hour).Format(time.RFC3339), - }) - - doc := &MergedDoc{ - Platform: PlatformClaudeCode, FileName: "x.md", - Body: []byte("body"), SizeBytes: 4, - ContentHash: "h", FileCount: 1, - DocumentKey: "doc_x", IdempotencyKey: "idem_x", - } - _, err := uploader.Upload(context.Background(), UploadParams{Doc: doc}, &Checkpoint{}) - require.Error(t, err) - ce, ok := output.AsCLIError(err) - require.True(t, ok) - assert.Equal(t, output.TypeUpstream, ce.Type) - assert.Equal(t, 403, ce.Code) -} - -func TestUpload_IdempotencyConflict_RetriesOnceWithFreshKey(t *testing.T) { - srv := httpmock.NewServer(t) - mem := credential.NewMem() - require.NoError(t, mem.Set(context.Background(), credential.APIKey(), - "emk_0123456789abcdef0123456789abcdef")) - require.NoError(t, mem.Set(context.Background(), credential.AgentToken(), - "evt_0123456789abcdef0123456789abcdef")) - cli := client.NewWithHTTP(srv.URL(), mem, srv.HTTPClient()) - s3 := newS3Server(t, false) - - srv.HandleEnvelope("POST /mem/uploads/presign", client.PresignResp{ - UploadURL: s3.URL + "/s3-upload", - FormFields: map[string]string{"key": "objects/x"}, - ObjectKey: "objects/x", - ExpiresAt: time.Now().Add(time.Hour).Format(time.RFC3339), - }) - - // Replay-style: first /mem/records call returns idempotency conflict; - // second succeeds. - calls := 0 - srv.Handle("POST /mem/sources", func(w http.ResponseWriter, _ *http.Request) { - calls++ - w.Header().Set("Content-Type", "application/json") - if calls == 1 { - _, _ = w.Write([]byte(`{"status":40005,"error":"ErrIdempotencyConflict","requestId":"r"}`)) - return - } - _, _ = w.Write([]byte(`{"status":0,"requestId":"r","result":{"id":"rec_yy","createdAt":"2026-01-01T00:00:00Z"}}`)) - }) - - uploader := NewUploaderWithHTTP(cli, srv.HTTPClient()) - doc := &MergedDoc{ - Platform: PlatformClaudeCode, FileName: "x.md", - Body: []byte("body"), SizeBytes: 4, - ContentHash: "h", FileCount: 1, - DocumentKey: "doc_x", IdempotencyKey: "idem_orig", - } - - res, err := uploader.Upload(context.Background(), UploadParams{Doc: doc}, &Checkpoint{}) - require.NoError(t, err) - assert.Equal(t, "rec_yy", res.RecordID) - assert.Equal(t, 2, calls, "must retry exactly once on idempotency conflict") - assert.NotEqual(t, "idem_orig", res.IdempotencyKey, "retry must mint a fresh idempotency key") -} - -// ---- service -------------------------------------------------------- - -func newServiceFixture(t *testing.T, platform PlatformID, scanRoot string) (*httpmock.Server, *Service, string) { - t.Helper() - srv := httpmock.NewServer(t) - mem := credential.NewMem() - require.NoError(t, mem.Set(context.Background(), credential.APIKey(), - "emk_0123456789abcdef0123456789abcdef")) - require.NoError(t, mem.Set(context.Background(), credential.AgentToken(), - "evt_0123456789abcdef0123456789abcdef")) - cli := client.NewWithHTTP(srv.URL(), mem, srv.HTTPClient()) - - cacheDir := t.TempDir() - paths := &core.Paths{ConfigDir: cacheDir, DataDir: cacheDir, CacheDir: cacheDir} - - svc := NewService(cli, paths, "https://api.test") - svc.SetScanners([]Scanner{stubScanner{platform: platform, root: scanRoot}}) - svc.SetUploadHTTPClient(srv.HTTPClient()) - return srv, svc, cacheDir -} - -func TestRun_DryRun_ProducesPreviewNoBackend(t *testing.T) { - tmp := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.md"), []byte("hello"), 0o600)) - - srv, svc, _ := newServiceFixture(t, PlatformClaudeCode, tmp) - - rep, err := svc.Run(context.Background(), RunOptions{DryRun: true}) - require.NoError(t, err) - require.True(t, rep.DryRun) - require.Len(t, rep.Previews, 1) - assert.Equal(t, 1, rep.Previews[0].FileCount) - assert.Empty(t, rep.Imports) - - assert.Nil(t, srv.LastRequest("POST /mem/uploads/presign"), "--dry-run must not call presign") -} - -func TestRun_NoFiles_Skipped(t *testing.T) { - tmp := t.TempDir() - _, svc, _ := newServiceFixture(t, PlatformClaudeCode, tmp) - - rep, err := svc.Run(context.Background(), RunOptions{}) - require.NoError(t, err) - require.Len(t, rep.Skipped, 1) - assert.Equal(t, "no files", rep.Skipped[0].Reason) -} - -func TestRun_HappyEndToEnd_PersistsThenDeletesCheckpoint(t *testing.T) { - tmp := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.md"), []byte("hello"), 0o600)) - - srv, svc, cache := newServiceFixture(t, PlatformClaudeCode, tmp) - s3 := newS3Server(t, false) - srv.HandleEnvelope("POST /mem/uploads/presign", client.PresignResp{ - UploadURL: s3.URL + "/s3-upload", - FormFields: map[string]string{"key": "objects/x"}, - ObjectKey: "objects/x", - ExpiresAt: time.Now().Add(time.Hour).Format(time.RFC3339), - }) - srv.HandleEnvelope("POST /mem/sources", client.CreateRecordResp{ID: "rec_x"}) - - rep, err := svc.Run(context.Background(), RunOptions{}) - require.NoError(t, err) - require.Len(t, rep.Imports, 1) - assert.Equal(t, "rec_x", rep.Imports[0].RecordID) - - // On success the checkpoint file must be cleaned up. - _, statErr := os.Stat(CheckpointPath(cache, PlatformClaudeCode)) - assert.True(t, os.IsNotExist(statErr), "checkpoint must be removed after recorded step") -} - -func TestRun_S3FailureLeavesCheckpointForResume(t *testing.T) { - tmp := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.md"), []byte("hello"), 0o600)) - - srv, svc, cache := newServiceFixture(t, PlatformClaudeCode, tmp) - s3 := newS3Server(t, true) // S3 returns 403 - srv.HandleEnvelope("POST /mem/uploads/presign", client.PresignResp{ - UploadURL: s3.URL + "/s3-upload", - FormFields: map[string]string{"key": "objects/x"}, - ObjectKey: "objects/x", - ExpiresAt: time.Now().Add(time.Hour).Format(time.RFC3339), - }) - - rep, err := svc.Run(context.Background(), RunOptions{}) - require.NoError(t, err) - require.Len(t, rep.Failed, 1) - assert.Equal(t, output.TypeUpstream, rep.Failed[0].Error.Type) - - // Checkpoint should be on disk so --resume can pick it up. - ck, err := LoadCheckpoint(CheckpointPath(cache, PlatformClaudeCode)) - require.NoError(t, err) - require.NotNil(t, ck, "checkpoint must be persisted on partial failure") - assert.Equal(t, "presigned", ck.Step) -} - -func TestRun_UnknownPlatform_InvalidArgs(t *testing.T) { - tmp := t.TempDir() - _, svc, _ := newServiceFixture(t, PlatformClaudeCode, tmp) - _, err := svc.Run(context.Background(), RunOptions{Platforms: []PlatformID{"nope"}}) - require.Error(t, err) - ce, ok := output.AsCLIError(err) - require.True(t, ok) - assert.Equal(t, output.TypeInvalidArgs, ce.Type) -} diff --git a/cli/internal/importer/merger.go b/cli/internal/importer/merger.go deleted file mode 100644 index 2c60142..0000000 --- a/cli/internal/importer/merger.go +++ /dev/null @@ -1,203 +0,0 @@ -package importer - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "fmt" - "os" - "strings" - "time" - - "evercli/internal/output" -) - -// MergeOptions controls per-merge knobs. SourceID populates the source -// field of the doc-key derivation and MUST be unique per (machine, user, -// platform) — otherwise two installs collide on the same documentKey and -// the backend's version chain treats unrelated installs as revisions. -// Callers pass machineid.Fingerprint(platform). Empty is allowed only in -// tests and falls back to "default_local". -type MergeOptions struct { - AgentID string // optional; embedded in front matter for diagnostics - SourceID string // per-(machine,user,platform); empty → "default_local" (tests only) - Now time.Time -} - -// MergeFormatVersion is the on-disk format the merger writes. The -// backend reverse-splitter dispatches on this value. -// -// v2: each file is delimited by an HTML-comment marker -// ``. Markdown-invisible, free of -// collision with user content, easy to parse. -const MergeFormatVersion = 2 - -// fileMarkerFormat is the per-section sentinel. HTML comments are NOT -// rendered by any markdown viewer, so they don't disturb the document -// when read by humans, while being trivial to parse byte-for-byte. -// -// We escape `"` and `\` in the rel attribute value so paths containing -// those characters round-trip cleanly. -const fileMarkerFormat = `` - -// MaxMergedTotalBytes caps the cumulative bytes the merger is willing -// to assemble. This is a separate ceiling from MaxMergedBytes (the -// uploader cap) because the merge step also has to hold every byte in -// RAM at once for the bytes.Buffer; even with a generous per-file -// limit (1 MiB), a directory with 10k files lands at ~10 GiB which -// would OOM the CLI process before the upload step even begins. 256 MiB -// is the same number we use for upload — anything past that should -// fail-fast at the scan boundary, not after we've already read every -// file. -const MaxMergedTotalBytes int64 = 256 << 20 - -// Merge produces a single MergedDoc from a SourceScan. Files are read in -// scan.Files order (which the scanner sorts by RelPath for stability). -// -// Output structure (v2): -// -// --- -// everme_import_version: 2 -// platform: -// agent_id: -// merged_at: -// file_count: -// total_bytes: -// document_key_hint: cold_start_merge_ -// --- -// -// # Cold-start memory merged from -// -// _This file is an automated merge of local memory files._ -// -// -// -// [file 1 content, line endings normalized to \n] -// -// -// -// [file 2 content] -// ... -// -// The backend `worker.SplitForIngest` reverses this format, producing -// one ContentChunk per original file with `Name=relPath` so EverOS -// retrieval can surface the source file in search results. -// -// DocumentKey is derived from (sourceKey, logicalPath) so reruns chain -// into the same backend version chain. -func Merge(scan *SourceScan, opts MergeOptions) (*MergedDoc, error) { - if scan == nil || len(scan.Files) == 0 { - return nil, output.Invalid("no files to merge", "Run `evercli import scan` to see what's available") - } - if scan.TotalBytes > MaxMergedTotalBytes { - return nil, output.Invalid( - fmt.Sprintf("scan total %d bytes exceeds local merge cap %d", scan.TotalBytes, MaxMergedTotalBytes), - "Tighten --exclude or split the import into smaller batches", - ) - } - if opts.Now.IsZero() { - opts.Now = time.Now().UTC() - } - - sourceKey := opts.SourceID - if sourceKey == "" { - sourceKey = "default_local" - } - logicalPath := "cold_start_merge_" + string(scan.Platform) - docKey := buildDocumentKey(sourceKey, logicalPath) - - var buf bytes.Buffer - // Front matter (v2). - fmt.Fprintf(&buf, "---\n") - fmt.Fprintf(&buf, "everme_import_version: %d\n", MergeFormatVersion) - fmt.Fprintf(&buf, "platform: %s\n", scan.Platform) - if opts.AgentID != "" { - fmt.Fprintf(&buf, "agent_id: %s\n", opts.AgentID) - } - fmt.Fprintf(&buf, "merged_at: %s\n", opts.Now.Format(time.RFC3339)) - fmt.Fprintf(&buf, "file_count: %d\n", len(scan.Files)) - fmt.Fprintf(&buf, "total_bytes: %d\n", scan.TotalBytes) - fmt.Fprintf(&buf, "document_key_hint: %s\n", logicalPath) - fmt.Fprintf(&buf, "---\n\n") - fmt.Fprintf(&buf, "# Cold-start memory merged from %s\n\n", scan.Platform) - fmt.Fprintf(&buf, "_This file is an automated merge of %d local memory files._\n\n", - len(scan.Files)) - - for _, f := range scan.Files { - // HTML comment marker — markdown-invisible, can't collide with - // user content. Backend SplitForIngest splits on these. - fmt.Fprintf(&buf, fileMarkerFormat, escapeRelPath(f.RelPath)) - buf.WriteString("\n\n") - - raw, err := os.ReadFile(f.Path) - if err != nil { - return nil, output.IOErr(f.Path, "read", err) - } - // Normalize line endings — keeps hash stable across CRLF/LF - // and makes downstream parsers (regex, scanners) predictable. - clean := normalizeLineEndings(raw) - buf.Write(clean) - // Each section ends with exactly one trailing blank line so - // the next marker sits on its own line. No more `---` - // separator — section boundary IS the next marker. - if !bytes.HasSuffix(clean, []byte("\n")) { - buf.WriteByte('\n') - } - buf.WriteByte('\n') - } - - body := buf.Bytes() - hash := sha256.Sum256(body) - - fileName := fmt.Sprintf("cold-start-%s-%s.md", scan.Platform, opts.Now.Format("20060102-150405")) - - return &MergedDoc{ - Platform: scan.Platform, - FileName: fileName, - Body: body, - SizeBytes: int64(len(body)), - ContentHash: hex.EncodeToString(hash[:]), - FileCount: len(scan.Files), - DocumentKey: docKey, - IdempotencyKey: newIdempotencyKey(), - }, nil -} - -// escapeRelPath produces a value safe to embed inside the marker's -// double-quoted `rel="..."` attribute. We backslash-escape `\` and `"` -// and substitute control characters (\r, \n, \t, NUL) with explicit -// escape sequences so a hostile / typo'd file name cannot break the -// marker grammar that the backend reverse-splitter relies on. The -// scanner already drops files whose name contains \r or \n at the -// outer boundary, but escapeRelPath is still defensive — paths with -// embedded tabs (legal on Unix) or NUL (rare but possible on raw FS) -// would otherwise corrupt the merged document. -func escapeRelPath(rel string) string { - rel = strings.ReplaceAll(rel, `\`, `\\`) - rel = strings.ReplaceAll(rel, `"`, `\"`) - rel = strings.ReplaceAll(rel, "\r", `\r`) - rel = strings.ReplaceAll(rel, "\n", `\n`) - rel = strings.ReplaceAll(rel, "\t", `\t`) - rel = strings.ReplaceAll(rel, "\x00", `\0`) - return rel -} - -// buildDocumentKey mirrors the backend's stable derivation: -// "doc_" + sha256(sourceKey + ":" + logicalPath)[:32]. -func buildDocumentKey(sourceKey, logicalPath string) string { - sum := sha256.Sum256([]byte(sourceKey + ":" + logicalPath)) - return "doc_" + hex.EncodeToString(sum[:])[:32] -} - -// normalizeLineEndings replaces \r\n with \n. Standalone \r (legacy mac) -// is also normalized for safety. -func normalizeLineEndings(in []byte) []byte { - out := bytes.ReplaceAll(in, []byte("\r\n"), []byte("\n")) - out = bytes.ReplaceAll(out, []byte("\r"), []byte("\n")) - return out -} - -// (WriteToTempFile and SanitizeRelPath were retired in the slimming -// pass — both had zero production callers. Reintroduce when an -// uploader path actually needs disk-buffered merge bodies or when -// merger-side path traversal becomes a concrete threat.) diff --git a/cli/internal/importer/merger_format_test.go b/cli/internal/importer/merger_format_test.go deleted file mode 100644 index 84a74b7..0000000 --- a/cli/internal/importer/merger_format_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package importer - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestMerge_V2FormatLocked asserts the on-disk shape produced by Merge. -// Backend SplitForIngest depends on this format byte-for-byte; if these -// assertions break we've changed the contract and must bump -// MergeFormatVersion before merging. -func TestMerge_V2FormatLocked(t *testing.T) { - tmp := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.md"), []byte("# A\n\nFirst body.\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(tmp, "b.md"), []byte("# B\n\nSecond body."), 0o600)) - - scan := &SourceScan{ - Platform: PlatformOpenClaw, - RootPath: tmp, - Files: []ScanFile{ - {Path: filepath.Join(tmp, "a.md"), RelPath: "a.md"}, - {Path: filepath.Join(tmp, "b.md"), RelPath: "b.md"}, - }, - TotalBytes: 30, - } - merged, err := Merge(scan, MergeOptions{ - Now: time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC), - }) - require.NoError(t, err) - body := string(merged.Body) - - // Front matter declares v2. - assert.Contains(t, body, "everme_import_version: 2", - "format version must be advertised in YAML front matter so the backend dispatcher can pick the right parser") - - // HTML-comment markers in place of `## relPath` headings. - assert.Contains(t, body, ``) - assert.Contains(t, body, ``) - - // The legacy v1 separators MUST be gone — backend v2 splitter does - // not look for them. - assert.NotContains(t, body, "## a.md", "v1 ## headings should not appear in v2 output") - assert.NotContains(t, body, "\n---\n\n## ", "v1 separator-then-heading sequence must be gone") - - // Section content order matches input order. - idxA := strings.Index(body, `rel="a.md"`) - idxB := strings.Index(body, `rel="b.md"`) - require.GreaterOrEqual(t, idxA, 0) - require.GreaterOrEqual(t, idxB, 0) - assert.Less(t, idxA, idxB, "files appear in scan order") - - // User-supplied content comes through unmodified (sans CRLF normalize). - assert.Contains(t, body, "First body.") - assert.Contains(t, body, "Second body.") -} - -func TestMerge_V2_EscapesQuotesInRelPath(t *testing.T) { - tmp := t.TempDir() - rel := `weird "quoted" name.md` - require.NoError(t, os.WriteFile(filepath.Join(tmp, "weird.md"), []byte("body"), 0o600)) - - scan := &SourceScan{ - Platform: PlatformClaudeCode, - Files: []ScanFile{ - {Path: filepath.Join(tmp, "weird.md"), RelPath: rel}, - }, - } - merged, err := Merge(scan, MergeOptions{Now: time.Now().UTC()}) - require.NoError(t, err) - - // Quote in relPath must be backslash-escaped so the regex parser - // on the backend still finds the closing `" -->`. - assert.Contains(t, string(merged.Body), `rel="weird \"quoted\" name.md"`) -} diff --git a/cli/internal/importer/scanner.go b/cli/internal/importer/scanner.go deleted file mode 100644 index 4d20494..0000000 --- a/cli/internal/importer/scanner.go +++ /dev/null @@ -1,481 +0,0 @@ -package importer - -import ( - "context" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" - - "evercli/internal/output" -) - -// SingleFileLimitBytes is the per-file ceiling. Files past this are -// skipped (with SkipReason) rather than counted toward the merge. -const SingleFileLimitBytes = 1 << 20 // 1 MB - -// blacklistDirs are pruned during scan walk. Lowercase comparison; users -// can extend via cmd flag (--exclude) which appends to this set. -// -// We bias toward over-pruning: false negatives ("you missed my notes -// in vendor/") are easier to recover from with --include than false -// positives ("import scan walked 200k generated files"). -var blacklistDirs = map[string]struct{}{ - ".git": {}, - ".hg": {}, - ".svn": {}, - "node_modules": {}, - ".venv": {}, - ".env": {}, - "__pycache__": {}, - ".tox": {}, - ".mypy_cache": {}, - ".pytest_cache": {}, - "dist": {}, - "build": {}, - "target": {}, // Rust / Java - "vendor": {}, // Go vendor / Composer - "coverage": {}, - ".next": {}, - ".nuxt": {}, - ".gradle": {}, - ".idea": {}, - ".vscode": {}, -} - -// Scanner probes one platform's memory directory. -type Scanner interface { - Platform() PlatformID - Root() (string, error) - Scan(ctx context.Context, extraExclude []string) (*SourceScan, error) -} - -// ---- Claude Code ---------------------------------------------------- - -type claudeCodeScanner struct{} - -func newClaudeCodeScanner() *claudeCodeScanner { return &claudeCodeScanner{} } - -func (claudeCodeScanner) Platform() PlatformID { return PlatformClaudeCode } - -// Root reports the Claude Code config directory the scanner anchors -// at. The actual scan covers two zones inside it: -// -// - depth-1 *.md under ~/.claude/ — picks up the user-level -// CLAUDE.md global memory file (Anthropic's documented per-user -// personalization slot) and any other markdown notes the user -// keeps next to it. Without this pass we never sweep the -// persona / preferences content that lives at this layer. -// - full recursion of ~/.claude/projects/ — per-project session -// directories where transcripts and project memory live, same -// as before. -// -// Other ~/.claude/ subdirectories (sessions/, plans/, file-history/, -// cache/, shell-snapshots/, …) are intentionally NOT descended into. -// They hold Claude Code runtime state, command history, and snapshot -// artifacts that don't belong in cold-start knowledge import; users -// who want them in scope should opt in via a future `--root` / -// `--include` flag. -func (claudeCodeScanner) Root() (string, error) { - return claudeConfigRoot() -} - -// claudeConfigRoot resolves the Claude Code config directory honoring -// $CLAUDE_CONFIG_DIR so tests / non-default installs work. -func claudeConfigRoot() (string, error) { - if dir := os.Getenv("CLAUDE_CONFIG_DIR"); dir != "" { - return dir, nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".claude"), nil -} - -func (s claudeCodeScanner) Scan(ctx context.Context, extraExclude []string) (*SourceScan, error) { - configRoot, err := claudeConfigRoot() - if err != nil { - return nil, output.IOErr("home-dir", "resolve", err) - } - scan := &SourceScan{ - Platform: PlatformClaudeCode, - Type: "cold-start", - RootPath: configRoot, - } - excluded := mergeExcludes(extraExclude) - - // Pass 1: top-level *.md at ~/.claude/ root — captures CLAUDE.md - // and any siblings the user keeps there. - if err := walkMarkdownAtDepth1(ctx, scan, configRoot, configRoot); err != nil { - return nil, err - } - - // Pass 2: ~/.claude/projects/ recursive. Relpath anchored at - // configRoot so files come out as `projects//.md`, - // leaving any root-level `CLAUDE.md` etc. unambiguously identifiable - // in the merged document. - projectsRoot := filepath.Join(configRoot, "projects") - if err := walkMarkdownTreeInto(ctx, scan, projectsRoot, configRoot, excluded); err != nil { - return nil, err - } - - sort.Slice(scan.Files, func(i, j int) bool { - return filepath.ToSlash(scan.Files[i].RelPath) < filepath.ToSlash(scan.Files[j].RelPath) - }) - return scan, nil -} - -// ---- OpenClaw ------------------------------------------------------- - -type openclawScanner struct{} - -func newOpenclawScanner() *openclawScanner { return &openclawScanner{} } - -func (openclawScanner) Platform() PlatformID { return PlatformOpenClaw } - -// Root reports the workspace directory the scanner anchors at. The -// actual scan covers two zones inside it: -// -// - depth-1 *.md under workspace/ — picks up persona / identity -// files (USER.md, IDENTITY.md, SOUL.md, BOOTSTRAP.md, AGENTS.md, -// plus any user-written project notes that landed at workspace -// root) that the old memory-only scan silently missed. -// - full recursion of workspace/memory/ — the per-conversation -// memory log, same as before. -// -// Subdirectories at workspace level other than memory/ (skills/, -// per-agent project workspaces, tmp/, …) are intentionally NOT -// descended into. They hold runtime state and per-skill scratch -// content that doesn't belong in cold-start knowledge import; users -// who want them in scope should opt in via a future `--root`/ -// `--include` flag rather than have us silently sweep gigabytes of -// agent state into the cloud. -func (openclawScanner) Root() (string, error) { - return openclawWorkspaceRoot() -} - -// openclawWorkspaceRoot resolves the workspace directory honoring -// $OPENCLAW_CONFIG_DIR so tests / non-default installs work. -func openclawWorkspaceRoot() (string, error) { - if dir := os.Getenv("OPENCLAW_CONFIG_DIR"); dir != "" { - return filepath.Join(dir, "workspace"), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".openclaw", "workspace"), nil -} - -func (s openclawScanner) Scan(ctx context.Context, extraExclude []string) (*SourceScan, error) { - workspaceRoot, err := openclawWorkspaceRoot() - if err != nil { - return nil, output.IOErr("home-dir", "resolve", err) - } - scan := &SourceScan{ - Platform: PlatformOpenClaw, - Type: "cold-start", - RootPath: workspaceRoot, - } - excluded := mergeExcludes(extraExclude) - - // Pass 1: top-level *.md at workspace root. Tolerates missing - // workspace dir (fresh install) — the next pass still tries - // memory/ which has its own missing-root handling. - if err := walkMarkdownAtDepth1(ctx, scan, workspaceRoot, workspaceRoot); err != nil { - return nil, err - } - - // Pass 2: workspace/memory/ recursive. Relpath anchored at - // workspaceRoot so files come out as `memory/.md`, leaving - // `USER.md` etc. unambiguously identifiable in the merged document. - memoryRoot := filepath.Join(workspaceRoot, "memory") - if err := walkMarkdownTreeInto(ctx, scan, memoryRoot, workspaceRoot, excluded); err != nil { - return nil, err - } - - sort.Slice(scan.Files, func(i, j int) bool { - return filepath.ToSlash(scan.Files[i].RelPath) < filepath.ToSlash(scan.Files[j].RelPath) - }) - return scan, nil -} - -// ---- shared walk ---------------------------------------------------- - -// scanMarkdownTree walks root recursively collecting *.md files. -// Behavior: -// - missing root → SourceScan with FileCount=0 (not an error) -// - blacklist dirs pruned -// - files > SingleFileLimitBytes → SkippedFiles -// - symlinks NOT followed (avoid loops, see 05-import.md §5.6) -// - the root itself is rejected when it's a symlink so a malicious / -// accidentally-misconfigured `~/.claude/projects → /etc` doesn't -// leak unrelated host content into the merge. -// -// This is the simple single-root entry used by scanners that have one -// directory tree to walk (Claude Code). Scanners that need to combine -// several roots (OpenClaw: workspace top-level + workspace/memory/) -// build their own SourceScan and call walkMarkdownTreeInto / -// walkMarkdownAtDepth1 directly so RelPaths can share a single anchor. -func scanMarkdownTree(ctx context.Context, p PlatformID, root string, extraExclude []string) (*SourceScan, error) { - scan := &SourceScan{Platform: p, Type: "cold-start", RootPath: root} - excluded := mergeExcludes(extraExclude) - if err := walkMarkdownTreeInto(ctx, scan, root, root, excluded); err != nil { - return nil, err - } - // Stable order matters — merger output must hash-match across runs. - sort.Slice(scan.Files, func(i, j int) bool { - return filepath.ToSlash(scan.Files[i].RelPath) < filepath.ToSlash(scan.Files[j].RelPath) - }) - return scan, nil -} - -// walkMarkdownTreeInto performs the recursive *.md walk used by -// scanMarkdownTree, but appends to a caller-supplied SourceScan and -// computes RelPath against relPathBase rather than walkRoot. -// -// relPathBase is what lets a scanner combine multiple walks into one -// SourceScan without RelPath collisions: pass the same workspace root -// for every walk, and a file at `workspace/memory/x.md` comes out as -// `memory/x.md` while a sibling `workspace/USER.md` stays `USER.md`. -// -// Missing walkRoot is "no files", not an error (fresh installs). -func walkMarkdownTreeInto(ctx context.Context, scan *SourceScan, walkRoot, relPathBase string, excluded map[string]struct{}) error { - // Lstat first so a symlink ROOT doesn't get followed silently. - // os.Stat would happily resolve `~/.claude/projects → /etc` and - // WalkDir would then walk /etc, which is exactly the symlink- - // escape attack surface we want to close. - linfo, err := os.Lstat(walkRoot) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil - } - return output.IOErr(walkRoot, "lstat-root", err) - } - if linfo.Mode()&os.ModeSymlink != 0 { - return output.Invalid( - fmt.Sprintf("scan root %s is a symlink; refusing to follow", walkRoot), - "Replace the symlink with a real directory or point the platform at a non-symlinked path", - ) - } - info, err := os.Stat(walkRoot) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil - } - return output.IOErr(walkRoot, "stat-root", err) - } - if !info.IsDir() { - return nil - } - - // rootResolved is the canonicalized form of walkRoot we use to - // verify every visited file actually lives under it. Cheap defense - // against rare cases where filepath.WalkDir would otherwise hand - // us a path that escaped via a parent-relative oddity. If - // EvalSymlinks itself fails, record a SkipEntry so users see "the - // safety net is not active" instead of the previous silent - // fall-through. - rootResolved, evalErr := filepath.EvalSymlinks(walkRoot) - if evalErr != nil { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{ - Path: walkRoot, - Reason: "EvalSymlinks failed; symlink-escape defense disabled: " + evalErr.Error(), - }) - } - - walkErr := filepath.WalkDir(walkRoot, func(p string, d fs.DirEntry, err error) error { - if ctx.Err() != nil { - return ctx.Err() - } - if err != nil { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{Path: p, Reason: "walk error: " + err.Error()}) - return nil - } - if d.IsDir() { - if p == walkRoot { - return nil - } - name := strings.ToLower(d.Name()) - if _, blocked := excluded[name]; blocked { - return fs.SkipDir - } - if d.Type()&os.ModeSymlink != 0 { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{Path: p, Reason: "directory symlink (not followed)"}) - return fs.SkipDir - } - return nil - } - if d.Type()&os.ModeSymlink != 0 { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{Path: p, Reason: "file symlink (not followed)"}) - return nil - } - if !strings.HasSuffix(strings.ToLower(d.Name()), ".md") { - return nil - } - // Defense in depth: if the resolved file path escapes the - // resolved root we drop it. - if rootResolved != "" { - if resolved, err := filepath.EvalSymlinks(p); err == nil { - if !strings.HasPrefix(resolved, rootResolved+string(filepath.Separator)) && resolved != rootResolved { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{Path: p, Reason: "escaped scan root after resolve"}) - return nil - } - } - } - fi, err := d.Info() - if err != nil { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{Path: p, Reason: "stat error"}) - return nil - } - appendMarkdownFile(scan, p, relPathBase, fi) - return nil - }) - // A walk that ended because ctx was cancelled OR timed out must be - // surfaced as cancellation (exit 130 / TypeCancelled), not as a - // generic IO error (exit 5 / TypeIO). The previous check missed - // DeadlineExceeded, so `--timeout`-driven cancellation during a - // large scan was being mis-classified. - if walkErr != nil { - if errors.Is(walkErr, context.Canceled) || errors.Is(walkErr, context.DeadlineExceeded) { - return walkErr - } - return output.IOErr(walkRoot, "walk", walkErr) - } - return nil -} - -// walkMarkdownAtDepth1 reads dir's top-level *.md files (no recursion) -// and appends them to scan with RelPath anchored at relPathBase. -// Missing dir is treated as "no files" (fresh install before any -// workspace setup). Symlinked dir is rejected the same way the -// recursive walker rejects symlinked roots, so a symlinked -// $OPENCLAW_CONFIG_DIR pointing at /etc cannot leak files in. -// -// This is the depth-1 counterpart to walkMarkdownTreeInto: same -// per-file safety checks (size cap, control-char filter, file-symlink -// skip), no subdirectory descent. -func walkMarkdownAtDepth1(ctx context.Context, scan *SourceScan, dir, relPathBase string) error { - linfo, err := os.Lstat(dir) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil - } - return output.IOErr(dir, "lstat-root", err) - } - if linfo.Mode()&os.ModeSymlink != 0 { - return output.Invalid( - fmt.Sprintf("scan root %s is a symlink; refusing to follow", dir), - "Replace the symlink with a real directory or point the platform at a non-symlinked path", - ) - } - if !linfo.IsDir() { - return nil - } - entries, err := os.ReadDir(dir) - if err != nil { - return output.IOErr(dir, "readdir", err) - } - for _, e := range entries { - if ctx.Err() != nil { - return ctx.Err() - } - if e.IsDir() { - continue - } - p := filepath.Join(dir, e.Name()) - if e.Type()&os.ModeSymlink != 0 { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{Path: p, Reason: "file symlink (not followed)"}) - continue - } - if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") { - continue - } - fi, err := e.Info() - if err != nil { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{Path: p, Reason: "stat error"}) - continue - } - appendMarkdownFile(scan, p, relPathBase, fi) - } - return nil -} - -// appendMarkdownFile applies the per-file size / control-char checks -// shared by walkMarkdownTreeInto and walkMarkdownAtDepth1, then -// records either a ScanFile or a SkipEntry. -// -// relPathBase is the anchor used when computing the file's RelPath — -// the merged document marker (``) keys -// off this value, so collisions across multiple walks are avoided by -// using a single shared anchor for them all. -func appendMarkdownFile(scan *SourceScan, fullPath, relPathBase string, fi fs.FileInfo) { - if fi.Size() > SingleFileLimitBytes { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{Path: fullPath, Reason: "file too large (>1MB)"}) - return - } - rel, _ := filepath.Rel(relPathBase, fullPath) - // Reject files whose path contains \r or \n: they would corrupt - // the merged-document marker and the backend reverse-splitter - // can't reconstruct them. The scanner is the right gate because - // we still know the original path; downstream merger only sees - // RelPath. - if strings.ContainsAny(rel, "\r\n") { - scan.SkippedFiles = append(scan.SkippedFiles, SkipEntry{Path: fullPath, Reason: "file path contains control characters"}) - return - } - scan.Files = append(scan.Files, ScanFile{ - Path: fullPath, - RelPath: rel, - Title: filepath.Base(fullPath), - SizeBytes: fi.Size(), - ModifiedAt: fi.ModTime(), - }) - scan.TotalBytes += fi.Size() -} - -func mergeExcludes(extra []string) map[string]struct{} { - out := make(map[string]struct{}, len(blacklistDirs)+len(extra)) - for k := range blacklistDirs { - out[k] = struct{}{} - } - for _, e := range extra { - out[strings.ToLower(strings.TrimSpace(e))] = struct{}{} - } - return out -} - -// ScanRegistry returns the production scanner set, in stable order. -func ScanRegistry() []Scanner { - return []Scanner{newClaudeCodeScanner(), newOpenclawScanner()} -} - -// ToSummary collapses a SourceScan into the smaller form returned by -// `import scan` (no per-file rows, only sample titles). -func (s *SourceScan) ToSummary() ScanSummary { - sum := ScanSummary{ - Platform: s.Platform, - Type: s.Type, - RootPath: s.RootPath, - FileCount: len(s.Files), - TotalBytes: s.TotalBytes, - SkippedCount: len(s.SkippedFiles), - } - for i, f := range s.Files { - if i >= 5 { - break - } - sum.SampleTitles = append(sum.SampleTitles, f.Title) - } - for i, sk := range s.SkippedFiles { - if i >= 3 { - break - } - sum.SkippedSamples = append(sum.SkippedSamples, sk) - } - return sum -} diff --git a/cli/internal/importer/scanner_test.go b/cli/internal/importer/scanner_test.go deleted file mode 100644 index 5f7dd0f..0000000 --- a/cli/internal/importer/scanner_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package importer - -import ( - "context" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func writeMD(t *testing.T, root, rel, body string) { - t.Helper() - full := filepath.Join(root, rel) - require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o700)) - require.NoError(t, os.WriteFile(full, []byte(body), 0o600)) -} - -func TestScanMarkdownTree_HappyPath(t *testing.T) { - root := t.TempDir() - writeMD(t, root, "a.md", "# a") - writeMD(t, root, "sub/b.md", "# b") - writeMD(t, root, "ignored.txt", "skip me") - - scan, err := scanMarkdownTree(context.Background(), PlatformClaudeCode, root, nil) - require.NoError(t, err) - require.NotNil(t, scan) - - assert.Equal(t, 2, len(scan.Files)) - // Stable order: a.md before sub/b.md regardless of FS walk order. - assert.Equal(t, "a.md", filepath.ToSlash(scan.Files[0].RelPath)) - assert.Equal(t, "sub/b.md", filepath.ToSlash(scan.Files[1].RelPath)) -} - -func TestScanMarkdownTree_SkipsBlacklistedAndOversize(t *testing.T) { - root := t.TempDir() - writeMD(t, root, "vendor/notes.md", "skipped") - writeMD(t, root, ".git/notes.md", "skipped") - writeMD(t, root, "node_modules/notes.md", "skipped") - writeMD(t, root, "kept.md", "kept") - // Oversize file - big := strings.Repeat("x", int(SingleFileLimitBytes+1)) - writeMD(t, root, "big.md", big) - - scan, err := scanMarkdownTree(context.Background(), PlatformClaudeCode, root, nil) - require.NoError(t, err) - require.NotNil(t, scan) - - var kept []string - for _, f := range scan.Files { - kept = append(kept, filepath.Base(f.Path)) - } - assert.Contains(t, kept, "kept.md") - assert.NotContains(t, kept, "notes.md") - assert.NotContains(t, kept, "big.md") - - // big.md must surface in skipped with the right reason. - var skipReasons []string - for _, sk := range scan.SkippedFiles { - skipReasons = append(skipReasons, sk.Reason) - } - assert.Contains(t, strings.Join(skipReasons, "|"), "too large") -} - -func TestScanMarkdownTree_SymlinkRootRejected(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlinks require admin on Windows; behavior intent is the same") - } - target := t.TempDir() - writeMD(t, target, "a.md", "secret") - - holder := t.TempDir() - link := filepath.Join(holder, "scan-root") - require.NoError(t, os.Symlink(target, link)) - - _, err := scanMarkdownTree(context.Background(), PlatformClaudeCode, link, nil) - require.Error(t, err, "scan root must reject symlinks (closes the ~/.claude/projects → /etc escape)") - assert.Contains(t, err.Error(), "symlink") -} - -func TestScanMarkdownTree_RecordsFileSymlinkSkip(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlinks require admin on Windows") - } - root := t.TempDir() - target := t.TempDir() - writeMD(t, target, "real.md", "real content") - link := filepath.Join(root, "linked.md") - require.NoError(t, os.Symlink(filepath.Join(target, "real.md"), link)) - - scan, err := scanMarkdownTree(context.Background(), PlatformClaudeCode, root, nil) - require.NoError(t, err) - assert.Empty(t, scan.Files, "symlink files must NOT be followed") - - // Skip entry must be visible so users know "why is linked.md missing". - var reasons []string - for _, sk := range scan.SkippedFiles { - reasons = append(reasons, sk.Reason) - } - assert.Contains(t, strings.Join(reasons, "|"), "symlink") -} - -func TestScanMarkdownTree_RejectsControlCharsInPath(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Windows file names can't contain newlines, so the gate is a no-op") - } - root := t.TempDir() - // macOS / Linux allow \n in file names. Build one explicitly. - bad := filepath.Join(root, "weird\nname.md") - require.NoError(t, os.WriteFile(bad, []byte("x"), 0o600)) - good := filepath.Join(root, "sane.md") - require.NoError(t, os.WriteFile(good, []byte("x"), 0o600)) - - scan, err := scanMarkdownTree(context.Background(), PlatformClaudeCode, root, nil) - require.NoError(t, err) - assert.Equal(t, 1, len(scan.Files), "the \\n-bearing file must be skipped, not silently merged") - assert.Equal(t, "sane.md", scan.Files[0].RelPath) -} - -// TestOpenclawScanner_PicksUpWorkspaceRootMarkdown is the regression -// test for feedback §3.1: persona / identity files (USER.md, -// IDENTITY.md, SOUL.md, BOOTSTRAP.md, plus user-written notes) sit at -// workspace/ root, not inside workspace/memory/. The old scanner -// rooted at workspace/memory/ silently missed all of them — so users -// reported "I told it I like durian, but recall can't find it", -// because the source-of-truth file (workspace/USER.md) was never -// uploaded in the first place. -// -// This test asserts that under a workspace layout matching the real -// install: -// - depth-1 *.md at workspace/ ARE included -// - workspace/memory/ tree IS still scanned recursively -// - other workspace subdirectories (skills/, agent project dirs) -// are NOT recursed into — their content is runtime state, not -// cold-start knowledge -// - RelPaths are anchored at workspace/ so memory tree files come -// out as "memory/.md" and never collide with a root-level -// file of the same basename. -func TestOpenclawScanner_PicksUpWorkspaceRootMarkdown(t *testing.T) { - tmp := t.TempDir() - workspace := filepath.Join(tmp, "workspace") - require.NoError(t, os.MkdirAll(workspace, 0o700)) - - // Workspace-root persona / identity files (feedback §3.1). - writeMD(t, workspace, "USER.md", "user is alice; favorite color: blue") - writeMD(t, workspace, "IDENTITY.md", "agent persona") - writeMD(t, workspace, "SOUL.md", "values") - writeMD(t, workspace, "BOOTSTRAP.md", "boot config") - writeMD(t, workspace, "project-notes.md", "user-written notes at workspace root") - - // memory/ subtree (legacy scan target, must still be picked up). - writeMD(t, workspace, "memory/2026-05-13.md", "today's notes") - writeMD(t, workspace, "memory/sub/older.md", "older notes") - - // A non-memory subdir whose content should NOT be swept in — - // agent skill workspace. - writeMD(t, workspace, "skills/some-skill/README.md", "do not include") - // And a subdir matching a name a memory-tree file might have, - // to prove RelPath anchoring keeps them distinct. - writeMD(t, workspace, "memory/USER.md", "memory-tree USER (distinct from root USER.md)") - - t.Setenv("OPENCLAW_CONFIG_DIR", tmp) - scan, err := newOpenclawScanner().Scan(context.Background(), nil) - require.NoError(t, err) - require.NotNil(t, scan) - assert.Equal(t, workspace, scan.RootPath, - "RootPath should be the workspace, not workspace/memory") - - got := map[string]bool{} - for _, f := range scan.Files { - got[filepath.ToSlash(f.RelPath)] = true - } - - // Workspace-root persona files: present. - for _, name := range []string{"USER.md", "IDENTITY.md", "SOUL.md", "BOOTSTRAP.md", "project-notes.md"} { - assert.True(t, got[name], "workspace-root file %q should be in the scan", name) - } - // memory/ tree files: present, with the memory/ prefix proving the - // RelPath anchor is workspace, not memory. - assert.True(t, got["memory/2026-05-13.md"], "memory tree top file should be present") - assert.True(t, got["memory/sub/older.md"], "memory tree nested file should be present") - assert.True(t, got["memory/USER.md"], - "memory/USER.md must coexist with root USER.md — different RelPaths") - - // Subdirs other than memory/ at workspace level: NOT recursed. - assert.False(t, got["skills/some-skill/README.md"], - "non-memory subdir content must not be swept in by the depth-1 workspace pass") - - // Sanity: total counts add up. 5 root files + 3 memory-tree files = 8. - assert.Equal(t, 8, len(scan.Files), - "expected 5 workspace-root + 3 memory-tree files; got %d", len(scan.Files)) -} - -// TestOpenclawScanner_MissingWorkspaceIsNotAnError covers a fresh -// install where ~/.openclaw/workspace/ doesn't exist yet. The legacy -// behavior (treat missing root as empty scan) must survive the -// refactor — users shouldn't see a hard error from a clean machine. -func TestOpenclawScanner_MissingWorkspaceIsNotAnError(t *testing.T) { - tmp := t.TempDir() - t.Setenv("OPENCLAW_CONFIG_DIR", tmp) - // No workspace/ dir created. - scan, err := newOpenclawScanner().Scan(context.Background(), nil) - require.NoError(t, err) - require.NotNil(t, scan) - assert.Empty(t, scan.Files) - assert.Empty(t, scan.SkippedFiles) -} - -// TestClaudeCodeScanner_PicksUpRootCLAUDEmd is the §3.1-analog -// regression for claude-code: the old scanner rooted at -// ~/.claude/projects/ silently missed ~/.claude/CLAUDE.md, the -// user-level global memory file Anthropic documents as the -// per-user personalization slot. After this fix, depth-1 *.md at -// ~/.claude/ is picked up, while projects/ is still recursed. -func TestClaudeCodeScanner_PicksUpRootCLAUDEmd(t *testing.T) { - tmp := t.TempDir() - - // Root-level user memory (the file the old scanner missed). - writeMD(t, tmp, "CLAUDE.md", "user is alice; loves hiking") - writeMD(t, tmp, "notes.md", "free-form notes the user dropped here") - - // projects/ tree (legacy scan target, must still be picked up). - writeMD(t, tmp, "projects/-Users-admin-code-foo/memory/x.md", "project foo memory") - writeMD(t, tmp, "projects/-Users-admin-code-bar/y.md", "bar transcript-adjacent note") - - // Subdirs at ~/.claude/ that hold runtime state — must NOT be - // swept in by the depth-1 pass. - writeMD(t, tmp, "sessions/some-session/state.md", "do not include") - writeMD(t, tmp, "plans/some-plan.md", "do not include") - writeMD(t, tmp, "file-history/x.md", "do not include") - - t.Setenv("CLAUDE_CONFIG_DIR", tmp) - scan, err := newClaudeCodeScanner().Scan(context.Background(), nil) - require.NoError(t, err) - require.NotNil(t, scan) - assert.Equal(t, tmp, scan.RootPath, - "RootPath should be the config dir, not config/projects") - - got := map[string]bool{} - for _, f := range scan.Files { - got[filepath.ToSlash(f.RelPath)] = true - } - - assert.True(t, got["CLAUDE.md"], - "root-level CLAUDE.md is the whole point of this fix") - assert.True(t, got["notes.md"], - "any user-written *.md at ~/.claude/ root should come along too") - assert.True(t, got["projects/-Users-admin-code-foo/memory/x.md"], - "projects/ recursion preserved") - assert.True(t, got["projects/-Users-admin-code-bar/y.md"], - "projects/ recursion preserved (any depth)") - - assert.False(t, got["sessions/some-session/state.md"], - "sessions/ holds runtime state — must not be in cold-start import") - assert.False(t, got["plans/some-plan.md"], - "plans/ is /plan-skill output — must not be swept by default") - assert.False(t, got["file-history/x.md"], - "file-history/ is snapshot state — must not be swept by default") - - // Sanity: 2 root + 2 projects = 4 expected. - assert.Equal(t, 4, len(scan.Files), - "expected 2 root + 2 projects files; got %d", len(scan.Files)) -} - -// TestClaudeCodeScanner_MissingConfigDirIsNotAnError covers a fresh -// install where ~/.claude/ doesn't exist yet — for example a user -// who just installed Claude Code but hasn't launched it. Importer -// must treat this as an empty scan, not a hard error. -func TestClaudeCodeScanner_MissingConfigDirIsNotAnError(t *testing.T) { - tmp := t.TempDir() - missing := filepath.Join(tmp, "no-such-claude-dir") - t.Setenv("CLAUDE_CONFIG_DIR", missing) - scan, err := newClaudeCodeScanner().Scan(context.Background(), nil) - require.NoError(t, err) - require.NotNil(t, scan) - assert.Empty(t, scan.Files) - assert.Empty(t, scan.SkippedFiles) -} diff --git a/cli/internal/importer/service.go b/cli/internal/importer/service.go deleted file mode 100644 index 21393a7..0000000 --- a/cli/internal/importer/service.go +++ /dev/null @@ -1,289 +0,0 @@ -package importer - -import ( - "context" - "fmt" - "net/http" - "sort" - "time" - - "evercli/internal/client" - "evercli/internal/core" - "evercli/internal/logger" - "evercli/internal/machineid" - "evercli/internal/output" -) - -// Service composes scan + merge + upload for the cmd layer. -type Service struct { - cli client.Client - paths *core.Paths - apiBase string - scanners []Scanner - upHTTP *http.Client // injectable for tests -} - -// NewService returns a Service backed by the production scanner registry. -func NewService(cli client.Client, paths *core.Paths, apiBase string) *Service { - return &Service{ - cli: cli, - paths: paths, - apiBase: apiBase, - scanners: ScanRegistry(), - } -} - -// SetScanners overrides the scanner registry. Used by tests to point -// scanners at a tmp dir without env-var dance. -func (s *Service) SetScanners(scs []Scanner) { s.scanners = scs } - -// SetUploadHTTPClient lets tests inject an in-process httptest client -// so the S3 PresignedPOST URL resolves without leaving the test binary. -func (s *Service) SetUploadHTTPClient(hc *http.Client) { s.upHTTP = hc } - -// ---- Scan ----------------------------------------------------------- - -// Scan runs every registered scanner and returns the per-platform -// summaries. Output ordering matches scanner registration (alpha). -func (s *Service) Scan(ctx context.Context, exclude []string) ([]ScanSummary, error) { - out := make([]ScanSummary, 0, len(s.scanners)) - for _, sc := range s.scanners { - res, err := sc.Scan(ctx, exclude) - if err != nil { - return nil, err - } - out = append(out, res.ToSummary()) - } - sort.Slice(out, func(i, j int) bool { return out[i].Platform < out[j].Platform }) - return out, nil -} - -// ---- Run ------------------------------------------------------------ - -// RunOptions per-call knobs (mirrors the cmd flag set). -// -// SourceIDByPlatform was retired in the slimming pass — the field had -// zero CLI callers (the cmd layer never set it) and was a library-style -// extension point with no concrete consumer. The backend resolves the -// source via the registered agent's machineFingerprint; per-platform -// override is reintroducible if a future caller actually needs it. -type RunOptions struct { - Platforms []PlatformID // empty → all detected scanners - Resume bool - DryRun bool - Exclude []string -} - -// RunReport is the public import-run result envelope. -type RunReport struct { - DryRun bool `json:"dryRun,omitempty"` - Imports []RecordResult `json:"imports,omitempty"` - Skipped []RunSkip `json:"skipped,omitempty"` - Failed []RunFail `json:"failed,omitempty"` - Previews []DryRunPreview `json:"previews,omitempty"` -} - -// RunSkip captures "this platform had no files / wasn't requested". -type RunSkip struct { - Platform PlatformID `json:"platform"` - Reason string `json:"reason"` -} - -// RunFail rewrites a CLIError into the per-platform error envelope. -type RunFail struct { - Platform PlatformID `json:"platform"` - Error runFailErr `json:"error"` -} - -type runFailErr struct { - Type output.ErrorType `json:"type"` - Message string `json:"message"` - Hint string `json:"hint,omitempty"` - Code int `json:"code,omitempty"` -} - -// DryRunPreview is the shape returned when --dry-run is set. -type DryRunPreview struct { - Platform PlatformID `json:"platform"` - FileCount int `json:"fileCount"` - TotalBytes int64 `json:"totalBytes"` - MergedBytes int64 `json:"mergedBytes"` - ContentHash string `json:"contentHash"` - DocumentKey string `json:"documentKey"` - WouldPostTo string `json:"wouldPostTo"` -} - -// Run executes the cold-start import for each requested platform. A -// per-platform failure is captured in Failed and the next platform is -// still attempted. Caller decides ok=false vs ok=true based on Failed. -func (s *Service) Run(ctx context.Context, opts RunOptions) (*RunReport, error) { - platforms, err := s.resolvePlatforms(opts) - if err != nil { - return nil, err - } - rep := &RunReport{DryRun: opts.DryRun} - - for _, p := range platforms { - s.runOne(ctx, p, opts, rep) - } - return rep, nil -} - -func (s *Service) resolvePlatforms(opts RunOptions) ([]PlatformID, error) { - if len(opts.Platforms) > 0 { - // Validate against scanner registry. - known := map[PlatformID]bool{} - for _, sc := range s.scanners { - known[sc.Platform()] = true - } - for _, p := range opts.Platforms { - if !known[p] { - return nil, output.Invalid(fmt.Sprintf("unknown platform %q", p), "") - } - } - return opts.Platforms, nil - } - out := make([]PlatformID, 0, len(s.scanners)) - for _, sc := range s.scanners { - out = append(out, sc.Platform()) - } - return out, nil -} - -// runOne is the per-platform body. Errors during scan/merge/upload are -// captured into rep.Failed; ctx-cancel is bubbled up by the caller's -// next iteration. -func (s *Service) runOne(ctx context.Context, p PlatformID, opts RunOptions, rep *RunReport) { - scanner := s.findScanner(p) - if scanner == nil { - rep.Failed = append(rep.Failed, makeFail(p, output.Invalid(fmt.Sprintf("scanner not registered: %s", p), ""))) - return - } - - scan, err := scanner.Scan(ctx, opts.Exclude) - if err != nil { - rep.Failed = append(rep.Failed, makeFail(p, err)) - return - } - if len(scan.Files) == 0 { - rep.Skipped = append(rep.Skipped, RunSkip{Platform: p, Reason: "no files"}) - return - } - - // SourceID is keyed per-(machine, user, platform) so the derived - // documentKey doesn't collide across installs. Without this, every - // machine importing the same platform (e.g. claude-code) ends up - // sharing one documentKey at the backend, and the version chain - // silently treats unrelated machines as revisions of one document. - merged, err := Merge(scan, MergeOptions{ - SourceID: machineid.Fingerprint(string(p)), - }) - if err != nil { - rep.Failed = append(rep.Failed, makeFail(p, err)) - return - } - - if opts.DryRun { - rep.Previews = append(rep.Previews, DryRunPreview{ - Platform: p, - FileCount: merged.FileCount, - TotalBytes: scan.TotalBytes, - MergedBytes: merged.SizeBytes, - ContentHash: merged.ContentHash, - DocumentKey: merged.DocumentKey, - WouldPostTo: "/mem/uploads/presign + /mem/records", - }) - return - } - - // Resume support: if a checkpoint exists for this platform, load - // it (otherwise start fresh). - ckPath := CheckpointPath(s.paths.CacheDir, p) - var ck *Checkpoint - if opts.Resume { - ck, _ = LoadCheckpoint(ckPath) - } - if ck == nil { - ck = &Checkpoint{} - } - - uploader := s.uploader() - res, err := uploader.Upload(ctx, UploadParams{ - Doc: merged, - APIBase: s.apiBase, - }, ck) - // Persist checkpoint regardless of outcome so partial progress is - // recoverable. On success we delete it. A checkpoint-write failure - // is not fatal — but we log a warning so a doctor / debug bundle - // surfaces "your --resume won't work next time" instead of the - // previous silent _ = SaveCheckpoint behavior. - if cpErr := SaveCheckpoint(ckPath, ck); cpErr != nil { - logger.L().Warnw("checkpoint save failed; --resume will start over", - "platform", p, - "path", ckPath, - "err", cpErr.Error(), - ) - } - if err != nil { - rep.Failed = append(rep.Failed, makeFail(p, err)) - return - } - _ = DeleteCheckpoint(ckPath) - rep.Imports = append(rep.Imports, *res) -} - -func (s *Service) findScanner(p PlatformID) Scanner { - for _, sc := range s.scanners { - if sc.Platform() == p { - return sc - } - } - return nil -} - -func (s *Service) uploader() *Uploader { - if s.upHTTP != nil { - return NewUploaderWithHTTP(s.cli, s.upHTTP) - } - return NewUploader(s.cli) -} - -func makeFail(p PlatformID, err error) RunFail { - ce := output.ClassifyError(err) - return RunFail{ - Platform: p, - Error: runFailErr{ - Type: ce.Type, - Message: ce.Message, - Hint: ce.Hint, - Code: ce.Code, - }, - } -} - -// CleanupExpiredCheckpoints removes checkpoint files older than maxAge, -// plus any that fail to parse (leftover from a crashed run with a -// schema-mismatch). Wired into `evercli doctor --cleanup`. -func CleanupExpiredCheckpoints(cacheDir string, maxAge time.Duration, now time.Time) (int, error) { - count := 0 - for _, p := range []PlatformID{PlatformClaudeCode, PlatformOpenClaw} { - path := CheckpointPath(cacheDir, p) - ck, err := LoadCheckpoint(path) - if err != nil { - // Unparseable checkpoint file — drop it. This includes the - // "user upgraded to a newer evercli that changed the - // Checkpoint shape" case. - _ = DeleteCheckpoint(path) - count++ - continue - } - if ck == nil { - continue - } - if !ck.CreatedAt.IsZero() && now.Sub(ck.CreatedAt) > maxAge { - _ = DeleteCheckpoint(path) - count++ - } - } - return count, nil -} diff --git a/cli/internal/importer/types.go b/cli/internal/importer/types.go deleted file mode 100644 index 79dc5a4..0000000 --- a/cli/internal/importer/types.go +++ /dev/null @@ -1,88 +0,0 @@ -// Package importer drives `evercli import scan / run` — scanning local -// AI-Agent memory files, merging them into a single markdown document, -// and uploading it via the EverMe presign-then-record flow. -// -// Three pipeline stages are independently testable: -// -// scanner → []SourceScan -// merger → MergedDoc -// uploader → RecordResult -// -// Service composes them. -package importer - -import "time" - -// PlatformID is the local-side platform tag (mirrors plugin.Platform). -// Re-declared here to avoid a circular import with internal/plugin. -type PlatformID string - -const ( - PlatformClaudeCode PlatformID = "claude-code" - PlatformOpenClaw PlatformID = "openclaw" -) - -// SourceScan is the scanner output for one platform. -type SourceScan struct { - Platform PlatformID `json:"platform"` - Type string `json:"type"` // "cold-start" - RootPath string `json:"rootPath"` - Files []ScanFile `json:"files"` - TotalBytes int64 `json:"totalBytes"` - SkippedFiles []SkipEntry `json:"skippedFiles,omitempty"` -} - -// ScanFile is one candidate file the scanner accepted. -type ScanFile struct { - Path string `json:"path"` - RelPath string `json:"relPath"` - Title string `json:"title"` - SizeBytes int64 `json:"sizeBytes"` - ModifiedAt time.Time `json:"modifiedAt"` -} - -// SkipEntry records why a candidate was rejected. Surfaced in the scan -// envelope so users can debug "why is this file missing". -type SkipEntry struct { - Path string `json:"path"` - Reason string `json:"reason"` -} - -// ScanSummary is the abbreviated form returned by `import scan`. -type ScanSummary struct { - Platform PlatformID `json:"platform"` - Type string `json:"type"` - RootPath string `json:"rootPath"` - FileCount int `json:"fileCount"` - TotalBytes int64 `json:"totalBytes"` - SampleTitles []string `json:"sampleTitles,omitempty"` - SkippedCount int `json:"skippedCount,omitempty"` - SkippedSamples []SkipEntry `json:"skippedSamples,omitempty"` -} - -// MergedDoc is the output of merger.Merge — a single markdown blob plus -// its precomputed integrity hash and the deterministic documentKey. -type MergedDoc struct { - Platform PlatformID - FileName string // "cold-start-claude-code-20260421-173000.md" - Body []byte // utf-8 markdown - SizeBytes int64 - ContentHash string // sha256 hex of Body - FileCount int - DocumentKey string // doc_ - IdempotencyKey string // UUID v4 -} - -// RecordResult is the per-platform success row in `import run`. -type RecordResult struct { - Platform PlatformID `json:"platform"` - RecordID string `json:"recordId"` - SourceID string `json:"sourceId,omitempty"` - ObjectKey string `json:"objectKey"` - FileCount int `json:"fileCount"` - TotalBytes int64 `json:"totalBytes"` - MergedBytes int64 `json:"mergedBytes"` - ContentHash string `json:"contentHash"` - DocumentKey string `json:"documentKey"` - IdempotencyKey string `json:"idempotencyKey"` -} diff --git a/cli/internal/importer/uploader.go b/cli/internal/importer/uploader.go deleted file mode 100644 index 9ab0307..0000000 --- a/cli/internal/importer/uploader.go +++ /dev/null @@ -1,299 +0,0 @@ -package importer - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "sort" - "strings" - "time" - - "evercli/internal/client" - "evercli/internal/output" -) - -// MaxMergedBytes is the hard ceiling for the merged-document size that -// the uploader is willing to push to S3. postMultipart serialises the -// whole payload into a bytes.Buffer to satisfy S3's Content-Length -// requirement, so peak memory is ~2× the merged size. Typical -// cold-start dumps are <2 MiB; 32 MiB gives a comfortable margin -// without letting a runaway batch pin ~512 MiB of CLI RAM. -const MaxMergedBytes int64 = 32 << 20 - -// Uploader wraps the three upload steps (presign, S3 POST, CreateRecord) -// and provides knobs for tests (custom http.Client). Production uses -// http.DefaultClient with sane timeouts. -type Uploader struct { - cli client.Client - http *http.Client -} - -// NewUploader returns a default Uploader. Caller passes the EverMe -// client; the underlying *http.Client is built with a 120s timeout -// matching the EverMe import upload contract. -func NewUploader(cli client.Client) *Uploader { - return &Uploader{ - cli: cli, - http: &http.Client{Timeout: 120 * time.Second}, - } -} - -// NewUploaderWithHTTP is the test-friendly variant that lets the -// httptest server's own client be threaded through (so the upload URL -// resolves to the in-process mock). -func NewUploaderWithHTTP(cli client.Client, hc *http.Client) *Uploader { - return &Uploader{cli: cli, http: hc} -} - -// UploadParams glues together everything Upload needs from the caller. -type UploadParams struct { - Doc *MergedDoc - SourceID string // backend source id; empty → backend routes by default - APIBase string // baseURL for record + presign request labelling -} - -// Upload runs the three-step pipeline and returns the resulting -// RecordResult on success. Idempotency-conflict on CreateRecord is -// retried once with a fresh key (per 05-import.md §5.2.3). -// -// Side-effects on the supplied checkpoint: -// - filled with presign output (step="presigned") before S3 POST -// - advanced to step="uploaded" after S3 POST -// - left at "uploaded" if CreateRecord fails (so --resume can retry) -// - caller deletes the checkpoint on success -func (u *Uploader) Upload(ctx context.Context, p UploadParams, ck *Checkpoint) (*RecordResult, error) { - if p.Doc == nil { - return nil, output.Internal(fmt.Errorf("nil merged doc")) - } - if p.Doc.SizeBytes > MaxMergedBytes { - return nil, output.Invalid( - fmt.Sprintf("merged document is %d bytes, exceeds local cap %d", p.Doc.SizeBytes, MaxMergedBytes), - "Split the import into smaller batches or raise MaxMergedBytes if you really need this size", - ) - } - - // Step 1: presign — unless checkpoint already has it. - if ck.UploadURL == "" || !ck.UploadURLValid(time.Now()) { - presign, err := u.cli.Presign(ctx, client.PresignReq{ - FileName: p.Doc.FileName, - ContentType: "text/markdown", - SizeBytes: p.Doc.SizeBytes, - ContentHash: p.Doc.ContentHash, - }) - if err != nil { - return nil, err - } - // Backend returns expiresAt as RFC3339 string — parse defensively. - // Empty / unparsable → leave zero so UploadURLValid() returns - // false and we re-presign on next attempt. - var expires time.Time - if presign.ExpiresAt != "" { - expires, _ = time.Parse(time.RFC3339, presign.ExpiresAt) - } - ck.Platform = p.Doc.Platform - ck.Step = "presigned" - ck.IdempotencyKey = p.Doc.IdempotencyKey - ck.DocumentKey = p.Doc.DocumentKey - ck.ContentHash = p.Doc.ContentHash - ck.SizeBytes = p.Doc.SizeBytes - ck.FileCount = p.Doc.FileCount - ck.SourceID = p.SourceID - ck.ObjectKey = presign.ObjectKey - ck.UploadURL = presign.UploadURL - ck.UploadFields = presign.FormFields - ck.UploadURLExpiresAt = expires - ck.CreatedAt = time.Now().UTC() - } - - // Step 2: S3 POST. Skip if step is already past "presigned". - if ck.Step != "uploaded" { - if err := u.postMultipart(ctx, ck.UploadURL, ck.UploadFields, p.Doc.Body, p.Doc.FileName); err != nil { - return nil, err - } - ck.Step = "uploaded" - } - - // Step 3: CreateRecord. Idempotency-conflict triggers one - // fresh-key retry. - rec, err := u.createRecordWithRetry(ctx, p, ck) - if err != nil { - return nil, err - } - ck.Step = "recorded" - - return &RecordResult{ - Platform: p.Doc.Platform, - RecordID: rec.ID, - SourceID: p.SourceID, - ObjectKey: ck.ObjectKey, - FileCount: p.Doc.FileCount, - TotalBytes: p.Doc.SizeBytes, - MergedBytes: p.Doc.SizeBytes, - ContentHash: p.Doc.ContentHash, - DocumentKey: p.Doc.DocumentKey, - IdempotencyKey: ck.IdempotencyKey, - }, nil -} - -func (u *Uploader) createRecordWithRetry(ctx context.Context, p UploadParams, ck *Checkpoint) (*client.CreateRecordResp, error) { - // Title is required by the backend's CreateRecordRequest binding. - // Build a deterministic, human-readable label per platform so the - // Web UI shows something meaningful for cold-start records. - title := "Cold-start memory · " + string(p.Doc.Platform) - req := client.CreateRecordReq{ - ObjectKey: ck.ObjectKey, - Title: title, - SizeBytes: p.Doc.SizeBytes, - ContentHash: p.Doc.ContentHash, - ContentType: "text/markdown", - RawFormat: "markdown", - Tags: []string{"cold-start", string(p.Doc.Platform)}, - Metadata: map[string]interface{}{ - "fileCount": p.Doc.FileCount, - "agent": string(p.Doc.Platform), - }, - DocumentKey: p.Doc.DocumentKey, - IdempotencyKey: ck.IdempotencyKey, - // Cold-start imports re-attribute to the target AI platform so - // they show under Claude Code / OpenClaw / etc. in the UI, not - // under EverCli (the write-channel agent). Server defaults to - // agent.Platform when this is absent, which would surface as - // "EverCli" — explicitly override here. - OriginPlatform: string(p.Doc.Platform), - } - rec, err := u.cli.CreateRecord(ctx, req) - if err == nil { - return rec, nil - } - - // Auto-retry on idempotency-conflict — backend reuses an in-flight - // row keyed by idempotencyKey, but a same-millisecond collision - // means we should mint a fresh key and try once more (05 §5.2.3). - ce, ok := output.AsCLIError(err) - if !ok || !isIdempotencyConflict(ce) { - return nil, err - } - - freshKey := newIdempotencyKey() - ck.IdempotencyKey = freshKey - req.IdempotencyKey = freshKey - rec2, err2 := u.cli.CreateRecord(ctx, req) - if err2 != nil { - // Wrap so the user sees we already retried — second failure is - // genuinely uncommon, so the hint steers toward investigation - // rather than another retry of our own. - ce2, ok := output.AsCLIError(err2) - if ok { - ce2.Hint = "Both the initial attempt and a fresh-key retry failed; rerun `evercli import run` to mint another key, or check the requestId in EverMe support" - } - return nil, err2 - } - return rec2, nil -} - -// isIdempotencyConflict matches both backend conflict flavors: -// - explicit TypeConflict surface -// - upstream errno where the message mentions "Idempotency" -// (defensive: backend may classify these as upstream rather than -// conflict depending on errno-range mapping) -func isIdempotencyConflict(ce *output.CLIError) bool { - if ce.Type == output.TypeConflict { - return true - } - return ce.Type == output.TypeUpstream && strings.Contains(ce.Message, "Idempotency") -} - -// postMultipart implements the AWS S3 Presigned POST protocol with two -// invariants: -// -// 1. Field order is deterministic. AWS S3 rejects PresignedPOST when -// the policy / x-amz-* fields appear after the `file` field in the -// multipart body — Go's map iteration is intentionally random, so -// ranging over `fields` produced occasional InvalidPolicyDocument -// failures depending on which way the runtime shuffled. We sort -// the keys with `policy` and `x-amz-*` first so the file is always -// last. -// -// 2. Content-Length is set. S3 rejects PresignedPOST without a -// Content-Length header (411 MissingContentLength) — chunked -// transfer encoding is not accepted. We serialise the whole body -// into a bytes.Buffer up front so net/http auto-fills it. -// MaxMergedBytes (32 MiB) bounds peak RAM at ~2× that. -func (u *Uploader) postMultipart(ctx context.Context, uploadURL string, fields map[string]string, body []byte, fileName string) error { - if uploadURL == "" { - return output.Internal(errors.New("empty uploadUrl")) - } - - keys := orderedFormFieldKeys(fields) - var buf bytes.Buffer - mw := multipart.NewWriter(&buf) - for _, k := range keys { - if err := mw.WriteField(k, fields[k]); err != nil { - return output.Internal(fmt.Errorf("multipart field %s: %w", k, err)) - } - } - fw, err := mw.CreateFormFile("file", fileName) - if err != nil { - return output.Internal(fmt.Errorf("multipart file: %w", err)) - } - if _, err := fw.Write(body); err != nil { - return output.Internal(fmt.Errorf("multipart body: %w", err)) - } - if err := mw.Close(); err != nil { - return output.Internal(fmt.Errorf("multipart close: %w", err)) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &buf) - if err != nil { - return output.Internal(fmt.Errorf("build s3 request: %w", err)) - } - req.Header.Set("Content-Type", mw.FormDataContentType()) - - resp, err := u.http.Do(req) - if err != nil { - host := "" - if req.URL != nil { - host = req.URL.Host - } - return output.Network(host, fmt.Errorf("s3 post: %w", err)) - } - defer resp.Body.Close() - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - _, _ = io.Copy(io.Discard, resp.Body) - return nil - } - // 4xx from S3 means the policy rejected us — usually size mismatch. - const maxS3ErrBytes = 8 << 10 - bodyText, _ := io.ReadAll(io.LimitReader(resp.Body, maxS3ErrBytes)) - return output.Upstream(resp.StatusCode, "S3 upload rejected: "+truncate(string(bodyText), 200), "") -} - -// orderedFormFieldKeys sorts the presign field map so the policy / -// signature fields are emitted before any user-data field and the -// `file` field is last (S3 requirement). Within each bucket we sort -// lexically so the order is reproducible across runs. -func orderedFormFieldKeys(fields map[string]string) []string { - policy := []string{} - other := []string{} - for k := range fields { - if k == "policy" || strings.HasPrefix(k, "x-amz-") || strings.HasPrefix(k, "X-Amz-") { - policy = append(policy, k) - } else { - other = append(other, k) - } - } - sort.Strings(policy) - sort.Strings(other) - return append(policy, other...) -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "..." -} diff --git a/cli/internal/importer/uploader_extra_test.go b/cli/internal/importer/uploader_extra_test.go deleted file mode 100644 index 197bc84..0000000 --- a/cli/internal/importer/uploader_extra_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package importer - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestOrderedFormFieldKeys_PolicyFirst(t *testing.T) { - in := map[string]string{ - "key": "v", - "x-amz-signature": "v", - "X-Amz-Date": "v", - "policy": "v", - "acl": "v", - "x-amz-credential": "v", - } - got := orderedFormFieldKeys(in) - // Every signed/policy field must come before any non-signed field. - policyEnd := 0 - for i, k := range got { - if strings.HasPrefix(k, "policy") || strings.HasPrefix(strings.ToLower(k), "x-amz-") { - policyEnd = i + 1 - } - } - for i := policyEnd; i < len(got); i++ { - assert.False(t, strings.HasPrefix(got[i], "policy") || strings.HasPrefix(strings.ToLower(got[i]), "x-amz-"), - "non-signed field %q must come after every policy / x-amz-* field", got[i]) - } - // Determinism: stable across runs. - got2 := orderedFormFieldKeys(in) - assert.Equal(t, got, got2, "ordering must be deterministic, regardless of map iteration randomness") -} - -func TestPostMultipart_OrdersFieldsBeforeFile(t *testing.T) { - // Mock S3: capture the multipart form, look at the field order in - // the raw body. This test verifies field ORDER (the - // `policy`/`x-amz-*` first, file last invariant); a separate test - // covers the Content-Length invariant. - var rawBody string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - rawBody = string(b) - w.WriteHeader(http.StatusNoContent) - })) - defer srv.Close() - - u := &Uploader{http: srv.Client()} - fields := map[string]string{ - "key": "objects/abc", - "x-amz-signature": "deadbeef", - "x-amz-credential": "AKIA", - "policy": "BASE64==", - "acl": "private", - } - require.NoError(t, u.postMultipart(context.Background(), srv.URL, fields, []byte("body"), "mem.md")) - - // In the raw body, every "name=\"\"" boundary must - // appear before the file boundary, and the file boundary must be - // last among Content-Disposition headers. - policyIdx := strings.Index(rawBody, `name="policy"`) - xamzIdx := strings.Index(rawBody, `name="x-amz-signature"`) - keyIdx := strings.Index(rawBody, `name="key"`) - fileIdx := strings.Index(rawBody, `name="file"`) - require.NotEqual(t, -1, policyIdx, "policy field must appear in the body") - require.NotEqual(t, -1, fileIdx, "file field must appear in the body") - assert.Less(t, policyIdx, fileIdx, "policy must precede file (S3 PresignedPOST contract)") - assert.Less(t, xamzIdx, fileIdx, "x-amz-signature must precede file") - assert.Less(t, keyIdx, fileIdx, "every non-file field must precede file") -} - -func TestPostMultipart_SetsContentLength(t *testing.T) { - // AWS S3 PresignedPOST rejects requests without Content-Length - // (411 MissingContentLength) — chunked transfer encoding is not - // accepted. Regression guard: the previous io.Pipe streaming - // implementation left ContentLength=0, so net/http fell back to - // Transfer-Encoding: chunked and every real upload failed against - // production S3. This test asserts the request carries a real - // Content-Length and no chunked transfer encoding. - var gotContentLength int64 = -1 - var gotTransferEncoding []string - var gotBodyLen int - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotContentLength = r.ContentLength - gotTransferEncoding = append([]string(nil), r.TransferEncoding...) - b, _ := io.ReadAll(r.Body) - gotBodyLen = len(b) - w.WriteHeader(http.StatusNoContent) - })) - defer srv.Close() - - u := &Uploader{http: srv.Client()} - fields := map[string]string{ - "policy": "x", - "x-amz-signature": "y", - } - body := make([]byte, 4<<10) - for i := range body { - body[i] = byte('a' + (i % 26)) - } - require.NoError(t, u.postMultipart(context.Background(), srv.URL, fields, body, "mem.md")) - - assert.Greater(t, gotContentLength, int64(0), - "S3 PresignedPOST requires Content-Length; chunked encoding triggers 411 MissingContentLength") - assert.Equal(t, int64(gotBodyLen), gotContentLength, - "Content-Length must equal the number of body bytes actually delivered") - assert.Empty(t, gotTransferEncoding, - "Transfer-Encoding must be empty (chunked encoding triggers S3 411)") -} - -func TestPostMultipart_S3RejectIsClassifiedUpstream(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`InvalidPolicyDocument`)) - })) - defer srv.Close() - - u := &Uploader{http: srv.Client()} - err := u.postMultipart(context.Background(), srv.URL, map[string]string{"policy": "x"}, []byte("body"), "mem.md") - require.Error(t, err) - // Error must surface the S3 status code rather than getting swallowed. - assert.Contains(t, err.Error(), "S3 upload rejected", "S3 4xx must be classified as upstream so users see the cause, not silent failure") -} - -func TestPostMultipart_RejectsEmptyURL(t *testing.T) { - u := &Uploader{http: http.DefaultClient} - err := u.postMultipart(context.Background(), "", map[string]string{}, []byte("x"), "mem.md") - require.Error(t, err) - assert.Contains(t, err.Error(), "uploadUrl") -} - -func TestPostMultipart_TruncatesLargeS3ErrorBody(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(strings.Repeat("z", 1<<14))) // 16 KiB - })) - defer srv.Close() - - u := &Uploader{http: srv.Client()} - err := u.postMultipart(context.Background(), srv.URL, map[string]string{}, []byte("body"), "mem.md") - require.Error(t, err) - assert.LessOrEqual(t, len(err.Error()), 1024, "error message must be truncated, not shipped as 16 KiB of S3 XML") -} - -// (joinURL is exercised by client/url_test.go; the previous placeholder -// here was a no-op that didn't touch project code, deleted to avoid -// false coverage signal.) diff --git a/cli/internal/output/envelope.go b/cli/internal/output/envelope.go index 27f189d..391bd6c 100644 --- a/cli/internal/output/envelope.go +++ b/cli/internal/output/envelope.go @@ -1,7 +1,7 @@ package output // Envelope is the canonical success-shape returned by every command in -// --format json | yaml mode (see docs/contracts.md). +// --format json | yaml mode (see AGENTS.md "Output contract is sacred"). // // Field-level rules: // - Ok is always true on success; AI Agents treat it as the primary success key. @@ -32,7 +32,7 @@ type ErrorEnvelope struct { // from EverMe; Hint is the next-step suggestion to feed an Agent; // Detail carries type-specific structured data (e.g. apiKeyPrefix, agent). // -// See docs/contracts.md for the public type taxonomy. +// See AGENTS.md "Output contract is sacred" for the public type taxonomy. type ErrorBody struct { Type ErrorType `json:"type" yaml:"type"` Code int `json:"code,omitempty" yaml:"code,omitempty"` diff --git a/cli/internal/output/errors.go b/cli/internal/output/errors.go index 8ca7862..9ef6b91 100644 --- a/cli/internal/output/errors.go +++ b/cli/internal/output/errors.go @@ -7,7 +7,7 @@ import ( ) // ErrorType is the AI-Agent-facing error taxonomy. Values are part of the -// stable ABI (docs/contracts.md) — adding a new type is +// stable ABI (AGENTS.md "Output contract is sacred") — adding a new type is // a minor change, renaming or removing one is a breaking change. type ErrorType string @@ -112,6 +112,20 @@ func NotLoggedIn() *CLIError { } } +// UploadTokenMissing is the NotLoggedIn variant for the upload path +// (presign / create-source, authenticated with the evt). The emk may +// still be present — what's missing is the agent token, typically after +// an EverMe account switch where it was never refreshed (ECA-689). Same +// type / exit code as NotLoggedIn, but the message and hint name the +// upload credential so the remediation is unambiguous. +func UploadTokenMissing() *CLIError { + return &CLIError{ + Type: TypeNotLoggedIn, + Message: "Upload credential (agent token) not found", + Hint: "Run `evercli auth login` to register this CLI for uploads", + } +} + // AuthErr signals an emk-level authentication failure (invalid / expired / // revoked). The optional apiKeyPrefix is surfaced as Detail.apiKeyPrefix // so Agents can disambiguate when multiple sessions are in play. diff --git a/cli/internal/output/exitcode.go b/cli/internal/output/exitcode.go index f71a48a..ae51c95 100644 --- a/cli/internal/output/exitcode.go +++ b/cli/internal/output/exitcode.go @@ -3,7 +3,7 @@ package output // ExitCode is the process exit code returned to the OS. // // The set of exit codes is part of EverCli's stable ABI for AI Agents -// (see docs/contracts.md). Six buckets, no more — finer +// (see AGENTS.md "Output contract is sacred"). Six buckets, no more — finer // grained errors flow through ErrorType in the JSON envelope. type ExitCode int diff --git a/cli/internal/output/format.go b/cli/internal/output/format.go index 5042fcb..aa5b874 100644 --- a/cli/internal/output/format.go +++ b/cli/internal/output/format.go @@ -13,7 +13,7 @@ import ( // // The serialization shape of FormatJSON / FormatYAML is the stable ABI; // FormatText output is human-only and may evolve between versions -// (see docs/contracts.md). +// (see AGENTS.md "Output contract is sacred"). type Format string const ( diff --git a/cli/internal/output/redact_test.go b/cli/internal/output/redact_test.go index 9fcd2e9..f33fde2 100644 --- a/cli/internal/output/redact_test.go +++ b/cli/internal/output/redact_test.go @@ -5,8 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io" - "os" "regexp" "testing" @@ -132,25 +130,3 @@ func TestWriter_OK_RedactsInDetail(t *testing.T) { assert.NotContains(t, stdout.String(), fullEMK) assert.NotRegexp(t, fullCredRe, stdout.String()) } - -func TestFatalErr_RedactsCredentialInEnvelope(t *testing.T) { - oldStdout := os.Stdout - r, w, err := os.Pipe() - require.NoError(t, err) - os.Stdout = w - t.Cleanup(func() { - os.Stdout = oldStdout - _ = r.Close() - }) - - got := FatalErr(errors.New("bootstrap saw " + fullEVT)) - require.NoError(t, w.Close()) - out, err := io.ReadAll(r) - require.NoError(t, err) - - var ee *ExitError - require.ErrorAs(t, got, &ee) - assert.NotContains(t, string(out), fullEVT) - assert.NotRegexp(t, fullCredRe, string(out)) - assert.Contains(t, string(out), "evt_bbbb_REDACTED") -} diff --git a/cli/internal/output/writer.go b/cli/internal/output/writer.go index e608327..275d628 100644 --- a/cli/internal/output/writer.go +++ b/cli/internal/output/writer.go @@ -174,13 +174,10 @@ func FatalErr(err error) error { Hint: ce.Hint, Detail: ce.Detail, } - var buf bytes.Buffer - enc := json.NewEncoder(&buf) + enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if encErr := enc.Encode(ErrorEnvelope{Ok: false, Error: body}); encErr != nil { fmt.Fprintf(os.Stderr, "fatal: %s (also: %v)\n", ce.Message, encErr) - } else if _, writeErr := os.Stdout.Write(redact(buf.Bytes())); writeErr != nil { - fmt.Fprintf(os.Stderr, "fatal: %s (also: failed to write envelope: %v)\n", ce.Message, writeErr) } return &ExitError{Code: ce.Type.ExitCode()} } diff --git a/cli/internal/plugin/build_register_req_test.go b/cli/internal/plugin/build_register_req_test.go new file mode 100644 index 0000000..fbba662 --- /dev/null +++ b/cli/internal/plugin/build_register_req_test.go @@ -0,0 +1,36 @@ +package plugin + +import "testing" + +// buildRegisterReq's ClientVersion ("evercli/" + osTag()) must never exceed +// the server's client_version varchar(32) column — same incident class as +// the auth Device Flow start (ECA: SQLSTATE 22001 bubbling up as an opaque +// upstream errno). osTag() is short in practice (darwin/linux/windows), so +// this pins the OS-name indirection to an oversized value to exercise the +// truncation at this callsite directly. +func TestBuildRegisterReq_TruncatesOversizedClientVersion(t *testing.T) { + prev := runtimeGOOSFn + t.Cleanup(func() { runtimeGOOSFn = prev }) + runtimeGOOSFn = func() string { return "a-very-long-fake-os-name-for-testing-truncation" } + + svc := NewService(nil, "") + req := svc.buildRegisterReq(Platform("claude-code"), "My Agent") + + if len(req.ClientVersion) > 32 { + t.Fatalf("ClientVersion = %q (%d bytes), want <=32 bytes", req.ClientVersion, len(req.ClientVersion)) + } +} + +func TestBuildRegisterReq_ShortClientVersionUnchanged(t *testing.T) { + prev := runtimeGOOSFn + t.Cleanup(func() { runtimeGOOSFn = prev }) + runtimeGOOSFn = func() string { return "darwin" } + + svc := NewService(nil, "") + req := svc.buildRegisterReq(Platform("claude-code"), "My Agent") + + want := "evercli/darwin" + if req.ClientVersion != want { + t.Fatalf("ClientVersion = %q, want unchanged %q", req.ClientVersion, want) + } +} diff --git a/cli/internal/plugin/claude_code.go b/cli/internal/plugin/claude_code.go index da3d42e..fa3092b 100644 --- a/cli/internal/plugin/claude_code.go +++ b/cli/internal/plugin/claude_code.go @@ -23,8 +23,21 @@ // 1. write ~/.claude/everme.env (KEY=value, 0600, atomic) so the // plugin's hooks/scripts/lib/config.js picks up evt without // the user having to mutate their shell profile. -// 2. `claude plugin marketplace add ` (idempotent) -// 3. `claude plugin install everme@everme` (idempotent) +// 2. `claude plugin marketplace add ` when the marketplace is +// absent or its recorded directory moved, else `claude plugin +// marketplace update everme` — `add` on an already-registered +// source only prints "already on disk" and re-reads nothing. +// 3. `claude plugin install everme@everme` when nothing is cached, +// else `claude plugin update everme@everme` — `install` on an +// installed plugin prints "already installed" and leaves the old +// cache directory in place. +// +// writer.Verify +// → env file carries a token, and the version Claude Code recorded in +// ~/.claude/plugins/installed_plugins.json equals the version the +// payload declares. Every shell-out above exits 0 in states that +// keep a stale cache, so this comparison is the only proof the user +// ends up running what we shipped. // // writer.Remove // 1. `claude plugin uninstall everme` (best-effort) @@ -32,15 +45,17 @@ // 3. delete ~/.claude/everme.env // // Atomicity: the env file is written via .tmp + rename so the plugin -// never sees a half-written file. The two `claude` shell-outs are -// each idempotent on the Claude Code side (re-add prints "already on -// disk"), so a partial commit is safe to retry. +// never sees a half-written file. Every `claude` shell-out is safe to +// re-run, so a partial commit is safe to retry. package plugin import ( "bytes" "context" + "encoding/json" + "errors" "fmt" + "io/fs" "os" "os/exec" "path/filepath" @@ -55,6 +70,12 @@ import ( const ( everMarketplaceName = "everme" evermePluginName = "everme" + + // evermePluginSpec is the `@` form. It is + // unambiguous when another marketplace registers a plugin of the same + // name, and `claude plugin update` accepts nothing else — the bare + // name fails with `Plugin "everme" not found`. + evermePluginSpec = evermePluginName + "@" + everMarketplaceName ) // claudeCommand resolves the `claude` CLI binary. EVERCLI_CLAUDE_CMD @@ -77,6 +98,195 @@ func envFilePath() (string, error) { return filepath.Join(home, ".claude", "everme.env"), nil } +// ---- Claude Code plugin state (read-only) -------------------------- +// +// Claude Code tracks plugin state in two JSON files under +// ~/.claude/plugins/. We only ever READ them — the claude CLI owns the +// writes. They answer the two questions exit codes can't: which verb to +// use (install vs update) and whether the cache actually moved. + +const ( + claudeInstalledPluginsFile = "installed_plugins.json" + claudeKnownMarketplacesFile = "known_marketplaces.json" + + // Scope of the entries evercli installs. Claude Code also supports + // project scope; a project-scoped copy is the user's own doing and + // not ours to reason about. + claudePluginUserScope = "user" +) + +// claudePluginsDir returns ~/.claude/plugins. Mirrors envFilePath's +// convention of resolving ~/.claude from the home directory. +func claudePluginsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".claude", "plugins"), nil +} + +// claudeInstalledPluginsPath is the best-effort display path used in +// error messages. Falls back to the bare filename when the home +// directory can't be resolved — a hint string is not worth an error. +func claudeInstalledPluginsPath() string { + dir, err := claudePluginsDir() + if err != nil { + return claudeInstalledPluginsFile + } + return filepath.Join(dir, claudeInstalledPluginsFile) +} + +// claudeCachedPluginVersion returns the plugin version Claude Code has +// cached for everme@everme at user scope. +// +// ("", nil) means "nothing cached": the file is absent, carries no entry +// for us, or the entry has no version. Each of those states means the +// caller should install rather than update. A malformed file IS an error +// — reporting it as "not installed" would silently pick the wrong verb +// and hide a broken host. +func claudeCachedPluginVersion() (string, error) { + dir, err := claudePluginsDir() + if err != nil { + return "", output.IOErr(claudeInstalledPluginsFile, "resolve-home", err) + } + path := filepath.Join(dir, claudeInstalledPluginsFile) + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", nil + } + return "", output.IOErr(path, "read", err) + } + var parsed struct { + Plugins map[string][]struct { + Scope string `json:"scope"` + Version string `json:"version"` + } `json:"plugins"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", output.IOErr(path, "parse-json", err) + } + for _, entry := range parsed.Plugins[evermePluginSpec] { + if entry.Scope == claudePluginUserScope { + return entry.Version, nil + } + } + return "", nil +} + +// claudeMarketplaceRegistration reports whether our marketplace is +// registered and, for a local-directory source, the path it points at. +// The path is empty for github / URL sources: those record a checkout +// location instead, which must never be compared against our source spec. +// +// Read failures degrade to (false, "") on purpose — the caller then falls +// back to `marketplace add`, which is the correct move in any state we +// can't read. +func claudeMarketplaceRegistration() (registered bool, dirSource string) { + dir, err := claudePluginsDir() + if err != nil { + return false, "" + } + raw, err := os.ReadFile(filepath.Join(dir, claudeKnownMarketplacesFile)) + if err != nil { + return false, "" + } + var parsed map[string]struct { + Source struct { + Source string `json:"source"` + Path string `json:"path"` + } `json:"source"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return false, "" + } + entry, ok := parsed[everMarketplaceName] + if !ok { + return false, "" + } + return true, entry.Source.Path +} + +// claudeSourceManifestVersion reads the plugin version the payload at +// source declares. The marketplace entry wins: it is the version Claude +// Code names its cache directory after. .claude-plugin/plugin.json is the +// fallback for a payload whose marketplace entry omits the field (bump.sh +// keeps both in sync, but only one is load-bearing here). +// +// ("", nil) means "not comparable", not "version zero": https sources +// can't be read without a network fetch, and a payload declaring no +// version anywhere gives us nothing to assert against. +func claudeSourceManifestVersion(source string) (string, error) { + if source == "" || !filepath.IsAbs(source) { + return "", nil + } + + marketplacePath := filepath.Join(source, ".claude-plugin", "marketplace.json") + raw, err := os.ReadFile(marketplacePath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", nil + } + return "", output.IOErr(marketplacePath, "read", err) + } + var marketplace struct { + Plugins []struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"plugins"` + } + if err := json.Unmarshal(raw, &marketplace); err != nil { + return "", output.IOErr(marketplacePath, "parse-json", err) + } + for _, p := range marketplace.Plugins { + if p.Name == evermePluginName && p.Version != "" { + return p.Version, nil + } + } + + manifestPath := filepath.Join(source, ".claude-plugin", "plugin.json") + raw, err = os.ReadFile(manifestPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", nil + } + return "", output.IOErr(manifestPath, "read", err) + } + var manifest struct { + Version string `json:"version"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + return "", output.IOErr(manifestPath, "parse-json", err) + } + return manifest.Version, nil +} + +// ClaudePluginVersionDrift compares what Claude Code has cached against +// what the resolved @everme/claude-code payload declares. Two empty +// strings mean "nothing to compare" — no payload on disk, plugin not +// installed, or an https source we can't read — so callers skip instead +// of warning. +// +// Exported for `evercli doctor`: the installer's Verify covers the +// install moment, this covers every host that drifted afterwards. +func ClaudePluginVersionDrift(ctx context.Context) (cached, available string, err error) { + // installIfMissing=false: doctor is a read-only diagnostic and must + // never mutate global node_modules. + source, resolved, err := (&claudeCodeWriter{}).pluginSourceSpec(ctx, false) + if err != nil || !resolved { + return "", "", nil + } + available, err = claudeSourceManifestVersion(source) + if err != nil || available == "" { + return "", "", err + } + cached, err = claudeCachedPluginVersion() + if err != nil { + return "", "", err + } + return cached, available, nil +} + // ---- detector ------------------------------------------------------ type claudeCodeDetector struct{} @@ -197,10 +407,73 @@ type claudeCodeWriter struct { // pluginSource lets tests inject a fake source. Empty in production // → resolved at Plan time via pluginSourceSpec(). pluginSource string + + // resolvedSource is the source Commit actually handed to the claude + // CLI. Verify reads the payload's manifest from it to assert the + // cache moved; Plan's value is deliberately not reused (Commit may + // npm-install and resolve a path Plan never saw). + resolvedSource string } func newClaudeCodeWriter() *claudeCodeWriter { return &claudeCodeWriter{} } +// Remove undoes what Commit wired up: best-effort `claude plugin +// uninstall` + `claude plugin marketplace remove`, then deletes the +// evercli-owned env file. +// +// configPath is the detector's ConfigPath — ~/.claude/everme.env, a +// KEY=value file. It must NEVER flow through the JSON mcpWriter.Remove +// path: that writer json-parses the file and fails on the '#' comment +// header, which used to make every `plugin uninstall claude-code` fail. +func (w *claudeCodeWriter) Remove(ctx context.Context, configPath string) (*RemoveResult, error) { + // Guard: an empty configPath must not silently become + // filepath.Abs("") == cwd — resolve the canonical env path instead. + envPath := configPath + if envPath == "" { + p, err := envFilePath() + if err != nil { + return nil, output.IOErr("env-file", "resolve-home", err) + } + envPath = p + } + abs, err := filepath.Abs(envPath) + if err != nil { + return nil, output.IOErr(envPath, "abs-path", err) + } + result := &RemoveResult{Platform: PlatformClaudeCode, ConfigPath: abs} + + // Best-effort host deregistration. Failures (plugin already + // uninstalled by hand, marketplace entry gone) surface as stderr + // warnings but never block the local cleanup — Remove stays + // idempotent. When the claude CLI isn't on PATH there is nothing + // to deregister from, so we skip silently. + if _, lookErr := exec.LookPath(claudeCommand()); lookErr == nil { + if runErr := runClaude(ctx, "plugin", "uninstall", evermePluginName); runErr != nil { + fmt.Fprintf(os.Stderr, + "warning: `claude plugin uninstall %s` failed: %v — if it still shows in `claude plugin list`, remove it manually\n", + evermePluginName, runErr) + } + if runErr := runClaude(ctx, "plugin", "marketplace", "remove", everMarketplaceName); runErr != nil { + fmt.Fprintf(os.Stderr, + "warning: `claude plugin marketplace remove %s` failed: %v\n", + everMarketplaceName, runErr) + } + } + + // Delete the env file. Missing file is a successful no-op + // (Removed=false) per the Remover contract. + switch _, statErr := os.Stat(abs); { + case statErr == nil: + if rmErr := os.Remove(abs); rmErr != nil { + return nil, output.IOErr(abs, "remove-env", rmErr) + } + result.Removed = true + case !os.IsNotExist(statErr): + return nil, output.IOErr(abs, "stat-env", statErr) + } + return result, nil +} + func (claudeCodeWriter) Platform() Platform { return PlatformClaudeCode } // pluginSourceSpec is the argument we pass to `claude plugin @@ -432,7 +705,7 @@ func (w *claudeCodeWriter) Commit(ctx context.Context, plan *WritePlan, params W return nil, output.IOErr(envPath, "mkdir-claude-dir", err) } - body, err := buildEnvFileBody(params) + body, err := buildEnvFileBody(PlatformClaudeCode, params) if err != nil { return nil, output.Internal(err) } @@ -457,24 +730,44 @@ func (w *claudeCodeWriter) Commit(ctx context.Context, plan *WritePlan, params W return nil, ce } - // 1. Add our marketplace (idempotent — Claude Code prints - // "already on disk" if the entry exists). - if err := w.addMarketplace(ctx, source); err != nil { + w.resolvedSource = source + + // 1. Register the marketplace, or refresh the registered one. + if err := w.syncMarketplace(ctx, source); err != nil { ce := output.IOErr("claude plugin marketplace add", "exec", err) ce.Hint = "marketplace add failed — this is NOT a GitHub auth issue. The plugin source is a local directory (" + source + "); inspect the stderr above. If the directory is missing, run `npm install -g @everme/claude-code` manually. Do not run `gh auth login`." ce.Detail = map[string]any{"source": source} return nil, ce } - // 2. Install (or re-install) the plugin. We always run install so - // the user picks up the freshest hooks even on a re-run. + // 2. Install the plugin, or update the cached one. Re-running install + // is NOT a refresh: Claude Code prints "already installed", exits + // 0, and keeps serving the previous cache directory — which is how + // an upgraded payload on disk never reaches the user. registered, _ := w.isPluginRegistered(ctx) - if err := w.installPlugin(ctx); err != nil { + cachedVersion, cacheErr := claudeCachedPluginVersion() + if cacheErr != nil { + // Degrade rather than abort: the env file is already written and + // the token already minted, so failing Commit here would strand a + // live agent. installOrUpdatePlugin's fallback covers the wrong + // guess, and Verify still reports the drift. + fmt.Fprintf(os.Stderr, "warning: could not read Claude Code's installed-plugin state (%v); assuming a fresh install\n", cacheErr) + } + if err := w.installOrUpdatePlugin(ctx, cachedVersion != ""); err != nil { ce := output.IOErr("claude plugin install", "exec", err) - ce.Hint = "Check `claude plugin list` and the stderr above; env file at " + envPath + " is in place." + ce.Hint = "Neither `claude plugin update " + evermePluginSpec + "` nor `claude plugin install " + evermePluginSpec + "` succeeded. Check `claude plugin list` and the stderr above; env file at " + envPath + " is in place." return nil, ce } + // A running Claude Code keeps the previous payload loaded until it + // restarts, so an actual version move is worth a next step. + var nextSteps []string + if newVersion, err := claudeCachedPluginVersion(); err == nil && cachedVersion != "" && newVersion != "" && newVersion != cachedVersion { + nextSteps = append(nextSteps, fmt.Sprintf( + "restart Claude Code so plugin %s replaces the %s payload still loaded in running sessions", + newVersion, cachedVersion)) + } + // 3. Post-install MCP visibility probe. `claude plugin install` // exit-0 only proves the plugin is registered; the bundled MCP // server is gated by a separate user-consent step (`/mcp` @@ -486,7 +779,7 @@ func (w *claudeCodeWriter) Commit(ctx context.Context, plan *WritePlan, params W if visible, err := ClaudeMcpListContainsEverme(ctx); err == nil && !visible { fmt.Fprintln(os.Stderr, "WARNING: plugin installed but its MCP server isn't visible to Claude Code yet.") fmt.Fprintln(os.Stderr, " Open Claude Code, run `/mcp`, and approve the `everme` server") - fmt.Fprintln(os.Stderr, " so tools like everme_search become callable. Hooks (auto-recall,") + fmt.Fprintln(os.Stderr, " so tools like mem_search become callable. Hooks (auto-recall,") fmt.Fprintln(os.Stderr, " auto-save) work without this — only manual MCP tool calls need it.") } @@ -494,25 +787,118 @@ func (w *claudeCodeWriter) Commit(ctx context.Context, plan *WritePlan, params W Platform: PlatformClaudeCode, ConfigPath: envPath, WroteNewEntry: !registered, + NextSteps: nextSteps, }, nil } -// (claudeCodeWriter.Remove was retired with `evercli plugin uninstall`. -// Manual cleanup steps for users: -// 1. `claude plugin uninstall everme` -// 2. `claude plugin marketplace remove everme` -// 3. `rm ~/.claude/everme.env` -// Plus disconnect the agent from the EverMe web UI.) +// Verify asserts the two things the shell-outs' exit codes cannot prove: +// the env file carries a token, and Claude Code's plugin cache sits on +// the version the payload declares. `marketplace add` ("already on +// disk") and `plugin install` ("already installed") both exit 0 while +// leaving a stale cache, so without this comparison an install reports +// success while the user keeps running an old plugin. +// +// Per the Verifier contract (types.go) a failure here surfaces as a +// warning on the InstallEntry, not a failed install: at this point the +// token is on disk at 0600 and registered server-side. +func (w *claudeCodeWriter) Verify(_ context.Context, result *WriteResult) error { + if result == nil { + return output.Internal(fmt.Errorf("nil result")) + } + envBody, err := os.ReadFile(result.ConfigPath) + if err != nil { + return output.IOErr(result.ConfigPath, "verify", err) + } + if !strings.Contains(string(envBody), "EVERME_AGENT_TOKEN=evt_") { + return output.IOErr(result.ConfigPath, "verify", + fmt.Errorf("everme.env has no agent token")) + } + + want, err := claudeSourceManifestVersion(w.resolvedSource) + if err != nil { + return err + } + if want == "" { + // An https source (unreadable without a fetch) or a payload that + // declares no version — nothing to compare. We skip rather than + // guess a version we don't know. + return nil + } + got, err := claudeCachedPluginVersion() + if err != nil { + return err + } + statePath := claudeInstalledPluginsPath() + if got == "" { + ce := output.IOErr(statePath, "verify-version", + fmt.Errorf("Claude Code reports no cached everme plugin after install")) + ce.Hint = "Run `claude plugin install " + evermePluginSpec + "`, then restart Claude Code" + return ce + } + if got != want { + ce := output.IOErr(statePath, "verify-version", + fmt.Errorf("Claude Code has plugin %s cached but the payload on disk is %s", got, want)) + ce.Hint = "Run `claude plugin update " + evermePluginSpec + "`, then restart Claude Code" + ce.Detail = map[string]any{"cached": got, "available": want} + return ce + } + return nil +} -func (w *claudeCodeWriter) addMarketplace(ctx context.Context, source string) error { +// syncMarketplace registers our marketplace or refreshes the registered +// one. +// +// `claude plugin marketplace add` is not a refresh: on an +// already-registered identical source it prints "already on disk" and +// exits 0 without re-reading anything, which is precisely how a stale +// marketplace survives a re-install. `marketplace update` is the refresh +// verb. `add` remains correct when the recorded directory source moved +// (npm's global prefix changed) — Claude Code then repoints the entry. +func (w *claudeCodeWriter) syncMarketplace(ctx context.Context, source string) error { + registered, recorded := claudeMarketplaceRegistration() + moved := recorded != "" && filepath.Clean(recorded) != filepath.Clean(source) + if registered && !moved { + if err := runClaude(ctx, "plugin", "marketplace", "update", everMarketplaceName); err == nil { + return nil + } + // A broken entry (source deleted, manifest unreadable) fails the + // update; re-adding is the repair path, so fall through rather + // than abort the install. + } return runClaude(ctx, "plugin", "marketplace", "add", source) } func (w *claudeCodeWriter) installPlugin(ctx context.Context) error { - // `@` form is unambiguous even when other - // marketplaces register a plugin of the same name. - spec := evermePluginName + "@" + everMarketplaceName - return runClaude(ctx, "plugin", "install", spec) + return runClaude(ctx, "plugin", "install", evermePluginSpec) +} + +// updatePlugin refreshes an already-cached plugin. The qualified +// `@` spec is load-bearing here, not cosmetic: +// `claude plugin update everme` fails with `Plugin "everme" not found`. +func (w *claudeCodeWriter) updatePlugin(ctx context.Context) error { + return runClaude(ctx, "plugin", "update", evermePluginSpec) +} + +// installOrUpdatePlugin picks the verb from what Claude Code has cached: +// update when a version is already there, install otherwise. When the +// chosen verb fails we try the other one once — installed_plugins.json +// and Claude Code's own view can disagree (a hand-deleted cache +// directory, an interrupted uninstall), and the fallback turns that into +// a working install instead of a hard failure. The first error is the one +// reported when both fail: it describes the state we expected to be in. +func (w *claudeCodeWriter) installOrUpdatePlugin(ctx context.Context, cached bool) error { + first, second := w.installPlugin, w.updatePlugin + if cached { + first, second = w.updatePlugin, w.installPlugin + } + err := first(ctx) + if err == nil { + return nil + } + if secondErr := second(ctx); secondErr == nil { + return nil + } + return err } // isPluginRegistered greps `claude plugin list` for our plugin name @@ -552,7 +938,7 @@ func runClaude(ctx context.Context, args ...string) error { // failure mode is what we want for a file derived from server-supplied // material. The expectation is that the backend never produces such // values, so a hit here is a hard error rather than silent escape. -func buildEnvFileBody(params WriteParams) (string, error) { +func buildEnvFileBody(platform Platform, params WriteParams) (string, error) { for k, v := range map[string]string{ "EVERME_API_BASE": params.APIBaseURL, "EVERME_AGENT_ID": params.AgentID, @@ -563,12 +949,12 @@ func buildEnvFileBody(params WriteParams) (string, error) { } } + p := string(platform) var b strings.Builder - b.WriteString("# Managed by evercli plugin install claude-code — do not edit by hand.\n") - b.WriteString("# Re-run `evercli plugin install claude-code` to refresh the token.\n") - b.WriteString("# To remove: disconnect the agent from the EverMe web UI, then\n") - b.WriteString("# `claude plugin uninstall everme` and delete this file manually.\n") - b.WriteString("# (`evercli plugin uninstall` was retired in V1 — see SKILL §3.)\n") + b.WriteString("# Managed by evercli plugin install " + p + " — do not edit by hand.\n") + b.WriteString("# Re-run `evercli plugin install " + p + "` to refresh the token.\n") + b.WriteString("# To remove: run `evercli plugin uninstall " + p + " --yes`.\n") + b.WriteString("# Host-managed registries may still require: " + hostUninstallHint(platform) + ".\n") b.WriteString("EVERME_API_BASE=") b.WriteString(params.APIBaseURL) b.WriteString("\n") @@ -581,6 +967,24 @@ func buildEnvFileBody(params WriteParams) (string, error) { return b.String(), nil } +// hostUninstallHint returns the host-specific guidance for removing the +// everme plugin, used in the everme.env header. Falls back to a generic +// note for platforms without a known host uninstall command. +func hostUninstallHint(platform Platform) string { + switch platform { + case PlatformClaudeCode: + return "run `claude plugin uninstall everme`" + case PlatformCodex: + return "run `codex plugin uninstall everme@everme` and remove the EverMe MCP entry" + case PlatformKimiCode: + return "remove the everme plugin from Kimi Code (`/plugins remove everme`)" + case PlatformDSH: + return "restart DeepSeek Harness after removing the managed patch" + default: + return "remove the everme plugin from the host" + } +} + // writeFileAtomic moved to mcp.go so both writers use the same .tmp + // rename path with explicit-mode O_CREATE|O_EXCL. Keeping a single // implementation prevents future divergence in token-permission diff --git a/cli/internal/plugin/claude_code_test.go b/cli/internal/plugin/claude_code_test.go index e8f67c0..94c19b5 100644 --- a/cli/internal/plugin/claude_code_test.go +++ b/cli/internal/plugin/claude_code_test.go @@ -2,6 +2,8 @@ package plugin import ( "context" + "os" + "path/filepath" "strings" "testing" @@ -70,7 +72,7 @@ func TestBuildEnvFileBody_RejectsControlChars(t *testing.T) { // agentToken with embedded \n breaks downstream `set -a; .` loaders // and KEY=value parsers — refuse the write rather than escape and // hope. - _, err := buildEnvFileBody(WriteParams{ + _, err := buildEnvFileBody(PlatformClaudeCode, WriteParams{ APIBaseURL: "https://api.everme.evermind.ai", AgentID: "agt_abc", AgentToken: "evt_value\ninjected=true", @@ -80,7 +82,7 @@ func TestBuildEnvFileBody_RejectsControlChars(t *testing.T) { } func TestBuildEnvFileBody_HappyPath(t *testing.T) { - body, err := buildEnvFileBody(WriteParams{ + body, err := buildEnvFileBody(PlatformClaudeCode, WriteParams{ APIBaseURL: "https://api.everme.evermind.ai", AgentID: "agt_abc", AgentToken: "evt_xyz", @@ -92,6 +94,413 @@ func TestBuildEnvFileBody_HappyPath(t *testing.T) { assert.True(t, strings.HasPrefix(body, "# Managed by evercli")) } +// writeClaudeStub drops an executable shell script that records every +// invocation's argv (one line per call) into logPath and exits 0. Used +// as the EVERCLI_CLAUDE_CMD seam so Remove tests can assert which +// `claude ...` commands ran without a real Claude Code install. +func writeClaudeStub(t *testing.T, logPath string) string { + t.Helper() + stub := filepath.Join(t.TempDir(), "claude") + script := "#!/bin/sh\necho \"$@\" >> " + logPath + "\nexit 0\n" + require.NoError(t, os.WriteFile(stub, []byte(script), 0o755)) + return stub +} + +// writeClaudeStubFailing behaves like writeClaudeStub but exits 1 for +// every invocation whose argv starts with failPrefix. Used to exercise +// the install/update fallback without a real Claude Code. +func writeClaudeStubFailing(t *testing.T, logPath, failPrefix string) string { + t.Helper() + stub := filepath.Join(t.TempDir(), "claude") + script := "#!/bin/sh\n" + + "echo \"$@\" >> " + logPath + "\n" + + "case \"$*\" in \"" + failPrefix + "\"*) exit 1;; esac\n" + + "exit 0\n" + require.NoError(t, os.WriteFile(stub, []byte(script), 0o755)) + return stub +} + +// seedClaudePluginState writes the two read-only files under +// $HOME/.claude/plugins that evercli consults to pick a verb and to +// assert the cache moved. An empty body skips that file. +func seedClaudePluginState(t *testing.T, home, knownMarketplaces, installedPlugins string) { + t.Helper() + dir := filepath.Join(home, ".claude", "plugins") + require.NoError(t, os.MkdirAll(dir, 0o700)) + if knownMarketplaces != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, claudeKnownMarketplacesFile), []byte(knownMarketplaces), 0o600)) + } + if installedPlugins != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, claudeInstalledPluginsFile), []byte(installedPlugins), 0o600)) + } +} + +// writeClaudePayload writes a minimal @everme/claude-code payload +// declaring the given versions in its marketplace entry and plugin +// manifest. An empty version omits that field. +func writeClaudePayload(t *testing.T, marketplaceVersion, manifestVersion string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "payload") + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".claude-plugin"), 0o755)) + entry := `{"name":"everme","source":"./"` + if marketplaceVersion != "" { + entry += `,"version":"` + marketplaceVersion + `"` + } + entry += `}` + marketplace := `{"name":"everme","plugins":[` + entry + `]}` + require.NoError(t, os.WriteFile(filepath.Join(dir, ".claude-plugin", "marketplace.json"), []byte(marketplace), 0o644)) + manifest := `{"name":"everme"` + if manifestVersion != "" { + manifest += `,"version":"` + manifestVersion + `"` + } + manifest += `}` + require.NoError(t, os.WriteFile(filepath.Join(dir, ".claude-plugin", "plugin.json"), []byte(manifest), 0o644)) + return dir +} + +// installedPluginsJSON renders an installed_plugins.json (schema 2) +// carrying one user-scope entry for everme@everme at version v. +func installedPluginsJSON(v string) string { + return `{"version":2,"plugins":{"everme@everme":[{"scope":"user","installPath":"/cache/everme/everme/` + v + `","version":"` + v + `"}]}}` +} + +// TestClaudeCachedPluginVersion pins how we read Claude Code's own +// install state: the user-scope entry wins, an absent file or a foreign +// entry means "nothing cached", and a malformed file is an error rather +// than a silent "not installed" (which would pick the install verb and +// hide a broken host). +func TestClaudeCachedPluginVersion(t *testing.T) { + t.Run("userScopeEntry", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + seedClaudePluginState(t, home, "", installedPluginsJSON("0.4.2")) + got, err := claudeCachedPluginVersion() + require.NoError(t, err) + assert.Equal(t, "0.4.2", got) + }) + + t.Run("missingFileIsNotAnError", func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + got, err := claudeCachedPluginVersion() + require.NoError(t, err) + assert.Equal(t, "", got) + }) + + t.Run("otherPluginsOnly", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + seedClaudePluginState(t, home, "", `{"version":2,"plugins":{"superpowers@official":[{"scope":"user","version":"6.2.0"}]}}`) + got, err := claudeCachedPluginVersion() + require.NoError(t, err) + assert.Equal(t, "", got) + }) + + t.Run("malformedIsErrored", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + seedClaudePluginState(t, home, "", "{not json") + _, err := claudeCachedPluginVersion() + require.Error(t, err) + }) +} + +func TestClaudeSourceManifestVersion(t *testing.T) { + t.Run("marketplaceEntryWins", func(t *testing.T) { + // The marketplace entry is the version Claude Code names its cache + // directory after, so it must beat plugin.json when they disagree. + got, err := claudeSourceManifestVersion(writeClaudePayload(t, "0.5.0", "0.4.2")) + require.NoError(t, err) + assert.Equal(t, "0.5.0", got) + }) + + t.Run("pluginManifestFallback", func(t *testing.T) { + got, err := claudeSourceManifestVersion(writeClaudePayload(t, "", "0.4.2")) + require.NoError(t, err) + assert.Equal(t, "0.4.2", got) + }) + + t.Run("httpsSourceIsNotComparable", func(t *testing.T) { + got, err := claudeSourceManifestVersion("https://github.com/example/repo.git") + require.NoError(t, err) + assert.Equal(t, "", got, "a remote source can't be read without a fetch; skip instead of guessing") + }) + + t.Run("missingPayloadIsNotComparable", func(t *testing.T) { + got, err := claudeSourceManifestVersion(filepath.Join(t.TempDir(), "absent")) + require.NoError(t, err) + assert.Equal(t, "", got) + }) +} + +// TestClaudeCodeWriter_SyncMarketplace pins the refresh verb. `add` on an +// already-registered identical source only prints "already on disk" and +// re-reads nothing, so a registered marketplace must go through `update`. +func TestClaudeCodeWriter_SyncMarketplace(t *testing.T) { + source := filepath.Join(t.TempDir(), "payload") + + t.Run("unregisteredIsAdded", func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + callLog := filepath.Join(t.TempDir(), "calls.log") + t.Setenv("EVERCLI_CLAUDE_CMD", writeClaudeStub(t, callLog)) + + require.NoError(t, newClaudeCodeWriter().syncMarketplace(context.Background(), source)) + calls, err := os.ReadFile(callLog) + require.NoError(t, err) + assert.Contains(t, string(calls), "plugin marketplace add "+source) + assert.NotContains(t, string(calls), "marketplace update") + }) + + t.Run("registeredIsUpdated", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + seedClaudePluginState(t, home, + `{"everme":{"source":{"source":"directory","path":"`+source+`"},"installLocation":"`+source+`"}}`, "") + callLog := filepath.Join(t.TempDir(), "calls.log") + t.Setenv("EVERCLI_CLAUDE_CMD", writeClaudeStub(t, callLog)) + + require.NoError(t, newClaudeCodeWriter().syncMarketplace(context.Background(), source)) + calls, err := os.ReadFile(callLog) + require.NoError(t, err) + assert.Contains(t, string(calls), "plugin marketplace update everme") + assert.NotContains(t, string(calls), "marketplace add", "add is not a refresh — it prints \"already on disk\"") + }) + + t.Run("movedDirectorySourceIsReAdded", func(t *testing.T) { + // npm's global prefix changed: the recorded path is stale, and + // `add` is what repoints the entry. + home := t.TempDir() + t.Setenv("HOME", home) + seedClaudePluginState(t, home, + `{"everme":{"source":{"source":"directory","path":"/old/prefix/@everme/claude-code"}}}`, "") + callLog := filepath.Join(t.TempDir(), "calls.log") + t.Setenv("EVERCLI_CLAUDE_CMD", writeClaudeStub(t, callLog)) + + require.NoError(t, newClaudeCodeWriter().syncMarketplace(context.Background(), source)) + calls, err := os.ReadFile(callLog) + require.NoError(t, err) + assert.Contains(t, string(calls), "plugin marketplace add "+source) + }) + + t.Run("failedUpdateFallsBackToAdd", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + seedClaudePluginState(t, home, + `{"everme":{"source":{"source":"directory","path":"`+source+`"}}}`, "") + callLog := filepath.Join(t.TempDir(), "calls.log") + t.Setenv("EVERCLI_CLAUDE_CMD", writeClaudeStubFailing(t, callLog, "plugin marketplace update")) + + require.NoError(t, newClaudeCodeWriter().syncMarketplace(context.Background(), source)) + calls, err := os.ReadFile(callLog) + require.NoError(t, err) + assert.Contains(t, string(calls), "plugin marketplace update everme") + assert.Contains(t, string(calls), "plugin marketplace add "+source, "a broken entry is repaired by re-adding") + }) +} + +// TestClaudeCodeWriter_InstallOrUpdatePlugin is the core of the fix: a +// cached plugin must be refreshed with `plugin update`, because `plugin +// install` exits 0 with "already installed" and keeps the old cache. +func TestClaudeCodeWriter_InstallOrUpdatePlugin(t *testing.T) { + t.Run("freshInstallUsesInstall", func(t *testing.T) { + callLog := filepath.Join(t.TempDir(), "calls.log") + t.Setenv("EVERCLI_CLAUDE_CMD", writeClaudeStub(t, callLog)) + + require.NoError(t, newClaudeCodeWriter().installOrUpdatePlugin(context.Background(), false)) + calls, err := os.ReadFile(callLog) + require.NoError(t, err) + assert.Equal(t, "plugin install everme@everme\n", string(calls)) + }) + + t.Run("cachedPluginUsesUpdate", func(t *testing.T) { + callLog := filepath.Join(t.TempDir(), "calls.log") + t.Setenv("EVERCLI_CLAUDE_CMD", writeClaudeStub(t, callLog)) + + require.NoError(t, newClaudeCodeWriter().installOrUpdatePlugin(context.Background(), true)) + calls, err := os.ReadFile(callLog) + require.NoError(t, err) + assert.Equal(t, "plugin update everme@everme\n", string(calls), + "the qualified spec is mandatory: `claude plugin update everme` fails with \"Plugin not found\"") + }) + + t.Run("failedUpdateFallsBackToInstall", func(t *testing.T) { + // installed_plugins.json said "cached" but Claude Code disagrees + // (hand-deleted cache directory) — the fallback must still land a + // working install. + callLog := filepath.Join(t.TempDir(), "calls.log") + t.Setenv("EVERCLI_CLAUDE_CMD", writeClaudeStubFailing(t, callLog, "plugin update")) + + require.NoError(t, newClaudeCodeWriter().installOrUpdatePlugin(context.Background(), true)) + calls, err := os.ReadFile(callLog) + require.NoError(t, err) + assert.Contains(t, string(calls), "plugin update everme@everme") + assert.Contains(t, string(calls), "plugin install everme@everme") + }) + + t.Run("bothVerbsFailingIsErrored", func(t *testing.T) { + stub := filepath.Join(t.TempDir(), "claude") + require.NoError(t, os.WriteFile(stub, []byte("#!/bin/sh\nexit 1\n"), 0o755)) + t.Setenv("EVERCLI_CLAUDE_CMD", stub) + + require.Error(t, newClaudeCodeWriter().installOrUpdatePlugin(context.Background(), true)) + }) +} + +// TestClaudeCodeWriter_Verify_VersionDrift is the "force a version check" +// half of the fix: every shell-out can exit 0 while Claude Code still +// serves an older cache, so the version comparison is the only proof the +// user runs what we shipped. +func TestClaudeCodeWriter_Verify_VersionDrift(t *testing.T) { + newEnvFile := func(t *testing.T, home string) string { + t.Helper() + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + envPath := filepath.Join(claudeDir, "everme.env") + require.NoError(t, os.WriteFile(envPath, []byte("# managed\nEVERME_AGENT_TOKEN=evt_x\n"), 0o600)) + return envPath + } + + t.Run("staleCacheIsReported", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + envPath := newEnvFile(t, home) + seedClaudePluginState(t, home, "", installedPluginsJSON("0.4.2")) + + w := newClaudeCodeWriter() + w.resolvedSource = writeClaudePayload(t, "0.5.0", "0.5.0") + err := w.Verify(context.Background(), &WriteResult{ConfigPath: envPath}) + require.Error(t, err) + assert.Contains(t, err.Error(), "0.4.2") + assert.Contains(t, err.Error(), "0.5.0") + }) + + t.Run("matchingVersionPasses", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + envPath := newEnvFile(t, home) + seedClaudePluginState(t, home, "", installedPluginsJSON("0.5.0")) + + w := newClaudeCodeWriter() + w.resolvedSource = writeClaudePayload(t, "0.5.0", "0.5.0") + require.NoError(t, w.Verify(context.Background(), &WriteResult{ConfigPath: envPath})) + }) + + t.Run("nothingCachedAfterInstallIsReported", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + envPath := newEnvFile(t, home) + + w := newClaudeCodeWriter() + w.resolvedSource = writeClaudePayload(t, "0.5.0", "0.5.0") + err := w.Verify(context.Background(), &WriteResult{ConfigPath: envPath}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no cached everme plugin") + }) + + t.Run("unreadableSourceSkipsTheAssertion", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + envPath := newEnvFile(t, home) + seedClaudePluginState(t, home, "", installedPluginsJSON("0.4.2")) + + w := newClaudeCodeWriter() + w.resolvedSource = "https://github.com/example/repo.git" + require.NoError(t, w.Verify(context.Background(), &WriteResult{ConfigPath: envPath}), + "a source we can't read must not be asserted against") + }) + + t.Run("missingTokenIsReported", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + envPath := filepath.Join(claudeDir, "everme.env") + require.NoError(t, os.WriteFile(envPath, []byte("# managed\nEVERME_AGENT_ID=agt_x\n"), 0o600)) + + err := newClaudeCodeWriter().Verify(context.Background(), &WriteResult{ConfigPath: envPath}) + require.Error(t, err) + assert.Contains(t, err.Error(), "agent token") + }) +} + +// TestClaudeCodeWriter_Remove_EnvFileIsNotJSON pins the HIGH bug: the +// detector's ConfigPath is ~/.claude/everme.env — a KEY=value file with +// '#' comments. Routing it through the JSON mcpWriter.Remove used to +// fail every `plugin uninstall claude-code` with a parse-json error. +// Remove must succeed, delete the env file, and run the best-effort +// host deregistration commands. +func TestClaudeCodeWriter_Remove_EnvFileIsNotJSON(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + envPath := filepath.Join(claudeDir, "everme.env") + body, err := buildEnvFileBody(PlatformClaudeCode, WriteParams{ + APIBaseURL: "https://api.test", + AgentID: "agt_x", + AgentToken: "evt_x", + }) + require.NoError(t, err) + require.True(t, strings.HasPrefix(body, "#"), "fixture must exercise the '#' comment header") + require.NoError(t, os.WriteFile(envPath, []byte(body), 0o600)) + + callLog := filepath.Join(t.TempDir(), "calls.log") + t.Setenv("EVERCLI_CLAUDE_CMD", writeClaudeStub(t, callLog)) + + res, err := newClaudeCodeWriter().Remove(context.Background(), envPath) + require.NoError(t, err, "the KEY=value env file must never be JSON-parsed") + assert.True(t, res.Removed) + assert.Equal(t, envPath, res.ConfigPath) + _, statErr := os.Stat(envPath) + assert.True(t, os.IsNotExist(statErr), "env file must be deleted") + + calls, readErr := os.ReadFile(callLog) + require.NoError(t, readErr, "the claude stub must have been invoked") + assert.Contains(t, string(calls), "plugin uninstall everme") + assert.Contains(t, string(calls), "plugin marketplace remove everme") +} + +// TestClaudeCodeWriter_Remove_HostCommandFailureIsNonFatal pins the +// best-effort contract: a failing `claude` CLI (already-uninstalled +// plugin, broken install) warns but never blocks the env-file cleanup. +func TestClaudeCodeWriter_Remove_HostCommandFailureIsNonFatal(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + envPath := filepath.Join(claudeDir, "everme.env") + require.NoError(t, os.WriteFile(envPath, []byte("# managed\nEVERME_AGENT_TOKEN=evt_x\n"), 0o600)) + + stub := filepath.Join(t.TempDir(), "claude") + require.NoError(t, os.WriteFile(stub, []byte("#!/bin/sh\nexit 1\n"), 0o755)) + t.Setenv("EVERCLI_CLAUDE_CMD", stub) + + res, err := newClaudeCodeWriter().Remove(context.Background(), envPath) + require.NoError(t, err) + assert.True(t, res.Removed) + _, statErr := os.Stat(envPath) + assert.True(t, os.IsNotExist(statErr)) +} + +// TestClaudeCodeWriter_Remove_EmptyConfigPath guards the +// filepath.Abs("") → cwd trap: an empty detector ConfigPath must +// resolve to the canonical ~/.claude/everme.env, never the working +// directory. Missing env file is an idempotent no-op. +func TestClaudeCodeWriter_Remove_EmptyConfigPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + // Point the claude seam at a non-existent binary so LookPath fails + // and the best-effort shell-outs are skipped entirely. + t.Setenv("EVERCLI_CLAUDE_CMD", filepath.Join(t.TempDir(), "no-such-claude")) + + res, err := newClaudeCodeWriter().Remove(context.Background(), "") + require.NoError(t, err) + assert.False(t, res.Removed, "missing env file is a successful no-op") + assert.Equal(t, filepath.Join(home, ".claude", "everme.env"), res.ConfigPath) + wd, wdErr := os.Getwd() + require.NoError(t, wdErr) + assert.NotEqual(t, wd, res.ConfigPath, "empty configPath must never resolve to the cwd") +} + func TestPluginSourceSpec_PriorityChain(t *testing.T) { // Order documented in pluginSourceSpec: // 1. struct-injected pluginSource (test-only) @@ -100,7 +509,7 @@ func TestPluginSourceSpec_PriorityChain(t *testing.T) { // 4. ensureNpmPluginInstalled() — run `npm install -g @everme/claude-code`, then retry probe // // Layers 3 and 4 require a working `npm` and are exercised by the - // end-to-end install verification (plan §`端到端验证`). The unit tests + // end-to-end install verification (plan, end-to-end section). The unit tests // below cover layers 1 and 2 plus the npm-missing error path — // enough to catch regressions in the priority chain without bringing // up a real npm registry in CI. diff --git a/cli/internal/plugin/claude_desktop_test.go b/cli/internal/plugin/claude_desktop_test.go index 6d2d8d1..aaea708 100644 --- a/cli/internal/plugin/claude_desktop_test.go +++ b/cli/internal/plugin/claude_desktop_test.go @@ -13,8 +13,7 @@ import ( // TestClaudeDesktopConfigPath_PerOS pins the per-OS layout. Anthropic's // Claude Desktop writes to a different parent directory on each OS, -// and the installer matrix in docs/mcp-codex-hermes-iteration-plan- -// 2026-05-26.md treats all three cells as load-bearing. Use the +// and the installer matrix treats all three cells as load-bearing. Use the // runtimeGOOSFn indirection (not build tags) so the test runs on any // CI host. func TestClaudeDesktopConfigPath_PerOS(t *testing.T) { diff --git a/cli/internal/plugin/codex.go b/cli/internal/plugin/codex.go index 5706d44..18efa91 100644 --- a/cli/internal/plugin/codex.go +++ b/cli/internal/plugin/codex.go @@ -3,25 +3,34 @@ // Codex (both the App and the CLI) reads MCP servers, plugins, and // marketplaces from a single TOML file at ~/.codex/config.toml. So // `evercli plugin install codex` lands a unified `platform=codex` -// configuration that both consume — see B.0 / H.2 in -// docs/mcp-codex-hermes-iteration-plan-2026-05-26.md for why we -// deliberately don't split into codex-cli / codex-desktop. +// configuration that both consume — we deliberately don't split into +// codex-cli / codex-desktop. // // Wire model: // // detector -// → Installed iff `codex` CLI is on PATH or ~/.codex/ exists. +// → Installed iff `codex` is on PATH or a supported desktop app bundle +// contains the Codex management binary. // → HasEverMeEntry := config.toml has [mcp_servers.everme] with a non-empty token. // // writer.Prepare (runs BEFORE token mint — see Preparer interface) -// → `codex plugin marketplace add EverMind-AI/EverMe` -// so the marketplace is registered before we ask the backend for an evt. +// → assert a Node >= codexHookNodeMinMajor runtime is on PATH. The Hook +// manifest spawns the bundled runner with a bare `node`, so without it +// the install would look healthy and every Hook would fail at spawn. +// → register or upgrade the EverMe marketplace, then run +// `codex plugin add everme@everme --json` and validate installedPath. // EverMind-AI/EverMe is a dedicated repo whose root IS the marketplace // (manifest at .agents/plugins/marketplace.json), so we full-clone it — // no --sparse (Codex treats the repo root as the marketplace root, and a // sparse cone would exclude the root-level .agents/ manifest). -// If this fails (network, missing CLI), /agents is +// If this fails (network, missing Codex binary), /agents is // never called, no stranded token. +// → best-effort: speak the app-server RPC protocol (codex_apprpc.go, +// codex_hook_trust.go) to trust the four EverMe lifecycle hooks Codex +// otherwise leaves in "pending trust" until a human runs `/hooks` +// inside a session. Never fatal — deferred to Verify as a warning, same +// as the marketplace-upgrade and plugin-install failures below, since +// older Codex CLI releases may not implement these RPCs at all. // // writer.Plan // → snapshot ~/.codex/config.toml (mtime/size) for TOCTOU; parse TOML; verify @@ -39,7 +48,9 @@ // by Prepare), [plugins."everme@everme"] (Commit), and // [mcp_servers.everme.env.EVERME_AGENT_TOKEN] non-empty (Commit) are // all present. Does NOT compare the token value against what -// RegisterAgent returned — that defense is `evercli doctor`. +// RegisterAgent returned — that defense is `evercli doctor`. Also +// surfaces any deferred Prepare-time warning (plugin-cache refresh, +// marketplace upgrade, hook trust) once the on-disk checks pass. // // Atomicity: TOML serialisation + .tmp + rename (writeFileAtomic, shared with // the JSON writer). A crash between marshal and rename leaves the original file @@ -49,12 +60,15 @@ package plugin import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" + "runtime" + "strconv" "strings" "time" @@ -74,14 +88,114 @@ const ( codexMarketplaceRepo = "EverMind-AI/EverMe" ) -// codexCommand resolves the `codex` CLI. EVERCLI_CODEX_CMD lets tests -// point at a stub so we don't shell out to the real CLI in unit tests. -// Same pattern as EVERCLI_CLAUDE_CMD. -func codexCommand() string { - if v := os.Getenv("EVERCLI_CODEX_CMD"); v != "" { - return v +// codexHookNodeMinMajor is the oldest Node major the bundled Hook runner is +// built for (esbuild target node18, mirrored by @everme/codex's engines field). +const codexHookNodeMinMajor = 18 + +// resolveNodeExecutable finds the Node runtime the marketplace Hook manifest +// depends on. The manifest spawns the bundled runner with a bare `node`, so a +// runtime that exists on disk but not on PATH does not satisfy the requirement. +func resolveNodeExecutable() (string, error) { + if override := os.Getenv("EVERCLI_NODE_CMD"); override != "" { + return exec.LookPath(override) + } + return exec.LookPath("node") +} + +// assertCodexHookRuntime refuses to install onto a machine whose lifecycle +// Hooks could not run. Without it the install still "succeeds" — token minted, +// config written, cache populated — and then every Hook dies at spawn time, +// which reads to the user as an EverMe outage rather than a missing +// dependency. Prepare calls it before any marketplace or backend side effect, +// so a rejected machine is left exactly as it was. +func assertCodexHookRuntime(ctx context.Context) error { + node, err := resolveNodeExecutable() + if err != nil { + ce := output.IOErr("node", "lookup-hook-runtime", err) + ce.Hint = fmt.Sprintf( + "Codex lifecycle Hooks run the bundled runner with `node`, so Node %d or newer must be resolvable on PATH. Install it from nodejs.org or your package manager, then re-run `evercli plugin install codex`. The MCP server has the same requirement (it starts through `npx`)", + codexHookNodeMinMajor) + return ce + } + major, err := nodeMajorVersion(ctx, node) + if err != nil { + ce := output.IOErr(node, "probe-hook-runtime", err) + ce.Hint = "`node -v` did not report a usable version. Repair the Node installation, or point evercli at a different one with EVERCLI_NODE_CMD, then retry" + return ce + } + if major < codexHookNodeMinMajor { + return output.Invalid( + fmt.Sprintf("node at %s reports major version %d, but the Codex Hook runner requires Node %d or newer", node, major, codexHookNodeMinMajor), + fmt.Sprintf("Upgrade Node to %d or newer, then re-run `evercli plugin install codex`", codexHookNodeMinMajor), + ) + } + return nil +} + +// nodeMajorVersion parses the major version out of `node -v` (e.g. "v22.11.0"). +func nodeMajorVersion(ctx context.Context, node string) (int, error) { + cmd := exec.CommandContext(ctx, node, "-v") + cmd.WaitDelay = 10 * time.Second + out, err := cmd.Output() + if err != nil { + return 0, err + } + raw := strings.TrimSpace(string(out)) + major, _, _ := strings.Cut(strings.TrimPrefix(raw, "v"), ".") + parsed, convErr := strconv.Atoi(major) + if convErr != nil { + return 0, fmt.Errorf("unexpected `node -v` output %q", raw) + } + return parsed, nil +} + +// resolveCodexExecutable finds the Codex binary used to manage marketplaces +// and plugins. A standalone CLI on PATH remains preferred, while macOS users +// with only the desktop app installed can fall back to its bundled binary. +func resolveCodexExecutable() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + home = "" + } + return resolveCodexExecutableFromCandidates(runtime.GOOS, codexAppBundleCandidates(home)) +} + +func resolveCodexExecutableFromCandidates(goos string, appCandidates []string) (string, error) { + if override := os.Getenv("EVERCLI_CODEX_CMD"); override != "" { + return exec.LookPath(override) + } + + path, lookupErr := exec.LookPath("codex") + if lookupErr == nil { + return path, nil + } + if goos != "darwin" { + return "", lookupErr + } + + for _, candidate := range appCandidates { + info, statErr := os.Stat(candidate) + if statErr != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { + continue + } + return candidate, nil + } + return "", lookupErr +} + +func codexAppBundleCandidates(home string) []string { + const bundledBinary = "Contents/Resources/codex" + candidates := []string{ + filepath.Join("/Applications", "ChatGPT.app", bundledBinary), + filepath.Join("/Applications", "Codex.app", bundledBinary), } - return "codex" + if home != "" { + candidates = append(candidates, + filepath.Join(home, "Applications", "ChatGPT.app", bundledBinary), + filepath.Join(home, "Applications", "Codex.app", bundledBinary), + ) + } + return candidates } // codexConfigPath returns ~/.codex/config.toml. EVERCLI_CODEX_CONFIG_DIR @@ -116,15 +230,10 @@ func (codexDetector) Detect(_ context.Context) (*Detection, error) { ConfigPath: path, } - // Installed requires the `codex` CLI on PATH — Prepare shells out - // to `codex plugin marketplace add` before the backend mints a - // token, so a Desktop-App-only install (config dir present, CLI - // missing) is guaranteed to fail at Prepare. Reporting Installed= - // true there would route the user into a doomed install. Detector - // and Prepare therefore agree on a single CLI-on-PATH precondition; - // a config-dir-only signal is treated as not-installed so the user - // gets the actionable "install the codex CLI" hint instead. - if _, err := exec.LookPath(codexCommand()); err == nil { + // Prepare shells out to Codex before the backend mints a token. Accept a + // standalone CLI or the binary bundled with the macOS desktop app, but do + // not treat a config-directory-only signal as installed. + if _, err := resolveCodexExecutable(); err == nil { d.Installed = true } @@ -145,50 +254,232 @@ func (codexDetector) Detect(_ context.Context) (*Detection, error) { // Commit's effects survived the round-trip (some Codex versions cache // config and need a restart, but the on-disk shape is the load-bearing // guarantee — Verify only checks the file, not the running app). -type codexWriter struct{} +type codexWriter struct { + // upgradeErr defers a failed best-effort `marketplace upgrade` from + // Prepare to Verify, where it surfaces as an install warning rather + // than a FailedEntry — the token is rotated and on disk either way. + upgradeErr error + // pluginInstallErr records a failed plugin refresh when an already-valid + // cache lets token rotation continue offline. Verify surfaces it as a + // warning after confirming the on-disk installation is usable. + pluginInstallErr error + // trustErr defers a failed best-effort app-server hook-trust attempt + // from Prepare to Verify, same precedent as upgradeErr/pluginInstallErr + // above: an older Codex CLI lacking the hooks/list/config/batchWrite + // app-server RPCs must not block token rotation — the existing manual + // "/hooks, review and trust" NextSteps instruction remains the fallback + // (see Commit). + trustErr error + // installedPath comes from `codex plugin add --json` and is the source of + // truth for post-install verification. Direct Verify unit tests leave it + // empty and exercise the legacy cache-discovery fallback. + installedPath string +} func newCodexWriter() *codexWriter { return &codexWriter{} } +// Remove deletes only EverMe-owned Codex state. Codex keeps plugins and MCP +// servers in one TOML file, so sibling entries and marketplace metadata must +// survive. The env sidecar is owned by evercli and is removed as well. +func (*codexWriter) Remove(_ context.Context, configPath string) (*RemoveResult, error) { + abs, err := filepath.Abs(configPath) + if err != nil { + return nil, output.IOErr(configPath, "abs-path", err) + } + cfg, exists, err := readCodexConfig(abs) + if err != nil { + return nil, err + } + result := &RemoveResult{Platform: PlatformCodex, ConfigPath: abs} + removed := false + if exists { + if plugins, ok := cfg["plugins"].(map[string]interface{}); ok { + if _, ok := plugins[codexPluginSpec]; ok { + delete(plugins, codexPluginSpec) + removed = true + } + } + if servers, ok := cfg["mcp_servers"].(map[string]interface{}); ok { + if _, ok := servers[codexMcpEntryName]; ok { + delete(servers, codexMcpEntryName) + removed = true + } + } + if marketplaces, ok := cfg["marketplaces"].(map[string]interface{}); ok { + if _, ok := marketplaces["everme"]; ok { + delete(marketplaces, "everme") + removed = true + } + } + } + envPath := filepath.Join(filepath.Dir(abs), "everme.env") + if _, statErr := os.Stat(envPath); statErr == nil { + removed = true + } + if !removed { + return result, nil + } + if exists { + // protected=true: config.toml carries the live agent token. + backup, berr := backupFile(abs, true) + if berr != nil { + return nil, berr + } + // Our token is gone from cfg by now, so leave the host's mode alone. + if err := writeCodexConfig(abs, cfg, configHasNoToken); err != nil { + return nil, err + } + result.BackupPath = backup + } + if err := os.Remove(envPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, output.IOErr(envPath, "remove-env", err) + } + result.Removed = true + return result, nil +} + func (*codexWriter) Platform() Platform { return PlatformCodex } -// Prepare runs `codex plugin marketplace add EverMind-AI/EverMe` -// BEFORE the backend mints a token, but only when the marketplace -// section is NOT yet in ~/.codex/config.toml. This lets a token -// rotation work fully offline once the marketplace has been registered -// once on the box — the shellout is the only step in this writer that -// needs network. +// Prepare registers or refreshes the marketplace BEFORE the backend +// mints a token — the shellout is the only step in this writer that +// needs network: // -// Codex's marketplace add is documented as idempotent, but each call -// re-fetches the repo from GitHub; skipping when already registered -// also avoids re-downloading the repo on every rotate. +// - marketplace not yet in ~/.codex/config.toml → `codex plugin +// marketplace add EverMind-AI/EverMe`. A failure here is fatal: +// without the marketplace nothing else can work. +// - already registered → best-effort `codex plugin marketplace +// upgrade everme`, so a box that registered once still picks up +// newer plugin content (Codex only refreshes its cache when the +// manifest version changes AND an upgrade runs). A failure here is +// deferred to Verify as a warning: token rotation must keep +// working fully offline. // // On failure we capture the CLI's stdout+stderr internally and surface // only the trimmed tail in the hint, so structured-JSON callers don't // get interleaved progress lines, and so a one-time device-auth URL // printed by Codex doesn't land in a tee'd install.log. func (w *codexWriter) Prepare(ctx context.Context, detection *Detection) error { + codexExecutable, err := resolveCodexExecutable() + if err != nil { + ce := output.IOErr("codex", "lookup-cli", err) + ce.Hint = "Install the Codex desktop app or CLI, then retry. On macOS, evercli automatically uses the binary bundled with ChatGPT.app or Codex.app" + return ce + } + + if err := assertCodexHookRuntime(ctx); err != nil { + return err + } + if marketplaceAlreadyAdded(detection) { - return nil + w.upgradeErr = upgradeCodexMarketplace(ctx) + } else { + cmd := exec.CommandContext(ctx, + codexExecutable, + "plugin", "marketplace", "add", + codexMarketplaceRepo, + ) + cmd.WaitDelay = 30 * time.Second + var captured bytes.Buffer + cmd.Stderr = &captured + cmd.Stdout = &captured + if err := cmd.Run(); err != nil { + ce := output.IOErr("codex plugin marketplace add", "exec", err) + ce.Hint = fmt.Sprintf( + "Marketplace add failed. Check network reachability for github.com/%s and that the repo is reachable. To override, run the command manually and re-attempt `evercli plugin install codex`. Codex CLI output: %s", + codexMarketplaceRepo, trimForHint(captured.String())) + return ce + } } - if _, err := exec.LookPath(codexCommand()); err != nil { + + installedPath, installErr := installCodexPlugin(ctx, codexExecutable) + if installErr == nil { + w.installedPath = installedPath + } else if detection != nil && detection.ConfigPath != "" { + // A healthy existing cache keeps token rotation available when the + // marketplace cannot be refreshed offline. Fresh installs still fail before + // the backend mints a token because there is no usable plugin to fall back to. + if existingPath, findErr := findCodexInstalledPath(detection.ConfigPath); findErr == nil { + w.installedPath = existingPath + w.pluginInstallErr = installErr + } + } + if w.installedPath == "" { + return installErr + } + + // Best-effort: establish app-server "hook trust" for the four lifecycle + // hooks the plugin ships, so they actually run instead of sitting in + // Codex's pending-trust state until a human opens `/hooks`. Runs on the + // same codexExecutable already resolved above; depends only on + // hooks.json already being on disk, which a non-empty w.installedPath + // guarantees. Never fatal — see trustErr's field comment. + w.trustErr = codexEstablishHookTrust(ctx, codexExecutable) + return nil +} + +type codexPluginInstallResult struct { + InstalledPath string `json:"installedPath"` +} + +func installCodexPlugin(ctx context.Context, codexExecutable string) (string, error) { + cmd := exec.CommandContext(ctx, + codexExecutable, + "plugin", "add", codexPluginSpec, "--json", + ) + cmd.WaitDelay = 30 * time.Second + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + ce := output.IOErr("codex plugin add", "exec", err) + ce.Hint = "The EverMe marketplace is configured but the plugin cache could not be installed. Codex CLI output: " + trimForHint(stderr.String()) + return "", ce + } + + var result codexPluginInstallResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + ce := output.IOErr("codex plugin add", "parse-json", err) + ce.Hint = "Codex returned an unexpected plugin installation response: " + trimForHint(stdout.String()) + return "", ce + } + if strings.TrimSpace(result.InstalledPath) == "" { + return "", output.IOErr("codex plugin add", "verify", fmt.Errorf("installedPath missing from Codex response")) + } + abs, err := filepath.Abs(result.InstalledPath) + if err != nil { + return "", output.IOErr(result.InstalledPath, "abs-path", err) + } + if err := validateCodexInstalledPath(abs); err != nil { + return "", output.IOErr(abs, "verify-plugin", err) + } + return abs, nil +} + +// upgradeCodexMarketplace runs `codex plugin marketplace upgrade +// everme` and returns a classified error on failure. Callers treat the +// result as advisory (see Prepare) — never abort an install on it. +func upgradeCodexMarketplace(ctx context.Context) error { + codexExecutable, err := resolveCodexExecutable() + if err != nil { ce := output.IOErr("codex", "lookup-cli", err) - ce.Hint = "Install Codex (https://codex.openai.com/) and ensure the `codex` CLI is on PATH, then retry" + ce.Hint = "Codex desktop app or CLI not found, so the everme marketplace cache was not refreshed; install Codex and retry" return ce } cmd := exec.CommandContext(ctx, - codexCommand(), - "plugin", "marketplace", "add", - codexMarketplaceRepo, + codexExecutable, + "plugin", "marketplace", "upgrade", + codexMarketplaceName, ) cmd.WaitDelay = 30 * time.Second var captured bytes.Buffer cmd.Stderr = &captured cmd.Stdout = &captured if err := cmd.Run(); err != nil { - ce := output.IOErr("codex plugin marketplace add", "exec", err) + ce := output.IOErr("codex plugin marketplace upgrade", "exec", err) ce.Hint = fmt.Sprintf( - "Marketplace add failed. Check network reachability for github.com/%s and that the repo is reachable. To override, run the command manually and re-attempt `evercli plugin install codex`. Codex CLI output: %s", - codexMarketplaceRepo, trimForHint(captured.String())) + "Marketplace upgrade failed, so the everme plugin cache may be stale. Run `codex plugin marketplace upgrade %s` manually when network is available. Codex CLI output: %s", + codexMarketplaceName, trimForHint(captured.String())) return ce } return nil @@ -298,7 +589,7 @@ func (*codexWriter) Plan(_ context.Context, configPath string) (*WritePlan, erro // rather than a strongly-typed struct precisely so unknown keys (other // marketplaces, other MCP servers, Codex-internal [desktop] settings) // round-trip unchanged. -func (*codexWriter) Commit(_ context.Context, plan *WritePlan, params WriteParams) (*WriteResult, error) { +func (w *codexWriter) Commit(_ context.Context, plan *WritePlan, params WriteParams) (*WriteResult, error) { if plan == nil { return nil, output.Internal(fmt.Errorf("nil plan")) } @@ -332,16 +623,41 @@ func (*codexWriter) Commit(_ context.Context, plan *WritePlan, params WriteParam "Fix the config file's shape manually (one of marketplaces.*, plugins.*, mcp_servers.* exists with an unexpected non-table value), then retry install", ) } + body, err := buildEnvFileBody(PlatformCodex, params) + if err != nil { + return nil, output.Internal(err) + } - if err := writeCodexConfig(plan.ConfigPath, cfg); err != nil { + // [mcp_servers.everme.env] carries the freshly minted evt token, so + // config.toml is credential-bearing even though everme.env exists too. + if err := writeCodexConfig(plan.ConfigPath, cfg, configCarriesToken); err != nil { return nil, err } + envPath := filepath.Join(filepath.Dir(plan.ConfigPath), "everme.env") + if err := writeFileAtomic(envPath, []byte(body), 0o600); err != nil { + return nil, output.IOErr(envPath, "write-env-file", err) + } + + // The Dock/PATH caveat applies regardless of trust outcome — it's about + // the hook's `node ...` command failing to spawn at runtime, a separate + // concern from whether Codex has trusted the hook content. The manual + // `/hooks` step is only needed when Prepare's automatic trust attempt + // (w.trustErr) failed; on success there's nothing left for the user to do. + nextSteps := []string{ + "if you start Codex from the macOS Dock rather than a terminal, confirm Codex resolves a Node on PATH: a Dock-launched app inherits the launchd PATH, which usually excludes a Node installed by a version manager, and the EverMe lifecycle hooks will fail to spawn even though they're trusted", + } + if w.trustErr != nil { + nextSteps = append([]string{ + "start a new Codex session, open `/hooks`, then review and trust the EverMe lifecycle hooks — evercli could not establish trust automatically for this install (see the reported warning for why)", + }, nextSteps...) + } return &WriteResult{ Platform: PlatformCodex, ConfigPath: plan.ConfigPath, BackupPath: wroteBackup, WroteNewEntry: !plan.WillReplace, + NextSteps: nextSteps, }, nil } @@ -365,7 +681,7 @@ func (*codexWriter) Commit(_ context.Context, plan *WritePlan, params WriteParam // It also does NOT probe the running Codex app — Codex caches config // in memory and may need a restart to pick up changes. We only validate // the file shape, which is the contract we own. -func (*codexWriter) Verify(_ context.Context, result *WriteResult) error { +func (w *codexWriter) Verify(_ context.Context, result *WriteResult) error { if result == nil { return output.Internal(fmt.Errorf("nil result")) } @@ -385,6 +701,36 @@ func (*codexWriter) Verify(_ context.Context, result *WriteResult) error { if !codexHasEverMeEntry(cfg) { return output.IOErr(result.ConfigPath, "verify", fmt.Errorf("mcp_servers.%s missing or empty", codexMcpEntryName)) } + envPath := filepath.Join(filepath.Dir(result.ConfigPath), "everme.env") + if !codexEnvHasToken(envPath) { + return output.IOErr(envPath, "verify", fmt.Errorf("EVERME_AGENT_TOKEN missing or empty")) + } + installedPath := w.installedPath + if installedPath == "" { + installedPath, err = findCodexInstalledPath(result.ConfigPath) + } + if err != nil { + return output.IOErr(result.ConfigPath, "verify-hooks", err) + } + if err := validateCodexInstalledPath(installedPath); err != nil { + return output.IOErr(installedPath, "verify-hooks", err) + } + // All on-disk checks passed; surface deferred Prepare-time warnings last, + // in the order plugin-cache refresh, marketplace upgrade, hook trust — + // each reaches the user as a warning without masking a genuinely broken + // install. trustErr goes last because a broken plugin/marketplace + // refresh is a more actionable root cause than a trust failure, and + // trust can't be attempted meaningfully without a valid hooks.json + // anyway (Prepare only calls it once installedPath is non-empty). + if w.pluginInstallErr != nil { + return w.pluginInstallErr + } + if w.upgradeErr != nil { + return w.upgradeErr + } + if w.trustErr != nil { + return w.trustErr + } return nil } @@ -414,18 +760,13 @@ func readCodexConfig(path string) (map[string]interface{}, bool, error) { } // writeCodexConfig serialises cfg as TOML and atomically replaces path. -// Mode is forced to 0600 — matches the JSON writer; the file holds a -// freshly minted token, so a pre-existing 0644 must be tightened rather -// than inherited. -func writeCodexConfig(path string, cfg map[string]interface{}) error { +// Mode selection matches the JSON writer: see configWriteMode. +func writeCodexConfig(path string, cfg map[string]interface{}, secrecy configSecrecy) error { raw, err := toml.Marshal(cfg) if err != nil { return output.Internal(fmt.Errorf("marshal config: %w", err)) } - if err := writeFileAtomic(path, raw, 0o600); err != nil { - return output.IOErr(path, "write-config", err) - } - return nil + return writeConfigFileAtomic(path, raw, secrecy) } // upsertCodexEntries replaces the EverMe-owned plugin + mcp_server @@ -456,7 +797,7 @@ func upsertCodexEntries(cfg map[string]interface{}, params WriteParams) error { // PATHEXT. Same constraint as the JSON writer's buildEntry(). mcpServers[codexMcpEntryName] = map[string]interface{}{ "command": npxCommand(), - "args": []interface{}{"-y", "@everme/memory-mcp"}, + "args": []interface{}{"-y", "@everme/memory-mcp@latest"}, "env": map[string]interface{}{ "EVERME_API_BASE": params.APIBaseURL, "EVERME_AGENT_ID": params.AgentID, @@ -508,3 +849,71 @@ func codexHasPluginEnabled(cfg map[string]interface{}) bool { enabled, _ := entry["enabled"].(bool) return enabled } + +func codexEnvHasToken(path string) bool { + raw, err := os.ReadFile(path) + if err != nil { + return false + } + for _, line := range strings.Split(string(raw), "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if ok && strings.TrimSpace(key) == "EVERME_AGENT_TOKEN" && strings.TrimSpace(value) != "" { + return true + } + } + return false +} + +func findCodexInstalledPath(configPath string) (string, error) { + root := filepath.Join( + filepath.Dir(configPath), + "plugins", "cache", codexMarketplaceName, codexMcpEntryName, + ) + versions, err := os.ReadDir(root) + if err != nil { + return "", fmt.Errorf("installed plugin cache missing under %s: %w", root, err) + } + var lastValidationErr error + for i := len(versions) - 1; i >= 0; i-- { + if !versions[i].IsDir() { + continue + } + path := filepath.Join(root, versions[i].Name()) + if validationErr := validateCodexInstalledPath(path); validationErr == nil { + return path, nil + } else { + lastValidationErr = validationErr + } + } + if lastValidationErr != nil { + return "", fmt.Errorf("installed EverMe plugin cache under %s is invalid: %w", root, lastValidationErr) + } + return "", fmt.Errorf("hooks/hooks.json missing from installed EverMe plugin cache under %s", root) +} + +func validateCodexInstalledPath(installedPath string) error { + info, err := os.Stat(installedPath) + if err != nil { + return fmt.Errorf("installed plugin path is missing: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("installed plugin path is not a directory") + } + hooksPath := filepath.Join(installedPath, "hooks", "hooks.json") + hooksInfo, err := os.Stat(hooksPath) + if err != nil { + return fmt.Errorf("hooks/hooks.json missing: %w", err) + } + if hooksInfo.IsDir() { + return fmt.Errorf("hooks/hooks.json is a directory") + } + runnerPath := filepath.Join(installedPath, "bin", "hook.mjs") + runnerInfo, err := os.Stat(runnerPath) + if err != nil { + return fmt.Errorf("bin/hook.mjs missing: %w", err) + } + if runnerInfo.IsDir() { + return fmt.Errorf("bin/hook.mjs is a directory") + } + return nil +} diff --git a/cli/internal/plugin/codex_apprpc.go b/cli/internal/plugin/codex_apprpc.go new file mode 100644 index 0000000..43e7471 --- /dev/null +++ b/cli/internal/plugin/codex_apprpc.go @@ -0,0 +1,306 @@ +// Package plugin — Codex app-server RPC transport. +// +// Codex CLI exposes a JSON-RPC-shaped protocol over stdio via +// `codex app-server --stdio`: newline-delimited JSON, requests carry an +// "id" and expect a matching `{"id","result"}` / `{"id","error"}` response, +// notifications omit "id" and expect none. The server also emits its own +// unsolicited notifications (observed live: `remoteControl/status/changed`) +// that must be skipped while a caller awaits a specific response id. +// +// codexRPCClient implements that wire protocol over an injected +// io.WriteCloser/io.Reader pair with zero exec.Cmd dependency, so it can be +// unit-tested with in-memory io.Pipe() pairs and a goroutine standing in for +// the app-server. codexAppServerProcess is the thin production wrapper that +// actually spawns the real `codex` binary. +// +// This whole transport exists to reach `hooks/list` and `config/batchWrite` +// (see codex_hook_trust.go), which are undocumented app-server RPCs, not a +// published API — Codex's own docs (https://learn.chatgpt.com/docs/hooks) +// describe only the interactive `/hooks` command for granting hook trust. +// OpenAI has an open feature request for a supported programmatic mechanism +// (https://github.com/openai/codex/issues/21615, filed 2026-05-07, still +// unresolved) that explicitly names this exact RPC dance as the unsupported +// workaround integrators currently rely on. Every failure here is therefore +// best-effort by design (see codexWriter.trustErr in codex.go) — a future +// Codex release changing or removing these RPCs must degrade to the manual +// `/hooks` fallback, not break the install. +package plugin + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "sync" + "sync/atomic" + "time" +) + +// codexAppServerRequestTimeout / codexAppServerCloseTimeout are `var`s (not +// `const`s) so tests can override them via the same save/restore-in-Cleanup +// pattern this package already uses for runtimeGOOSFn (runtime.go). App-server +// RPCs are local IPC against an already-spawned process, not a network fetch +// or a marketplace clone, so they warrant far shorter budgets than the 30s +// cmd.WaitDelay installCodexPlugin/upgradeCodexMarketplace use in codex.go. +var ( + codexAppServerRequestTimeout = 10 * time.Second + codexAppServerCloseTimeout = 1 * time.Second +) + +// codexAppServerClient is the minimal surface codex_hook_trust.go's +// orchestration needs. Both codexRPCClient (real transport) and test fakes +// implement it, so the trust orchestration logic never needs a real +// subprocess to be unit-tested. +type codexAppServerClient interface { + Request(ctx context.Context, method string, params interface{}) (json.RawMessage, error) + Notify(method string, params interface{}) error + Close() error +} + +// codexRPCError is the JSON-RPC error shape: `{"id":N,"error":{"message":...}}`. +type codexRPCError struct { + Message string `json:"message"` +} + +func (e *codexRPCError) Error() string { return e.Message } + +// codexRPCLine is the union of every shape a line from the app-server can +// take. A line with no "id" is a server-initiated notification and is +// dropped by the read loop rather than decoded further. +type codexRPCLine struct { + ID *int64 `json:"id"` + Result json.RawMessage `json:"result"` + Error *codexRPCError `json:"error"` +} + +// codexRPCResult is what the read loop delivers to a waiting Request: either +// a decoded response line, or a transport-level failure (stdout closed +// before a response for this id ever arrived). +type codexRPCResult struct { + line codexRPCLine + err error +} + +// codexRPCClient implements codexAppServerClient over newline-delimited JSON +// on an injected io.WriteCloser (stdin) + io.Reader (stdout). It has zero +// exec.Cmd dependency, so unit tests drive it with in-memory io.Pipe() pairs +// and a goroutine standing in for the app-server — no subprocess, no shell, +// runs on every platform including Windows. +type codexRPCClient struct { + stdin io.WriteCloser + writeMu sync.Mutex + nextID int64 // atomic + + pendingMu sync.Mutex + pending map[int64]chan codexRPCResult + closed bool // guarded by pendingMu; true once the read loop has exited + + closeOnce sync.Once +} + +// newCodexRPCClient starts a background goroutine scanning stdout for +// newline-delimited JSON responses. It never blocks the caller and never +// exits on a single malformed or unsolicited line — only on stdout actually +// closing (subprocess exited, pipe closed), at which point every still +// pending Request fails immediately instead of waiting out its full timeout. +func newCodexRPCClient(stdin io.WriteCloser, stdout io.Reader) *codexRPCClient { + c := &codexRPCClient{ + stdin: stdin, + pending: make(map[int64]chan codexRPCResult), + } + go c.readLoop(stdout) + return c +} + +func (c *codexRPCClient) readLoop(stdout io.Reader) { + scanner := bufio.NewScanner(stdout) + // hooks/list can list many hooks; the default 64KB token size is not a + // safe assumption for that payload. + scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + var decoded codexRPCLine + if err := json.Unmarshal(line, &decoded); err != nil { + continue // never let one bad line kill the reader + } + if decoded.ID == nil { + continue // unsolicited server notification, e.g. remoteControl/status/changed + } + c.deliver(*decoded.ID, codexRPCResult{line: decoded}) + } + err := scanner.Err() + if err == nil { + err = io.EOF + } + c.failAllPending(fmt.Errorf("codex app-server stdout closed: %w", err)) +} + +func (c *codexRPCClient) deliver(id int64, result codexRPCResult) { + c.pendingMu.Lock() + ch, ok := c.pending[id] + if ok { + delete(c.pending, id) + } + c.pendingMu.Unlock() + if ok { + ch <- result + } +} + +func (c *codexRPCClient) failAllPending(err error) { + c.pendingMu.Lock() + pending := c.pending + c.pending = make(map[int64]chan codexRPCResult) + c.closed = true + c.pendingMu.Unlock() + for _, ch := range pending { + ch <- codexRPCResult{err: err} + } +} + +// Request sends {"method","id","params"} and blocks until a matching +// response arrives, the request times out (codexAppServerRequestTimeout), +// or ctx is done. A JSON-RPC `error` field is surfaced as a Go error. +func (c *codexRPCClient) Request(ctx context.Context, method string, params interface{}) (json.RawMessage, error) { + id := atomic.AddInt64(&c.nextID, 1) + ch := make(chan codexRPCResult, 1) + + c.pendingMu.Lock() + if c.closed { + c.pendingMu.Unlock() + return nil, fmt.Errorf("codex app-server connection is closed") + } + c.pending[id] = ch + c.pendingMu.Unlock() + + if err := c.writeLine(map[string]interface{}{"method": method, "id": id, "params": params}); err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, fmt.Errorf("write %s request: %w", method, err) + } + + timer := time.NewTimer(codexAppServerRequestTimeout) + defer timer.Stop() + select { + case <-ctx.Done(): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, ctx.Err() + case <-timer.C: + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, fmt.Errorf("codex app-server request timed out: %s", method) + case result := <-ch: + if result.err != nil { + return nil, result.err + } + if result.line.Error != nil { + return nil, fmt.Errorf("codex app-server rejected %s: %s", method, result.line.Error.Message) + } + return result.line.Result, nil + } +} + +// Notify sends {"method","params"} (no id) and returns as soon as the write +// completes — the app-server never responds to a notification. +func (c *codexRPCClient) Notify(method string, params interface{}) error { + if err := c.writeLine(map[string]interface{}{"method": method, "params": params}); err != nil { + return fmt.Errorf("write %s notification: %w", method, err) + } + return nil +} + +func (c *codexRPCClient) writeLine(payload interface{}) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + body = append(body, '\n') + c.writeMu.Lock() + defer c.writeMu.Unlock() + _, err = c.stdin.Write(body) + return err +} + +// Close closes stdin. It never touches a process — that's +// codexAppServerProcess's job — so it's also what unit tests exercise +// directly against an io.Pipe() writer. +func (c *codexRPCClient) Close() error { + var err error + c.closeOnce.Do(func() { + err = c.stdin.Close() + }) + return err +} + +// codexAppServerProcess pairs a codexRPCClient with the exec.Cmd that owns +// its pipes so Close() also reaps the subprocess: close stdin (which ends +// the app-server's stdio loop), wait up to codexAppServerCloseTimeout, then +// kill. Only this type — never codexRPCClient — depends on os/exec, keeping +// the wire protocol unit-testable without a subprocess. +type codexAppServerProcess struct { + *codexRPCClient + cmd *exec.Cmd + stderr *bytes.Buffer +} + +func (p *codexAppServerProcess) Close() error { + stdinErr := p.codexRPCClient.Close() + + done := make(chan error, 1) + go func() { done <- p.cmd.Wait() }() + + select { + case <-done: + case <-time.After(codexAppServerCloseTimeout): + _ = p.cmd.Process.Kill() + <-done + } + return stdinErr +} + +// Stderr returns a hint-sized tail of the process's captured stderr. Not +// part of codexAppServerClient — callers that want it (codex_hook_trust.go, +// to explain *why* automatic trust failed, e.g. an older Codex CLI printing +// "unknown subcommand app-server") type-assert for it, so test fakes that +// don't implement it are unaffected. +func (p *codexAppServerProcess) Stderr() string { + return trimForHint(p.stderr.String()) +} + +// spawnCodexAppServer starts `codexExecutable app-server --stdio` and wires +// it to a codexRPCClient. cmd.WaitDelay mirrors the convention already used +// by installCodexPlugin/upgradeCodexMarketplace in codex.go. +func spawnCodexAppServer(ctx context.Context, codexExecutable string) (codexAppServerClient, error) { + cmd := exec.CommandContext(ctx, codexExecutable, "app-server", "--stdio") + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("open codex app-server stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("open codex app-server stdout: %w", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.WaitDelay = codexAppServerCloseTimeout + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start codex app-server: %w", err) + } + + return &codexAppServerProcess{ + codexRPCClient: newCodexRPCClient(stdin, stdout), + cmd: cmd, + stderr: &stderr, + }, nil +} diff --git a/cli/internal/plugin/codex_apprpc_test.go b/cli/internal/plugin/codex_apprpc_test.go new file mode 100644 index 0000000..979c0b6 --- /dev/null +++ b/cli/internal/plugin/codex_apprpc_test.go @@ -0,0 +1,294 @@ +package plugin + +import ( + "bufio" + "context" + "encoding/json" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeCodexServer stands in for `codex app-server --stdio` over a pair of +// io.Pipe()s, so codexRPCClient's wire protocol is exercised with zero +// subprocess/shell dependency and runs on every platform including Windows. +type fakeCodexServer struct { + t *testing.T + scanner *bufio.Scanner + out io.Writer +} + +func newFakeCodexServer(t *testing.T, in io.Reader, out io.Writer) *fakeCodexServer { + t.Helper() + scanner := bufio.NewScanner(in) + scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) + return &fakeCodexServer{t: t, scanner: scanner, out: out} +} + +// nextLine blocks for the next line written by the client, decoded into a +// generic map so tests can inspect "method"/"id"/"params" directly. +func (f *fakeCodexServer) nextLine() map[string]interface{} { + f.t.Helper() + if !f.scanner.Scan() { + return nil + } + var decoded map[string]interface{} + require.NoError(f.t, json.Unmarshal(f.scanner.Bytes(), &decoded)) + return decoded +} + +func (f *fakeCodexServer) writeLine(v interface{}) { + f.t.Helper() + body, err := json.Marshal(v) + require.NoError(f.t, err) + body = append(body, '\n') + _, err = f.out.Write(body) + require.NoError(f.t, err) +} + +func (f *fakeCodexServer) respond(id interface{}, result interface{}) { + f.writeLine(map[string]interface{}{"id": id, "result": result}) +} + +// TestCodexRPCClient_Request_MatchesResponseByID proves responses are routed +// by "id", not by arrival order: two concurrent requests are answered in +// reverse of the order their lines were received. +func TestCodexRPCClient_Request_MatchesResponseByID(t *testing.T) { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + defer stdinW.Close() + defer stdoutW.Close() + + server := newFakeCodexServer(t, stdinR, stdoutW) + client := newCodexRPCClient(stdinW, stdoutR) + + type outcome struct { + raw json.RawMessage + err error + } + resultsA := make(chan outcome, 1) + resultsB := make(chan outcome, 1) + + go func() { + raw, err := client.Request(context.Background(), "callA", map[string]interface{}{"marker": "A"}) + resultsA <- outcome{raw, err} + }() + go func() { + raw, err := client.Request(context.Background(), "callB", map[string]interface{}{"marker": "B"}) + resultsB <- outcome{raw, err} + }() + + lines := []map[string]interface{}{server.nextLine(), server.nextLine()} + for i := len(lines) - 1; i >= 0; i-- { // respond in reverse arrival order + line := lines[i] + params, _ := line["params"].(map[string]interface{}) + server.respond(line["id"], map[string]interface{}{"marker": params["marker"]}) + } + + a := <-resultsA + require.NoError(t, a.err) + assert.JSONEq(t, `{"marker":"A"}`, string(a.raw)) + + b := <-resultsB + require.NoError(t, b.err) + assert.JSONEq(t, `{"marker":"B"}`, string(b.raw)) +} + +// TestCodexRPCClient_SkipsUnsolicitedNotifications mirrors the real observed +// shape: the app-server sends an id-less "remoteControl/status/changed" +// notification ahead of the actual response. It must not be mistaken for a +// response or wedge the pending request. +func TestCodexRPCClient_SkipsUnsolicitedNotifications(t *testing.T) { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + defer stdinW.Close() + defer stdoutW.Close() + + server := newFakeCodexServer(t, stdinR, stdoutW) + client := newCodexRPCClient(stdinW, stdoutR) + + type outcome struct { + raw json.RawMessage + err error + } + resultCh := make(chan outcome, 1) + go func() { + raw, err := client.Request(context.Background(), "hooks/list", nil) + resultCh <- outcome{raw, err} + }() + + line := server.nextLine() + server.writeLine(map[string]interface{}{ + "method": "remoteControl/status/changed", + "params": map[string]interface{}{"status": "disabled"}, + }) + server.respond(line["id"], map[string]interface{}{"data": []interface{}{}}) + + got := <-resultCh + require.NoError(t, got.err) + assert.JSONEq(t, `{"data":[]}`, string(got.raw)) +} + +// TestCodexRPCClient_Notify_NoResponseExpected asserts Notify returns as +// soon as the write completes, without registering anything that could hang +// waiting for a response the app-server never sends to a notification. +func TestCodexRPCClient_Notify_NoResponseExpected(t *testing.T) { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + defer stdinW.Close() + defer stdoutW.Close() + + server := newFakeCodexServer(t, stdinR, stdoutW) + client := newCodexRPCClient(stdinW, stdoutR) + + // A real reader draining stdin: io.Pipe's Write blocks until something + // Reads, so Notify's write only completes once this goroutine consumes + // the line — which is exactly the "no response is ever awaited" behavior + // under test, just without a synchronous reader on the main goroutine. + lineCh := make(chan map[string]interface{}, 1) + go func() { lineCh <- server.nextLine() }() + + done := make(chan error, 1) + go func() { done <- client.Notify("initialized", struct{}{}) }() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Notify blocked as if awaiting a response") + } + + var line map[string]interface{} + select { + case line = <-lineCh: + case <-time.After(2 * time.Second): + t.Fatal("notification line was never read") + } + _, hasID := line["id"] + assert.False(t, hasID, "a notification must not carry an id") + assert.Equal(t, "initialized", line["method"]) +} + +// TestCodexRPCClient_RequestError_SurfacesMessage asserts a JSON-RPC +// {"error":{"message"}} response becomes a Go error carrying that message. +func TestCodexRPCClient_RequestError_SurfacesMessage(t *testing.T) { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + defer stdinW.Close() + defer stdoutW.Close() + + server := newFakeCodexServer(t, stdinR, stdoutW) + client := newCodexRPCClient(stdinW, stdoutR) + + resultCh := make(chan error, 1) + go func() { + _, err := client.Request(context.Background(), "hooks/list", nil) + resultCh <- err + }() + + line := server.nextLine() + server.writeLine(map[string]interface{}{ + "id": line["id"], + "error": map[string]interface{}{"message": "boom"}, + }) + + err := <-resultCh + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} + +// TestCodexRPCClient_RequestTimeout overrides codexAppServerRequestTimeout +// (same save/restore-in-Cleanup convention as runtimeGOOSFn in runtime.go) +// so a server that never responds fails the request promptly. +func TestCodexRPCClient_RequestTimeout(t *testing.T) { + prevTimeout := codexAppServerRequestTimeout + codexAppServerRequestTimeout = 20 * time.Millisecond + t.Cleanup(func() { codexAppServerRequestTimeout = prevTimeout }) + + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + defer stdinW.Close() + defer stdoutW.Close() + + // Drain stdin so the client's write doesn't block, but never respond. + go func() { + scanner := bufio.NewScanner(stdinR) + for scanner.Scan() { + } + }() + + client := newCodexRPCClient(stdinW, stdoutR) + _, err := client.Request(context.Background(), "hooks/list", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out") +} + +// TestCodexRPCClient_StdoutClosed_FailsPendingRequests asserts a pending +// request fails as soon as stdout closes (subprocess exited), rather than +// waiting out the full request timeout. The drain goroutine signals once it +// has seen the request line, which happens-after the client registers the +// pending entry, so closing stdout at that point deterministically exercises +// the "pending request killed by EOF" path. +func TestCodexRPCClient_StdoutClosed_FailsPendingRequests(t *testing.T) { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + defer stdinW.Close() + + registered := make(chan struct{}) + go func() { + scanner := bufio.NewScanner(stdinR) + if scanner.Scan() { + close(registered) + } + for scanner.Scan() { + } + }() + + client := newCodexRPCClient(stdinW, stdoutR) + + resultCh := make(chan error, 1) + go func() { + _, err := client.Request(context.Background(), "hooks/list", nil) + resultCh <- err + }() + + select { + case <-registered: + case <-time.After(2 * time.Second): + t.Fatal("request was never written to stdin") + } + require.NoError(t, stdoutW.Close()) + + select { + case err := <-resultCh: + require.Error(t, err) + assert.Contains(t, err.Error(), "stdout closed") + case <-time.After(2 * time.Second): + t.Fatal("request did not fail promptly after stdout closed") + } +} + +// TestCodexRPCClient_Close_ClosesStdin asserts Close() closes stdin and is +// safe to call twice (sync.Once-guarded). +func TestCodexRPCClient_Close_ClosesStdin(t *testing.T) { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + defer stdoutW.Close() + + go func() { + scanner := bufio.NewScanner(stdinR) + for scanner.Scan() { + } + }() + + client := newCodexRPCClient(stdinW, stdoutR) + require.NoError(t, client.Close()) + require.NoError(t, client.Close()) + + _, err := stdinW.Write([]byte("x")) + require.Error(t, err) + assert.ErrorIs(t, err, io.ErrClosedPipe) +} diff --git a/cli/internal/plugin/codex_hook_trust.go b/cli/internal/plugin/codex_hook_trust.go new file mode 100644 index 0000000..80f95d4 --- /dev/null +++ b/cli/internal/plugin/codex_hook_trust.go @@ -0,0 +1,227 @@ +// Package plugin — Codex hook-trust orchestration. +// +// Codex CLI keeps every non-managed hook — including plugin-installed ones — +// in a "pending trust" state, gated by a per-hook content hash recorded in +// `[hooks.state]` in ~/.codex/config.toml, until something approves it: a +// human running `/hooks` inside a Codex session, or the same app-server RPC +// dance run programmatically. Without it, `evercli plugin install codex` +// "succeeds" but the EverMe lifecycle hooks it just installed never execute. +// +// codexEstablishHookTrust drives that RPC dance over codexAppServerClient +// (codex_apprpc.go): initialize -> initialized -> hooks/list -> (if any of +// the four EverMe hooks aren't already trusted) config/batchWrite -> +// hooks/list again to confirm the write took. It is Prepare's best-effort +// call — see codexWriter.trustErr in codex.go for why a failure here must +// never block token issuance. +// +// hooks/list and config/batchWrite are not a published Codex API — see the +// package doc on codex_apprpc.go for the upstream tracking issue +// (openai/codex#21615) confirming this is the only known way to grant +// trust programmatically today, and why every error path here is advisory. +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "evercli/internal/output" +) + +// codexExpectedHookEvents are the four lifecycle hooks EverMind-AI/EverMe's +// hooks/hooks.json declares. Discovering fewer than this many hooks under +// codexPluginSpec means the plugin install itself is broken (missing or +// corrupt hooks.json), not merely untrusted. +var codexExpectedHookEvents = []string{"sessionStart", "userPromptSubmit", "stop", "preCompact"} + +// codexHookMetadata is the subset of the app-server's HookMetadata shape +// (confirmed live against codex-cli 0.147.0's `hooks/list`) this package +// needs. PluginID is nullable — only plugin-sourced hooks carry one. +type codexHookMetadata struct { + Key string `json:"key"` + EventName string `json:"eventName"` + PluginID *string `json:"pluginId"` + CurrentHash string `json:"currentHash"` + TrustStatus string `json:"trustStatus"` +} + +type codexHooksListEntry struct { + Hooks []codexHookMetadata `json:"hooks"` +} + +type codexHooksListResult struct { + Data []codexHooksListEntry `json:"data"` +} + +// newCodexAppServerClientFn is the test seam for codexEstablishHookTrust, +// following the same var-for-override convention as runtimeGOOSFn +// (runtime.go): production wires the real subprocess, tests substitute a +// fake client with zero subprocess dependency. +var newCodexAppServerClientFn = spawnCodexAppServer + +// codexStderrProvider is implemented by codexAppServerProcess (never by test +// fakes) so a process that started but behaved unexpectedly (e.g. an older +// Codex CLI without the app-server RPCs this needs) can have its stderr +// tail folded into the surfaced warning. +type codexStderrProvider interface{ Stderr() string } + +// codexEstablishHookTrust is Prepare's best-effort entry point: spawn the +// Codex app-server, trust the four EverMe lifecycle hooks, tear the process +// down. Every failure is returned as a *output.CLIError with a Hint pointing +// at the manual `/hooks` fallback — callers must treat it as advisory, never +// fatal, per the file header comment above. +func codexEstablishHookTrust(ctx context.Context, codexExecutable string) error { + client, err := newCodexAppServerClientFn(ctx, codexExecutable) + if err != nil { + return codexHookTrustErr("spawn", err) + } + + trustErr := codexEstablishHookTrustWithClient(ctx, client) + // Close before inspecting Stderr(): codexAppServerProcess.Close() waits + // for the subprocess to exit, which is also what guarantees the + // internal os/exec goroutine copying its stderr pipe into the buffer + // has finished — reading it any earlier races with that copy. + _ = client.Close() + if trustErr == nil { + return nil + } + if sp, ok := client.(codexStderrProvider); ok { + if tail := sp.Stderr(); tail != "" { + trustErr = fmt.Errorf("%w (codex app-server stderr: %s)", trustErr, tail) + } + } + return codexHookTrustErr("trust", trustErr) +} + +// codexEstablishHookTrustWithClient is the interface-testable core: given +// anything implementing codexAppServerClient, it runs the full +// initialize -> hooks/list -> [config/batchWrite -> hooks/list] sequence. +func codexEstablishHookTrustWithClient(ctx context.Context, client codexAppServerClient) error { + if _, err := client.Request(ctx, "initialize", codexInitializeParams()); err != nil { + return fmt.Errorf("initialize: %w", err) + } + if err := client.Notify("initialized", struct{}{}); err != nil { + return fmt.Errorf("initialized: %w", err) + } + + hooks, err := codexListEverMeHooks(ctx, client) + if err != nil { + return err + } + if codexAllHooksTrusted(hooks) { + return nil + } + + state := make(map[string]interface{}, len(hooks)) + for _, hook := range hooks { + // Matches the real persisted TOML shape exactly (confirmed against a + // live ~/.codex/config.toml): a hooks.state entry carries only + // trusted_hash, no `enabled` field. + state[hook.Key] = map[string]interface{}{"trusted_hash": hook.CurrentHash} + } + batchWriteParams := map[string]interface{}{ + "edits": []interface{}{ + map[string]interface{}{ + "keyPath": "hooks.state", + "mergeStrategy": "upsert", + "value": state, + }, + }, + "reloadUserConfig": true, + } + if _, err := client.Request(ctx, "config/batchWrite", batchWriteParams); err != nil { + return fmt.Errorf("config/batchWrite: %w", err) + } + + verified, err := codexListEverMeHooks(ctx, client) + if err != nil { + return fmt.Errorf("re-verify after trust write: %w", err) + } + if !codexAllHooksTrusted(verified) { + return fmt.Errorf("codex did not persist trust for all EverMe lifecycle hooks") + } + return nil +} + +// codexListEverMeHooks calls hooks/list and returns exactly one +// codexHookMetadata per codexExpectedHookEvents entry, matched by +// pluginId == codexPluginSpec — simpler and more precise than matching by +// sourcePath+command, since plugin-sourced hooks carry a pluginId that +// user-level hooks don't. Errors naming whichever expected events are +// missing if Codex reports fewer than all four. +func codexListEverMeHooks(ctx context.Context, client codexAppServerClient) ([]codexHookMetadata, error) { + raw, err := client.Request(ctx, "hooks/list", map[string]interface{}{"cwds": codexHooksListCwds()}) + if err != nil { + return nil, fmt.Errorf("hooks/list: %w", err) + } + var result codexHooksListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("hooks/list: parse response: %w", err) + } + + byEvent := make(map[string]codexHookMetadata, len(codexExpectedHookEvents)) + for _, entry := range result.Data { + for _, hook := range entry.Hooks { + if hook.PluginID == nil || *hook.PluginID != codexPluginSpec { + continue + } + byEvent[hook.EventName] = hook + } + } + + hooks := make([]codexHookMetadata, 0, len(codexExpectedHookEvents)) + var missing []string + for _, event := range codexExpectedHookEvents { + hook, ok := byEvent[event] + if !ok { + missing = append(missing, event) + continue + } + hooks = append(hooks, hook) + } + if len(missing) > 0 { + return nil, fmt.Errorf("codex did not report EverMe lifecycle hooks for: %s", strings.Join(missing, ", ")) + } + return hooks, nil +} + +// codexHooksListCwds resolves the `cwds` hooks/list param. Plugin-sourced +// hooks are not cwd-scoped (unlike project-level hooks.json entries), so any +// valid directory returns them; verified live against a real app-server. +func codexHooksListCwds() []string { + if cwd, err := os.Getwd(); err == nil && cwd != "" { + return []string{cwd} + } + return []string{} +} + +func codexAllHooksTrusted(hooks []codexHookMetadata) bool { + for _, hook := range hooks { + if hook.TrustStatus != "trusted" { + return false + } + } + return true +} + +func codexInitializeParams() map[string]interface{} { + return map[string]interface{}{ + "clientInfo": map[string]interface{}{ + "name": "evercli", + "title": "EverMe CLI", + "version": "1", + }, + } +} + +// codexHookTrustErr wraps cause in the same output.IOErr shape codex.go's +// exec-based helpers use (e.g. installCodexPlugin's +// output.IOErr("codex plugin add", "exec", err)), with a Hint pointing at +// the manual /hooks fallback so a rendered warning is actionable on its own. +func codexHookTrustErr(op string, cause error) *output.CLIError { + ce := output.IOErr("codex app-server", op, cause) + ce.Hint = "EverMe's lifecycle hooks are installed but evercli could not auto-trust them automatically — this can happen on older Codex CLI releases that don't implement the hooks/list / config/batchWrite app-server RPCs. Start a new Codex session, open `/hooks`, and review and trust the EverMe hooks manually" + return ce +} diff --git a/cli/internal/plugin/codex_hook_trust_test.go b/cli/internal/plugin/codex_hook_trust_test.go new file mode 100644 index 0000000..b797ed4 --- /dev/null +++ b/cli/internal/plugin/codex_hook_trust_test.go @@ -0,0 +1,241 @@ +package plugin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + + "evercli/internal/output" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeCodexRPCStep pins one expected call in a fakeCodexAppServerClient's +// script: the method it must be, and what to hand back. +type fakeCodexRPCStep struct { + wantMethod string + result json.RawMessage + err error +} + +// fakeCodexAppServerClient is a strict ordered script: any call beyond the +// scripted steps, or one whose method doesn't match the next expected step, +// fails the test immediately. This also pins the exact fixed RPC sequence +// codexEstablishHookTrustWithClient issues, which the shell-stub integration +// test in codex_test.go depends on being deterministic. +type fakeCodexAppServerClient struct { + t *testing.T + steps []fakeCodexRPCStep + notifies []string + index int + closed bool +} + +func (f *fakeCodexAppServerClient) Request(_ context.Context, method string, _ interface{}) (json.RawMessage, error) { + f.t.Helper() + if f.index >= len(f.steps) { + f.t.Fatalf("unexpected extra Request(%q): no more steps scripted", method) + } + step := f.steps[f.index] + f.index++ + if step.wantMethod != method { + f.t.Fatalf("Request #%d: want method %q, got %q", f.index, step.wantMethod, method) + } + return step.result, step.err +} + +func (f *fakeCodexAppServerClient) Notify(method string, _ interface{}) error { + f.notifies = append(f.notifies, method) + return nil +} + +func (f *fakeCodexAppServerClient) Close() error { + f.closed = true + return nil +} + +// codexHookFixture builds one HookMetadata-shaped entry for tests. +// pluginID == "" produces a null pluginId (mirroring a non-plugin hook). +func codexHookFixture(event, pluginID, hash, trustStatus string) codexHookMetadata { + var pid *string + if pluginID != "" { + pid = &pluginID + } + return codexHookMetadata{ + Key: "everme@everme:hooks/hooks.json:" + event + ":0:0", + EventName: event, + PluginID: pid, + CurrentHash: hash, + TrustStatus: trustStatus, + } +} + +// codexAllExpectedHooks builds one fixture per codexExpectedHookEvents entry +// under codexPluginSpec, all with the same trustStatus. +func codexAllExpectedHooks(trustStatus string) []codexHookMetadata { + hooks := make([]codexHookMetadata, 0, len(codexExpectedHookEvents)) + for i, event := range codexExpectedHookEvents { + hooks = append(hooks, codexHookFixture(event, codexPluginSpec, fmt.Sprintf("sha256:%d", i), trustStatus)) + } + return hooks +} + +func codexHooksListResultJSON(t *testing.T, hooks ...codexHookMetadata) json.RawMessage { + t.Helper() + raw, err := json.Marshal(codexHooksListResult{Data: []codexHooksListEntry{{Hooks: hooks}}}) + require.NoError(t, err) + return raw +} + +func TestCodexEstablishHookTrust_AlreadyTrusted_SkipsWrite(t *testing.T) { + client := &fakeCodexAppServerClient{ + t: t, + steps: []fakeCodexRPCStep{ + {wantMethod: "initialize", result: json.RawMessage(`{}`)}, + {wantMethod: "hooks/list", result: codexHooksListResultJSON(t, codexAllExpectedHooks("trusted")...)}, + }, + } + + err := codexEstablishHookTrustWithClient(context.Background(), client) + + require.NoError(t, err) + assert.Equal(t, 2, client.index, "must not call config/batchWrite or re-verify once already trusted") + assert.Equal(t, []string{"initialized"}, client.notifies) +} + +func TestCodexEstablishHookTrust_NeedsTrust_UpsertsAndReverifies(t *testing.T) { + needsTrust := codexAllExpectedHooks("untrusted") + needsTrust[1].TrustStatus = "modified" // e.g. after a plugin version bump changed currentHash + + client := &fakeCodexAppServerClient{ + t: t, + steps: []fakeCodexRPCStep{ + {wantMethod: "initialize", result: json.RawMessage(`{}`)}, + {wantMethod: "hooks/list", result: codexHooksListResultJSON(t, needsTrust...)}, + {wantMethod: "config/batchWrite", result: json.RawMessage(`{}`)}, + {wantMethod: "hooks/list", result: codexHooksListResultJSON(t, codexAllExpectedHooks("trusted")...)}, + }, + } + + err := codexEstablishHookTrustWithClient(context.Background(), client) + + require.NoError(t, err) + assert.Equal(t, 4, client.index, "untrusted/modified hooks must trigger the full write-then-reverify sequence") +} + +func TestCodexEstablishHookTrust_IgnoresHooksFromOtherSources(t *testing.T) { + hooks := codexAllExpectedHooks("trusted") + // Same event name as one of ours, but not our plugin — must not shadow + // the real everme entry in the by-event lookup. + hooks = append(hooks, + codexHookFixture("stop", "", "sha256:no-plugin", "untrusted"), + codexHookFixture("stop", "other@marketplace", "sha256:other-plugin", "untrusted"), + ) + + client := &fakeCodexAppServerClient{ + t: t, + steps: []fakeCodexRPCStep{ + {wantMethod: "initialize", result: json.RawMessage(`{}`)}, + {wantMethod: "hooks/list", result: codexHooksListResultJSON(t, hooks...)}, + }, + } + + err := codexEstablishHookTrustWithClient(context.Background(), client) + + require.NoError(t, err) + assert.Equal(t, 2, client.index, "unrelated hooks must not trigger a write") +} + +func TestCodexEstablishHookTrust_MissingHooks_Errors(t *testing.T) { + hooks := codexAllExpectedHooks("trusted") + hooks = hooks[:len(hooks)-1] // drop the last expected event (preCompact) + + client := &fakeCodexAppServerClient{ + t: t, + steps: []fakeCodexRPCStep{ + {wantMethod: "initialize", result: json.RawMessage(`{}`)}, + {wantMethod: "hooks/list", result: codexHooksListResultJSON(t, hooks...)}, + }, + } + + err := codexEstablishHookTrustWithClient(context.Background(), client) + + require.Error(t, err) + assert.Contains(t, err.Error(), "preCompact") + assert.Equal(t, 2, client.index, "config/batchWrite must never be reached when a hook is missing") +} + +func TestCodexEstablishHookTrust_ReverifyStillUntrusted_Errors(t *testing.T) { + needsTrust := codexAllExpectedHooks("untrusted") + stillUntrusted := codexAllExpectedHooks("trusted") + stillUntrusted[0].TrustStatus = "untrusted" + + client := &fakeCodexAppServerClient{ + t: t, + steps: []fakeCodexRPCStep{ + {wantMethod: "initialize", result: json.RawMessage(`{}`)}, + {wantMethod: "hooks/list", result: codexHooksListResultJSON(t, needsTrust...)}, + {wantMethod: "config/batchWrite", result: json.RawMessage(`{}`)}, + {wantMethod: "hooks/list", result: codexHooksListResultJSON(t, stillUntrusted...)}, + }, + } + + err := codexEstablishHookTrustWithClient(context.Background(), client) + + require.Error(t, err) + assert.Contains(t, err.Error(), "did not persist trust") +} + +func TestCodexEstablishHookTrust_InitializeFails_Errors(t *testing.T) { + client := &fakeCodexAppServerClient{ + t: t, + steps: []fakeCodexRPCStep{ + {wantMethod: "initialize", err: errors.New("boom")}, + }, + } + + err := codexEstablishHookTrustWithClient(context.Background(), client) + + require.Error(t, err) + assert.Contains(t, err.Error(), "initialize:") + assert.Contains(t, err.Error(), "boom") +} + +func TestCodexEstablishHookTrust_ClosesClientEvenOnError(t *testing.T) { + fake := &fakeCodexAppServerClient{ + t: t, + steps: []fakeCodexRPCStep{ + {wantMethod: "initialize", err: errors.New("boom")}, + }, + } + prev := newCodexAppServerClientFn + newCodexAppServerClientFn = func(context.Context, string) (codexAppServerClient, error) { return fake, nil } + t.Cleanup(func() { newCodexAppServerClientFn = prev }) + + err := codexEstablishHookTrust(context.Background(), "codex") + + require.Error(t, err) + ce, ok := output.AsCLIError(err) + require.True(t, ok) + assert.Contains(t, ce.Hint, "/hooks") + assert.True(t, fake.closed, "client must be closed even when the trust sequence errors") +} + +func TestCodexEstablishHookTrust_SpawnFails_WrapsError(t *testing.T) { + prev := newCodexAppServerClientFn + newCodexAppServerClientFn = func(context.Context, string) (codexAppServerClient, error) { + return nil, errors.New("no such binary") + } + t.Cleanup(func() { newCodexAppServerClientFn = prev }) + + err := codexEstablishHookTrust(context.Background(), "codex") + + require.Error(t, err) + ce, ok := output.AsCLIError(err) + require.True(t, ok) + assert.Equal(t, "spawn", ce.Detail["op"]) + assert.Contains(t, ce.Hint, "/hooks") +} diff --git a/cli/internal/plugin/codex_test.go b/cli/internal/plugin/codex_test.go index e229bf6..1757f3a 100644 --- a/cli/internal/plugin/codex_test.go +++ b/cli/internal/plugin/codex_test.go @@ -2,8 +2,10 @@ package plugin import ( "context" + "errors" "os" "path/filepath" + "strconv" "strings" "testing" @@ -24,9 +26,29 @@ func withCodexEnv(t *testing.T, fakeCodex string) string { fakeCodex = "/bin/true" } t.Setenv("EVERCLI_CODEX_CMD", fakeCodex) + // Prepare refuses to install without a Hook interpreter. Pin a stub so the + // writer tests do not depend on the test machine carrying Node on PATH. + if runtimeGOOS() != "windows" { + t.Setenv("EVERCLI_NODE_CMD", writeFakeNode(t, "v22.11.0")) + } return filepath.Join(dir, "config.toml") } +// writeFakeNode writes a stub that answers `node -v` with the given version +// string, modelling the only Node invocation the install preflight makes. +func writeFakeNode(t *testing.T, version string) string { + t.Helper() + stub := filepath.Join(t.TempDir(), "node") + body := []byte(`#!/bin/sh +case "$1" in + -v|--version) printf '%s\n' "` + version + `" ;; + *) exit 0 ;; +esac +`) + require.NoError(t, os.WriteFile(stub, body, 0o755)) + return stub +} + func readTOML(t *testing.T, path string) map[string]interface{} { t.Helper() raw, err := os.ReadFile(path) @@ -36,6 +58,77 @@ func readTOML(t *testing.T, path string) map[string]interface{} { return m } +func writeExecutable(t *testing.T, path string, mode os.FileMode) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), mode)) +} + +func TestCodexAppBundleCandidates(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{ + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/Codex.app/Contents/Resources/codex", + "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex", + "/Users/test/Applications/Codex.app/Contents/Resources/codex", + }, codexAppBundleCandidates("/Users/test")) +} + +func TestResolveCodexExecutable_OverridePrecedesAppBundle(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + override := filepath.Join(t.TempDir(), "custom-codex") + writeExecutable(t, override, 0o755) + t.Setenv("EVERCLI_CODEX_CMD", override) + + appExecutable := filepath.Join(t.TempDir(), "Codex.app", "Contents", "Resources", "codex") + writeExecutable(t, appExecutable, 0o755) + + got, err := resolveCodexExecutableFromCandidates("darwin", []string{appExecutable}) + require.NoError(t, err) + assert.Equal(t, override, got) +} + +func TestResolveCodexExecutable_PathPrecedesAppBundle(t *testing.T) { + t.Setenv("EVERCLI_CODEX_CMD", "") + pathDir := t.TempDir() + pathExecutable := filepath.Join(pathDir, "codex") + writeExecutable(t, pathExecutable, 0o755) + t.Setenv("PATH", pathDir) + + appExecutable := filepath.Join(t.TempDir(), "ChatGPT.app", "Contents", "Resources", "codex") + writeExecutable(t, appExecutable, 0o755) + + got, err := resolveCodexExecutableFromCandidates("darwin", []string{appExecutable}) + require.NoError(t, err) + assert.Equal(t, pathExecutable, got) +} + +func TestResolveCodexExecutable_AppBundleFallback(t *testing.T) { + t.Setenv("EVERCLI_CODEX_CMD", "") + t.Setenv("PATH", t.TempDir()) + + nonExecutable := filepath.Join(t.TempDir(), "ChatGPT.app", "Contents", "Resources", "codex") + writeExecutable(t, nonExecutable, 0o644) + executable := filepath.Join(t.TempDir(), "Codex.app", "Contents", "Resources", "codex") + writeExecutable(t, executable, 0o755) + + got, err := resolveCodexExecutableFromCandidates("darwin", []string{nonExecutable, executable}) + require.NoError(t, err) + assert.Equal(t, executable, got) +} + +func TestResolveCodexExecutable_NonDarwinDoesNotUseAppBundle(t *testing.T) { + t.Setenv("EVERCLI_CODEX_CMD", "") + t.Setenv("PATH", t.TempDir()) + + executable := filepath.Join(t.TempDir(), "Codex.app", "Contents", "Resources", "codex") + writeExecutable(t, executable, 0o755) + + _, err := resolveCodexExecutableFromCandidates("linux", []string{executable}) + require.Error(t, err) +} + // TestCodexDetector_NoConfig_NotInstalled covers the "Codex not on this // box" path: the EVERCLI_CODEX_CONFIG_DIR override is a tmp dir that // exists (so Installed=true via dir presence), but the file inside @@ -68,6 +161,48 @@ EVERME_AGENT_TOKEN = "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" assert.True(t, d.HasEverMeEntry, "non-empty token must flag entry as present") } +func TestCodexWriter_RemovePreservesSiblingState(t *testing.T) { + path := withCodexEnv(t, "") + body := `[plugins."other@vendor"] +enabled = true + +[plugins."everme@everme"] +enabled = true + +[marketplaces.everme] +source_type = "git" +source = "https://github.com/EverMind-AI/EverMe.git" + +[marketplaces.other] +source_type = "git" +source = "https://example.com/other.git" + +[mcp_servers.other] +command = "other" + +[mcp_servers.everme] +command = "npx" + +[mcp_servers.everme.env] +EVERME_AGENT_TOKEN = "evt_secret" +` + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(path), "everme.env"), []byte("EVERME_AGENT_TOKEN=evt_secret\n"), 0o600)) + + res, err := newCodexWriter().Remove(context.Background(), path) + require.NoError(t, err) + assert.True(t, res.Removed) + assert.FileExists(t, res.BackupPath) + got := readTOML(t, path) + assert.NotContains(t, got["plugins"].(map[string]interface{}), "everme@everme") + assert.Contains(t, got["plugins"].(map[string]interface{}), "other@vendor") + assert.NotContains(t, got["marketplaces"].(map[string]interface{}), "everme") + assert.Contains(t, got["marketplaces"].(map[string]interface{}), "other") + assert.NotContains(t, got["mcp_servers"].(map[string]interface{}), "everme") + assert.Contains(t, got["mcp_servers"].(map[string]interface{}), "other") + assert.NoFileExists(t, filepath.Join(filepath.Dir(path), "everme.env")) +} + // TestCodexDetector_EntryWithEmptyToken treats an existing-but-empty // token as "no real entry" — guards against marking a half-installed / // scrubbed config as good. @@ -94,12 +229,29 @@ func TestCodexWriter_Commit_FreshFile(t *testing.T) { assert.True(t, plan.WillCreate) assert.False(t, plan.WillReplace) - _, err = w.Commit(context.Background(), plan, WriteParams{ + res, err := w.Commit(context.Background(), plan, WriteParams{ APIBaseURL: "https://api.everme.evermind.ai", AgentID: "agt_fresh", AgentToken: "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }) require.NoError(t, err) + require.NotEmpty(t, res.NextSteps) + // w.trustErr is nil here (Commit was called without Prepare ever + // running), so under the new logic that means "no manual /hooks step is + // needed" — see TestCodexWriter_Commit_NextSteps_TrustFailed for the + // opposite case. + assert.NotContains(t, strings.Join(res.NextSteps, "\n"), "/hooks") + + envPath := filepath.Join(dir, "everme.env") + envBody, err := os.ReadFile(envPath) + require.NoError(t, err) + assert.Contains(t, string(envBody), "EVERME_API_BASE=https://api.everme.evermind.ai") + assert.Contains(t, string(envBody), "EVERME_AGENT_ID=agt_fresh") + assert.Contains(t, string(envBody), "EVERME_AGENT_TOKEN=evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + info, err := os.Stat(envPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + assert.NotContains(t, strings.Join(res.NextSteps, "\n"), "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") got := readTOML(t, path) @@ -138,6 +290,30 @@ func TestCodexWriter_Commit_FreshFile(t *testing.T) { assert.Equal(t, "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", env["EVERME_AGENT_TOKEN"]) } +// TestCodexWriter_Commit_NextSteps_TrustFailed asserts the manual "/hooks" +// fallback instruction is restored when Prepare's automatic hook-trust +// attempt failed — the opposite of TestCodexWriter_Commit_FreshFile, which +// covers w.trustErr == nil. +func TestCodexWriter_Commit_NextSteps_TrustFailed(t *testing.T) { + w := newCodexWriter() + w.trustErr = errors.New("boom") + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + + plan, err := w.Plan(context.Background(), path) + require.NoError(t, err) + + res, err := w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_fresh", + AgentToken: "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + require.NoError(t, err) + joined := strings.Join(res.NextSteps, "\n") + assert.Contains(t, joined, "/hooks") + assert.Contains(t, joined, "trust") +} + // TestCodexWriter_Commit_PreservesUnrelatedKeys is load-bearing: users // may have custom marketplaces, MCP servers, plugins, [desktop] // settings, etc. in ~/.codex/config.toml. If install ever clobbers any @@ -277,33 +453,212 @@ EVERME_AGENT_TOKEN = "evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" require.Error(t, err, "Verify must catch missing marketplaces.everme") } -// writeFakeCodex writes a tiny shell script that exits `code` and -// returns (stub path, argv sentinel path). The stub records its argv -// to the sentinel file before exiting so the test can assert Prepare -// invoked the CLI with the right flags — a regression renaming -// EverMind-AI/EverMe or re-adding a --sparse flag would otherwise pass -// with any stub that ignores argv. -// -// On Windows this script can't execute (CreateProcess won't honour the -// shebang and there's no .cmd shim) — the Codex Prepare tests skip on -// non-Unix platforms via runtimeGOOS. See test bodies for the guard. -func writeFakeCodex(t *testing.T, code int) (stub, argvPath string) { +func TestCodexWriter_Verify_DetectsMissingHooks(t *testing.T) { + w := newCodexWriter() + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + body := `[marketplaces.everme] +source_type = "git" +source = "https://github.com/EverMind-AI/EverMe.git" + +[plugins."everme@everme"] +enabled = true + +[mcp_servers.everme.env] +EVERME_AGENT_TOKEN = "evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" +` + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "everme.env"), []byte("EVERME_AGENT_TOKEN=evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\n"), 0o600)) + + err := w.Verify(context.Background(), &WriteResult{ConfigPath: path}) + require.Error(t, err) + assert.Contains(t, err.Error(), "hooks") +} + +func TestCodexWriter_Verify_AcceptsInstalledHooks(t *testing.T) { + w := newCodexWriter() + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + body := `[marketplaces.everme] +source_type = "git" +source = "https://github.com/EverMind-AI/EverMe.git" + +[plugins."everme@everme"] +enabled = true + +[mcp_servers.everme.env] +EVERME_AGENT_TOKEN = "evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" +` + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "everme.env"), []byte("EVERME_AGENT_TOKEN=evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\n"), 0o600)) + hooksPath := filepath.Join(dir, "plugins", "cache", "everme", "everme", "0.4.0", "hooks", "hooks.json") + require.NoError(t, os.MkdirAll(filepath.Dir(hooksPath), 0o700)) + require.NoError(t, os.WriteFile(hooksPath, []byte(`{"hooks":{}}`), 0o600)) + runnerPath := filepath.Join(dir, "plugins", "cache", "everme", "everme", "0.4.0", "bin", "hook.mjs") + require.NoError(t, os.MkdirAll(filepath.Dir(runnerPath), 0o700)) + require.NoError(t, os.WriteFile(runnerPath, []byte("#!/usr/bin/env node\n"), 0o700)) + + require.NoError(t, w.Verify(context.Background(), &WriteResult{ConfigPath: path})) +} + +func TestCodexWriter_Verify_DetectsMissingBundledRunner(t *testing.T) { + w := newCodexWriter() + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + body := `[marketplaces.everme] +source_type = "git" +source = "https://github.com/EverMind-AI/EverMe.git" + +[plugins."everme@everme"] +enabled = true + +[mcp_servers.everme.env] +EVERME_AGENT_TOKEN = "evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" +` + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "everme.env"), []byte("EVERME_AGENT_TOKEN=evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\n"), 0o600)) + hooksPath := filepath.Join(dir, "plugins", "cache", "everme", "everme", "0.4.1", "hooks", "hooks.json") + require.NoError(t, os.MkdirAll(filepath.Dir(hooksPath), 0o700)) + require.NoError(t, os.WriteFile(hooksPath, []byte(`{"hooks":{}}`), 0o600)) + + err := w.Verify(context.Background(), &WriteResult{ConfigPath: path}) + require.Error(t, err) + assert.Contains(t, err.Error(), "bin/hook.mjs") +} + +type fakeCodexOptions struct { + marketplaceExit int + upgradeExit int + pluginExit int + pluginJSON string + // appServerHooksJSON, when non-empty, adds an `app-server --stdio` + // branch to the stub that speaks just enough of the real protocol for + // codexEstablishHookTrust's fixed, deterministic call sequence: it + // answers the first non-"initialized" line with an `initialize`-shaped + // result, and every line after that with a `hooks/list`-shaped result + // carrying this literal JSON array as the "hooks" list. Left empty + // (every test but the hook-trust ones), the call falls through to the + // default `*) exit 2` branch — the process exits without reading + // stdin, so codexRPCClient sees stdout EOF and the trust attempt fails + // fast into w.trustErr, leaving every other Prepare behavior unaffected. + appServerHooksJSON string +} + +// writeFakeCodex writes a shell stub that models the four Codex commands +// used by Prepare. Calls are recorded one per line and plugin add emits +// structured JSON so tests exercise the same contract as the real desktop +// binary. +func writeFakeCodex(t *testing.T, options fakeCodexOptions) (stub, callsPath string) { t.Helper() dir := t.TempDir() stub = filepath.Join(dir, "codex") - argvPath = filepath.Join(dir, "argv.txt") - exitStr := "0" - if code != 0 { - exitStr = "1" + callsPath = filepath.Join(dir, "calls.txt") + pluginJSON := options.pluginJSON + if pluginJSON == "" { + installedPath := filepath.Join(t.TempDir(), "plugins", "cache", "everme", "everme", "0.4.1") + require.NoError(t, os.MkdirAll(filepath.Join(installedPath, "hooks"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(installedPath, "hooks", "hooks.json"), []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(installedPath, "bin"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(installedPath, "bin", "hook.mjs"), []byte("#!/usr/bin/env node\n"), 0o700)) + pluginJSON = `{"pluginId":"everme@everme","installedPath":"` + installedPath + `"}` + } + appServerCase := "" + if options.appServerHooksJSON != "" { + appServerCase = ` "app-server --stdio") + i=0 + while IFS= read -r line; do + case "$line" in + *'"method":"initialized"'*) continue ;; + esac + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + i=$((i + 1)) + if [ "$i" -eq 1 ]; then + printf '{"id":%s,"result":{"userAgent":"fake"}}\n' "$id" + else + printf '{"id":%s,"result":{"data":[{"cwd":"","hooks":[` + options.appServerHooksJSON + `]}]}}\n' "$id" + fi + done + ;; +` } body := []byte(`#!/bin/sh -# evercli test stub — records argv and exits with the configured code -for arg in "$@"; do - printf '%s\n' "$arg" >> "` + argvPath + `" -done -exit ` + exitStr + "\n") +printf '%s\n' "$*" >> "` + callsPath + `" +case "$*" in + "plugin marketplace add ` + codexMarketplaceRepo + `") exit ` + strconv.Itoa(options.marketplaceExit) + ` ;; + "plugin marketplace upgrade ` + codexMarketplaceName + `") exit ` + strconv.Itoa(options.upgradeExit) + ` ;; + "plugin add ` + codexPluginSpec + ` --json") + printf '%s\n' '` + pluginJSON + `' + exit ` + strconv.Itoa(options.pluginExit) + ` + ;; +` + appServerCase + ` *) exit 2 ;; +esac +`) require.NoError(t, os.WriteFile(stub, body, 0o755)) - return stub, argvPath + return stub, callsPath +} + +// The marketplace Hook manifest runs the bundled runner with a bare `node`, so +// a Node runtime that is not resolvable on PATH leaves every lifecycle Hook +// failing at spawn time. Prepare must reject that machine before the +// marketplace is touched and before the backend mints a token, and must say +// what is missing. +func TestCodexWriter_Prepare_FailsWithoutNodeRuntime(t *testing.T) { + if runtimeGOOS() == "windows" { + t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") + } + stub, callsPath := writeFakeCodex(t, fakeCodexOptions{}) + _ = withCodexEnv(t, stub) + t.Setenv("EVERCLI_NODE_CMD", "") + t.Setenv("PATH", t.TempDir()) + + err := newCodexWriter().Prepare(context.Background(), &Detection{Platform: PlatformCodex}) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "node") + + _, statErr := os.Stat(callsPath) + assert.True(t, os.IsNotExist(statErr), "no codex command may run when the Hook runtime is missing") +} + +// A Node older than the runner's build target parses the bundle but can fail on +// syntax or APIs it does not implement, so the preflight pins the floor too. +func TestCodexWriter_Prepare_FailsWhenNodeRuntimeTooOld(t *testing.T) { + if runtimeGOOS() == "windows" { + t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") + } + stub, callsPath := writeFakeCodex(t, fakeCodexOptions{}) + _ = withCodexEnv(t, stub) + t.Setenv("EVERCLI_NODE_CMD", writeFakeNode(t, "v16.20.2")) + + err := newCodexWriter().Prepare(context.Background(), &Detection{Platform: PlatformCodex}) + require.Error(t, err) + assert.Contains(t, err.Error(), strconv.Itoa(codexHookNodeMinMajor)) + + _, statErr := os.Stat(callsPath) + assert.True(t, os.IsNotExist(statErr), "no codex command may run when the Hook runtime is too old") +} + +func TestResolveNodeExecutable_OverridePrecedesPath(t *testing.T) { + pathDir := t.TempDir() + writeExecutable(t, filepath.Join(pathDir, "node"), 0o755) + t.Setenv("PATH", pathDir) + override := writeFakeNode(t, "v22.11.0") + t.Setenv("EVERCLI_NODE_CMD", override) + + resolved, err := resolveNodeExecutable() + require.NoError(t, err) + assert.Equal(t, override, resolved) +} + +func TestResolveNodeExecutable_FallsBackToPath(t *testing.T) { + pathDir := t.TempDir() + onPath := filepath.Join(pathDir, "node") + writeExecutable(t, onPath, 0o755) + t.Setenv("PATH", pathDir) + t.Setenv("EVERCLI_NODE_CMD", "") + + resolved, err := resolveNodeExecutable() + require.NoError(t, err) + assert.Equal(t, onPath, resolved) } // TestCodexWriter_Prepare_HappyPath stubs the codex CLI with a script @@ -315,7 +670,7 @@ func TestCodexWriter_Prepare_HappyPath(t *testing.T) { if runtimeGOOS() == "windows" { t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") } - stub, argvPath := writeFakeCodex(t, 0) + stub, callsPath := writeFakeCodex(t, fakeCodexOptions{}) _ = withCodexEnv(t, stub) w := newCodexWriter() err := w.Prepare(context.Background(), &Detection{Platform: PlatformCodex}) @@ -324,38 +679,29 @@ func TestCodexWriter_Prepare_HappyPath(t *testing.T) { // Pin the exact argv the production Prepare passes to `codex`. A // regression renaming codexMarketplaceRepo, re-adding --sparse, or // reordering args trips this test. - got, err := os.ReadFile(argvPath) + got, err := os.ReadFile(callsPath) require.NoError(t, err, "stub must have written argv") - gotArgs := strings.Split(strings.TrimSpace(string(got)), "\n") - want := []string{ - "plugin", "marketplace", "add", - codexMarketplaceRepo, - } - assert.Equal(t, want, gotArgs, "Prepare must call `codex plugin marketplace add ` with the canonical repo constant (no --sparse: the repo root is the marketplace)") -} - -// TestCodexWriter_Prepare_SkipsCLIWhenMarketplaceAlreadyAdded pins -// the offline-rotate optimization: if `[marketplaces.everme]` is -// already in config.toml (Codex CLI wrote it on a previous install), -// Prepare must NOT shell out to `codex` again. This lets a token -// rotate work fully offline once the marketplace has been registered, -// and avoids re-fetching the GitHub repo on every rotate. -// -// The stub is wired to exit 1 — if it gets called at all, Prepare -// fails and the test reports it. Argv sentinel is also checked: the -// file must remain absent (no writes happened) for the skip path to -// be truly skipped, not just "called with no args". -func TestCodexWriter_Prepare_SkipsCLIWhenMarketplaceAlreadyAdded(t *testing.T) { + assert.Equal(t, + "plugin marketplace add "+codexMarketplaceRepo+"\nplugin add "+codexPluginSpec+" --json\napp-server --stdio\n", + string(got), + "Prepare must register the marketplace, install the plugin, and then attempt hook trust") +} + +// TestCodexWriter_Prepare_UpgradesWhenMarketplaceAlreadyAdded pins the +// stale-cache refresh: if `[marketplaces.everme]` is already in +// config.toml (Codex CLI wrote it on a previous install), Prepare must +// run `codex plugin marketplace upgrade everme` instead of `add`. +// Without the upgrade, a machine that registered the marketplace once +// keeps its plugin cache at that first version forever, so content +// shipped later (e.g. lifecycle hooks) never reaches it and +// verify-hooks warns about a missing hooks.json. +func TestCodexWriter_Prepare_UpgradesWhenMarketplaceAlreadyAdded(t *testing.T) { if runtimeGOOS() == "windows" { t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") } - // Stub fails if invoked — proves the skip is real. - stub, argvPath := writeFakeCodex(t, 1) + stub, callsPath := writeFakeCodex(t, fakeCodexOptions{}) configPath := withCodexEnv(t, stub) - // Pre-seed the config with `[marketplaces.everme]` (and matching - // MCP entry so HasEverMeEntry would also be true). Both signals - // should route Prepare into the skip branch. body := `[marketplaces.everme] last_updated = "2026-05-26T10:32:09Z" source_type = "local" @@ -371,14 +717,132 @@ EVERME_AGENT_TOKEN = "evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" require.NoError(t, err) require.NotEmpty(t, detection.ConfigPath, "Detector must surface the config path Prepare reads") - err = w.Prepare(context.Background(), detection) - require.NoError(t, err, "Prepare must skip the CLI shellout when marketplace is already registered — token rotation should not require network") + require.NoError(t, w.Prepare(context.Background(), detection)) + + raw, err := os.ReadFile(callsPath) + require.NoError(t, err, "Prepare must invoke the codex CLI to refresh the marketplace cache") + assert.Equal(t, + "plugin marketplace upgrade everme\nplugin add "+codexPluginSpec+" --json\napp-server --stdio\n", + string(raw), + "already-registered path must upgrade, reinstall the everme plugin, and attempt hook trust") +} + +// TestCodexWriter_Prepare_EstablishesHookTrust is the real-subprocess +// integration test proving the spawnCodexAppServer -> OS pipes -> +// codexRPCClient -> codexEstablishHookTrustWithClient wiring works +// end-to-end against an actual child process (not just the interface-level +// fakes in codex_hook_trust_test.go). It only needs to cover the +// already-trusted happy path — the branchy needs-trust / missing-hooks / +// reverify-failure logic is already fully pinned by those fake-client tests. +func TestCodexWriter_Prepare_EstablishesHookTrust(t *testing.T) { + if runtimeGOOS() == "windows" { + t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") + } + allTrustedHooks := `` + + `{"key":"everme@everme:hooks/hooks.json:session_start:0:0","eventName":"sessionStart","pluginId":"everme@everme","currentHash":"sha256:a","trustStatus":"trusted"},` + + `{"key":"everme@everme:hooks/hooks.json:user_prompt_submit:0:0","eventName":"userPromptSubmit","pluginId":"everme@everme","currentHash":"sha256:b","trustStatus":"trusted"},` + + `{"key":"everme@everme:hooks/hooks.json:stop:0:0","eventName":"stop","pluginId":"everme@everme","currentHash":"sha256:c","trustStatus":"trusted"},` + + `{"key":"everme@everme:hooks/hooks.json:pre_compact:0:0","eventName":"preCompact","pluginId":"everme@everme","currentHash":"sha256:d","trustStatus":"trusted"}` + stub, _ := writeFakeCodex(t, fakeCodexOptions{appServerHooksJSON: allTrustedHooks}) + _ = withCodexEnv(t, stub) + + w := newCodexWriter() + err := w.Prepare(context.Background(), &Detection{Platform: PlatformCodex}) + require.NoError(t, err) + assert.NoError(t, w.trustErr, "already-trusted hooks must round-trip through the real app-server RPC wiring without error") +} + +// TestCodexWriter_Prepare_UpgradeFailureDoesNotBlockInstall pins the +// offline-rotate contract that the old skip path provided: the upgrade +// is best-effort. A box without network (or with a broken codex CLI) +// must still be able to rotate its token — Prepare returns nil and the +// failure is deferred to Verify, where it surfaces as an install +// warning instead of a FailedEntry. +func TestCodexWriter_Prepare_UpgradeFailureDoesNotBlockInstall(t *testing.T) { + if runtimeGOOS() == "windows" { + t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") + } + stub, _ := writeFakeCodex(t, fakeCodexOptions{upgradeExit: 1}) + configPath := withCodexEnv(t, stub) + + body := `[marketplaces.everme] +source_type = "git" +source = "https://github.com/EverMind-AI/EverMe.git" + +[plugins."everme@everme"] +enabled = true + +[mcp_servers.everme.env] +EVERME_AGENT_TOKEN = "evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" +` + require.NoError(t, os.WriteFile(configPath, []byte(body), 0o600)) + + w := newCodexWriter() + detection, err := codexDetector{}.Detect(context.Background()) + require.NoError(t, err) + + require.NoError(t, w.Prepare(context.Background(), detection), + "a failed marketplace upgrade must not block token rotation") + + // Build the rest of a healthy install so Verify's own checks pass + // and the only remaining signal is the deferred upgrade failure. + dir := filepath.Dir(configPath) + require.NoError(t, os.WriteFile(filepath.Join(dir, "everme.env"), []byte("EVERME_AGENT_TOKEN=evt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\n"), 0o600)) + hooksPath := filepath.Join(dir, "plugins", "cache", "everme", "everme", "0.4.0", "hooks", "hooks.json") + require.NoError(t, os.MkdirAll(filepath.Dir(hooksPath), 0o700)) + require.NoError(t, os.WriteFile(hooksPath, []byte(`{"hooks":{}}`), 0o600)) + runnerPath := filepath.Join(dir, "plugins", "cache", "everme", "everme", "0.4.0", "bin", "hook.mjs") + require.NoError(t, os.MkdirAll(filepath.Dir(runnerPath), 0o700)) + require.NoError(t, os.WriteFile(runnerPath, []byte("#!/usr/bin/env node\n"), 0o700)) + + err = w.Verify(context.Background(), &WriteResult{ConfigPath: configPath}) + require.Error(t, err, "Verify must surface the deferred upgrade failure as a warning") + assert.Contains(t, err.Error(), "upgrade", "warning must point at the marketplace upgrade step") +} + +func TestCodexWriter_Prepare_ReusesHealthyCacheWhenPluginRefreshFails(t *testing.T) { + if runtimeGOOS() == "windows" { + t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") + } + stub, _ := writeFakeCodex(t, fakeCodexOptions{upgradeExit: 1, pluginExit: 1}) + configPath := withCodexEnv(t, stub) + body := `[marketplaces.everme] +source_type = "git" +source = "https://github.com/EverMind-AI/EverMe.git" +` + require.NoError(t, os.WriteFile(configPath, []byte(body), 0o600)) + installedPath := filepath.Join(filepath.Dir(configPath), "plugins", "cache", "everme", "everme", "0.4.1") + require.NoError(t, os.MkdirAll(filepath.Join(installedPath, "hooks"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(installedPath, "hooks", "hooks.json"), []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(installedPath, "bin"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(installedPath, "bin", "hook.mjs"), []byte("#!/usr/bin/env node\n"), 0o700)) + + w := newCodexWriter() + require.NoError(t, w.Prepare(context.Background(), &Detection{Platform: PlatformCodex, ConfigPath: configPath})) + assert.Equal(t, installedPath, w.installedPath) + require.Error(t, w.pluginInstallErr) +} + +func TestInstallCodexPlugin_RejectsMalformedJSON(t *testing.T) { + if runtimeGOOS() == "windows" { + t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") + } + stub, _ := writeFakeCodex(t, fakeCodexOptions{pluginJSON: "not-json"}) - // Argv sentinel must be absent: the fake codex stub creates it on - // invocation. Skip path = no invocation = no sentinel. - _, statErr := os.Stat(argvPath) - assert.True(t, os.IsNotExist(statErr), - "fake codex stub recorded argv at %s — Prepare invoked the CLI when it should have skipped", argvPath) + _, err := installCodexPlugin(context.Background(), stub) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse-json") +} + +func TestInstallCodexPlugin_RejectsMissingInstalledPath(t *testing.T) { + if runtimeGOOS() == "windows" { + t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") + } + stub, _ := writeFakeCodex(t, fakeCodexOptions{pluginJSON: `{}`}) + + _, err := installCodexPlugin(context.Background(), stub) + require.Error(t, err) + assert.Contains(t, err.Error(), "installedPath") } // TestMarketplaceAlreadyAdded covers the helper's branches directly, @@ -456,7 +920,7 @@ func TestCodexWriter_Prepare_FailsClosed(t *testing.T) { if runtimeGOOS() == "windows" { t.Skip("shell-script stub doesn't execute on Windows; see writeFakeCodex comment") } - stub, _ := writeFakeCodex(t, 1) + stub, _ := writeFakeCodex(t, fakeCodexOptions{marketplaceExit: 1}) _ = withCodexEnv(t, stub) w := newCodexWriter() err := w.Prepare(context.Background(), &Detection{Platform: PlatformCodex}) diff --git a/cli/internal/plugin/config_mode_test.go b/cli/internal/plugin/config_mode_test.go new file mode 100644 index 0000000..2c7ee5e --- /dev/null +++ b/cli/internal/plugin/config_mode_test.go @@ -0,0 +1,150 @@ +package plugin + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testEvtToken = "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// seedConfig writes body at mode and returns the path, so a case can +// start from a host-created config that is world-readable. +func seedConfig(t *testing.T, path, body string, mode os.FileMode) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(body), mode)) + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, mode, info.Mode().Perm(), "seed must start from the mode under test") +} + +// TestCommit_TightensWorldReadableTokenConfig is the regression for the +// 2026-08-17 review item 1.4.i. The config writers inherited the host's +// existing file mode so that installing would not "surprise the user by +// tightening their config". When the host created the file 0644 — Raven +// does, and ~/.raven/config.json was found world-readable in the wild — +// the freshly minted evt token landed in a file every local user can +// read. A file that stores a token must be 0600 no matter who created it. +func TestCommit_TightensWorldReadableTokenConfig(t *testing.T) { + cases := []struct { + name string + configName string + seedBody string + newWriter func() Writer + }{ + {"raven", "config.json", "{}", func() Writer { return newRavenWriter() }}, + {"workbuddy", "mcp.json", "{}", func() Writer { return newWorkBuddyWriter() }}, + {"claude-desktop", "claude_desktop_config.json", "{}", func() Writer { return newClaudeDesktopWriter() }}, + {"openclaw", "openclaw.json", "{}", func() Writer { return newOpenClawWriter() }}, + {"opencode", "opencode.json", "{}", func() Writer { return newOpenCodeWriter() }}, + {"cursor", "mcp.json", "{}", func() Writer { return newCursorWriter() }}, + {"devin", "mcp.json", "{}", func() Writer { return newDevinWriter() }}, + {"codex", "config.toml", "", func() Writer { return newCodexWriter() }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, tc.configName) + seedConfig(t, path, tc.seedBody, 0o644) + + w := tc.newWriter() + plan, err := w.Plan(context.Background(), path) + require.NoError(t, err) + _, err = w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_" + tc.name, + AgentToken: testEvtToken, + }) + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), + "%s config stores the evt token and must not stay world-readable", tc.name) + }) + } +} + +// TestCommit_KeepsHostModeOnConfigWithoutToken pins the other half of the +// contract: only token-bearing files are force-tightened. Cursor's +// hooks.json holds command lines, not credentials, so a host that created +// it 0644 keeps 0644 — while the sibling mcp.json, which does carry the +// token, is tightened by the same Commit. +func TestCommit_KeepsHostModeOnConfigWithoutToken(t *testing.T) { + dir := t.TempDir() + mcpPath := filepath.Join(dir, "mcp.json") + hooksPath := filepath.Join(dir, "hooks.json") + seedConfig(t, mcpPath, "{}", 0o644) + seedConfig(t, hooksPath, "{}", 0o644) + + w := newCursorWriter() + plan, err := w.Plan(context.Background(), mcpPath) + require.NoError(t, err) + _, err = w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_cursor", + AgentToken: testEvtToken, + }) + require.NoError(t, err) + + mcpInfo, err := os.Stat(mcpPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), mcpInfo.Mode().Perm(), "mcp.json carries the token") + + hooksInfo, err := os.Stat(hooksPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o644), hooksInfo.Mode().Perm(), "hooks.json carries no credential") +} + +// captureConfigNotices redirects the permission-notice writer for the +// duration of fn and returns what was written. +func captureConfigNotices(t *testing.T, fn func()) string { + t.Helper() + var buf strings.Builder + orig := configNoticeWriter + configNoticeWriter = &buf + t.Cleanup(func() { configNoticeWriter = orig }) + fn() + return buf.String() +} + +// TestCommit_ExplainsWhyItTightenedTheConfig: changing the permissions of +// a file the user did not create is a surprise, so say it out loud once. +// Only when we actually narrow a group/other-readable file — a config +// that was already owner-only gets no noise. +func TestCommit_ExplainsWhyItTightenedTheConfig(t *testing.T) { + commit := func(t *testing.T, path string) { + t.Helper() + w := newRavenWriter() + plan, err := w.Plan(context.Background(), path) + require.NoError(t, err) + _, err = w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_raven", + AgentToken: testEvtToken, + }) + require.NoError(t, err) + } + + t.Run("world readable config is announced", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + seedConfig(t, path, "{}", 0o644) + out := captureConfigNotices(t, func() { commit(t, path) }) + assert.Contains(t, out, path) + assert.Contains(t, out, "0600") + assert.NotContains(t, out, testEvtToken, "the notice must never quote the token") + }) + + t.Run("owner only config stays quiet", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + seedConfig(t, path, "{}", 0o600) + out := captureConfigNotices(t, func() { commit(t, path) }) + assert.NotContains(t, out, "0600", "nothing changed, so say nothing") + }) +} diff --git a/cli/internal/plugin/cursor.go b/cli/internal/plugin/cursor.go index fc19cb0..3c6539c 100644 --- a/cli/internal/plugin/cursor.go +++ b/cli/internal/plugin/cursor.go @@ -79,5 +79,22 @@ func cursorConfigPath() (string, error) { // upsert / TOCTOU / atomic-write machinery from mcp.go is reused // verbatim. func newCursorWriter() Writer { - return newMCPWriter(PlatformCursor) + return newNativeHookWriter( + PlatformCursor, + func(configPath string) string { return filepath.Join(filepath.Dir(configPath), "hooks.json") }, + func(cfg map[string]interface{}) error { + if _, exists := cfg["version"]; !exists { + cfg["version"] = 1 + } + return mergeFlatHooks(cfg, "@everme/cursor", []hookSpec{ + {Event: "sessionStart", Entry: map[string]interface{}{"command": "npx -y @everme/cursor@latest hook sessionStart"}}, + {Event: "stop", Entry: map[string]interface{}{"command": "npx -y @everme/cursor@latest hook stop"}}, + {Event: "preCompact", Entry: map[string]interface{}{"command": "npx -y @everme/cursor@latest hook preCompact"}}, + // Cursor's transcript intentionally omits tool outputs; + // postToolUse spools each call locally so the stop hook can + // upload the turn with its tool calls attached. + {Event: "postToolUse", Entry: map[string]interface{}{"command": "npx -y @everme/cursor@latest hook postToolUse"}}, + }) + }, + ) } diff --git a/cli/internal/plugin/cursor_test.go b/cli/internal/plugin/cursor_test.go index 45329f7..eed9c73 100644 --- a/cli/internal/plugin/cursor_test.go +++ b/cli/internal/plugin/cursor_test.go @@ -108,3 +108,66 @@ func TestCursorWriter_UsesSharedMcpWriter(t *testing.T) { env, _ := entry["env"].(map[string]interface{}) assert.Equal(t, "agt_cursor", env["EVERME_AGENT_ID"]) } + +func TestCursorWriter_WritesNativeHooksAndPreservesSiblings(t *testing.T) { + dir := t.TempDir() + mcpPath := filepath.Join(dir, "mcp.json") + hooksPath := filepath.Join(dir, "hooks.json") + envPath := filepath.Join(dir, "everme.env") + require.NoError(t, os.WriteFile(mcpPath, []byte(`{"mcpServers":{"other":{"command":"other-mcp"}}}`), 0o600)) + require.NoError(t, os.WriteFile(hooksPath, []byte(`{ + "version": 1, + "hooks": { + "sessionStart": [ + {"command":"other-session"}, + {"command":"npx -y @everme/cursor@old hook sessionStart"} + ], + "stop": [{"command":"other-stop"}], + "custom": [{"command":"custom-hook"}] + } + }`), 0o644)) + require.NoError(t, os.WriteFile(envPath, []byte("old-token\n"), 0o600)) + + w := newCursorWriter() + plan, err := w.Plan(context.Background(), mcpPath) + require.NoError(t, err) + _, err = w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_cursor", + AgentToken: "test-agent-token", + }) + require.NoError(t, err) + + mcp := readJSON(t, mcpPath) + assert.Equal(t, "other-mcp", mcp["mcpServers"].(map[string]interface{})["other"].(map[string]interface{})["command"]) + hooks := readJSON(t, hooksPath) + assert.Contains(t, hookCommands(t, hooks, "sessionStart"), "other-session") + assert.Contains(t, hookCommands(t, hooks, "custom"), "custom-hook") + for _, event := range []string{"sessionStart", "stop", "preCompact", "postToolUse"} { + assert.Equal(t, 1, countOwnedHookCommands(t, hooks, event, "@everme/cursor@latest"), event) + } + info, err := os.Stat(envPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + for _, path := range []string{mcpPath, hooksPath, envPath} { + _, err := os.Stat(path + backupSuffix) + require.NoError(t, err, "backup missing for %s", path) + } +} + +func TestCursorWriter_FreshHookConfigIncludesSchemaVersion(t *testing.T) { + dir := t.TempDir() + mcpPath := filepath.Join(dir, "mcp.json") + w := newCursorWriter() + plan, err := w.Plan(context.Background(), mcpPath) + require.NoError(t, err) + _, err = w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_cursor", + AgentToken: "test-agent-token", + }) + require.NoError(t, err) + + hooks := readJSON(t, filepath.Join(dir, "hooks.json")) + assert.Equal(t, float64(1), hooks["version"]) +} diff --git a/cli/internal/plugin/devin.go b/cli/internal/plugin/devin.go new file mode 100644 index 0000000..aaa1791 --- /dev/null +++ b/cli/internal/plugin/devin.go @@ -0,0 +1,158 @@ +package plugin + +import ( + "context" + "os" + "os/exec" + "path/filepath" + + "evercli/internal/output" +) + +type devinDetector struct{} + +func (devinDetector) Platform() Platform { return PlatformDevin } + +func (devinDetector) DisplayName() string { return "Devin" } + +func (devinDetector) Detect(_ context.Context) (*Detection, error) { + path, err := devinConfigPath() + if err != nil { + return &Detection{Platform: PlatformDevin, DisplayName: "Devin"}, nil + } + detection := &Detection{ + Platform: PlatformDevin, + DisplayName: "Devin", + ConfigPath: path, + } + if home, err := os.UserHomeDir(); err == nil { + for _, candidate := range []string{ + filepath.Join(home, "Applications", "Devin.app"), + "/Applications/Devin.app", + filepath.Join(home, ".config", "devin", "config.json"), + } { + if _, statErr := os.Stat(candidate); statErr == nil { + detection.Installed = true + break + } + } + } + if !detection.Installed { + if _, err := exec.LookPath("devin"); err == nil { + detection.Installed = true + } + } + // An install made before Devin moved its config still lives in the + // Windsurf tree. Report where the entry actually is so `plugin list` + // and uninstall act on the file that holds the token. + for _, candidate := range append([]string{path}, devinLegacyConfigPaths()...) { + cfg, exists, err := readConfig(candidate) + if err != nil { + return detection, err + } + if !exists { + continue + } + hasEntry := nestedMcpServersHasEntry(cfg, claudeCodeServersPath, mcpEntryName) + if candidate == path || hasEntry { + detection.ConfigPath = candidate + detection.ConfigExists = true + detection.HasEverMeEntry = hasEntry + } + if hasEntry { + break + } + } + return detection, nil +} + +// devinConfigPath is Devin's current user config location. Devin moved +// out of the Windsurf tree: launching it with an MCP config still at +// ~/.codeium/windsurf pops a dialog offering to copy it to +// ~/.config/devin, and accepting leaves the agent token in a second file. +// Note hooks do NOT live beside it — see devinHooksPath. +func devinConfigPath() (string, error) { + if dir := os.Getenv("EVERCLI_DEVIN_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "mcp_config.json"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", output.IOErr("devin", "resolve-home", err) + } + return filepath.Join(home, ".config", "devin", "mcp_config.json"), nil +} + +// devinHooksPath is where Devin's hook discovery looks, which is NOT +// beside mcp_config.json: config moved to ~/.config/devin but hooks are +// still loaded from the Windsurf tree by the "windsurf" provider. A real +// session with hooks in both places logged +// `loaded=7 (global=7 cascade=0)` — every hook came from the Windsurf +// tree, none from ~/.config/devin. +// The split only applies to the canonical install: asked to write some +// other config, keep hooks beside it rather than reaching into the user's +// real Windsurf tree — the same scoping mistake the legacy sweep made. +func devinHooksPath(configPath string) string { + beside := filepath.Join(filepath.Dir(configPath), "hooks.json") + if os.Getenv("EVERCLI_DEVIN_CONFIG_DIR") != "" { + return beside + } + canonical, err := devinConfigPath() + if err != nil || canonical != configPath { + return beside + } + home, err := os.UserHomeDir() + if err != nil { + return beside + } + return filepath.Join(home, ".codeium", "windsurf", "hooks.json") +} + +// devinLegacyConfigPaths are locations earlier installs wrote to. They +// are never written again, only detected and cleaned up. +func devinLegacyConfigPaths() []string { + if os.Getenv("EVERCLI_DEVIN_CONFIG_DIR") != "" { + return nil + } + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{filepath.Join(home, ".codeium", "windsurf", "mcp_config.json")} +} + +func newDevinWriter() Writer { + return newDevinHookWriter() +} + +func newDevinHookWriter() *nativeHookWriter { + return newNativeHookWriter( + PlatformDevin, + devinHooksPath, + func(cfg map[string]interface{}) error { + // The events a real Devin session emits: the question, the + // tool call, and the answer. post_cascade_response_with_transcript + // is still registered because an older Devin emits that one + // instead — the hook ignores whichever never arrives. + var specs []hookSpec + for _, event := range []string{ + "pre_user_prompt", + "post_run_command", + "post_read_code", + "post_cascade_response", + "post_cascade_response_with_transcript", + } { + specs = append(specs, hookSpec{ + Event: event, + Entry: map[string]interface{}{ + "command": "npx -y @everme/devin@latest hook " + event, + "show_output": false, + }, + }) + } + if err := mergeFlatHooks(cfg, "@everme/windsurf", specs); err != nil { + return err + } + return mergeFlatHooks(cfg, "@everme/devin", specs) + }, + ).withLegacyCleanup(devinConfigPath, devinLegacyConfigPaths) +} diff --git a/cli/internal/plugin/devin_test.go b/cli/internal/plugin/devin_test.go new file mode 100644 index 0000000..f831cb8 --- /dev/null +++ b/cli/internal/plugin/devin_test.go @@ -0,0 +1,225 @@ +package plugin + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDevinDetector_ConfigWithEverMeReportsEntry(t *testing.T) { + dir := t.TempDir() + t.Setenv("EVERCLI_DEVIN_CONFIG_DIR", dir) + t.Setenv("HOME", t.TempDir()) + path := filepath.Join(dir, "mcp_config.json") + require.NoError(t, os.WriteFile(path, []byte(`{"mcpServers":{"everme-memory":{"command":"npx"}}}`), 0o600)) + + detection, err := devinDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.Equal(t, PlatformDevin, detection.Platform) + assert.Equal(t, "Devin", detection.DisplayName) + assert.True(t, detection.ConfigExists) + assert.True(t, detection.HasEverMeEntry) +} + +// Devin moved its user config out of the Windsurf tree: running it with +// an MCP config at ~/.codeium/windsurf pops a dialog offering to copy it +// to ~/.config/devin. Installing to the old path means every user gets +// that prompt, and accepting it leaves the agent token in a second file +// that `plugin uninstall` does not know about. +func TestDevinConfigPath_UsesCurrentUserConfigDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("EVERCLI_DEVIN_CONFIG_DIR", "") + + path, err := devinConfigPath() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".config", "devin", "mcp_config.json"), path) +} + +// Installs made before the move live in the Windsurf tree. Detection has +// to report where the entry actually is, otherwise uninstall cleans an +// empty file and leaves the real token behind. +func TestDevinDetect_ReportsLegacyPathWhenTheEntryLivesThere(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("EVERCLI_DEVIN_CONFIG_DIR", "") + legacy := filepath.Join(home, ".codeium", "windsurf", "mcp_config.json") + require.NoError(t, os.MkdirAll(filepath.Dir(legacy), 0o700)) + require.NoError(t, os.WriteFile(legacy, []byte(`{"mcpServers":{"everme-memory":{"command":"npx"}}}`), 0o600)) + + detection, err := devinDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.Equal(t, legacy, detection.ConfigPath) + assert.True(t, detection.ConfigExists) + assert.True(t, detection.HasEverMeEntry) +} + +func TestDevinDetect_PrefersTheCurrentLocationWhenBothHaveAnEntry(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("EVERCLI_DEVIN_CONFIG_DIR", "") + entry := []byte(`{"mcpServers":{"everme-memory":{"command":"npx"}}}`) + for _, p := range []string{ + filepath.Join(home, ".codeium", "windsurf", "mcp_config.json"), + filepath.Join(home, ".config", "devin", "mcp_config.json"), + } { + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o700)) + require.NoError(t, os.WriteFile(p, entry, 0o600)) + } + + detection, err := devinDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".config", "devin", "mcp_config.json"), detection.ConfigPath) +} + +// Devin's own copy prompt duplicates the token across both locations, so +// removing one is not enough: uninstall must sweep the legacy path too. +func TestDevinRemove_SweepsTheLegacyLocationToo(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("EVERCLI_DEVIN_CONFIG_DIR", "") + entry := []byte(`{"mcpServers":{"everme-memory":{"command":"npx","env":{"EVERME_AGENT_TOKEN":"evt_x"}},"other":{"command":"keep"}}}`) + current := filepath.Join(home, ".config", "devin", "mcp_config.json") + legacy := filepath.Join(home, ".codeium", "windsurf", "mcp_config.json") + for _, p := range []string{current, legacy} { + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o700)) + require.NoError(t, os.WriteFile(p, entry, 0o600)) + } + + res, err := newDevinWriter().(Remover).Remove(context.Background(), current) + require.NoError(t, err) + assert.True(t, res.Removed) + + for _, p := range []string{current, legacy} { + cfg, exists, err := readConfig(p) + require.NoError(t, err) + require.True(t, exists, p) + servers := cfg["mcpServers"].(map[string]interface{}) + assert.NotContains(t, servers, mcpEntryName, "everme entry must be gone from %s", p) + assert.Contains(t, servers, "other", "unrelated servers must survive in %s", p) + } +} + +func TestDevinWriter_WritesMCPHookAndProtectedEnv(t *testing.T) { + dir := t.TempDir() + mcpPath := filepath.Join(dir, "mcp_config.json") + hooksPath := filepath.Join(dir, "hooks.json") + require.NoError(t, os.WriteFile(mcpPath, []byte(`{ + "mcpServers":{"other":{"command":"other-mcp"}} + }`), 0o644)) + require.NoError(t, os.WriteFile(hooksPath, []byte(`{ + "hooks": { + "post_cascade_response_with_transcript": [ + {"command":"other-transcript-hook"}, + {"command":"npx -y @everme/windsurf@latest hook post_cascade_response_with_transcript"}, + {"command":"npx -y @everme/devin@old hook post_cascade_response_with_transcript"} + ], + "pre_user_prompt":[{"command":"policy-check"}] + } + }`), 0o644)) + + writer := newDevinWriter() + plan, err := writer.Plan(context.Background(), mcpPath) + require.NoError(t, err) + _, err = writer.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_devin", + AgentToken: "test-agent-token", + }) + require.NoError(t, err) + + mcp := readJSON(t, mcpPath) + assert.Equal(t, "other-mcp", mcp["mcpServers"].(map[string]interface{})["other"].(map[string]interface{})["command"]) + hooks := readJSON(t, hooksPath) + assert.Contains(t, hookCommands(t, hooks, "post_cascade_response_with_transcript"), "other-transcript-hook") + assert.Contains(t, hookCommands(t, hooks, "pre_user_prompt"), "policy-check") + assert.NotContains(t, hookCommands(t, hooks, "post_cascade_response_with_transcript"), "npx -y @everme/windsurf@latest hook post_cascade_response_with_transcript") + assert.Equal(t, 1, countOwnedHookCommands(t, hooks, "post_cascade_response_with_transcript", "@everme/devin@latest")) + info, err := os.Stat(filepath.Join(dir, "everme.env")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +// The legacy sweep must be scoped to a real uninstall of this host. An +// earlier version keyed it off $HOME alone, so removing an unrelated +// config path reached into ~/.codeium/windsurf and cleaned the developer's +// actual install - a test with a temp config dir but no HOME override was +// enough to do it. +func TestDevinRemove_DoesNotReachOutsideTheRequestedConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("EVERCLI_DEVIN_CONFIG_DIR", "") + entry := []byte(`{"mcpServers":{"everme-memory":{"command":"npx"}}}`) + legacy := filepath.Join(home, ".codeium", "windsurf", "mcp_config.json") + require.NoError(t, os.MkdirAll(filepath.Dir(legacy), 0o700)) + require.NoError(t, os.WriteFile(legacy, entry, 0o600)) + + // An unrelated config path, the shape a writer unit test uses. + unrelated := filepath.Join(t.TempDir(), "mcp.json") + require.NoError(t, os.WriteFile(unrelated, entry, 0o600)) + + _, err := newDevinWriter().(Remover).Remove(context.Background(), unrelated) + require.NoError(t, err) + + cfg, exists, err := readConfig(legacy) + require.NoError(t, err) + require.True(t, exists, "the legacy config must still be there") + assert.Contains(t, cfg["mcpServers"], mcpEntryName, + "removing an unrelated config must not touch the host's own location") + _, statErr := os.Stat(legacy + backupSuffix) + assert.True(t, os.IsNotExist(statErr), "and must not leave a backup behind either") +} + +// Devin reads MCP config and hooks from DIFFERENT trees, so they cannot +// both be derived from one directory. Its own dialog demands mcp_config +// live at ~/.config/devin, but hook discovery loads the Windsurf tree +// (provider "windsurf"): with hooks.json written next to the new +// mcp_config, a real session logged `loaded=7 (global=7 cascade=0)` - +// exactly the seven probes still sitting in ~/.codeium/windsurf, and +// nothing from ~/.config/devin. +func TestDevinHooksPath_StaysInTheWindsurfTree(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("EVERCLI_DEVIN_CONFIG_DIR", "") + + configPath, err := devinConfigPath() + require.NoError(t, err) + require.Equal(t, filepath.Join(home, ".config", "devin", "mcp_config.json"), configPath) + + assert.Equal(t, + filepath.Join(home, ".codeium", "windsurf", "hooks.json"), + devinHooksPath(configPath), + "hooks must go where Devin's windsurf provider looks, not beside mcp_config") +} + +// Devin never emitted post_cascade_response_with_transcript in a real +// session, so registering only that event meant the hook never ran. It +// does emit pre_user_prompt, post_run_command and post_cascade_response - +// the question, the tool call, and the answer. +func TestDevinWriter_RegistersTheEventsDevinEmits(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := t.TempDir() + t.Setenv("EVERCLI_DEVIN_CONFIG_DIR", dir) + mcpPath := filepath.Join(dir, "mcp_config.json") + + w := newDevinWriter() + plan, err := w.Plan(context.Background(), mcpPath) + require.NoError(t, err) + _, err = w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_devin", + AgentToken: testEvtToken, + }) + require.NoError(t, err) + + hooks := readJSON(t, filepath.Join(dir, "hooks.json")) + for _, event := range []string{"pre_user_prompt", "post_run_command", "post_read_code", "post_cascade_response"} { + assert.Equal(t, 1, countOwnedHookCommands(t, hooks, event, "@everme/devin@latest"), + "exactly one EverMe hook on %s", event) + } +} diff --git a/cli/internal/plugin/dsh.go b/cli/internal/plugin/dsh.go new file mode 100644 index 0000000..4ccdc95 --- /dev/null +++ b/cli/internal/plugin/dsh.go @@ -0,0 +1,931 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "evercli/internal/output" + + "gopkg.in/yaml.v3" +) + +const ( + dshPatchFile = "cordis.patch.yml" + dshEnvFile = ".env" + dshProfilesDir = "profiles" + dshProfileName = "web" + dshHeadlessProfileName = "headless" + dshNativePatchEntryID = "memory-everme-native" + dshPatchEntryID = "memory-everme" + dshNativePackage = "@everme/dsh" + dshNativePackageSpec = "@everme/dsh@latest" + dshMemoryPackageSpec = "@everme/memory-mcp@latest" + dshLauncherPackage = "@deepseek-ai/dsh@latest" + dshPatchBlockStart = "# >>> everme-memory managed by evercli" + dshPatchBlockEnd = "# <<< everme-memory managed by evercli" + dshEnvBlockStart = "# >>> everme-memory credentials managed by evercli" + dshEnvBlockEnd = "# <<< everme-memory credentials managed by evercli" +) + +var ( + dshEnvKeys = []string{"EVERME_API_BASE", "EVERME_AGENT_ID", "EVERME_AGENT_TOKEN"} + dshProfileNames = []string{dshProfileName, dshHeadlessProfileName} +) + +type dshDetector struct{} + +func (dshDetector) Platform() Platform { return PlatformDSH } +func (dshDetector) DisplayName() string { return "DeepSeek Harness" } +func (dshDetector) Detect(_ context.Context) (*Detection, error) { + home, err := dshHomePath() + if err != nil { + return nil, err + } + patchPaths := dshProfilePatchPaths(home) + envPath := filepath.Join(home, dshEnvFile) + + patchExists := false + patchManaged := true + for _, patchPath := range patchPaths { + patchBody, exists, readErr := readOptionalFile(patchPath) + if readErr != nil { + return nil, readErr + } + managed, inspectErr := inspectDshPatch(patchPath, patchBody) + if inspectErr != nil { + return nil, inspectErr + } + patchExists = patchExists || exists + patchManaged = patchManaged && managed + } + envBody, envExists, err := readOptionalFile(envPath) + if err != nil { + return nil, err + } + envManaged, err := inspectDshEnv(envPath, envBody) + if err != nil { + return nil, err + } + + installed := dshRealLauncherPresent() + if !installed { + if info, statErr := os.Stat(filepath.Join(home, dshProfilesDir)); statErr == nil && info.IsDir() { + installed = true + } + } + + return &Detection{ + Platform: PlatformDSH, + DisplayName: "DeepSeek Harness", + Installed: installed, + ConfigPath: patchPaths[0], + ConfigExists: patchExists || envExists, + HasEverMeEntry: patchManaged && envManaged, + }, nil +} + +type dshWriter struct{} + +func newDSHWriter() Writer { return &dshWriter{} } +func (*dshWriter) Platform() Platform { return PlatformDSH } +func (*dshWriter) Prepare(ctx context.Context, _ *Detection) error { + launcher, launcherArgs, err := dshLauncher() + if err != nil { + return output.Invalid( + "no DeepSeek Harness launcher with Node.js 22.19+ was found on PATH", + "Install Node.js 22.19+ with npm, then retry `evercli plugin install dsh`", + ) + } + if strings.TrimSpace(os.Getenv("EVERCLI_DSH_NATIVE_PACKAGE_PATH")) == "" { + for _, profile := range dshProfileNames { + fmt.Fprintf(os.Stderr, "Installing or updating %s in the DSH %s profile…\n", dshNativePackageSpec, profile) + args := append(append([]string{}, launcherArgs...), "plugin", "--profile", profile, "add", "--workspace-root", dshNativePackageSpec) + cmd := exec.CommandContext(ctx, launcher, args...) + cmd.WaitDelay = 5 * time.Second + cmd.Env = dshLauncherEnv(launcher) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return output.IOErr(dshNativePackage, "dsh-profile-install-"+profile, err) + } + } + } + nativeInstalled, err := dshNativePackageInstalled(ctx) + if err != nil { + return err + } + if !nativeInstalled { + return output.IOErr(dshNativePackage, "resolve-package", fmt.Errorf("%s is still unavailable after DSH profile install", dshNativePackage)) + } + if _, err := exec.LookPath(dshMemoryCommand()); err != nil { + return output.Invalid( + "npx was not found on PATH", + "Install Node.js/npm, then retry `evercli plugin install dsh`", + ) + } + return nil +} + +func (*dshWriter) Plan(_ context.Context, configPath string) (*WritePlan, error) { + patchPaths, envPath, err := resolveDshManagedPaths(configPath) + if err != nil { + return nil, err + } + patchSnapshots := make([]fileSnapshot, 0, len(patchPaths)) + patchManaged := false + for _, patchPath := range patchPaths { + snapshot, snapshotErr := captureFileSnapshot(patchPath) + if snapshotErr != nil { + return nil, snapshotErr + } + body, _, readErr := readOptionalFile(patchPath) + if readErr != nil { + return nil, readErr + } + managed, inspectErr := inspectDshPatch(patchPath, body) + if inspectErr != nil { + return nil, inspectErr + } + patchSnapshots = append(patchSnapshots, snapshot) + patchManaged = patchManaged || managed + } + envSnapshot, err := captureFileSnapshot(envPath) + if err != nil { + return nil, err + } + envBody, _, err := readOptionalFile(envPath) + if err != nil { + return nil, err + } + envManaged, err := inspectDshEnv(envPath, envBody) + if err != nil { + return nil, err + } + + primarySnapshot := patchSnapshots[0] + auxiliaryFiles := append([]fileSnapshot(nil), patchSnapshots[1:]...) + auxiliaryFiles = append(auxiliaryFiles, envSnapshot) + plan := &WritePlan{ + Platform: PlatformDSH, + ConfigPath: patchPaths[0], + WillCreate: !primarySnapshot.Exists, + WillReplace: patchManaged || envManaged, + SnapshotModTime: primarySnapshot.ModTime, + SnapshotSize: primarySnapshot.Size, + PreviewEntry: map[string]interface{}{ + "patchFiles": patchPaths, + "profiles": append([]string(nil), dshProfileNames...), + "envFile": envPath, + "nativePlugin": dshNativePackage, + "mcpPlugin": "@deepseek-ai/dsh-mcp-client", + "mcpServer": dshMemoryPackageSpec, + "serverName": "everme", + "agentId": "agt_", + "agentToken": "evt_", + }, + auxiliaryFiles: auxiliaryFiles, + } + return plan, nil +} + +func (*dshWriter) Commit(_ context.Context, plan *WritePlan, params WriteParams) (*WriteResult, error) { + if plan == nil { + return nil, output.Internal(fmt.Errorf("nil plan")) + } + if plan.Platform != PlatformDSH { + return nil, output.Internal(fmt.Errorf("unexpected plan platform %q", plan.Platform)) + } + if err := assertNoConcurrentChange(plan); err != nil { + return nil, err + } + for _, snapshot := range plan.auxiliaryFiles { + if err := assertFileSnapshot(snapshot); err != nil { + return nil, err + } + } + patchPaths, envPath, err := resolveDshManagedPaths(plan.ConfigPath) + if err != nil { + return nil, err + } + for _, patchPath := range patchPaths { + if err := os.MkdirAll(filepath.Dir(patchPath), 0o700); err != nil { + return nil, output.IOErr(filepath.Dir(patchPath), "mkdir-dsh-profile", err) + } + } + + type patchWrite struct { + path string + body string + exists bool + } + patchWrites := make([]patchWrite, 0, len(patchPaths)) + for _, patchPath := range patchPaths { + patchBody, patchExists, readErr := readOptionalFile(patchPath) + if readErr != nil { + return nil, readErr + } + newPatch, mergeErr := mergeDshPatch(patchPath, patchBody) + if mergeErr != nil { + return nil, mergeErr + } + patchWrites = append(patchWrites, patchWrite{path: patchPath, body: newPatch, exists: patchExists}) + } + envBody, envExists, err := readOptionalFile(envPath) + if err != nil { + return nil, err + } + newEnv, err := mergeDshEnv(envPath, envBody, params) + if err != nil { + return nil, err + } + + var backupPath string + for _, patch := range patchWrites { + if patch.exists { + backup, backupErr := backupFile(patch.path, false) + if backupErr != nil { + return nil, backupErr + } + if backupPath == "" { + backupPath = backup + } + } + } + if envExists { + envBackup, backupErr := backupFile(envPath, true) + err = backupErr + if err != nil { + return nil, err + } + if backupPath == "" { + backupPath = envBackup + } + } + if err := writeFileAtomic(envPath, []byte(newEnv), 0o600); err != nil { + return nil, output.IOErr(envPath, "write-env", err) + } + for _, patch := range patchWrites { + if err := writeFileAtomic(patch.path, []byte(patch.body), 0o600); err != nil { + return nil, output.IOErr(patch.path, "write-patch", err) + } + } + + return &WriteResult{ + Platform: PlatformDSH, + ConfigPath: patchPaths[0], + BackupPath: backupPath, + WroteNewEntry: !plan.WillReplace, + NextSteps: []string{"Restart DeepSeek Harness, or wait for its patch watcher to reload the configuration."}, + }, nil +} + +func (*dshWriter) Verify(ctx context.Context, result *WriteResult) error { + if result == nil { + return output.Internal(fmt.Errorf("nil result")) + } + patchPaths, envPath, err := resolveDshManagedPaths(result.ConfigPath) + if err != nil { + return err + } + envBody, _, err := readOptionalFile(envPath) + if err != nil { + return err + } + for _, patchPath := range patchPaths { + patchBody, _, readErr := readOptionalFile(patchPath) + if readErr != nil { + return readErr + } + patchManaged, inspectErr := inspectDshPatch(patchPath, patchBody) + if inspectErr != nil { + return inspectErr + } + if !patchManaged { + return output.IOErr(patchPath, "verify", fmt.Errorf("EverMe DSH patch is missing")) + } + } + envManaged, err := inspectDshEnv(envPath, envBody) + if err != nil { + return err + } + if !envManaged { + return output.IOErr(envPath, "verify", fmt.Errorf("EverMe DSH credentials block is missing")) + } + if runtime.GOOS != "windows" { + info, statErr := os.Stat(envPath) + if statErr != nil { + return output.IOErr(envPath, "verify-mode", statErr) + } + if info.Mode().Perm()&0o077 != 0 { + return output.IOErr(envPath, "verify-mode", fmt.Errorf("credential file permissions are %04o, want 0600", info.Mode().Perm())) + } + } + if _, err := exec.LookPath(dshMemoryCommand()); err != nil { + return output.IOErr(dshMemoryCommand(), "verify-command", err) + } + nativeInstalled, err := dshNativePackageInstalled(ctx) + if err != nil { + return err + } + if !nativeInstalled { + return output.IOErr(dshNativePackage, "verify-package", fmt.Errorf("package is not installed and active in every managed DSH profile")) + } + return nil +} + +func (*dshWriter) Remove(ctx context.Context, configPath string) (*RemoveResult, error) { + patchPaths, envPath, err := resolveDshManagedPaths(configPath) + if err != nil { + return nil, err + } + result := &RemoveResult{Platform: PlatformDSH, ConfigPath: patchPaths[0]} + envBody, envExists, err := readOptionalFile(envPath) + if err != nil { + return nil, err + } + + for _, patchPath := range patchPaths { + patchBody, patchExists, readErr := readOptionalFile(patchPath) + if readErr != nil { + return nil, readErr + } + if !patchExists { + continue + } + managed, inspectErr := inspectDshPatch(patchPath, patchBody) + if inspectErr != nil { + return nil, inspectErr + } + if !managed { + continue + } + backup, backupErr := backupFile(patchPath, false) + if backupErr != nil { + return nil, backupErr + } + remaining, removeErr := removeManagedBlock(patchPath, patchBody, dshPatchBlockStart, dshPatchBlockEnd) + if removeErr != nil { + return nil, removeErr + } + if strings.TrimSpace(remaining) == "" { + remaining = "[]\n" + } + if err := writeFileAtomic(patchPath, []byte(ensureTrailingNewline(remaining)), 0o600); err != nil { + return nil, output.IOErr(patchPath, "remove-patch", err) + } + if result.BackupPath == "" { + result.BackupPath = backup + } + result.Removed = true + } + if envExists { + managed, inspectErr := inspectDshEnv(envPath, envBody) + if inspectErr != nil { + return nil, inspectErr + } + if managed { + backup, backupErr := backupFile(envPath, true) + if backupErr != nil { + return nil, backupErr + } + remaining, removeErr := removeManagedBlock(envPath, envBody, dshEnvBlockStart, dshEnvBlockEnd) + if removeErr != nil { + return nil, removeErr + } + if strings.TrimSpace(remaining) == "" { + if err := os.Remove(envPath); err != nil && !os.IsNotExist(err) { + return nil, output.IOErr(envPath, "remove-env", err) + } + } else if err := writeFileAtomic(envPath, []byte(ensureTrailingNewline(remaining)), 0o600); err != nil { + return nil, output.IOErr(envPath, "remove-env-block", err) + } + if result.BackupPath == "" { + result.BackupPath = backup + } + result.Removed = true + } + } + home, err := dshHomePath() + if err != nil { + return nil, err + } + launcher := "" + var launcherArgs []string + for _, profile := range dshProfileNames { + nativeInstalled, installedErr := dshNativePackageInstalledInProfile(home, profile) + if installedErr != nil { + return nil, installedErr + } + if !nativeInstalled { + continue + } + if launcher == "" { + var launcherErr error + launcher, launcherArgs, launcherErr = dshLauncher() + if launcherErr != nil { + return nil, output.Invalid( + "no DeepSeek Harness launcher with Node.js 22.19+ was found on PATH", + "Remove @everme/dsh from the web and headless profiles after restoring Node.js/npm", + ) + } + } + args := append(append([]string{}, launcherArgs...), "plugin", "--profile", profile, "remove", "--workspace-root", dshNativePackage) + cmd := exec.CommandContext(ctx, launcher, args...) + cmd.WaitDelay = 5 * time.Second + cmd.Env = dshLauncherEnv(launcher) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return nil, output.IOErr(dshNativePackage, "dsh-profile-remove-"+profile, err) + } + result.Removed = true + } + return result, nil +} + +func dshLauncher() (string, []string, error) { + if value := strings.TrimSpace(os.Getenv("EVERCLI_DSH_COMMAND")); value != "" { + path, err := exec.LookPath(value) + return path, nil, err + } + if path, ok := dshExecutableWithCompatibleNode("dsh"); ok { + return path, nil, nil + } + if path, ok := dshExecutableWithCompatibleNode(npxCommand()); ok { + return path, []string{"--yes", dshLauncherPackage}, nil + } + return "", nil, fmt.Errorf("DeepSeek Harness requires Node.js 22.19+ and a matching dsh or npx executable") +} + +// dshRealLauncherPresent reports whether DeepSeek Harness is genuinely +// present - an explicit EVERCLI_DSH_COMMAND override, or a real `dsh` +// executable on PATH. Deliberately NOT the same check as dshLauncher(), +// which also succeeds when only npx is available (the "we can bootstrap it" +// fallback plugin install needs). Detect() must not reuse that fallback as +// an installed signal: any machine with Node.js 22.19+ and npx - true of +// most developer machines - would then be misreported as already having +// DeepSeek Harness, which drove the desktop Onboarding autoWire flow to +// silently run a real `plugin install dsh` (a genuine npx/pnpm bootstrap of +// the DSH package tree) for users who had never installed it. +func dshRealLauncherPresent() bool { + if value := strings.TrimSpace(os.Getenv("EVERCLI_DSH_COMMAND")); value != "" { + _, err := exec.LookPath(value) + return err == nil + } + _, ok := dshExecutableWithCompatibleNode("dsh") + return ok +} + +func dshExecutableWithCompatibleNode(name string) (string, bool) { + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + if strings.TrimSpace(dir) == "" { + continue + } + candidate := filepath.Join(dir, name) + info, err := os.Stat(candidate) + if err != nil || info.IsDir() { + continue + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + continue + } + if !dshNodeVersionSupported(filepath.Join(dir, nodeCommandName())) { + continue + } + return candidate, true + } + return "", false +} + +func dshNodeVersionSupported(path string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, path, "--version").Output() + if err != nil { + return false + } + parts := strings.Split(strings.TrimPrefix(strings.TrimSpace(string(out)), "v"), ".") + if len(parts) < 2 { + return false + } + major, majorErr := strconv.Atoi(parts[0]) + minor, minorErr := strconv.Atoi(parts[1]) + if majorErr != nil || minorErr != nil { + return false + } + return major > 22 || major == 22 && minor >= 19 +} + +func nodeCommandName() string { + if runtime.GOOS == "windows" { + return "node.exe" + } + return "node" +} + +func dshLauncherEnv(launcher string) []string { + env := os.Environ() + dir := filepath.Dir(launcher) + for index, entry := range env { + if !strings.HasPrefix(entry, "PATH=") { + continue + } + updated := append([]string(nil), env...) + updated[index] = "PATH=" + dir + string(os.PathListSeparator) + strings.TrimPrefix(entry, "PATH=") + return updated + } + return append(env, "PATH="+dir) +} + +func dshMemoryCommand() string { + if value := strings.TrimSpace(os.Getenv("EVERCLI_DSH_MEMORY_COMMAND")); value != "" { + return value + } + return npxCommand() +} + +func dshNativePackageInstalled(_ context.Context) (bool, error) { + packagePath := strings.TrimSpace(os.Getenv("EVERCLI_DSH_NATIVE_PACKAGE_PATH")) + if packagePath != "" { + info, err := os.Stat(filepath.Join(packagePath, "package.json")) + if err == nil { + return !info.IsDir(), nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, output.IOErr(packagePath, "stat-package", err) + } + + home, err := dshHomePath() + if err != nil { + return false, err + } + for _, profile := range dshProfileNames { + installed, installedErr := dshNativePackageInstalledInProfile(home, profile) + if installedErr != nil { + return false, installedErr + } + if !installed { + return false, nil + } + } + return true, nil +} + +func dshNativePackageInstalledInProfile(home, profile string) (bool, error) { + profileDir := filepath.Join(home, dshProfilesDir, profile) + packagePath := filepath.Join(profileDir, "node_modules", "@everme", "dsh") + packageBody, err := os.ReadFile(filepath.Join(packagePath, "package.json")) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, output.IOErr(packagePath, "read-package", err) + } + var packageManifest struct { + DSH struct { + Bundle struct { + Patch string `json:"patch"` + } `json:"bundle"` + } `json:"dsh"` + } + if err := json.Unmarshal(packageBody, &packageManifest); err != nil { + return false, output.IOErr(packagePath, "parse-package", err) + } + if strings.TrimSpace(packageManifest.DSH.Bundle.Patch) == "" { + return false, nil + } + + profileBody, err := os.ReadFile(filepath.Join(profileDir, "package.json")) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, output.IOErr(profileDir, "read-profile", err) + } + var profileManifest struct { + DSH struct { + Profile struct { + Bundles []string `json:"bundles"` + } `json:"profile"` + } `json:"dsh"` + } + if err := json.Unmarshal(profileBody, &profileManifest); err != nil { + return false, output.IOErr(profileDir, "parse-profile", err) + } + for _, bundle := range profileManifest.DSH.Profile.Bundles { + if bundle == dshNativePackage { + return true, nil + } + } + return false, nil +} + +func dshHomePath() (string, error) { + home := strings.TrimSpace(os.Getenv("DSH_HOME")) + if home == "" { + userHome, err := os.UserHomeDir() + if err != nil { + return "", output.IOErr("dsh-home", "resolve-home", err) + } + home = filepath.Join(userHome, ".dsh") + } + abs, err := filepath.Abs(home) + if err != nil { + return "", output.IOErr(home, "abs-path", err) + } + return abs, nil +} + +func dshProfilePatchPath(home string) string { + return dshNamedProfilePatchPath(home, dshProfileName) +} + +func dshNamedProfilePatchPath(home, profile string) string { + return filepath.Join(home, dshProfilesDir, profile, dshPatchFile) +} + +func dshProfilePatchPaths(home string) []string { + paths := make([]string, 0, len(dshProfileNames)) + for _, profile := range dshProfileNames { + paths = append(paths, dshNamedProfilePatchPath(home, profile)) + } + return paths +} + +func resolveDshManagedPaths(configPath string) ([]string, string, error) { + home, err := dshHomePath() + if err != nil { + return nil, "", err + } + if strings.TrimSpace(configPath) == "" { + configPath = dshProfilePatchPath(home) + } + patchPath, err := filepath.Abs(configPath) + if err != nil { + return nil, "", output.IOErr(configPath, "abs-path", err) + } + patchPaths := []string{patchPath} + for _, profile := range dshProfileNames[1:] { + patchPaths = append(patchPaths, dshNamedProfilePatchPath(home, profile)) + } + return patchPaths, filepath.Join(home, dshEnvFile), nil +} + +func readOptionalFile(path string) (string, bool, error) { + body, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, output.IOErr(path, "read", err) + } + return string(body), true, nil +} + +func inspectDshPatch(path, body string) (bool, error) { + managed, err := inspectManagedBlock(path, body, dshPatchBlockStart, dshPatchBlockEnd) + if err != nil { + return false, err + } + trimmed := strings.TrimSpace(body) + if trimmed == "" { + return managed, nil + } + var doc yaml.Node + if err := yaml.Unmarshal([]byte(body), &doc); err != nil { + return false, output.Invalid(fmt.Sprintf("DSH patch at %s is invalid YAML: %v", path, err), "Fix the YAML, then retry install") + } + if len(doc.Content) == 0 { + return managed, nil + } + if doc.Content[0].Kind != yaml.SequenceNode { + return false, output.Invalid(fmt.Sprintf("DSH patch at %s must be a YAML sequence", path), "Use [] for an empty patch or a list of insert/patch entries") + } + nativeFound := dshPatchContainsID(doc.Content[0], dshNativePatchEntryID) + mcpFound := dshPatchContainsID(doc.Content[0], dshPatchEntryID) + if !managed && (nativeFound || mcpFound) { + collisionID := dshPatchEntryID + if nativeFound { + collisionID = dshNativePatchEntryID + } + return false, output.Invalid( + fmt.Sprintf("DSH patch at %s already contains id %q outside the evercli-managed block", path, collisionID), + "Remove or rename that entry, then retry so evercli can manage its own block safely", + ) + } + if managed && !mcpFound { + return false, output.Invalid(fmt.Sprintf("DSH patch at %s has an incomplete EverMe managed block", path), "Remove the broken managed block and retry install") + } + return managed && mcpFound, nil +} + +func dshPatchContainsID(sequence *yaml.Node, id string) bool { + for _, item := range sequence.Content { + if item.Kind != yaml.MappingNode { + continue + } + for i := 0; i+1 < len(item.Content); i += 2 { + if item.Content[i].Value != "insert" { + continue + } + entries := item.Content[i+1] + if entries.Kind != yaml.SequenceNode { + continue + } + for _, entry := range entries.Content { + if entry.Kind != yaml.MappingNode { + continue + } + for j := 0; j+1 < len(entry.Content); j += 2 { + if entry.Content[j].Value == "id" && entry.Content[j+1].Value == id { + return true + } + } + } + } + } + return false +} + +func inspectDshEnv(path, body string) (bool, error) { + managed, err := inspectManagedBlock(path, body, dshEnvBlockStart, dshEnvBlockEnd) + if err != nil { + return false, err + } + outside := body + if managed { + outside, err = removeManagedBlock(path, body, dshEnvBlockStart, dshEnvBlockEnd) + if err != nil { + return false, err + } + } + for _, line := range strings.Split(outside, "\n") { + trimmed := strings.TrimSpace(line) + for _, key := range dshEnvKeys { + if strings.HasPrefix(trimmed, key+"=") { + return false, output.Invalid( + fmt.Sprintf("DSH env file at %s already defines %s outside the evercli-managed block", path, key), + "Remove the conflicting EverMe variables or move them into the managed block, then retry", + ) + } + } + } + if !managed { + return false, nil + } + block, err := managedBlock(path, body, dshEnvBlockStart, dshEnvBlockEnd) + if err != nil { + return false, err + } + for _, key := range dshEnvKeys { + if !strings.Contains(block, "\n"+key+"=") { + return false, output.Invalid(fmt.Sprintf("DSH env file at %s has an incomplete EverMe managed block", path), "Remove the broken managed block and retry install") + } + } + return true, nil +} + +func inspectManagedBlock(path, body, start, end string) (bool, error) { + starts := strings.Count(body, start) + ends := strings.Count(body, end) + if starts == 0 && ends == 0 { + return false, nil + } + if starts != 1 || ends != 1 || strings.Index(body, start) > strings.Index(body, end) { + return false, output.Invalid(fmt.Sprintf("managed block markers are malformed in %s", path), "Repair or remove the EverMe managed block, then retry") + } + return true, nil +} + +func managedBlock(path, body, start, end string) (string, error) { + startIndex := strings.Index(body, start) + endIndex := strings.Index(body, end) + if startIndex < 0 || endIndex < startIndex { + return "", output.Invalid(fmt.Sprintf("managed block markers are malformed in %s", path), "Repair or remove the EverMe managed block, then retry") + } + endIndex += len(end) + return body[startIndex:endIndex], nil +} + +func removeManagedBlock(path, body, start, end string) (string, error) { + block, err := managedBlock(path, body, start, end) + if err != nil { + return "", err + } + remaining := strings.Replace(body, block, "", 1) + return strings.TrimRight(remaining, " \t\r\n"), nil +} + +func mergeDshPatch(path, body string) (string, error) { + managed, err := inspectDshPatch(path, body) + if err != nil { + return "", err + } + remaining := strings.TrimRight(body, " \t\r\n") + if managed { + remaining, err = removeManagedBlock(path, body, dshPatchBlockStart, dshPatchBlockEnd) + if err != nil { + return "", err + } + } + if isEmptyDshPatch(remaining) { + remaining = dshPatchComments(remaining) + } + block := dshPatchManagedBlock() + if strings.TrimSpace(remaining) == "" { + return block, nil + } + return ensureTrailingNewline(remaining) + "\n" + block, nil +} + +func mergeDshEnv(path, body string, params WriteParams) (string, error) { + managed, err := inspectDshEnv(path, body) + if err != nil { + return "", err + } + remaining := strings.TrimRight(body, " \t\r\n") + if managed { + remaining, err = removeManagedBlock(path, body, dshEnvBlockStart, dshEnvBlockEnd) + if err != nil { + return "", err + } + } + envBody, err := buildEnvFileBody(PlatformDSH, params) + if err != nil { + return "", output.Internal(err) + } + block := dshEnvBlockStart + "\n" + strings.TrimSpace(envBody) + "\n" + dshEnvBlockEnd + "\n" + if strings.TrimSpace(remaining) == "" { + return block, nil + } + return ensureTrailingNewline(remaining) + "\n" + block, nil +} + +func dshPatchManagedBlock() string { + command := yamlSingleQuoted(dshMemoryCommand()) + args := "" + if strings.TrimSpace(os.Getenv("EVERCLI_DSH_MEMORY_COMMAND")) == "" { + args = ` + args: + - '-y' + - '` + dshMemoryPackageSpec + `'` + } + return dshPatchBlockStart + ` +- insert: + - id: memory-everme + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: everme + transport: stdio + command: ` + command + args + ` + cwd: !!js process.cwd() + failOnStartupError: true + env: + EVERME_API_BASE: !!js process.env.EVERME_API_BASE?.trim() || '' + EVERME_AGENT_ID: !!js process.env.EVERME_AGENT_ID?.trim() || '' + EVERME_AGENT_TOKEN: !!js process.env.EVERME_AGENT_TOKEN?.trim() || '' +` + dshPatchBlockEnd + "\n" +} + +func yamlSingleQuoted(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func isEmptyDshPatch(body string) bool { + var doc yaml.Node + if strings.TrimSpace(body) == "" { + return true + } + if err := yaml.Unmarshal([]byte(body), &doc); err != nil { + return false + } + if len(doc.Content) == 0 { + return true + } + return doc.Content[0].Kind == yaml.SequenceNode && len(doc.Content[0].Content) == 0 +} + +func dshPatchComments(body string) string { + var lines []string + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + lines = append(lines, line) + } + } + return strings.TrimRight(strings.Join(lines, "\n"), " \t\r\n") +} + +func ensureTrailingNewline(body string) string { + return strings.TrimRight(body, "\r\n") + "\n" +} diff --git a/cli/internal/plugin/dsh_test.go b/cli/internal/plugin/dsh_test.go new file mode 100644 index 0000000..5d9d165 --- /dev/null +++ b/cli/internal/plugin/dsh_test.go @@ -0,0 +1,510 @@ +package plugin + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestDSHHomePath(t *testing.T) { + t.Run("override", func(t *testing.T) { + home := filepath.Join(t.TempDir(), "custom-dsh") + t.Setenv("DSH_HOME", home) + + got, err := dshHomePath() + require.NoError(t, err) + assert.Equal(t, home, got) + }) + + t.Run("default", func(t *testing.T) { + home := t.TempDir() + t.Setenv("DSH_HOME", "") + t.Setenv("HOME", home) + + got, err := dshHomePath() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".dsh"), got) + }) +} + +func TestDSHLauncherPrefersInstalledDSH(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable lookup fixture is POSIX-only") + } + dir := t.TempDir() + nodePath := filepath.Join(dir, "node") + npxPath := filepath.Join(dir, "npx") + dshPath := filepath.Join(dir, "dsh") + require.NoError(t, os.WriteFile(nodePath, []byte("#!/bin/sh\necho v24.19.0\n"), 0o700)) + require.NoError(t, os.WriteFile(npxPath, []byte("#!/bin/sh\nexit 0\n"), 0o700)) + require.NoError(t, os.WriteFile(dshPath, []byte("#!/bin/sh\nexit 0\n"), 0o700)) + t.Setenv("PATH", dir) + t.Setenv("EVERCLI_DSH_COMMAND", "") + + launcher, args, err := dshLauncher() + require.NoError(t, err) + assert.Equal(t, dshPath, launcher) + assert.Empty(t, args) +} + +func TestDSHLauncherFallsBackToLatestNpx(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable lookup fixture is POSIX-only") + } + dir := t.TempDir() + nodePath := filepath.Join(dir, "node") + npxPath := filepath.Join(dir, "npx") + require.NoError(t, os.WriteFile(nodePath, []byte("#!/bin/sh\necho v24.19.0\n"), 0o700)) + require.NoError(t, os.WriteFile(npxPath, []byte("#!/bin/sh\nexit 0\n"), 0o700)) + t.Setenv("PATH", dir) + t.Setenv("EVERCLI_DSH_COMMAND", "") + + launcher, args, err := dshLauncher() + require.NoError(t, err) + assert.Equal(t, npxPath, launcher) + assert.Equal(t, []string{"--yes", "@deepseek-ai/dsh@latest"}, args) +} + +func TestDSHLauncherSkipsIncompatibleNodePair(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable lookup fixture is POSIX-only") + } + oldDir := t.TempDir() + newDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(oldDir, "node"), []byte("#!/bin/sh\necho v20.11.1\n"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(oldDir, "npx"), []byte("#!/bin/sh\nexit 0\n"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(oldDir, "dsh"), []byte("#!/bin/sh\nexit 0\n"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(newDir, "node"), []byte("#!/bin/sh\necho v24.19.0\n"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(newDir, "npx"), []byte("#!/bin/sh\nexit 0\n"), 0o700)) + t.Setenv("PATH", oldDir+string(os.PathListSeparator)+newDir) + t.Setenv("EVERCLI_DSH_COMMAND", "") + + launcher, args, err := dshLauncher() + require.NoError(t, err) + assert.Equal(t, filepath.Join(newDir, "npx"), launcher) + assert.Equal(t, []string{"--yes", "@deepseek-ai/dsh@latest"}, args) +} + +// TestDSHDetectorNpxOnlyIsNotInstalled is a regression test for a false +// positive found on a real developer machine: any machine with Node.js +// 22.19+ and npx on PATH - true of most dev machines, whether or not they +// have ever touched DeepSeek Harness - must NOT be reported as "installed". +// Detect() must not conflate "npx could bootstrap it" with "it is here". +func TestDSHDetectorNpxOnlyIsNotInstalled(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable lookup fixture is POSIX-only") + } + home := t.TempDir() + t.Setenv("DSH_HOME", home) + t.Setenv("EVERCLI_DSH_COMMAND", "") + + binDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(binDir, "node"), []byte("#!/bin/sh\necho v24.19.0\n"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(binDir, "npx"), []byte("#!/bin/sh\nexit 0\n"), 0o700)) + t.Setenv("PATH", binDir) + + // dshLauncher() itself DOES resolve here (that's the point of the npx + // fallback, for the install path) - Detect() must still say not-installed. + _, _, err := dshLauncher() + require.NoError(t, err) + + detection, err := dshDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.False(t, detection.Installed) +} + +func TestDSHDetector(t *testing.T) { + home := t.TempDir() + t.Setenv("DSH_HOME", home) + t.Setenv("EVERCLI_DSH_COMMAND", filepath.Join(home, "missing-dsh")) + + detection, err := dshDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.Equal(t, PlatformDSH, detection.Platform) + assert.Equal(t, dshProfilePatchPath(home), detection.ConfigPath) + assert.False(t, detection.Installed) + assert.False(t, detection.ConfigExists) + assert.False(t, detection.HasEverMeEntry) + + require.NoError(t, os.Mkdir(filepath.Join(home, "profiles"), 0o700)) + detection, err = dshDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.True(t, detection.Installed) + + writer := newDSHWriter() + plan, err := writer.Plan(context.Background(), dshProfilePatchPath(home)) + require.NoError(t, err) + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_detect")) + require.NoError(t, err) + detection, err = dshDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.True(t, detection.HasEverMeEntry) +} + +func TestDSHWriter_FreshCommit(t *testing.T) { + writer := newDSHWriter() + home, patchPath, envPath := dshTestPaths(t) + params := dshTestParams("evt_fresh") + + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + assert.True(t, plan.WillCreate) + assert.False(t, plan.WillReplace) + + result, err := writer.Commit(context.Background(), plan, params) + require.NoError(t, err) + assert.True(t, result.WroteNewEntry) + assert.Equal(t, patchPath, result.ConfigPath) + + patchBody := readTestFile(t, patchPath) + headlessPatchBody := readTestFile(t, dshNamedProfilePatchPath(home, dshHeadlessProfileName)) + envBody := readTestFile(t, envPath) + + var doc yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(patchBody), &doc)) + assert.NotContains(t, patchBody, "name: '@everme/dsh'") + assert.True(t, strings.Contains(patchBody, "name: '@deepseek-ai/dsh-mcp-client'")) + assert.Contains(t, patchBody, "command: "+yamlSingleQuoted(npxCommand())) + assert.Contains(t, patchBody, "- '@everme/memory-mcp@latest'") + assert.NotContains(t, patchBody, params.AgentToken) + assert.Contains(t, envBody, "EVERME_AGENT_TOKEN="+params.AgentToken) + assert.Equal(t, 1, strings.Count(patchBody, dshPatchBlockStart)) + assert.Equal(t, 1, strings.Count(headlessPatchBody, dshPatchBlockStart)) + assert.Contains(t, headlessPatchBody, dshMemoryPackageSpec) + assert.Equal(t, 1, strings.Count(envBody, dshEnvBlockStart)) + + if runtime.GOOS != "windows" { + info, statErr := os.Stat(envPath) + require.NoError(t, statErr) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } +} + +func TestDSHWriter_PreservesAndReplacesManagedBlocks(t *testing.T) { + writer := newDSHWriter() + _, patchPath, envPath := dshTestPaths(t) + require.NoError(t, os.MkdirAll(filepath.Dir(patchPath), 0o700)) + userPatch := "# user patch\n- patch:\n id: keep-me\n config:\n enabled: true\n" + userEnv := "# user env\nOTHER_KEY=keep-me\n" + require.NoError(t, os.WriteFile(patchPath, []byte(userPatch), 0o640)) + require.NoError(t, os.WriteFile(envPath, []byte(userEnv), 0o600)) + + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_first")) + require.NoError(t, err) + + plan, err = writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + assert.True(t, plan.WillReplace) + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_second")) + require.NoError(t, err) + + patchBody := readTestFile(t, patchPath) + envBody := readTestFile(t, envPath) + assert.Contains(t, patchBody, userPatch) + assert.Contains(t, envBody, userEnv) + assert.Equal(t, 1, strings.Count(patchBody, dshPatchBlockStart)) + assert.Equal(t, 1, strings.Count(envBody, dshEnvBlockStart)) + assert.NotContains(t, envBody, "evt_first") + assert.Contains(t, envBody, "evt_second") + assert.NotContains(t, patchBody, "evt_second") +} + +func TestDSHWriter_AcceptsCommentsOnlyPatch(t *testing.T) { + writer := newDSHWriter() + _, patchPath, _ := dshTestPaths(t) + require.NoError(t, os.MkdirAll(filepath.Dir(patchPath), 0o700)) + require.NoError(t, os.WriteFile(patchPath, []byte("# keep this comment\n"), 0o600)) + + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_comment")) + require.NoError(t, err) + assert.Contains(t, readTestFile(t, patchPath), "# keep this comment") +} + +func TestDSHWriter_RejectsUnmanagedCollisions(t *testing.T) { + writer := newDSHWriter() + + t.Run("patch id", func(t *testing.T) { + _, patchPath, _ := dshTestPaths(t) + require.NoError(t, os.MkdirAll(filepath.Dir(patchPath), 0o700)) + body := "- insert:\n - id: memory-everme\n name: custom\n" + require.NoError(t, os.WriteFile(patchPath, []byte(body), 0o600)) + + _, err := writer.Plan(context.Background(), patchPath) + require.Error(t, err) + assert.Contains(t, err.Error(), dshPatchEntryID) + }) + + t.Run("native patch id", func(t *testing.T) { + _, patchPath, _ := dshTestPaths(t) + require.NoError(t, os.MkdirAll(filepath.Dir(patchPath), 0o700)) + body := "- insert:\n - id: memory-everme-native\n name: custom\n" + require.NoError(t, os.WriteFile(patchPath, []byte(body), 0o600)) + + _, err := writer.Plan(context.Background(), patchPath) + require.Error(t, err) + assert.Contains(t, err.Error(), dshNativePatchEntryID) + }) + + t.Run("env key", func(t *testing.T) { + _, patchPath, envPath := dshTestPaths(t) + require.NoError(t, os.MkdirAll(filepath.Dir(patchPath), 0o700)) + require.NoError(t, os.WriteFile(patchPath, []byte("[]\n"), 0o600)) + require.NoError(t, os.WriteFile(envPath, []byte("EVERME_AGENT_TOKEN=user-owned\n"), 0o600)) + + _, err := writer.Plan(context.Background(), patchPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "EVERME_AGENT_TOKEN") + }) +} + +func TestDSHWriter_RefusesConcurrentChanges(t *testing.T) { + writer := newDSHWriter() + + t.Run("patch", func(t *testing.T) { + _, patchPath, _ := dshTestPaths(t) + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(patchPath), 0o700)) + require.NoError(t, os.WriteFile(patchPath, []byte("[]\n"), 0o600)) + + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_patch_race")) + require.Error(t, err) + }) + + t.Run("env", func(t *testing.T) { + _, patchPath, envPath := dshTestPaths(t) + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(envPath, []byte("OTHER=changed\n"), 0o600)) + + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_env_race")) + require.Error(t, err) + }) + + t.Run("headless patch", func(t *testing.T) { + home, patchPath, _ := dshTestPaths(t) + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + headlessPatchPath := dshNamedProfilePatchPath(home, dshHeadlessProfileName) + require.NoError(t, os.MkdirAll(filepath.Dir(headlessPatchPath), 0o700)) + require.NoError(t, os.WriteFile(headlessPatchPath, []byte("[]\n"), 0o600)) + + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_headless_race")) + require.Error(t, err) + }) +} + +func TestDSHWriter_RemovePreservesUnrelatedContent(t *testing.T) { + writer := newDSHWriter() + home, patchPath, envPath := dshTestPaths(t) + require.NoError(t, os.MkdirAll(filepath.Dir(patchPath), 0o700)) + userPatch := "# user patch\n- patch:\n id: keep-me\n" + userEnv := "OTHER_KEY=keep-me\n" + require.NoError(t, os.WriteFile(patchPath, []byte(userPatch), 0o600)) + require.NoError(t, os.WriteFile(envPath, []byte(userEnv), 0o600)) + + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_remove")) + require.NoError(t, err) + + result, err := writer.(Remover).Remove(context.Background(), patchPath) + require.NoError(t, err) + assert.True(t, result.Removed) + assert.Equal(t, userPatch, readTestFile(t, patchPath)) + assert.Equal(t, "[]\n", readTestFile(t, dshNamedProfilePatchPath(home, dshHeadlessProfileName))) + assert.Equal(t, userEnv, readTestFile(t, envPath)) + assert.FileExists(t, result.BackupPath) +} + +func TestDSHWriter_RemoveFreshInstall(t *testing.T) { + writer := newDSHWriter() + home, patchPath, envPath := dshTestPaths(t) + + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_remove_fresh")) + require.NoError(t, err) + + result, err := writer.(Remover).Remove(context.Background(), patchPath) + require.NoError(t, err) + assert.True(t, result.Removed) + assert.Equal(t, "[]\n", readTestFile(t, patchPath)) + assert.Equal(t, "[]\n", readTestFile(t, dshNamedProfilePatchPath(home, dshHeadlessProfileName))) + _, statErr := os.Stat(envPath) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestDSHWriter_MigratesManagedNativeInsertionIntoBundle(t *testing.T) { + writer := newDSHWriter() + _, patchPath, _ := dshTestPaths(t) + require.NoError(t, os.MkdirAll(filepath.Dir(patchPath), 0o700)) + legacy := dshPatchBlockStart + ` +- insert: + - id: memory-everme-native + name: '@everme/dsh' + config: {} + - id: memory-everme + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: everme + transport: stdio + command: everme-memory-mcp +` + dshPatchBlockEnd + "\n" + require.NoError(t, os.WriteFile(patchPath, []byte(legacy), 0o600)) + + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + assert.True(t, plan.WillReplace) + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_migrate")) + require.NoError(t, err) + + patchBody := readTestFile(t, patchPath) + assert.NotContains(t, patchBody, "name: '@everme/dsh'") + assert.Contains(t, patchBody, "name: '@deepseek-ai/dsh-mcp-client'") +} + +func TestDSHWriter_RemoveUninstallsNativeBundle(t *testing.T) { + writer := newDSHWriter() + home, patchPath, _ := dshTestPaths(t) + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + _, err = writer.Commit(context.Background(), plan, dshTestParams("evt_remove_bundle")) + require.NoError(t, err) + setDSHNativePackagesInstalled(t, home) + + dir := t.TempDir() + logPath := filepath.Join(dir, "dsh-remove.log") + dshCommandPath := filepath.Join(dir, "dsh") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" >> " + logPath + "\n" + require.NoError(t, os.WriteFile(dshCommandPath, []byte(script), 0o700)) + t.Setenv("EVERCLI_DSH_COMMAND", dshCommandPath) + + result, err := writer.(Remover).Remove(context.Background(), patchPath) + require.NoError(t, err) + assert.True(t, result.Removed) + assert.Equal(t, "plugin\n--profile\nweb\nremove\n--workspace-root\n@everme/dsh\nplugin\n--profile\nheadless\nremove\n--workspace-root\n@everme/dsh\n", readTestFile(t, logPath)) +} + +func TestDSHWriter_LifecycleAndVerify(t *testing.T) { + writer := newDSHWriter() + _, isPreparer := writer.(Preparer) + _, isVerifier := writer.(Verifier) + _, isRemover := writer.(Remover) + assert.True(t, isPreparer) + assert.True(t, isVerifier) + assert.True(t, isRemover) + + home, patchPath, _ := dshTestPaths(t) + dir := t.TempDir() + memoryCommand := filepath.Join(dir, "everme-memory-mcp-test") + require.NoError(t, os.WriteFile(memoryCommand, []byte("#!/bin/sh\nexit 0\n"), 0o700)) + t.Setenv("EVERCLI_DSH_MEMORY_COMMAND", memoryCommand) + setDSHNativePackagesInstalled(t, home) + + plan, err := writer.Plan(context.Background(), patchPath) + require.NoError(t, err) + result, err := writer.Commit(context.Background(), plan, dshTestParams("evt_verify")) + require.NoError(t, err) + patchBody := readTestFile(t, patchPath) + headlessPatchBody := readTestFile(t, dshNamedProfilePatchPath(home, dshHeadlessProfileName)) + assert.Contains(t, patchBody, "command: '"+memoryCommand+"'") + assert.Contains(t, headlessPatchBody, "command: '"+memoryCommand+"'") + assert.NotContains(t, patchBody, dshMemoryPackageSpec) + require.NoError(t, writer.(Verifier).Verify(context.Background(), result)) + + t.Setenv("EVERCLI_DSH_MEMORY_COMMAND", filepath.Join(dir, "missing-memory-command")) + require.Error(t, writer.(Verifier).Verify(context.Background(), result)) +} + +func TestDSHWriter_PrepareRequiresHostAndAcceptsInstalledCommands(t *testing.T) { + writer := newDSHWriter().(Preparer) + home, _, _ := dshTestPaths(t) + dir := t.TempDir() + t.Setenv("EVERCLI_DSH_COMMAND", filepath.Join(dir, "missing-dsh")) + require.Error(t, writer.Prepare(context.Background(), nil)) + + dshCommandPath := filepath.Join(dir, "dsh") + require.NoError(t, os.WriteFile(dshCommandPath, []byte("#!/bin/sh\nexit 0\n"), 0o700)) + t.Setenv("EVERCLI_DSH_COMMAND", dshCommandPath) + packageDir := filepath.Join(home, dshProfilesDir, dshProfileName, "node_modules", "@everme", "dsh") + setDSHNativePackageInstalled(t, packageDir) + t.Setenv("EVERCLI_DSH_NATIVE_PACKAGE_PATH", packageDir) + require.NoError(t, writer.Prepare(context.Background(), nil)) +} + +func TestDSHWriter_PrepareRefreshesLatestNativePackageIntoManagedProfiles(t *testing.T) { + writer := newDSHWriter().(Preparer) + home, _, _ := dshTestPaths(t) + dir := t.TempDir() + logPath := filepath.Join(dir, "dsh-args.log") + dshCommandPath := filepath.Join(dir, "dsh") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" >> " + logPath + "\n" + + "profile_dir=" + filepath.Join(home, dshProfilesDir) + "/$3\n" + + "package_dir=\"$profile_dir/node_modules/@everme/dsh\"\n" + + "mkdir -p \"$package_dir\"\n" + + "printf '{\"dsh\":{\"bundle\":{\"patch\":\"./cordis.patch.yml\"}}}\\n' > \"$package_dir/package.json\"\n" + + "printf '{\"dsh\":{\"profile\":{\"bundles\":[\"@everme/dsh\"]}}}\\n' > \"$profile_dir/package.json\"\n" + require.NoError(t, os.WriteFile(dshCommandPath, []byte(script), 0o700)) + t.Setenv("EVERCLI_DSH_COMMAND", dshCommandPath) + + require.NoError(t, writer.Prepare(context.Background(), nil)) + assert.Equal(t, "plugin\n--profile\nweb\nadd\n--workspace-root\n@everme/dsh@latest\nplugin\n--profile\nheadless\nadd\n--workspace-root\n@everme/dsh@latest\n", readTestFile(t, logPath)) +} + +func setDSHNativePackagesInstalled(t *testing.T, home string) { + t.Helper() + for _, profile := range dshProfileNames { + setDSHNativePackageInstalled(t, filepath.Join(home, dshProfilesDir, profile, "node_modules", "@everme", "dsh")) + } +} + +func setDSHNativePackageInstalled(t *testing.T, packageDir string) { + t.Helper() + profileDir := filepath.Clean(filepath.Join(packageDir, "..", "..", "..")) + require.NoError(t, os.MkdirAll(packageDir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(packageDir, "package.json"), + []byte(`{"dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}`+"\n"), + 0o600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(profileDir, "package.json"), + []byte(`{"dsh":{"profile":{"bundles":["@everme/dsh"]}}}`+"\n"), + 0o600, + )) +} + +func dshTestPaths(t *testing.T) (string, string, string) { + t.Helper() + home := t.TempDir() + t.Setenv("DSH_HOME", home) + return home, dshProfilePatchPath(home), filepath.Join(home, dshEnvFile) +} + +func dshTestParams(token string) WriteParams { + return WriteParams{ + APIBaseURL: "https://api.everme.example", + AgentID: "agt_dsh", + AgentToken: token, + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + body, err := os.ReadFile(path) + require.NoError(t, err) + return string(body) +} diff --git a/cli/internal/plugin/gemini.go b/cli/internal/plugin/gemini.go deleted file mode 100644 index fc2d17e..0000000 --- a/cli/internal/plugin/gemini.go +++ /dev/null @@ -1,85 +0,0 @@ -// Package plugin — Gemini CLI support. -// -// Gemini CLI reads MCP servers from a top-level `mcpServers.` map -// in ~/.gemini/settings.json — the identical JSON shape Cursor and -// Claude Desktop use. So the writer is the shared mcpWriter; only the -// config-file location is Gemini-specific. -// -// EVERCLI_GEMINI_CONFIG_DIR lets tests point at a tmp dir; production -// always resolves under $HOME. -// -// Caveat (documented, not auto-handled): if the user has set -// `mcp.allowed` in settings.json, only listed server names connect — -// they must add "everme-memory" to it by hand. -package plugin - -import ( - "context" - "os" - "os/exec" - "path/filepath" - - "evercli/internal/output" -) - -type geminiDetector struct{} - -func (geminiDetector) Platform() Platform { return PlatformGemini } - -func (geminiDetector) DisplayName() string { return "Gemini CLI" } - -func (geminiDetector) Detect(_ context.Context) (*Detection, error) { - path, err := geminiConfigPath() - if err != nil { - return &Detection{Platform: PlatformGemini, DisplayName: "Gemini CLI"}, nil - } - d := &Detection{ - Platform: PlatformGemini, - DisplayName: "Gemini CLI", - ConfigPath: path, - } - - // "Installed" = Gemini CLI is on the box. Dir-based signal first - // (most reliable), CLI-on-PATH as fallback — same pattern as cursor.go. - if home, err := os.UserHomeDir(); err == nil { - if _, statErr := os.Stat(filepath.Join(home, ".gemini")); statErr == nil { - d.Installed = true - } - } - if !d.Installed { - if _, err := exec.LookPath("gemini"); err == nil { - d.Installed = true - } - } - - cfg, exists, err := readConfig(path) - if err != nil { - return d, err - } - d.ConfigExists = exists - if exists { - d.HasEverMeEntry = nestedMcpServersHasEntry(cfg, claudeCodeServersPath, mcpEntryName) - } - return d, nil -} - -// geminiConfigPath resolves ~/.gemini/settings.json with an optional dir -// override for tests. -func geminiConfigPath() (string, error) { - if dir := os.Getenv("EVERCLI_GEMINI_CONFIG_DIR"); dir != "" { - return filepath.Join(dir, "settings.json"), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", output.IOErr("gemini", "resolve-home", err) - } - return filepath.Join(home, ".gemini", "settings.json"), nil -} - -// newGeminiWriter returns the shared mcpWriter under PlatformGemini — -// the `mcpServers` JSON shape is identical to Cursor / Claude Desktop, -// so all upsert / TOCTOU / atomic-write machinery from mcp.go is reused -// verbatim. The entry name is the canonical `everme-memory`. -func newGeminiWriter() Writer { - return newMCPWriter(PlatformGemini) -} diff --git a/cli/internal/plugin/gemini_test.go b/cli/internal/plugin/gemini_test.go deleted file mode 100644 index dc2c48d..0000000 --- a/cli/internal/plugin/gemini_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package plugin - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Not installed: no ~/.gemini dir, no gemini CLI, no config file. Detect -// must still return a usable Detection with ConfigPath set. -func TestGeminiDetector_NoConfig_NotInstalled(t *testing.T) { - dir := t.TempDir() - t.Setenv("EVERCLI_GEMINI_CONFIG_DIR", dir) - t.Setenv("HOME", t.TempDir()) - - d, err := geminiDetector{}.Detect(context.Background()) - require.NoError(t, err) - assert.Equal(t, PlatformGemini, d.Platform) - assert.Equal(t, filepath.Join(dir, "settings.json"), d.ConfigPath) - assert.False(t, d.ConfigExists) - assert.False(t, d.HasEverMeEntry) -} - -// HasEverMeEntry is true when mcpServers.everme-memory already exists. -func TestGeminiDetector_ConfigWithEverMe_ReportsEntry(t *testing.T) { - dir := t.TempDir() - t.Setenv("EVERCLI_GEMINI_CONFIG_DIR", dir) - t.Setenv("HOME", t.TempDir()) - - path := filepath.Join(dir, "settings.json") - require.NoError(t, os.WriteFile(path, []byte(`{ - "mcpServers": { - "everme-memory": {"command": "npx", "args": ["-y", "@everme/memory-mcp"]}, - "other": {"command": "noop"} - } - }`), 0o600)) - - d, err := geminiDetector{}.Detect(context.Background()) - require.NoError(t, err) - assert.True(t, d.ConfigExists) - assert.True(t, d.HasEverMeEntry) -} - -// Presence of ~/.gemini is enough to flag installed, even when the CLI -// isn't on PATH. -func TestGeminiDetector_InstalledFromHomeDir(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("EVERCLI_GEMINI_CONFIG_DIR", t.TempDir()) - require.NoError(t, os.MkdirAll(filepath.Join(home, ".gemini"), 0o700)) - - d, err := geminiDetector{}.Detect(context.Background()) - require.NoError(t, err) - assert.True(t, d.Installed) -} - -// Writer reuses the shared mcpWriter: Plan→Commit writes -// mcpServers.everme-memory with the token in env. -func TestGeminiWriter_WritesMcpServersEntry(t *testing.T) { - w := newGeminiWriter() - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - plan, err := w.Plan(context.Background(), path) - require.NoError(t, err) - - _, err = w.Commit(context.Background(), plan, WriteParams{ - APIBaseURL: "https://api.everme.evermind.ai", - AgentID: "agt_gemini", - AgentToken: "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }) - require.NoError(t, err) - - got := readJSON(t, path) - servers, ok := got["mcpServers"].(map[string]interface{}) - require.True(t, ok, "top-level mcpServers required") - entry, ok := servers["everme-memory"].(map[string]interface{}) - require.True(t, ok, "mcpServers.everme-memory required") - env, _ := entry["env"].(map[string]interface{}) - assert.Equal(t, "agt_gemini", env["EVERME_AGENT_ID"]) -} - -// The shared mcpWriter must NOT implement Preparer/Verifier — that would -// also activate them for cursor/claude-desktop/claude-code. -func TestGeminiWriter_DoesNotImplementLifecycleInterfaces(t *testing.T) { - w := newGeminiWriter() - _, isPreparer := any(w).(Preparer) - assert.False(t, isPreparer) - _, isVerifier := any(w).(Verifier) - assert.False(t, isVerifier) -} diff --git a/cli/internal/plugin/hermes.go b/cli/internal/plugin/hermes.go index 21503a1..28fdbfb 100644 --- a/cli/internal/plugin/hermes.go +++ b/cli/internal/plugin/hermes.go @@ -27,10 +27,10 @@ import ( "os" "os/exec" "path/filepath" - "strings" "gopkg.in/yaml.v3" + "evercli/internal/core" "evercli/internal/output" ) @@ -39,75 +39,16 @@ import ( // removeLegacyMcpEntry can delete a leftover entry during migration. const hermesMcpEntryName = "everme" -// hermesCommand resolves the `hermes` CLI. EVERCLI_HERMES_CMD lets tests -// point at a stub so we don't shell out (or PATH-probe) the real CLI. -// Same pattern as EVERCLI_CODEX_CMD. -func hermesCommand() string { - if v := os.Getenv("EVERCLI_HERMES_CMD"); v != "" { - return v - } - return "hermes" -} +// hermesCommand resolves the `hermes` CLI. Delegates to the shared +// core resolver so internal/importer can reuse the same logic without +// importing internal/plugin. +func hermesCommand() string { return core.HermesCommand() } -// hermesHome resolves the Hermes home directory using the priority chain -// mandated by Hermes maintainers: installer code MUST NOT hard-guess -// `~/.hermes` when a user has overridden the location. Order: -// -// 1. EVERCLI_HERMES_CONFIG_DIR — test / advanced override; if set, -// wins outright. Same env var the Detector / Writer use to pin -// the config dir in unit tests. -// 2. HERMES_HOME — Hermes's own well-known env var; multi-instance -// setups (dev / prod) use this to keep separate config trees. -// 3. `hermes config path` — authoritative source of truth from the -// installed Hermes CLI itself; works on any user who has Hermes -// on PATH, regardless of how they configured home. Returns the -// full config.yaml path; we strip the basename to recover home. -// 4. `$HOME/.hermes` — last-resort fallback only when none of the -// above resolve (no env override, no CLI on PATH). Matches what -// a fresh Hermes install does. -// -// Returns (home, err) where err is non-nil only on a genuine OS -// failure (e.g. user has no $HOME) — the three preceding steps all -// degrade gracefully so the fallback fires when no signal is present. -func hermesHome() (string, error) { - if v := os.Getenv("EVERCLI_HERMES_CONFIG_DIR"); v != "" { - return v, nil - } - if v := os.Getenv("HERMES_HOME"); v != "" { - return v, nil - } - if p, ok := probeHermesConfigPathCLI(); ok { - // `hermes config path` prints the config.yaml absolute path; we - // want the parent directory. - return filepath.Dir(p), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", output.IOErr("hermes", "resolve-home", err) - } - return filepath.Join(home, ".hermes"), nil -} - -// probeHermesConfigPathCLI runs `hermes config path` and returns the -// trimmed stdout when it succeeds (one absolute file path per Hermes -// v0.14+ contract). Best-effort: any failure — CLI not on PATH, -// non-zero exit, garbage output — returns ("", false) so the caller -// falls through to the next priority level. We intentionally do NOT -// surface the exec error: Hermes may not be installed yet (Detector -// path) or the user may have explicitly broken it, and the fallback -// is correct in both cases. -func probeHermesConfigPathCLI() (string, bool) { - cmd := exec.Command(hermesCommand(), "config", "path") - out, err := cmd.Output() - if err != nil { - return "", false - } - p := strings.TrimSpace(string(out)) - if p == "" || !filepath.IsAbs(p) { - return "", false - } - return p, true -} +// hermesHome resolves the Hermes home directory using the four-step +// priority chain in core.HermesHome (EVERCLI_HERMES_CONFIG_DIR → +// HERMES_HOME → `hermes config path` → $HOME/.hermes). Delegates so +// internal/importer can call core directly without importing this package. +func hermesHome() (string, error) { return core.HermesHome() } // hermesConfigPath returns the absolute path to Hermes's config.yaml, // resolved via hermesHome's four-step priority chain. @@ -178,6 +119,66 @@ type hermesWriter struct{} func newHermesWriter() *hermesWriter { return &hermesWriter{} } +func (*hermesWriter) Remove(_ context.Context, configPath string) (*RemoveResult, error) { + abs, err := filepath.Abs(configPath) + if err != nil { + return nil, output.IOErr(configPath, "abs-path", err) + } + cfg, exists, err := readHermesConfig(abs) + if err != nil { + return nil, err + } + home := filepath.Dir(abs) + result := &RemoveResult{Platform: PlatformHermes, ConfigPath: abs} + changed := false + if exists { + if memory, ok := cfg["memory"].(map[string]interface{}); ok { + if provider, _ := memory["provider"].(string); provider == "everme" { + delete(memory, "provider") + changed = true + } + } + if servers, ok := cfg["mcp_servers"].(map[string]interface{}); ok { + if _, ok := servers[hermesMcpEntryName]; ok { + delete(servers, hermesMcpEntryName) + changed = true + } + } + } + pluginDir := filepath.Join(home, "plugins", "everme") + envPath := filepath.Join(home, "everme.env") + if _, statErr := os.Stat(pluginDir); statErr == nil { + changed = true + } + if _, statErr := os.Stat(envPath); statErr == nil { + changed = true + } + if !changed { + return result, nil + } + if exists && (cfg != nil) { + // protected=true: the config may still carry a legacy + // mcp_servers entry with a live agent token. + backup, berr := backupFile(abs, true) + if berr != nil { + return nil, berr + } + // Our token is gone from cfg by now, so leave the host's mode alone. + if err := writeHermesConfig(abs, cfg, configHasNoToken); err != nil { + return nil, err + } + result.BackupPath = backup + } + if err := os.RemoveAll(pluginDir); err != nil { + return nil, output.IOErr(pluginDir, "remove-plugin", err) + } + if err := os.Remove(envPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, output.IOErr(envPath, "remove-env", err) + } + result.Removed = true + return result, nil +} + func (*hermesWriter) Platform() Platform { return PlatformHermes } // Plan reads ~/.hermes/config.yaml to decide WillCreate / WillReplace @@ -272,7 +273,9 @@ func (*hermesWriter) Commit(_ context.Context, plan *WritePlan, params WritePara return nil, err } removeLegacyMcpEntry(cfg) - if err := writeHermesConfig(plan.ConfigPath, cfg); err != nil { + // config.yaml only selects the provider; the credential goes to + // everme.env (writeEvermeEnv, 0600). + if err := writeHermesConfig(plan.ConfigPath, cfg, configHasNoToken); err != nil { return nil, err } @@ -384,9 +387,10 @@ func readHermesConfig(path string) (map[string]interface{}, bool, error) { // writeHermesConfig serialises cfg as YAML (2-space indent matching // hermes_cli/mcp_config.py's save_config output) and atomically replaces -// path. Mode inheritance: existing files keep their mode (Hermes writes -// 0600), fresh files land 0600 because they carry a token. -func writeHermesConfig(path string, cfg map[string]interface{}) error { +// path. Mode selection follows configWriteMode; the caller says whether +// cfg holds a credential (config.yaml does not — the token lives in +// everme.env, written separately at 0600). +func writeHermesConfig(path string, cfg map[string]interface{}, secrecy configSecrecy) error { var buf bytes.Buffer enc := yaml.NewEncoder(&buf) enc.SetIndent(2) @@ -398,14 +402,7 @@ func writeHermesConfig(path string, cfg map[string]interface{}) error { return output.Internal(fmt.Errorf("close yaml encoder: %w", err)) } - mode := os.FileMode(0o600) - if info, err := os.Stat(path); err == nil { - mode = info.Mode().Perm() - } - if err := writeFileAtomic(path, buf.Bytes(), mode); err != nil { - return output.IOErr(path, "write-config", err) - } - return nil + return writeConfigFileAtomic(path, buf.Bytes(), secrecy) } // hermesProviderInstalled reports whether the EverMe provider is wired: diff --git a/cli/internal/plugin/hermes_test.go b/cli/internal/plugin/hermes_test.go index 9ab22d6..2e32014 100644 --- a/cli/internal/plugin/hermes_test.go +++ b/cli/internal/plugin/hermes_test.go @@ -44,6 +44,32 @@ func TestHermesDetector_NoConfig_NotInstalled(t *testing.T) { assert.False(t, d.HasEverMeEntry) } +func TestHermesWriter_RemovePreservesSiblingState(t *testing.T) { + dir := t.TempDir() + t.Setenv("EVERCLI_HERMES_CONFIG_DIR", dir) + t.Setenv("HOME", t.TempDir()) + path := filepath.Join(dir, "config.yaml") + body := "memory:\n provider: everme\n keep: true\nmcp_servers:\n everme:\n command: npx\n other:\n command: other\n" + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugins", "everme"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "plugins", "everme", "__init__.py"), []byte("x"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "everme.env"), []byte("EVERME_AGENT_TOKEN=evt_secret\n"), 0o600)) + + res, err := newHermesWriter().Remove(context.Background(), path) + require.NoError(t, err) + assert.True(t, res.Removed) + assert.FileExists(t, res.BackupPath) + cfg, exists, err := readHermesConfig(path) + require.NoError(t, err) + require.True(t, exists) + assert.NotEqual(t, "everme", cfg["memory"].(map[string]interface{})["provider"]) + assert.Equal(t, true, cfg["memory"].(map[string]interface{})["keep"]) + assert.Contains(t, cfg["mcp_servers"].(map[string]interface{}), "other") + assert.NotContains(t, cfg["mcp_servers"].(map[string]interface{}), "everme") + assert.NoDirExists(t, filepath.Join(dir, "plugins", "everme")) + assert.NoFileExists(t, filepath.Join(dir, "everme.env")) +} + // TestHermesDetector_InstalledFromHomeDir confirms that presence of // ~/.hermes/ alone (without a `hermes` CLI on PATH) flags Hermes as // installed. Hermes's installer creates the home dir before linking diff --git a/cli/internal/plugin/hermesassets/everme/__init__.py b/cli/internal/plugin/hermesassets/everme/__init__.py index 7441a47..2727b30 100644 --- a/cli/internal/plugin/hermesassets/everme/__init__.py +++ b/cli/internal/plugin/hermesassets/everme/__init__.py @@ -512,6 +512,38 @@ def _render_search(res: Any) -> str: for fact in it.get("atomicFacts") or []: if fact: lines.append(f" - {fact}") + + # agentMemory.{cases,skills} are the products of this provider's + # /mem/agent-memory writes; surfacing episodes only (the original bug) + # meant recall could never read back the cases/skills those writes + # produced. Field names are the EverMe BFF camelCase shape. + agent_memory = res.get("agentMemory") + if isinstance(agent_memory, dict): + for c in agent_memory.get("cases") or []: + if not isinstance(c, dict): + continue + intent = (c.get("taskIntent") or "").strip() + approach = (c.get("approach") or "").strip() + if not intent and not approach: + continue + if intent: + lines.append(f"- Task: {intent}") + if approach: + lines.append(f" - Approach: {approach}") + for s in agent_memory.get("skills") or []: + if not isinstance(s, dict): + continue + name = (s.get("name") or "").strip() + desc = (s.get("description") or "").strip() + content = (s.get("content") or "").strip() + if not (name or desc or content): + continue + head = f"- Skill: {name}" if name else "- Skill" + if desc: + head += f" — {desc}" + lines.append(head) + if content: + lines.append(f" - {content}") return "\n".join(lines) diff --git a/cli/internal/plugin/hermesassets/tests/test_provider.py b/cli/internal/plugin/hermesassets/tests/test_provider.py index e7c4843..89d0c90 100644 --- a/cli/internal/plugin/hermesassets/tests/test_provider.py +++ b/cli/internal/plugin/hermesassets/tests/test_provider.py @@ -153,6 +153,34 @@ def test_prefetch_renders_atomic_facts(self): self.assertIn("uses Python", out) self.assertIn("prefers dark mode", out) + def test_prefetch_renders_agent_cases_and_skills(self): + # /mem/search returns agentMemory.{cases,skills} next to the episodic + # items; they are the products of this provider's /mem/agent-memory + # writes, so recall has to surface them — rendering episodes only + # (the original bug) meant an agent could never read back the + # cases/skills its own trajectories produced. + p, fc = make_provider() + fc.results["/mem/search"] = { + "items": [{"episode": "ran the build", "atomicFacts": []}], + "agentMemory": { + "cases": [{"taskIntent": "fix a failing test", + "approach": "read the trace, patched the assertion"}], + "skills": [{"name": "debug-pytest-failure", + "description": "triage a failing pytest", + "content": "1. read trace 2. locate assert 3. fix"}], + }, + } + p.queue_prefetch("work") + if p._prefetch_thread: + p._prefetch_thread.join(timeout=3.0) + out = p.prefetch("work") + self.assertIn("ran the build", out) + self.assertIn("fix a failing test", out) + self.assertIn("read the trace", out) + self.assertIn("debug-pytest-failure", out) + self.assertIn("triage a failing pytest", out) + self.assertIn("locate assert", out) + class TestSyncTurn(unittest.TestCase): def _drain(self, p): diff --git a/cli/internal/plugin/hook_writer.go b/cli/internal/plugin/hook_writer.go new file mode 100644 index 0000000..9129c1f --- /dev/null +++ b/cli/internal/plugin/hook_writer.go @@ -0,0 +1,487 @@ +package plugin + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "evercli/internal/output" +) + +type fileSnapshot struct { + Path string + Exists bool + ModTime int64 + Size int64 +} + +type hookSpec struct { + Event string + Entry map[string]interface{} +} + +type nativeHookWriter struct { + platform Platform + hooksPath func(string) string + mergeHooks func(map[string]interface{}) error + // legacyConfigPaths lists config locations this host used to keep, so + // Remove can clean an install made before the host moved. Nil for + // hosts that never moved. Only consulted when Remove was asked for + // canonicalConfigPath — see Remove. + legacyConfigPaths func() []string + // canonicalConfigPath is this host's current config location. + canonicalConfigPath func() (string, error) +} + +func newNativeHookWriter( + platform Platform, + hooksPath func(string) string, + mergeHooks func(map[string]interface{}) error, +) *nativeHookWriter { + return &nativeHookWriter{platform: platform, hooksPath: hooksPath, mergeHooks: mergeHooks} +} + +// withLegacyCleanup enables sweeping older config locations, but only +// when Remove is asked for `canonical`. Keying the sweep off $HOME alone +// would let a Remove of any unrelated path reach into the user's real +// install — which is exactly what happened once. +func (w *nativeHookWriter) withLegacyCleanup(canonical func() (string, error), legacy func() []string) *nativeHookWriter { + w.canonicalConfigPath = canonical + w.legacyConfigPaths = legacy + return w +} + +func (w *nativeHookWriter) Platform() Platform { return w.platform } + +// Remove cleans the requested config and every legacy location this host +// used to keep. Devin's own "copy your config to the new location" prompt +// duplicates the agent token across both, so clearing only the path the +// caller named would leave a live token on disk. +func (w *nativeHookWriter) Remove(ctx context.Context, configPath string) (*RemoveResult, error) { + r, err := w.removeAt(ctx, configPath) + if err != nil { + return nil, err + } + if w.legacyConfigPaths == nil || w.canonicalConfigPath == nil { + return r, nil + } + // Sweep only when this is an uninstall of the host's own config. A + // Remove aimed at some other file must stay inside that file. + canonical, cErr := w.canonicalConfigPath() + if cErr != nil || canonical != configPath { + return r, nil + } + for _, legacy := range w.legacyConfigPaths() { + if legacy == r.ConfigPath { + continue + } + legacyResult, err := w.removeAt(ctx, legacy) + if err != nil { + return nil, err + } + if legacyResult.Removed { + r.Removed = true + } + } + return r, nil +} + +func (w *nativeHookWriter) removeAt(ctx context.Context, configPath string) (*RemoveResult, error) { + r, err := newMCPWriter(w.platform).Remove(ctx, configPath) + if err != nil { + return nil, err + } + envPath := filepath.Join(filepath.Dir(r.ConfigPath), "everme.env") + if _, statErr := os.Stat(envPath); statErr == nil { + if rmErr := os.Remove(envPath); rmErr != nil { + return nil, output.IOErr(envPath, "remove-env", rmErr) + } + r.Removed = true + } else if !errorsIsNotExist(statErr) { + return nil, output.IOErr(envPath, "stat-env", statErr) + } + hp := w.hooksPath(r.ConfigPath) + if hp != r.ConfigPath { + cfg, exists, err := readConfig(hp) + if err != nil { + return nil, err + } + if exists && cfg != nil { + if changed := removeEverMeHooks(cfg); changed { + if _, err := backupFile(hp, false); err != nil { + return nil, err + } + // Hook entries are command lines, never credentials. + if err := writeConfigAtomic(hp, cfg, configHasNoToken); err != nil { + return nil, err + } + r.Removed = true + } + } + } + return r, nil +} + +// removeEverMeHooks strips EverMe-owned hook entries from cfg in place +// and reports whether anything was dropped. It handles both hook-config +// shapes this codebase has ever written: +// +// - map of event → entry array ({"hooks": {"stop": [entries]}}) — the +// shape mergeFlatHooks writes for Cursor and Devin; +// - a flat entry array ({"hooks": [entries]}) — legacy shape kept for +// configs written before the map layout landed. +// +// Ownership is decided by the entry's "command" field containing one of the +// exact npm packages this writer has installed. A generic "everme" substring +// match would delete unrelated user commands such as backup-everme-notes.sh. +func removeEverMeHooks(cfg map[string]interface{}) bool { + changed := false + for _, key := range []string{"hooks", "lifecycleHooks"} { + switch value := cfg[key].(type) { + case map[string]interface{}: + for event, raw := range value { + entries, ok := raw.([]interface{}) + if !ok { + continue + } + if kept, dropped := dropEverMeHookEntries(entries); dropped { + value[event] = kept + changed = true + } + } + case []interface{}: + if kept, dropped := dropEverMeHookEntries(value); dropped { + cfg[key] = kept + changed = true + } + } + } + return changed +} + +// dropEverMeHookEntries filters EverMe-owned rows out of one entry +// array. Non-map entries and entries without a string command are kept +// verbatim — we only ever delete what mergeFlatHooks could have written. +func dropEverMeHookEntries(entries []interface{}) ([]interface{}, bool) { + kept := make([]interface{}, 0, len(entries)) + dropped := false + for _, entry := range entries { + if row, ok := entry.(map[string]interface{}); ok { + if command, ok := row["command"].(string); ok && isManagedEverMeHookCommand(command) { + dropped = true + continue + } + } + kept = append(kept, entry) + } + return kept, dropped +} + +func isManagedEverMeHookCommand(command string) bool { + managedPackages := [...]string{ + "@everme/cursor", + "@everme/devin", + "@everme/windsurf", + } + for _, field := range strings.Fields(strings.ToLower(command)) { + token := strings.Trim(field, `"'`) + for _, packageName := range managedPackages { + if token == packageName || strings.HasPrefix(token, packageName+"@") { + return true + } + } + } + return false +} + +func (w *nativeHookWriter) Plan(ctx context.Context, configPath string) (*WritePlan, error) { + plan, err := newMCPWriter(w.platform).Plan(ctx, configPath) + if err != nil { + return nil, err + } + hooksPath := w.hooksPath(plan.ConfigPath) + if hooksPath != plan.ConfigPath { + cfg, _, err := readConfig(hooksPath) + if err != nil { + return nil, err + } + if cfg == nil { + cfg = map[string]interface{}{} + } + if err := w.mergeHooks(cfg); err != nil { + return nil, invalidHookConfig(hooksPath, err) + } + snapshot, err := captureFileSnapshot(hooksPath) + if err != nil { + return nil, err + } + plan.auxiliaryFiles = append(plan.auxiliaryFiles, snapshot) + } else { + cfg, _, err := readConfig(plan.ConfigPath) + if err != nil { + return nil, err + } + if cfg == nil { + cfg = map[string]interface{}{} + } + if err := w.mergeHooks(cfg); err != nil { + return nil, invalidHookConfig(hooksPath, err) + } + } + + envPath := filepath.Join(filepath.Dir(plan.ConfigPath), "everme.env") + envSnapshot, err := captureFileSnapshot(envPath) + if err != nil { + return nil, err + } + plan.auxiliaryFiles = append(plan.auxiliaryFiles, envSnapshot) + return plan, nil +} + +func (w *nativeHookWriter) Commit(_ context.Context, plan *WritePlan, params WriteParams) (*WriteResult, error) { + if plan == nil { + return nil, output.Internal(fmt.Errorf("nil plan")) + } + primary := fileSnapshot{ + Path: plan.ConfigPath, + Exists: !plan.WillCreate, + ModTime: plan.SnapshotModTime, + Size: plan.SnapshotSize, + } + if err := assertFileSnapshot(primary); err != nil { + return nil, err + } + for _, snapshot := range plan.auxiliaryFiles { + if err := assertFileSnapshot(snapshot); err != nil { + return nil, err + } + } + + envBody, err := buildEnvFileBody(w.platform, params) + if err != nil { + return nil, output.Internal(err) + } + primaryCfg, primaryExists, err := readConfig(plan.ConfigPath) + if err != nil { + return nil, err + } + if primaryCfg == nil { + primaryCfg = map[string]interface{}{} + } + if err := nestedMcpUpsertEntry( + primaryCfg, + claudeCodeServersPath, + mcpEntryName, + buildEntry(params.APIBaseURL, params.AgentID, params.AgentToken), + ); err != nil { + return nil, output.Invalid( + fmt.Sprintf("config at %s has a shape collision under mcp.*: %v", plan.ConfigPath, err), + "Fix the config file's shape manually, then retry install", + ) + } + + hooksPath := w.hooksPath(plan.ConfigPath) + hooksCfg := primaryCfg + hooksExists := primaryExists + if hooksPath != plan.ConfigPath { + hooksCfg, hooksExists, err = readConfig(hooksPath) + if err != nil { + return nil, err + } + if hooksCfg == nil { + hooksCfg = map[string]interface{}{} + } + } + if err := w.mergeHooks(hooksCfg); err != nil { + return nil, invalidHookConfig(hooksPath, err) + } + + wroteBackup := "" + if primaryExists { + // protected=true: the pre-rewrite config may already carry a + // live evt token, so the backup must be 0600 regardless of the + // original file's mode. + wroteBackup, err = backupFile(plan.ConfigPath, true) + if err != nil { + return nil, err + } + } + if hooksPath != plan.ConfigPath && hooksExists { + if _, err := backupFile(hooksPath, false); err != nil { + return nil, err + } + } + envPath := filepath.Join(filepath.Dir(plan.ConfigPath), "everme.env") + if envSnapshot := findSnapshot(plan.auxiliaryFiles, envPath); envSnapshot.Exists { + if _, err := backupFile(envPath, true); err != nil { + return nil, err + } + } + + // The MCP entry upserted into primaryCfg carries the freshly minted + // evt token. hooksCfg is only a separate file here (when it is not, + // the write above already covered it) and holds command lines only. + if err := writeConfigAtomic(plan.ConfigPath, primaryCfg, configCarriesToken); err != nil { + return nil, err + } + if hooksPath != plan.ConfigPath { + if err := writeConfigAtomic(hooksPath, hooksCfg, configHasNoToken); err != nil { + return nil, err + } + } + if err := writeFileAtomic(envPath, []byte(envBody), 0o600); err != nil { + return nil, output.IOErr(envPath, "write-env-file", err) + } + + return &WriteResult{ + Platform: w.platform, + ConfigPath: plan.ConfigPath, + BackupPath: wroteBackup, + WroteNewEntry: !plan.WillReplace, + }, nil +} + +func mergeFlatHooks(cfg map[string]interface{}, owner string, specs []hookSpec) error { + hooks, err := ensureObject(cfg, "hooks") + if err != nil { + return err + } + for _, spec := range specs { + entries, err := eventEntries(hooks, spec.Event) + if err != nil { + return err + } + kept := make([]interface{}, 0, len(entries)+1) + for _, entry := range entries { + row, ok := entry.(map[string]interface{}) + if ok { + if command, ok := row["command"].(string); ok && strings.Contains(command, owner) { + continue + } + } + kept = append(kept, entry) + } + kept = append(kept, cloneMap(spec.Entry)) + hooks[spec.Event] = kept + } + return nil +} + +func ensureObject(parent map[string]interface{}, key string) (map[string]interface{}, error) { + value, present := parent[key] + if !present || value == nil { + object := map[string]interface{}{} + parent[key] = object + return object, nil + } + object, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be an object", key) + } + return object, nil +} + +func eventEntries(hooks map[string]interface{}, event string) ([]interface{}, error) { + value, present := hooks[event] + if !present || value == nil { + return []interface{}{}, nil + } + entries, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf("hooks.%s must be an array", event) + } + return entries, nil +} + +func captureFileSnapshot(path string) (fileSnapshot, error) { + abs, err := filepath.Abs(path) + if err != nil { + return fileSnapshot{}, output.IOErr(path, "abs-path", err) + } + info, err := os.Stat(abs) + if err != nil { + if errorsIsNotExist(err) { + return fileSnapshot{Path: abs}, nil + } + return fileSnapshot{}, output.IOErr(abs, "stat", err) + } + return fileSnapshot{Path: abs, Exists: true, ModTime: info.ModTime().UnixNano(), Size: info.Size()}, nil +} + +func assertFileSnapshot(snapshot fileSnapshot) error { + info, err := os.Stat(snapshot.Path) + if !snapshot.Exists { + if err == nil { + return concurrentFileError(snapshot.Path, "create") + } + if errorsIsNotExist(err) { + return nil + } + return output.IOErr(snapshot.Path, "stat", err) + } + if err != nil { + if errorsIsNotExist(err) { + return concurrentFileError(snapshot.Path, "remove") + } + return output.IOErr(snapshot.Path, "stat", err) + } + if info.ModTime().UnixNano() != snapshot.ModTime || info.Size() != snapshot.Size { + return concurrentFileError(snapshot.Path, "edit") + } + return nil +} + +func concurrentFileError(path, action string) error { + ce := output.IOErr(path, "concurrent-"+action, fmt.Errorf("file changed between Plan and Commit")) + ce.Hint = "Another process changed the file; re-run `evercli plugin install` to re-plan against the latest content" + return ce +} + +func backupFile(path string, protected bool) (string, error) { + body, err := os.ReadFile(path) + if err != nil { + return "", output.IOErr(path, "read-for-backup", err) + } + mode := os.FileMode(0o600) + if !protected { + if info, statErr := os.Stat(path); statErr == nil { + mode = info.Mode().Perm() + } + } + backupPath := path + backupSuffix + if err := writeFileAtomic(backupPath, body, mode); err != nil { + return "", output.IOErr(backupPath, "write-backup", err) + } + return backupPath, nil +} + +func findSnapshot(snapshots []fileSnapshot, path string) fileSnapshot { + for _, snapshot := range snapshots { + if snapshot.Path == path { + return snapshot + } + } + return fileSnapshot{Path: path} +} + +func invalidHookConfig(path string, err error) error { + return output.Invalid( + fmt.Sprintf("hook config at %s has an unsupported shape: %v", path, err), + "Fix the hook config shape manually, then retry install", + ) +} + +func cloneMap(value map[string]interface{}) map[string]interface{} { + copy := make(map[string]interface{}, len(value)) + for key, item := range value { + copy[key] = item + } + return copy +} + +func errorsIsNotExist(err error) bool { + return err != nil && (os.IsNotExist(err) || err == fs.ErrNotExist) +} diff --git a/cli/internal/plugin/hook_writer_test.go b/cli/internal/plugin/hook_writer_test.go new file mode 100644 index 0000000..1f10d5f --- /dev/null +++ b/cli/internal/plugin/hook_writer_test.go @@ -0,0 +1,222 @@ +package plugin + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHookWriter_RejectsAuxiliaryFileChangesBeforeAnyMutation(t *testing.T) { + dir := t.TempDir() + mcpPath := filepath.Join(dir, "mcp.json") + hooksPath := filepath.Join(dir, "hooks.json") + originalMCP := []byte(`{"mcpServers":{"other":{"command":"other-mcp"}}}`) + require.NoError(t, os.WriteFile(mcpPath, originalMCP, 0o600)) + require.NoError(t, os.WriteFile(hooksPath, []byte(`{"version":1,"hooks":{}}`), 0o600)) + + w := newCursorWriter() + plan, err := w.Plan(context.Background(), mcpPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(hooksPath, []byte(`{"version":1,"hooks":{"custom":[]}}`), 0o600)) + + _, err = w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_cursor", + AgentToken: "test-agent-token", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "concurrent") + got, readErr := os.ReadFile(mcpPath) + require.NoError(t, readErr) + assert.JSONEq(t, string(originalMCP), string(got)) + _, statErr := os.Stat(filepath.Join(dir, "everme.env")) + assert.ErrorIs(t, statErr, os.ErrNotExist) +} + +// TestNativeHookWriter_Remove_MapShapeHooks pins the HIGH bug: hooks +// written by mergeFlatHooks live in the map shape +// {"hooks": {event: [entries]}}, but the old removeHooks only handled a +// flat array — so cursor/devin uninstall silently left EverMe hooks +// behind. Remove must delete only EverMe-owned entries (matched on the +// entry's command field), preserve user siblings — including one whose +// unrelated field mentions "everme" — remove everme.env, and leave a +// hooks.json backup. +func TestNativeHookWriter_Remove_MapShapeHooks(t *testing.T) { + cases := []struct { + name string + newWriter func() Writer + event string + evermeCmd string + }{ + {"cursor", newCursorWriter, "sessionStart", "npx -y @everme/cursor@latest hook sessionStart"}, + {"devin", newDevinWriter, "post_cascade_response_with_transcript", "npx -y @everme/devin@latest hook post_cascade_response_with_transcript"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // No writer test may resolve a host path against the real home. + t.Setenv("HOME", t.TempDir()) + dir := t.TempDir() + mcpPath := filepath.Join(dir, "mcp.json") + hooksPath := filepath.Join(dir, "hooks.json") + envPath := filepath.Join(dir, "everme.env") + + require.NoError(t, os.WriteFile(mcpPath, []byte( + `{"mcpServers":{"everme-memory":{"command":"npx"},"other":{"command":"other-mcp"}}}`), 0o600)) + hooksCfg := map[string]interface{}{ + "version": 1, + "hooks": map[string]interface{}{ + tc.event: []interface{}{ + map[string]interface{}{"command": tc.evermeCmd}, + // User hook whose unrelated field mentions everme — + // must survive (no marshal-the-entry substring match). + map[string]interface{}{ + "command": "my-custom-hook.sh", + "notes": "runs alongside everme", + }, + map[string]interface{}{"command": "other-tool hook"}, + }, + }, + } + raw, err := json.Marshal(hooksCfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(hooksPath, raw, 0o600)) + require.NoError(t, os.WriteFile(envPath, []byte("# managed\nEVERME_AGENT_TOKEN=evt_x\n"), 0o600)) + + rm, ok := tc.newWriter().(Remover) + require.True(t, ok, "native hook writer must implement Remover") + res, err := rm.Remove(context.Background(), mcpPath) + require.NoError(t, err) + assert.True(t, res.Removed) + + // mcp.json: everme-memory gone, sibling preserved. + mcpCfg, exists, err := readConfig(mcpPath) + require.NoError(t, err) + require.True(t, exists) + servers, ok := mcpCfg["mcpServers"].(map[string]interface{}) + require.True(t, ok) + assert.NotContains(t, servers, "everme-memory") + assert.Contains(t, servers, "other") + + // hooks.json: EverMe entry gone, both user siblings preserved. + gotHooks, exists, err := readConfig(hooksPath) + require.NoError(t, err) + require.True(t, exists) + commands := hookCommands(t, gotHooks, tc.event) + assert.NotContains(t, commands, tc.evermeCmd) + assert.Contains(t, commands, "my-custom-hook.sh", + "user hook mentioning everme in an unrelated field must survive") + assert.Contains(t, commands, "other-tool hook") + + // everme.env deleted; hooks.json backup left behind. + _, statErr := os.Stat(envPath) + assert.ErrorIs(t, statErr, os.ErrNotExist) + _, bakErr := os.Stat(hooksPath + backupSuffix) + assert.NoError(t, bakErr, "hooks.json must be backed up before the rewrite") + }) + } +} + +// TestNativeHookWriter_Remove_FlatArrayHooks keeps the legacy flat-array +// shape working: entries whose command contains everme are dropped, +// siblings survive verbatim. +func TestNativeHookWriter_Remove_FlatArrayHooks(t *testing.T) { + dir := t.TempDir() + mcpPath := filepath.Join(dir, "mcp.json") + hooksPath := filepath.Join(dir, "hooks.json") + require.NoError(t, os.WriteFile(mcpPath, []byte(`{"mcpServers":{"everme-memory":{}}}`), 0o600)) + require.NoError(t, os.WriteFile(hooksPath, []byte( + `{"hooks":[{"command":"npx -y @everme/cursor@latest hook stop"},{"command":"user-hook"}]}`), 0o600)) + + rm := newCursorWriter().(Remover) + res, err := rm.Remove(context.Background(), mcpPath) + require.NoError(t, err) + assert.True(t, res.Removed) + + got, exists, err := readConfig(hooksPath) + require.NoError(t, err) + require.True(t, exists) + entries, ok := got["hooks"].([]interface{}) + require.True(t, ok, "flat array shape must be preserved") + require.Len(t, entries, 1) + row, ok := entries[0].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "user-hook", row["command"]) +} + +func TestDropEverMeHookEntries_MatchesManagedPackagesOnly(t *testing.T) { + entries := []interface{}{ + map[string]interface{}{"command": "npx -y @everme/cursor@latest hook stop"}, + map[string]interface{}{"command": "npx -y @everme/devin@0.3.4 hook post_cascade_response_with_transcript"}, + map[string]interface{}{"command": "npx -y @everme/windsurf@latest hook post_cascade_response_with_transcript"}, + map[string]interface{}{"command": "/Users/me/bin/backup-everme-notes.sh"}, + map[string]interface{}{"command": "npx -y @everme/cursor-tools@latest hook stop"}, + map[string]interface{}{"command": "user-hook"}, + } + + kept, dropped := dropEverMeHookEntries(entries) + + require.True(t, dropped) + require.Len(t, kept, 3) + commands := make([]string, 0, len(kept)) + for _, entry := range kept { + row, ok := entry.(map[string]interface{}) + require.True(t, ok) + commands = append(commands, row["command"].(string)) + } + assert.Equal(t, []string{ + "/Users/me/bin/backup-everme-notes.sh", + "npx -y @everme/cursor-tools@latest hook stop", + "user-hook", + }, commands) +} + +func hookCommands(t *testing.T, cfg map[string]interface{}, event string) []string { + t.Helper() + hooks, ok := cfg["hooks"].(map[string]interface{}) + require.True(t, ok, "hooks object missing") + eventValue, ok := hooks[event] + require.True(t, ok, "event %s missing", event) + commands := []string{} + collectCommands(eventValue, &commands) + return commands +} + +func countOwnedHookCommands(t *testing.T, cfg map[string]interface{}, event, marker string) int { + t.Helper() + count := 0 + for _, command := range hookCommands(t, cfg, event) { + if strings.Contains(command, marker) { + count++ + } + } + return count +} + +func collectCommands(value interface{}, commands *[]string) { + switch typed := value.(type) { + case []interface{}: + for _, item := range typed { + collectCommands(item, commands) + } + case map[string]interface{}: + if command, ok := typed["command"].(string); ok { + *commands = append(*commands, command) + } + for key, item := range typed { + if key != "command" { + collectCommands(item, commands) + } + } + case json.RawMessage: + var decoded interface{} + if json.Unmarshal(typed, &decoded) == nil { + collectCommands(decoded, commands) + } + } +} diff --git a/cli/internal/plugin/kimicode.go b/cli/internal/plugin/kimicode.go new file mode 100644 index 0000000..1877b1a --- /dev/null +++ b/cli/internal/plugin/kimicode.go @@ -0,0 +1,585 @@ +// Package plugin — Kimi Code support (stage-only footprint, Option 2). +// +// Kimi Code has NO headless install command (no `kimi plugin install`) and +// NO per-user secret injection. Its own `/plugins install` builds a rich, +// internal installed.json record (absolute root, parsed+embedded manifest, +// diagnostics, skillCount, …) via its `recordFrom` builder. A minimal record +// direct-written by evercli passes the top-level `Array.isArray(plugins)` +// check but Kimi Code silently fails to load the plugin (no embedded +// manifest, no absolute root). Reproducing that internal record is too +// brittle, so evercli no longer writes installed.json at all. +// +// Instead evercli OWNS exactly two paths under the Kimi Code home +// (kimicodeHome(): EVERCLI_KIMICODE_CONFIG_DIR > KIMI_CODE_HOME > ~/.kimi-code): +// +// /everme.env ← evt credentials (0600), read by the plugin at runtime +// /everme/ ← recursive copy of the @everme/kimicode bundle, +// INCLUDING node_modules so hooks can resolve +// `@everme/agent-sdk` at runtime +// +// evercli does NOT write /plugins/managed/... and does NOT write +// /plugins/installed.json — those belong to Kimi Code's own +// `/plugins install`. The user finishes registration by running +// `/plugins install /everme` inside Kimi Code (the unavoidable manual +// last step, since Kimi Code ships no headless installer). +// +// Wire model: +// +// detector +// → "Installed" iff dir exists OR `kimi` CLI is on PATH. +// → "HasEverMeEntry" iff /everme.env exists (non-empty token) AND +// /everme/kimi.plugin.json exists — i.e. evercli has staged it. +// +// writer.Plan +// → resolves the bundle source (env override > $(npm root -g)/@everme/kimicode). +// If unresolved, Plan does NOT install (Writer contract: no on-disk side +// effects) — it previews the deferred `npm install -g @everme/kimicode`. +// Snapshots everme.env mtime/size for the TOCTOU check. PreviewEntry +// surfaces the stage dir and the `/plugins install` registration hint. +// +// writer.Commit (after the backend mints a fresh evt) +// 1. assertNoConcurrentChange (TOCTOU guard). +// 2. resolve the bundle source; if it is not on disk, run +// `npm install -g @everme/kimicode` (fail-hard — no bundle, nothing to +// stage). This is the npm step evercli automates for you; the TUI +// `/plugins install` registration below stays manual. +// 3. mkdir (0700). +// 4. recursively copy the bundle into /everme/ (overwrite; skip +// .git only — node_modules IS copied so hooks resolve at runtime). +// 5. if /everme/node_modules is absent (dev source), best-effort +// `npm install --omit=dev`; warn (not fail) if npm is missing/fails. +// 6. write everme.env (0600) via buildEnvFileBody. +// +// writer.Verify +// → everme.env has a non-empty EVERME_AGENT_TOKEN=evt_ and +// /everme/kimi.plugin.json exists. +package plugin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "evercli/internal/output" +) + +// kimiCommand resolves the `kimi` CLI binary. EVERCLI_KIMICODE_CMD lets +// tests point at a stub (or a nonexistent path to neutralize the PATH +// heuristic) without messing with $PATH. Mirrors claudeCommand(). +func kimiCommand() string { + if v := os.Getenv("EVERCLI_KIMICODE_CMD"); v != "" { + return v + } + return "kimi" +} + +// kimicodeHome resolves the Kimi Code home directory. Priority: +// +// EVERCLI_KIMICODE_CONFIG_DIR (test/override) > KIMI_CODE_HOME > ~/.kimi-code +func kimicodeHome() (string, error) { + if dir := os.Getenv("EVERCLI_KIMICODE_CONFIG_DIR"); dir != "" { + return dir, nil + } + if dir := os.Getenv("KIMI_CODE_HOME"); dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", output.IOErr("kimicode", "resolve-home", err) + } + return filepath.Join(home, ".kimi-code"), nil +} + +// kimicodeStageDir returns /everme — the staged bundle directory +// evercli copies the @everme/kimicode bundle into. The user registers it +// with Kimi Code via `/plugins install `. +func kimicodeStageDir(home string) string { + return filepath.Join(home, "everme") +} + +// kimicodeEnvPath returns /everme.env. +func kimicodeEnvPath(home string) string { + return filepath.Join(home, "everme.env") +} + +// ---- detector ------------------------------------------------------ + +type kimiCodeDetector struct{} + +func (kimiCodeDetector) Platform() Platform { return PlatformKimiCode } + +func (kimiCodeDetector) DisplayName() string { return "Kimi Code" } + +func (kimiCodeDetector) Detect(_ context.Context) (*Detection, error) { + home, err := kimicodeHome() + if err != nil { + return &Detection{Platform: PlatformKimiCode, DisplayName: "Kimi Code"}, nil + } + envPath := kimicodeEnvPath(home) + d := &Detection{ + Platform: PlatformKimiCode, + DisplayName: "Kimi Code", + ConfigPath: envPath, + } + + // Dual heuristic: home dir present, or `kimi` on PATH. + if _, statErr := os.Stat(home); statErr == nil { + d.Installed = true + } + if !d.Installed { + if _, lpErr := exec.LookPath(kimiCommand()); lpErr == nil { + d.Installed = true + } + } + + // ConfigExists: evercli has written everme.env. + if _, envErr := os.Stat(envPath); envErr == nil { + d.ConfigExists = true + } + + // Token-gated: everme.env must carry a non-empty token AND the bundle + // must be staged (everme/kimi.plugin.json present). A half-written + // footprint reads as not-configured so install (re)runs rather than skips. + if d.ConfigExists && kimicodeEnvHasToken(envPath) { + manifest := filepath.Join(kimicodeStageDir(home), "kimi.plugin.json") + if _, mErr := os.Stat(manifest); mErr == nil { + d.HasEverMeEntry = true + } + } + return d, nil +} + +// kimicodeEnvHasToken reports whether everme.env carries a non-empty +// EVERME_AGENT_TOKEN=evt_ entry. +func kimicodeEnvHasToken(envPath string) bool { + body, err := os.ReadFile(envPath) + if err != nil { + return false + } + return strings.Contains(string(body), "EVERME_AGENT_TOKEN=evt_") +} + +// ---- writer -------------------------------------------------------- + +// kimiCodeWriter implements Writer + Verifier. No Preparer: there is no +// out-of-band registration step before token mint — the entire footprint +// (everme.env + staged everme/ bundle) is materialized in Commit. The final +// `/plugins install` step is performed by the user inside Kimi Code. +type kimiCodeWriter struct { + // pluginSource lets tests inject a fake bundle. Empty in production → + // resolved at Plan/Commit time via kimicodePluginSource. + pluginSource string +} + +func newKimiCodeWriter() *kimiCodeWriter { return &kimiCodeWriter{} } + +func (*kimiCodeWriter) Remove(_ context.Context, configPath string) (*RemoveResult, error) { + abs, err := filepath.Abs(configPath) + if err != nil { + return nil, output.IOErr(configPath, "abs-path", err) + } + home := filepath.Dir(abs) + stageDir := kimicodeStageDir(home) + result := &RemoveResult{Platform: PlatformKimiCode, ConfigPath: abs} + if _, envErr := os.Stat(abs); envErr != nil && errors.Is(envErr, fs.ErrNotExist) { + if _, dirErr := os.Stat(stageDir); dirErr != nil && errors.Is(dirErr, fs.ErrNotExist) { + return result, nil + } + } else if envErr != nil { + return nil, output.IOErr(abs, "stat-env", envErr) + } + if _, envErr := os.Stat(abs); envErr == nil { + // protected=true: everme.env carries the live agent token. + backup, berr := backupFile(abs, true) + if berr != nil { + return nil, berr + } + result.BackupPath = backup + if err := os.Remove(abs); err != nil { + return nil, output.IOErr(abs, "remove-env", err) + } + } + if err := os.RemoveAll(stageDir); err != nil { + return nil, output.IOErr(stageDir, "remove-plugin", err) + } + result.Removed = true + return result, nil +} + +func (*kimiCodeWriter) Platform() Platform { return PlatformKimiCode } + +func (*kimiCodeWriter) UninstallNextSteps() []string { + return []string{ + "If Kimi Code still lists EverMe, run `/plugins remove everme` in its TUI to unregister the managed plugin entry.", + } +} + +// kimicodePluginSource resolves the @everme/kimicode bundle directory. +// Order of resolution (mirrors claude_code.go's pluginSourceSpec): +// +// test override (struct field) → unit tests +// $EVERCLI_KIMICODE_PLUGIN_SOURCE (env) → override / dev +// `$(npm root -g)/@everme/kimicode` → production: already on disk +// `npm install -g @everme/kimicode` + retry → production: not yet present +// +// Return tuple is (source, resolved, err): +// +// - installIfMissing=false (Plan): never install and never fail — if the +// bundle is unresolved, return ("", false, nil) so the caller can render +// a "would npm-install at Commit" preview (Writer contract: Plan has no +// on-disk side effects). +// - installIfMissing=true (Commit): if the bundle is unresolved, run +// `npm install -g @everme/kimicode` and re-probe. FAIL-HARD on any +// failure — the global package IS the bundle source, so with no bundle +// there is nothing to stage and no meaningful degraded path. +func (w *kimiCodeWriter) kimicodePluginSource(ctx context.Context, installIfMissing bool) (string, bool, error) { + if w.pluginSource != "" { + return w.pluginSource, true, nil + } + if v := os.Getenv("EVERCLI_KIMICODE_PLUGIN_SOURCE"); v != "" { + fmt.Fprintf(os.Stderr, + "warning: using EVERCLI_KIMICODE_PLUGIN_SOURCE override (%q); set this only if you know why\n", + v, + ) + return v, true, nil + } + if p := globalNpmKimicodePath(); p != "" { + return p, true, nil + } + if !installIfMissing { + // Plan path: don't install, but don't fail either — signal + // "would install" so the dry-run preview can describe it. Commit + // runs the actual `npm install -g`. + return "", false, nil + } + if err := ensureNpmKimicodeInstalled(ctx); err != nil { + return "", false, err + } + p := globalNpmKimicodePath() + if p == "" { + return "", false, fmt.Errorf("after `npm install -g @everme/kimicode`, the package is still not resolvable via `npm root -g`; check npm's global prefix") + } + return p, true, nil +} + +// ensureNpmKimicodeInstalled runs `npm install -g @everme/kimicode`, streaming +// output to our stderr so the user sees npm's download/extract progress (5–30s +// on a slow link; a silent CLI looks hung). Uses ctx for cancellation; +// WaitDelay grants a small grace window to flush if ctx is cancelled. Mirrors +// claude_code.go's ensureNpmPluginInstalled. +func ensureNpmKimicodeInstalled(ctx context.Context) error { + npm, err := exec.LookPath("npm") + if err != nil { + return fmt.Errorf("npm not found on PATH — install Node 18+ from nodejs.org or your package manager, then retry: %w", err) + } + fmt.Fprintln(os.Stderr, "Installing @everme/kimicode from npm…") + cmd := exec.CommandContext(ctx, npm, "install", "-g", "@everme/kimicode") + cmd.WaitDelay = 5 * time.Second + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("`npm install -g @everme/kimicode` failed: %w", err) + } + return nil +} + +// globalNpmKimicodePath probes `npm root -g` for a global install of +// @everme/kimicode. Returns "" if npm is missing, errors, or the package +// (with its kimi.plugin.json manifest) is not present. No error is +// surfaced here — the caller treats "" as unresolved. +func globalNpmKimicodePath() string { + npm, err := exec.LookPath("npm") + if err != nil { + return "" + } + cmd := exec.Command(npm, "root", "-g") + out, err := cmd.Output() + if err != nil { + return "" + } + root := strings.TrimSpace(string(out)) + if root == "" { + return "" + } + candidate := filepath.Join(root, "@everme", "kimicode") + if _, mErr := readKimicodeManifest(candidate); mErr != nil { + return "" + } + return candidate +} + +func (w *kimiCodeWriter) Plan(ctx context.Context, configPath string) (*WritePlan, error) { + if configPath == "" { + return nil, output.Invalid("configPath is required", "") + } + abs, err := filepath.Abs(configPath) + if err != nil { + return nil, output.IOErr(configPath, "abs-path", err) + } + + // Resolve the bundle source. Plan must not install — installIfMissing + // is false (Writer contract: Plan has no on-disk side effects). If the + // bundle isn't on disk yet, resolved=false and we preview the deferred + // `npm install -g`; Commit runs it. + source, resolved, srcErr := w.kimicodePluginSource(ctx, false) + if srcErr != nil { + return nil, srcErr + } + + home := filepath.Dir(abs) // /everme.env → + stageDir := kimicodeStageDir(home) + + plan := &WritePlan{Platform: PlatformKimiCode, ConfigPath: abs} + + // Snapshot the everme.env file for the TOCTOU check. + if info, statErr := os.Stat(abs); statErr == nil { + plan.SnapshotModTime = info.ModTime().UnixNano() + plan.SnapshotSize = info.Size() + plan.WillReplace = true + } else { + plan.WillCreate = true + } + + previewSource := source + if !resolved { + previewSource = "" + } + plan.PreviewEntry = map[string]interface{}{ + "pluginSource": previewSource, + "envFile": abs, + "stageDir": stageDir, + "registerHint": fmt.Sprintf("in Kimi Code, run `/plugins install %s` to finish registration", stageDir), + "agentId": "agt_", + "agentToken": "evt_", + } + return plan, nil +} + +func (w *kimiCodeWriter) Commit(ctx context.Context, plan *WritePlan, params WriteParams) (*WriteResult, error) { + if plan == nil { + return nil, output.Internal(fmt.Errorf("nil plan")) + } + // 1. TOCTOU guard. + if err := assertNoConcurrentChange(plan); err != nil { + return nil, err + } + + home := filepath.Dir(plan.ConfigPath) // /everme.env → + + // 2. Re-resolve the bundle source. Plan deliberately skipped the npm + // install (Writer contract), so installIfMissing=true here may be the + // call that actually runs `npm install -g @everme/kimicode`. FAIL-HARD: + // with no bundle there is nothing to stage. The resolved bool is unused + // — Commit surfaces an error rather than degrading. + source, _, srcErr := w.kimicodePluginSource(ctx, true) + if srcErr != nil { + ce := output.IOErr("@everme/kimicode", "resolve-plugin-source", srcErr) + ce.Hint = "ensure `npm` is on your PATH and you can reach the public npm registry, or set EVERCLI_KIMICODE_PLUGIN_SOURCE to the bundle directory" + return nil, ce + } + + // 3. Ensure (0700, token-bearing). + if err := os.MkdirAll(home, 0o700); err != nil { + return nil, output.IOErr(home, "mkdir-home", err) + } + + // 4. Copy the bundle into /everme/ (overwrite to target version). + // The dest is wiped first, then copied. .git is skipped; node_modules + // IS copied so the hooks can resolve @everme/agent-sdk at runtime. + stageDir := kimicodeStageDir(home) + if err := copyTreeAtomic(source, stageDir); err != nil { + return nil, err + } + + // 5. If the staged bundle has no node_modules (dev source without deps), + // best-effort `npm install --omit=dev`. Never fail the whole Commit: + // creds + bundle are still written; the user can npm install manually. + var warnings []string + if _, nmErr := os.Stat(filepath.Join(stageDir, "node_modules")); os.IsNotExist(nmErr) { + if w := installKimicodeDeps(stageDir); w != "" { + warnings = append(warnings, w) + } + } + + // 6. Write everme.env (0600) via the shared env-file formatter. + body, err := buildEnvFileBody(PlatformKimiCode, params) + if err != nil { + return nil, output.Internal(err) + } + if err := writeFileAtomic(kimicodeEnvPath(home), []byte(body), 0o600); err != nil { + return nil, output.IOErr(kimicodeEnvPath(home), "write-env-file", err) + } + + for _, msg := range warnings { + fmt.Fprintf(os.Stderr, "warning: %s\n", msg) + } + + return &WriteResult{ + Platform: PlatformKimiCode, + ConfigPath: plan.ConfigPath, + WroteNewEntry: !plan.WillReplace, + // evercli only staged the bundle + creds; Kimi Code has no headless + // install command, so registration is the user's manual last step. + NextSteps: []string{ + fmt.Sprintf("in the Kimi Code TUI, run `/plugins install %s` to register (no headless install)", stageDir), + }, + }, nil +} + +// installKimicodeDeps runs `npm install --omit=dev --no-audit --no-fund` in +// stageDir to populate node_modules for a dev source that shipped without +// deps. It NEVER fails the caller: it returns a non-empty warning string if +// npm is missing or the install fails (creds + bundle are still written; +// the user can run npm install manually), or "" on success. +func installKimicodeDeps(stageDir string) string { + npm, err := exec.LookPath("npm") + if err != nil { + return fmt.Sprintf("npm not found; %s/node_modules not installed — run `npm install --omit=dev` there manually so the plugin hooks resolve", stageDir) + } + cmd := exec.Command(npm, "install", "--omit=dev", "--no-audit", "--no-fund") + cmd.Dir = stageDir + if out, runErr := cmd.CombinedOutput(); runErr != nil { + return fmt.Sprintf("npm install in %s failed (%v); run it manually so the plugin hooks resolve: %s", stageDir, runErr, strings.TrimSpace(string(out))) + } + return "" +} + +// Verify re-reads on-disk state: everme.env carries a non-empty token and +// the staged bundle manifest exists. +func (w *kimiCodeWriter) Verify(_ context.Context, result *WriteResult) error { + if result == nil { + return output.Internal(fmt.Errorf("nil result")) + } + home := filepath.Dir(result.ConfigPath) // /everme.env → + + envPath := kimicodeEnvPath(home) + envBody, err := os.ReadFile(envPath) + if err != nil { + return output.IOErr(envPath, "verify", err) + } + if !strings.Contains(string(envBody), "EVERME_AGENT_TOKEN=evt_") { + return output.IOErr(envPath, "verify", + fmt.Errorf("everme.env has no agent token")) + } + + manifest := filepath.Join(kimicodeStageDir(home), "kimi.plugin.json") + if _, err := os.Stat(manifest); err != nil { + return output.IOErr(manifest, "verify", fmt.Errorf("staged bundle manifest missing after Commit")) + } + return nil +} + +// ---- bundle manifest ----------------------------------------------- + +// kimicodeManifest is the subset of kimi.plugin.json we read. +type kimicodeManifest struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// readKimicodeManifest reads the bundle manifest, trying kimi.plugin.json +// at the bundle root first, then .kimi-plugin/plugin.json. Returns an +// error if neither is present/parseable. Used by globalNpmKimicodePath to +// confirm a global install really is the @everme/kimicode bundle. +func readKimicodeManifest(bundle string) (*kimicodeManifest, error) { + candidates := []string{ + filepath.Join(bundle, "kimi.plugin.json"), + filepath.Join(bundle, ".kimi-plugin", "plugin.json"), + } + var lastErr error + for _, p := range candidates { + raw, err := os.ReadFile(p) + if err != nil { + lastErr = err + continue + } + var m kimicodeManifest + if err := json.Unmarshal(raw, &m); err != nil { + lastErr = err + continue + } + return &m, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("no manifest found") + } + return nil, lastErr +} + +// ---- recursive copy ------------------------------------------------ + +// copyTreeAtomic recursively copies src into dest, creating dirs at 0755 +// and files at 0644 (written atomically via writeFileAtomic). dest is wiped +// first so a re-install fully replaces the prior staged bundle (no stale +// files linger). .git directories are skipped; node_modules IS copied so +// the plugin hooks can resolve their runtime deps from the staged tree. +func copyTreeAtomic(src, dest string) error { + srcInfo, err := os.Stat(src) + if err != nil { + return output.IOErr(src, "stat-bundle", err) + } + if !srcInfo.IsDir() { + return output.Invalid( + fmt.Sprintf("plugin bundle source %s is not a directory", src), + "EVERCLI_KIMICODE_PLUGIN_SOURCE must point at the @everme/kimicode bundle directory") + } + + // Wipe dest first so a re-install fully replaces the prior bundle. + if err := os.RemoveAll(dest); err != nil { + return output.IOErr(dest, "rm-dest", err) + } + + return filepath.WalkDir(src, func(path string, dEntry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return output.IOErr(path, "walk-bundle", walkErr) + } + rel, err := filepath.Rel(src, path) + if err != nil { + return output.IOErr(path, "rel-bundle", err) + } + if rel == "." { + if err := os.MkdirAll(dest, 0o755); err != nil { + return output.IOErr(dest, "mkdir-dest", err) + } + return nil + } + + base := dEntry.Name() + if dEntry.IsDir() { + // Skip .git only — node_modules IS copied (hooks need deps). + if base == ".git" { + return fs.SkipDir + } + target := filepath.Join(dest, rel) + if err := os.MkdirAll(target, 0o755); err != nil { + return output.IOErr(target, "mkdir-dest", err) + } + return nil + } + + // Skip symlinks and other non-regular files defensively. + if !dEntry.Type().IsRegular() { + return nil + } + + body, err := os.ReadFile(path) + if err != nil { + return output.IOErr(path, "read-bundle-file", err) + } + target := filepath.Join(dest, rel) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return output.IOErr(filepath.Dir(target), "mkdir-dest", err) + } + if err := writeFileAtomic(target, body, 0o644); err != nil { + return output.IOErr(target, "write-bundle-file", err) + } + return nil + }) +} diff --git a/cli/internal/plugin/kimicode_test.go b/cli/internal/plugin/kimicode_test.go new file mode 100644 index 0000000..8be0eed --- /dev/null +++ b/cli/internal/plugin/kimicode_test.go @@ -0,0 +1,363 @@ +package plugin + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestKimicodeEnvFileHeaderNamesPlatform locks the fix for the everme.env +// header naming the wrong host: a kimicode install must reference kimicode / +// Kimi Code, never claude-code. +func TestKimicodeEnvFileHeaderNamesPlatform(t *testing.T) { + body, err := buildEnvFileBody(PlatformKimiCode, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_abc", + AgentToken: "evt_xyz", + }) + require.NoError(t, err) + assert.Contains(t, body, "evercli plugin install kimicode") + assert.Contains(t, body, "/plugins remove everme") // Kimi Code uninstall + assert.NotContains(t, body, "claude-code") + assert.NotContains(t, body, "claude plugin uninstall") +} + +func TestKimiCodeWriter_RemoveCleansEverMeOwnedFiles(t *testing.T) { + home := t.TempDir() + envPath := filepath.Join(home, "everme.env") + stage := filepath.Join(home, "everme") + require.NoError(t, os.WriteFile(envPath, []byte("EVERME_AGENT_TOKEN=evt_secret\n"), 0o600)) + require.NoError(t, os.MkdirAll(stage, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(stage, "kimi.plugin.json"), []byte("{}"), 0o600)) + res, err := newKimiCodeWriter().Remove(context.Background(), envPath) + require.NoError(t, err) + assert.True(t, res.Removed) + assert.FileExists(t, res.BackupPath) + assert.NoFileExists(t, envPath) + assert.NoDirExists(t, stage) +} + +func TestKimiCodeWriter_UninstallNextStepsExplainManagedRegistry(t *testing.T) { + steps := newKimiCodeWriter().UninstallNextSteps() + require.Len(t, steps, 1) + assert.Contains(t, steps[0], "/plugins remove everme") +} + +// TestKimicodePluginSource_PriorityChain locks the resolution order of +// kimicodePluginSource, which now mirrors claude-code's pluginSourceSpec: +// +// 1. struct-injected pluginSource (test-only) +// 2. EVERCLI_KIMICODE_PLUGIN_SOURCE env override +// 3. globalNpmKimicodePath() — probe `npm root -g`/@everme/kimicode +// 4. ensureNpmKimicodeInstalled() — `npm install -g @everme/kimicode`, retry probe +// +// Layers 3/4 need a working npm and are exercised by end-to-end install +// verification. The unit tests below cover layers 1 and 2 plus the +// npm-missing fail-hard path and the Plan "would install" preview path — +// enough to catch priority-chain regressions without a real npm registry. +func TestKimicodePluginSource_PriorityChain(t *testing.T) { + // (1) Struct injection wins over env. + t.Run("structInjectionBeatsEnv", func(t *testing.T) { + t.Setenv("EVERCLI_KIMICODE_PLUGIN_SOURCE", "/abs/from/env") + w := &kimiCodeWriter{pluginSource: "/abs/from/struct"} + got, resolved, err := w.kimicodePluginSource(context.Background(), false) + require.NoError(t, err) + assert.Equal(t, "/abs/from/struct", got) + assert.True(t, resolved) + }) + + // (2) Env override wins over the npm probe. + t.Run("envBeatsNpmProbe", func(t *testing.T) { + t.Setenv("EVERCLI_KIMICODE_PLUGIN_SOURCE", "/abs/from/env") + w := &kimiCodeWriter{} + got, resolved, err := w.kimicodePluginSource(context.Background(), false) + require.NoError(t, err) + assert.Equal(t, "/abs/from/env", got) + assert.True(t, resolved) + }) + + // (3) Commit path (installIfMissing=true) with no env and no npm on PATH: + // FAIL-HARD with a clear npm error rather than a silent degrade. The + // global package is the bundle source; with no bundle there is nothing + // to stage. + t.Run("missingNpmIsErroredHard", func(t *testing.T) { + t.Setenv("EVERCLI_KIMICODE_PLUGIN_SOURCE", "") + t.Setenv("PATH", "") + w := &kimiCodeWriter{} + _, _, err := w.kimicodePluginSource(context.Background(), true) + require.Error(t, err) + assert.Contains(t, err.Error(), "npm") + }) + + // (4) Plan path (installIfMissing=false) with no env and no npm returns + // ("", false, nil) — the caller surfaces a "would install" preview + // rather than aborting Plan (Writer contract: Plan has no side effects). + t.Run("planSkipsInstallAndReturnsUnresolved", func(t *testing.T) { + t.Setenv("EVERCLI_KIMICODE_PLUGIN_SOURCE", "") + t.Setenv("PATH", "") + w := &kimiCodeWriter{} + got, resolved, err := w.kimicodePluginSource(context.Background(), false) + require.NoError(t, err) + assert.Equal(t, "", got) + assert.False(t, resolved) + }) +} + +// neutralizeKimicodeEnv points the config-dir override at a fresh empty +// temp dir and pins KIMI_CODE_HOME / EVERCLI_KIMICODE_CMD at nonexistent +// paths so a dev machine with a real Kimi Code install can't leak a true +// "installed" signal into the not-installed tests. Returns the home dir. +func neutralizeKimicodeEnv(t *testing.T) string { + t.Helper() + home := filepath.Join(t.TempDir(), "kimi-home") + t.Setenv("EVERCLI_KIMICODE_CONFIG_DIR", home) + t.Setenv("KIMI_CODE_HOME", filepath.Join(t.TempDir(), "nonexistent-kimi")) + // EVERCLI_KIMICODE_CMD points kimiCommand() at a binary name that + // exec.LookPath will never resolve, so the PATH heuristic is neutral. + t.Setenv("EVERCLI_KIMICODE_CMD", filepath.Join(t.TempDir(), "no-such-kimi")) + t.Setenv("HOME", t.TempDir()) + return home +} + +// writeFakeBundle creates a minimal Kimi Code plugin bundle on disk with a +// manifest, one nested hooks file, a node_modules dir (so the copy includes +// it and the npm-install branch is skipped — no network in tests), and a +// .git dir (which must be skipped). Points EVERCLI_KIMICODE_PLUGIN_SOURCE at +// it. Returns the bundle dir. +func writeFakeBundle(t *testing.T) string { + t.Helper() + bundle := filepath.Join(t.TempDir(), "bundle") + require.NoError(t, os.MkdirAll(filepath.Join(bundle, "hooks", "scripts"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(bundle, "kimi.plugin.json"), + []byte(`{"name":"everme","version":"0.1.0"}`), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(bundle, "hooks", "scripts", "x.mjs"), + []byte("export const x = 1;\n"), 0o644)) + // node_modules IS copied (hooks need runtime deps) and its presence + // makes Commit skip the npm-install branch — so tests never hit the + // network. + require.NoError(t, os.MkdirAll(filepath.Join(bundle, "node_modules", "junk"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(bundle, "node_modules", "junk", "a.js"), []byte("x"), 0o644)) + // .git must be skipped by the recursive copy. + require.NoError(t, os.MkdirAll(filepath.Join(bundle, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(bundle, ".git", "HEAD"), []byte("ref"), 0o644)) + t.Setenv("EVERCLI_KIMICODE_PLUGIN_SOURCE", bundle) + return bundle +} + +// ---- detector ------------------------------------------------------- + +func TestKimiCodeDetector_NotInstalled(t *testing.T) { + neutralizeKimicodeEnv(t) + t.Setenv("PATH", t.TempDir()) // no kimi on PATH + + d, err := kimiCodeDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.Equal(t, PlatformKimiCode, d.Platform) + assert.Equal(t, "Kimi Code", d.DisplayName) + assert.False(t, d.Installed, "empty home + no kimi CLI => not installed") + assert.False(t, d.ConfigExists) + assert.False(t, d.HasEverMeEntry) + // ConfigPath now points at /everme.env. + assert.Equal(t, "everme.env", filepath.Base(d.ConfigPath)) +} + +func TestKimiCodeDetector_HomeExistsNoEverme(t *testing.T) { + home := neutralizeKimicodeEnv(t) + t.Setenv("PATH", t.TempDir()) + require.NoError(t, os.MkdirAll(home, 0o700)) // home dir exists + + d, err := kimiCodeDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.True(t, d.Installed, "home dir exists => installed") + assert.False(t, d.ConfigExists) + assert.False(t, d.HasEverMeEntry) +} + +func TestKimiCodeDetector_HasEverMeEntryAfterCommit(t *testing.T) { + home := neutralizeKimicodeEnv(t) + t.Setenv("PATH", t.TempDir()) + writeFakeBundle(t) + + w := newKimiCodeWriter() + plan, err := w.Plan(context.Background(), kimicodeEnvPath(home)) + require.NoError(t, err) + _, err = w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_kc", + AgentToken: "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + require.NoError(t, err) + + d, err := kimiCodeDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.True(t, d.Installed) + assert.True(t, d.ConfigExists) + assert.True(t, d.HasEverMeEntry, "after Commit everme.env + staged bundle exist") +} + +// ---- writer --------------------------------------------------------- + +func TestKimiCodeWriter_PlanCommitRoundTrip(t *testing.T) { + home := neutralizeKimicodeEnv(t) + writeFakeBundle(t) + + w := newKimiCodeWriter() + plan, err := w.Plan(context.Background(), kimicodeEnvPath(home)) + require.NoError(t, err) + assert.True(t, plan.WillCreate, "fresh home => everme.env will be created") + // PreviewEntry surfaces the stage dir + register hint. + assert.Equal(t, filepath.Join(home, "everme"), plan.PreviewEntry["stageDir"]) + assert.Contains(t, plan.PreviewEntry["registerHint"], "/plugins install") + + res, err := w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_kc", + AgentToken: "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + require.NoError(t, err) + assert.True(t, res.WroteNewEntry) + + stage := filepath.Join(home, "everme") + + // Manifest copied into the stage dir. + _, statErr := os.Stat(filepath.Join(stage, "kimi.plugin.json")) + require.NoError(t, statErr, "kimi.plugin.json must be copied") + + // Nested hooks file copied. + _, statErr = os.Stat(filepath.Join(stage, "hooks", "scripts", "x.mjs")) + require.NoError(t, statErr, "nested hooks file must be copied") + + // node_modules IS copied (hooks need runtime deps). + _, statErr = os.Stat(filepath.Join(stage, "node_modules", "junk", "a.js")) + require.NoError(t, statErr, "node_modules must be copied") + + // .git skipped. + _, statErr = os.Stat(filepath.Join(stage, ".git")) + assert.Error(t, statErr, ".git must be skipped") + + // everme.env carries the token, mode 0600. + envBody, rerr := os.ReadFile(filepath.Join(home, "everme.env")) + require.NoError(t, rerr) + assert.Contains(t, string(envBody), "EVERME_AGENT_TOKEN=evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + info, _ := os.Stat(filepath.Join(home, "everme.env")) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + // evercli must NOT write installed.json or plugins/managed anymore. + _, statErr = os.Stat(filepath.Join(home, "plugins", "installed.json")) + assert.Error(t, statErr, "evercli must not write installed.json") + _, statErr = os.Stat(filepath.Join(home, "plugins", "managed")) + assert.Error(t, statErr, "evercli must not write plugins/managed") +} + +// Commit must return a NextSteps entry telling the user to finish +// registration inside the Kimi Code TUI — evercli only stages the bundle; +// there is no headless install/register command. +func TestKimiCodeWriter_CommitReturnsRegisterNextStep(t *testing.T) { + home := neutralizeKimicodeEnv(t) + writeFakeBundle(t) + + w := newKimiCodeWriter() + plan, err := w.Plan(context.Background(), kimicodeEnvPath(home)) + require.NoError(t, err) + res, err := w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_kc", + AgentToken: "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + require.NoError(t, err) + + stageDir := filepath.Join(home, "everme") + require.NotEmpty(t, res.NextSteps, "Commit must return a register next-step") + joined := strings.Join(res.NextSteps, "\n") + assert.Contains(t, joined, "/plugins install "+stageDir, "must name the exact TUI register command with the staged dir") + assert.Contains(t, joined, "no headless install", "must clarify why registration is manual") +} + +// A re-install (existing everme.env) replaces the staged bundle cleanly and +// reports WroteNewEntry=false. +func TestKimiCodeWriter_ReinstallReplaces(t *testing.T) { + home := neutralizeKimicodeEnv(t) + writeFakeBundle(t) + envPath := kimicodeEnvPath(home) + + w := newKimiCodeWriter() + + // First install. + plan, err := w.Plan(context.Background(), envPath) + require.NoError(t, err) + _, err = w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_kc", + AgentToken: "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + require.NoError(t, err) + + // Drop a stale file into the staged dir; the wipe-then-copy must remove it. + stale := filepath.Join(home, "everme", "stale.txt") + require.NoError(t, os.WriteFile(stale, []byte("old"), 0o644)) + + // Second install over the existing footprint. + plan2, err := w.Plan(context.Background(), envPath) + require.NoError(t, err) + assert.True(t, plan2.WillReplace, "pre-existing everme.env => replace") + res2, err := w.Commit(context.Background(), plan2, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_kc", + AgentToken: "evt_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }) + require.NoError(t, err) + assert.False(t, res2.WroteNewEntry, "re-install over existing env => not a new entry") + + _, statErr := os.Stat(stale) + assert.Error(t, statErr, "stale file must be wiped on re-install") +} + +// ---- verify --------------------------------------------------------- + +func TestKimiCodeWriter_VerifyAfterCommit(t *testing.T) { + home := neutralizeKimicodeEnv(t) + writeFakeBundle(t) + + w := newKimiCodeWriter() + plan, err := w.Plan(context.Background(), kimicodeEnvPath(home)) + require.NoError(t, err) + res, err := w.Commit(context.Background(), plan, WriteParams{ + APIBaseURL: "https://api.everme.evermind.ai", + AgentID: "agt_kc", + AgentToken: "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + require.NoError(t, err) + + require.NoError(t, w.Verify(context.Background(), res), "Verify must pass after a successful Commit") +} + +func TestKimiCodeWriter_VerifyFailsOnFreshDir(t *testing.T) { + home := neutralizeKimicodeEnv(t) + w := newKimiCodeWriter() + err := w.Verify(context.Background(), &WriteResult{ + Platform: PlatformKimiCode, + ConfigPath: kimicodeEnvPath(home), + }) + assert.Error(t, err, "Verify must fail when nothing is installed") +} + +// Plan must NOT fail when no bundle source resolves: it previews the +// deferred `npm install -g @everme/kimicode` (run at Commit) rather than +// aborting. Mirrors claude-code's Plan preview. +func TestKimiCodeWriter_PlanPreviewsInstallWithoutBundleSource(t *testing.T) { + home := neutralizeKimicodeEnv(t) + t.Setenv("EVERCLI_KIMICODE_PLUGIN_SOURCE", "") + t.Setenv("PATH", t.TempDir()) // no npm => npm root -g unresolvable + + w := newKimiCodeWriter() + plan, err := w.Plan(context.Background(), kimicodeEnvPath(home)) + require.NoError(t, err, "Plan must preview the install, not fail, when the bundle is unresolved") + assert.Equal(t, "", plan.PreviewEntry["pluginSource"]) +} diff --git a/cli/internal/plugin/mcp.go b/cli/internal/plugin/mcp.go index 11eb806..45d322c 100644 --- a/cli/internal/plugin/mcp.go +++ b/cli/internal/plugin/mcp.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "os" "path/filepath" @@ -26,10 +27,10 @@ const mcpEntryName = "everme-memory" const backupSuffix = "-bak" // mcpWriter is the Writer implementation for hosts whose plugin slot is -// a top-level `mcpServers.` JSON map. Cursor, Claude Desktop, and -// Gemini CLI all share this exact shape, so they reuse this writer with -// only a host-specific config path (see cursor.go / claude_desktop.go / -// gemini.go). OpenClaw moved to an in-process context-engine plugin and +// a top-level `mcpServers.` JSON map. Cursor and Claude Desktop +// share this exact shape, so they reuse this writer with only a +// host-specific config path (see cursor.go / claude_desktop.go). +// OpenClaw moved to an in-process context-engine plugin and // owns its own writer in openclaw.go. The path is parameterised so a // future MCP-style host can drop in with just a new path constant. type mcpWriter struct { @@ -143,7 +144,8 @@ func (m *mcpWriter) Commit(_ context.Context, plan *WritePlan, params WriteParam ) } - if err := writeConfigAtomic(plan.ConfigPath, cfg); err != nil { + // The entry just upserted carries the freshly minted evt token. + if err := writeConfigAtomic(plan.ConfigPath, cfg, configCarriesToken); err != nil { return nil, err } @@ -155,6 +157,57 @@ func (m *mcpWriter) Commit(_ context.Context, plan *WritePlan, params WriteParam }, nil } +// Remove deletes only EverMe's MCP entry and preserves every sibling entry. +// Missing files and missing entries are intentionally idempotent. +func (m *mcpWriter) Remove(_ context.Context, configPath string) (*RemoveResult, error) { + abs, err := filepath.Abs(configPath) + if err != nil { + return nil, output.IOErr(configPath, "abs-path", err) + } + cfg, exists, err := readConfig(abs) + if err != nil { + return nil, err + } + result := &RemoveResult{Platform: m.platform, ConfigPath: abs} + if !exists || !nestedMcpServersHasEntry(cfg, m.serversPath, mcpEntryName) { + return result, nil + } + // protected=true: the entry being removed carries the live agent + // token, so the backup must be 0600 regardless of the config's mode. + backup, err := backupFile(abs, true) + if err != nil { + return nil, err + } + nestedMcpDeleteEntry(cfg, m.serversPath, mcpEntryName) + // Our token is gone from cfg by now, so leave the host's mode alone. + if err := writeConfigAtomic(abs, cfg, configHasNoToken); err != nil { + return nil, err + } + result.BackupPath, result.Removed = backup, true + return result, nil +} + +func nestedMcpDeleteEntry(cfg map[string]interface{}, path []string, name string) { + cur := cfg + parents := make([]map[string]interface{}, 0, len(path)) + for _, k := range path { + parents = append(parents, cur) + n, ok := cur[k].(map[string]interface{}) + if !ok { + return + } + cur = n + } + delete(cur, name) + for i := len(path) - 1; i >= 0; i-- { + if len(cur) > 0 { + return + } + delete(parents[i], path[i]) + cur = parents[i] + } +} + // ---- helpers --------------------------------------------------------- // readConfig parses the JSON config at path. Returns (cfg, exists, err). @@ -180,25 +233,74 @@ func readConfig(path string) (map[string]interface{}, bool, error) { return cfg, true, nil } -// writeConfigAtomic writes cfg to path via .tmp + rename, forcing 0600 -// because the file carries a freshly-minted evt token. We deliberately -// tighten a pre-existing 0644 ~/.claude.json — a world-readable token -// is the worse surprise. +// configNoticeWriter is where writeConfigFileAtomic explains a +// permission change. stderr in production (stdout is the AI-Agent +// envelope); the indirection point lets tests read the notice without +// swapping the process-wide os.Stderr out from under other goroutines. +var configNoticeWriter io.Writer = os.Stderr + +// configSecrecy states whether the file a writer is about to rewrite +// stores an EverMe agent token. It is a named type rather than a bare +// bool so every call site has to answer the question explicitly: an +// implicit "inherit whatever mode the host used" default is what let a +// live evt token sit in a world-readable ~/.raven/config.json. +type configSecrecy bool + +const ( + configCarriesToken configSecrecy = true + configHasNoToken configSecrecy = false +) + +// configWriteMode returns the mode a config rewrite must use. // -// On rename failure we delete the orphaned .tmp — it would otherwise -// linger on disk containing the freshly minted evt token. -func writeConfigAtomic(path string, cfg map[string]interface{}) error { - raw, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return output.Internal(fmt.Errorf("marshal config: %w", err)) +// A file that stores a token is always 0600, even when the host created +// it wider — Raven creates config.json at 0644 and that file is its only +// credential store, so inheriting made the token world-readable. Files +// that carry no credential keep the host's mode: tightening a user's +// unrelated config is a surprise we have no reason to spring. Fresh +// files default to 0600 either way. +func configWriteMode(path string, secrecy configSecrecy) os.FileMode { + if secrecy == configCarriesToken { + return 0o600 } + if info, err := os.Stat(path); err == nil { + return info.Mode().Perm() + } + return 0o600 +} - if err := writeFileAtomic(path, raw, 0o600); err != nil { +// writeConfigFileAtomic writes an already-serialised config to path at +// the mode configWriteMode picks. Every host config writer (JSON, TOML, +// YAML) goes through here so the credential-mode rule has one home. +// +// Narrowing a file the host created is a permission change the user did +// not ask for, so it gets exactly one line of explanation on stderr — +// never the token itself. +// +// On rename failure writeFileAtomic deletes the orphaned .tmp; it would +// otherwise linger on disk containing the freshly minted evt token. +func writeConfigFileAtomic(path string, raw []byte, secrecy configSecrecy) error { + mode := configWriteMode(path, secrecy) + if info, statErr := os.Stat(path); statErr == nil && info.Mode().Perm()&0o077 != 0 && mode == 0o600 { + fmt.Fprintf(configNoticeWriter, + "note: tightened %s to 0600 — it stores an EverMe agent token\n", path) + } + if err := writeFileAtomic(path, raw, mode); err != nil { return output.IOErr(path, "write-config", err) } return nil } +// writeConfigAtomic serialises cfg as indented JSON and writes it via +// writeConfigFileAtomic. +func writeConfigAtomic(path string, cfg map[string]interface{}, secrecy configSecrecy) error { + raw, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return output.Internal(fmt.Errorf("marshal config: %w", err)) + } + return writeConfigFileAtomic(path, raw, secrecy) +} + // writeFileAtomic writes `body` to `path` via .tmp + fsync + rename, // applying `mode` on creation. The token-bearing files this powers // (~/.claude/everme.env, ~/.claude.json, ~/.openclaw/openclaw.json) @@ -379,7 +481,7 @@ func ensureServersMap(cfg map[string]interface{}, path []string) (map[string]int func buildEntry(apiBaseURL, agentID, agentToken string) map[string]interface{} { return map[string]interface{}{ "command": npxCommand(), - "args": []interface{}{"-y", "@everme/memory-mcp"}, + "args": []interface{}{"-y", "@everme/memory-mcp@latest"}, "env": map[string]interface{}{ "EVERME_API_BASE": apiBaseURL, "EVERME_AGENT_ID": agentID, diff --git a/cli/internal/plugin/openclaw.go b/cli/internal/plugin/openclaw.go index 500943c..06c9468 100644 --- a/cli/internal/plugin/openclaw.go +++ b/cli/internal/plugin/openclaw.go @@ -105,6 +105,63 @@ func newOpenClawWriter() *openclawWriter { return &openclawWriter{} } func (*openclawWriter) Platform() Platform { return PlatformOpenClaw } +func (*openclawWriter) Remove(_ context.Context, configPath string) (*RemoveResult, error) { + abs, err := filepath.Abs(configPath) + if err != nil { + return nil, output.IOErr(configPath, "abs-path", err) + } + cfg, exists, err := readConfig(abs) + if err != nil { + return nil, err + } + if !exists { + return &RemoveResult{Platform: PlatformOpenClaw, ConfigPath: abs}, nil + } + plugins, _ := cfg["plugins"].(map[string]interface{}) + if plugins == nil { + return &RemoveResult{Platform: PlatformOpenClaw, ConfigPath: abs}, nil + } + changed := false + if entries, ok := plugins["entries"].(map[string]interface{}); ok { + if _, ok := entries[OpenClawPluginID]; ok { + delete(entries, OpenClawPluginID) + changed = true + } + } + if slots, ok := plugins["slots"].(map[string]interface{}); ok { + if slots["contextEngine"] == OpenClawPluginID { + delete(slots, "contextEngine") + changed = true + } + } + if allow, ok := plugins["allow"].([]interface{}); ok { + out := allow[:0] + for _, v := range allow { + if v != OpenClawPluginID { + out = append(out, v) + } else { + changed = true + } + } + plugins["allow"] = out + } + if !changed { + return &RemoveResult{Platform: PlatformOpenClaw, ConfigPath: abs}, nil + } + // protected=true: openclaw.json carries the live agent_token, so + // the backup must be 0600. backupFile also propagates read errors + // instead of silently writing an empty .bak. + backup, err := backupFile(abs, true) + if err != nil { + return nil, err + } + // Our token is gone from cfg by now, so leave the host's mode alone. + if err := writeConfigAtomic(abs, cfg, configHasNoToken); err != nil { + return nil, err + } + return &RemoveResult{Platform: PlatformOpenClaw, ConfigPath: abs, BackupPath: backup, Removed: true}, nil +} + // Plan validates the target path (parent writable, file parses as JSON // if it exists) and records whether the entry already lives at // plugins.entries.. @@ -187,7 +244,8 @@ func (*openclawWriter) Commit(_ context.Context, plan *WritePlan, params WritePa ) } - if err := writeConfigAtomic(plan.ConfigPath, cfg); err != nil { + // plugins.entries[everme].config carries the freshly minted agent_token. + if err := writeConfigAtomic(plan.ConfigPath, cfg, configCarriesToken); err != nil { return nil, err } diff --git a/cli/internal/plugin/opencode.go b/cli/internal/plugin/opencode.go index cfcec66..4f9679e 100644 --- a/cli/internal/plugin/opencode.go +++ b/cli/internal/plugin/opencode.go @@ -17,7 +17,7 @@ // and there are `type`/`enabled` fields — so we can't reuse mcpWriter's // buildEntry. We DO reuse the shared JSON read / atomic-write / TOCTOU / // upsert helpers from mcp.go. The entry key is the canonical -// `everme-memory` (mcpEntryName), same as the Cursor/Gemini family. +// `everme-memory` (mcpEntryName), same as the Cursor family. // // We only write opencode.json (not opencode.jsonc): round-tripping JSONC // comments through encoding/json is lossy. Documented caveat. @@ -106,6 +106,37 @@ func newOpenCodeWriter() *opencodeWriter { return &opencodeWriter{} } func (*opencodeWriter) Platform() Platform { return PlatformOpenCode } +func (*opencodeWriter) Remove(_ context.Context, configPath string) (*RemoveResult, error) { + abs, err := filepath.Abs(configPath) + if err != nil { + return nil, output.IOErr(configPath, "abs-path", err) + } + cfg, exists, err := readConfig(abs) + if err != nil { + return nil, err + } + if !exists { + return &RemoveResult{Platform: PlatformOpenCode, ConfigPath: abs}, nil + } + if !nestedMcpServersHasEntry(cfg, opencodeServersPath, mcpEntryName) { + return &RemoveResult{Platform: PlatformOpenCode, ConfigPath: abs}, nil + } + // protected=true: opencode.json carries the live agent token in the + // MCP entry's environment, so the backup must be 0600. backupFile + // also propagates read errors instead of silently writing an empty + // .bak and then still rewriting the original. + backup, err := backupFile(abs, true) + if err != nil { + return nil, err + } + nestedMcpDeleteEntry(cfg, opencodeServersPath, mcpEntryName) + // Our token is gone from cfg by now, so leave the host's mode alone. + if err := writeConfigAtomic(abs, cfg, configHasNoToken); err != nil { + return nil, err + } + return &RemoveResult{Platform: PlatformOpenCode, ConfigPath: abs, BackupPath: backup, Removed: true}, nil +} + // Plan reads opencode.json to decide WillCreate / WillReplace, stages a // single BackupPath, and snapshots mtime/size for the TOCTOU check. func (*opencodeWriter) Plan(_ context.Context, configPath string) (*WritePlan, error) { @@ -184,7 +215,8 @@ func (*opencodeWriter) Commit(_ context.Context, plan *WritePlan, params WritePa ) } - if err := writeConfigAtomic(plan.ConfigPath, cfg); err != nil { + // The MCP entry's `environment` carries the freshly minted evt token. + if err := writeConfigAtomic(plan.ConfigPath, cfg, configCarriesToken); err != nil { return nil, err } @@ -203,7 +235,7 @@ func (*opencodeWriter) Commit(_ context.Context, plan *WritePlan, params WritePa func buildOpenCodeEntry(apiBaseURL, agentID, agentToken string) map[string]interface{} { return map[string]interface{}{ "type": "local", - "command": []interface{}{npxCommand(), "-y", "@everme/memory-mcp"}, + "command": []interface{}{npxCommand(), "-y", "@everme/memory-mcp@latest"}, "environment": map[string]interface{}{ "EVERME_API_BASE": apiBaseURL, "EVERME_AGENT_ID": agentID, diff --git a/cli/internal/plugin/opencode_test.go b/cli/internal/plugin/opencode_test.go index 7c7c1cc..8b5233b 100644 --- a/cli/internal/plugin/opencode_test.go +++ b/cli/internal/plugin/opencode_test.go @@ -12,13 +12,18 @@ import ( // Not installed: config dir does not exist, no opencode CLI. Detect must // still return a usable Detection with ConfigPath set. NOTE: opencode's -// "installed" signal is os.Stat(filepath.Dir(configPath)), so the dir -// must NOT exist here or Installed would be wrongly true. +// "installed" signal is a dual heuristic — os.Stat(filepath.Dir(configPath)) +// OR `opencode` on PATH — so this test must neutralize BOTH: the config dir +// must NOT exist, AND PATH must be empty of an opencode binary (otherwise a +// dev machine with opencode installed makes Installed wrongly true). func TestOpenCodeDetector_NoConfig_NotInstalled(t *testing.T) { dir := filepath.Join(t.TempDir(), "does-not-exist") t.Setenv("EVERCLI_OPENCODE_CONFIG_DIR", dir) t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", "") + // Point PATH at an empty dir so exec.LookPath("opencode") fails + // regardless of what is installed on the host running the tests. + t.Setenv("PATH", t.TempDir()) d, err := opencodeDetector{}.Detect(context.Background()) require.NoError(t, err) @@ -98,7 +103,7 @@ func TestOpenCodeWriter_WritesOpenCodeShape(t *testing.T) { assert.Equal(t, true, entry["enabled"]) cmd, ok := entry["command"].([]interface{}) require.True(t, ok, "command must be an array") - assert.Equal(t, []interface{}{"npx", "-y", "@everme/memory-mcp"}, cmd) + assert.Equal(t, []interface{}{"npx", "-y", "@everme/memory-mcp@latest"}, cmd) env, ok := entry["environment"].(map[string]interface{}) require.True(t, ok, "environment (not env) required") assert.Equal(t, "agt_oc", env["EVERME_AGENT_ID"]) diff --git a/cli/internal/plugin/raven.go b/cli/internal/plugin/raven.go new file mode 100644 index 0000000..2e4d886 --- /dev/null +++ b/cli/internal/plugin/raven.go @@ -0,0 +1,409 @@ +// Package plugin — Raven support (memory-backend mode). +// +// Raven (EverMind-AI/Raven, Python) discovers external plugins from +// ~/.raven/plugins//raven-plugin.toml and selects exactly one +// memory backend via config.json's memory.backend (single-slot, same +// exclusivity as OpenClaw's plugins.slots.contextEngine). evercli +// installs the embedded EverMe backend there: +// +// writer.Commit +// → writeRavenPluginFiles(~/.raven/plugins/everme-memory/) (embedded python) +// → config.json: memory.backend=everme (activate backend) +// → config.json: plugins.config["everme-memory"] (evt credentials) +// → config.json: drop "everme-memory" from plugins.disabled (un-opt-out) +// +// writer.Verify +// → plugins/everme-memory/raven-plugin.toml exists AND +// memory.backend==everme. +// +// The install follows the Hermes precedent (embedded Python dropped into +// the host's user-level plugin dir) with OpenClaw's credential posture +// (evt lives inside the host's own JSON config, not a separate env +// file — config.json is Raven's canonical credential store and already +// holds provider API keys). +// +// Selecting memory.backend=everme supersedes Raven's bundled everos +// backend for the session; Raven core's MEMORY.md / consolidation +// pipeline is unaffected (the backend owns no compaction). +package plugin + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "evercli/internal/output" +) + +// RavenPluginID is the Raven plugin id (and plugin directory name) for +// the EverMe memory backend. It MUST match the plugin-side sources of +// truth kept in ravenassets/everme-memory/: +// +// - raven-plugin.toml : [plugin] id +// - the config key Raven hands to the factory (plugins.config.) +// +// Raven's discovery scans //raven-plugin.toml and warns +// when != manifest id, so the directory Commit creates uses this +// value too. TestRavenPluginIDConsistency asserts the values stay in +// sync. +const RavenPluginID = "everme-memory" + +// ravenBackendName is the memory_backends contribution name the +// manifest declares — the value memory.backend must be set to. +const ravenBackendName = "everme" + +// ravenCommand resolves the `raven` CLI binary name/path (`uv tool +// install` drops it at ~/.local/bin/raven). EVERCLI_RAVEN_CMD lets +// tests substitute a fake so detection doesn't depend on the host's +// PATH (same escape hatch as EVERCLI_HERMES_CMD). +func ravenCommand() string { + if v := os.Getenv("EVERCLI_RAVEN_CMD"); v != "" { + return v + } + return "raven" +} + +// RavenHome resolves the Raven home directory: $EVERCLI_RAVEN_CONFIG_DIR +// (tests / non-default installs) → $HOME/.raven. Raven itself hardcodes +// Path.home()/".raven" (raven/config/loader.py), so unlike Hermes there +// is no host-side override chain to mirror. +func RavenHome() (string, error) { + if dir := os.Getenv("EVERCLI_RAVEN_CONFIG_DIR"); dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".raven"), nil +} + +// ravenConfigPath returns the absolute path to Raven's config.json. +func ravenConfigPath() (string, error) { + home, err := RavenHome() + if err != nil { + return "", err + } + return filepath.Join(home, "config.json"), nil +} + +// ---- detector ------------------------------------------------------ + +type ravenDetector struct{} + +func (ravenDetector) Platform() Platform { return PlatformRaven } + +func (ravenDetector) DisplayName() string { return "Raven" } + +func (ravenDetector) Detect(_ context.Context) (*Detection, error) { + path, err := ravenConfigPath() + if err != nil { + return &Detection{Platform: PlatformRaven, DisplayName: "Raven"}, nil + } + d := &Detection{ + Platform: PlatformRaven, + DisplayName: "Raven", + ConfigPath: path, + } + + // Dual heuristic, same shape as hermes.go: presence of the resolved + // Raven home directory or `raven` on PATH. + if home, err := RavenHome(); err == nil { + if _, statErr := os.Stat(home); statErr == nil { + d.Installed = true + } + } + if !d.Installed { + if _, err := exec.LookPath(ravenCommand()); err == nil { + d.Installed = true + } + } + + cfg, exists, err := readConfig(path) + if err != nil { + return d, err + } + d.ConfigExists = exists + if home, herr := RavenHome(); herr == nil { + d.HasEverMeEntry = ravenBackendInstalled(home, cfg) + } + return d, nil +} + +// ---- writer -------------------------------------------------------- + +// ravenWriter implements Writer + Verifier. No Preparer: like Hermes, +// there is no out-of-band registration phase — the embedded Python +// backend IS the install, and it lands atomically in Commit. +type ravenWriter struct{} + +func newRavenWriter() *ravenWriter { return &ravenWriter{} } + +func (*ravenWriter) Remove(_ context.Context, configPath string) (*RemoveResult, error) { + abs, err := filepath.Abs(configPath) + if err != nil { + return nil, output.IOErr(configPath, "abs-path", err) + } + cfg, exists, err := readConfig(abs) + if err != nil { + return nil, err + } + home := filepath.Dir(abs) + result := &RemoveResult{Platform: PlatformRaven, ConfigPath: abs} + changed := false + if exists { + if memory, ok := cfg["memory"].(map[string]interface{}); ok { + if backend, _ := memory["backend"].(string); backend == ravenBackendName { + delete(memory, "backend") + changed = true + } + } + if plugins, ok := cfg["plugins"].(map[string]interface{}); ok { + if pluginCfg, ok := plugins["config"].(map[string]interface{}); ok { + if _, ok := pluginCfg[RavenPluginID]; ok { + delete(pluginCfg, RavenPluginID) + changed = true + } + } + } + } + pluginDir := filepath.Join(home, "plugins", RavenPluginID) + if _, statErr := os.Stat(pluginDir); statErr == nil { + changed = true + } + if !changed { + return result, nil + } + if exists { + // protected=true: Raven's config.json is its canonical + // credential store, so the backup carries a live agent token. + backup, berr := backupFile(abs, true) + if berr != nil { + return nil, berr + } + // Our token is gone from cfg by now, so leave the host's mode alone. + if err := writeConfigAtomic(abs, cfg, configHasNoToken); err != nil { + return nil, err + } + result.BackupPath = backup + } + if err := os.RemoveAll(pluginDir); err != nil { + return nil, output.IOErr(pluginDir, "remove-plugin", err) + } + result.Removed = true + return result, nil +} + +func (*ravenWriter) Platform() Platform { return PlatformRaven } + +// Plan reads ~/.raven/config.json to decide WillCreate / WillReplace +// and stages a single BackupPath. The TOCTOU snapshot (mtime+size) is +// taken here so Commit refuses to overwrite if Raven itself (or +// another evercli) wrote between Plan and Commit. +func (*ravenWriter) Plan(_ context.Context, configPath string) (*WritePlan, error) { + if configPath == "" { + return nil, output.Invalid("configPath is required", "") + } + abs, err := filepath.Abs(configPath) + if err != nil { + return nil, output.IOErr(configPath, "abs-path", err) + } + + plan := &WritePlan{Platform: PlatformRaven, ConfigPath: abs} + + parent := filepath.Dir(abs) + if err := os.MkdirAll(parent, 0o700); err != nil { + return nil, output.IOErr(parent, "mkdir-parent", err) + } + + cfg, exists, err := readConfig(abs) + if err != nil { + return nil, err + } + plan.WillCreate = !exists + plan.WillReplace = ravenBackendInstalled(parent, cfg) + if exists { + plan.BackupPath = abs + backupSuffix + if info, statErr := os.Stat(abs); statErr == nil { + plan.SnapshotModTime = info.ModTime().UnixNano() + plan.SnapshotSize = info.Size() + } + } + + // Preview uses placeholders that visibly aren't tokens — Plan output + // also feeds --dry-run, which a user might paste into an issue. + plan.PreviewEntry = map[string]interface{}{ + "memory.backend": ravenBackendName, + "plugins/" + RavenPluginID + "/": "", + "plugins.config." + RavenPluginID: buildRavenEntry( + "https://api.everme.evermind.ai", + "", + "", + ), + } + return plan, nil +} + +// Commit writes the embedded Python backend, then updates config.json: +// selects memory.backend=everme, writes the credential entry under +// plugins.config, and removes a leftover plugins.disabled opt-out. +func (*ravenWriter) Commit(_ context.Context, plan *WritePlan, params WriteParams) (*WriteResult, error) { + if plan == nil { + return nil, output.Internal(fmt.Errorf("nil plan")) + } + if err := assertNoConcurrentChange(plan); err != nil { + return nil, err + } + + home := filepath.Dir(plan.ConfigPath) + + // 1. Materialize the embedded Python backend into + // ~/.raven/plugins/everme-memory/. + if err := writeRavenPluginFiles(filepath.Join(home, "plugins")); err != nil { + return nil, err + } + + // 2. Update config.json: backend selection + credentials. + cfg, exists, err := readConfig(plan.ConfigPath) + if err != nil { + return nil, err + } + wroteBackup := "" + if exists && plan.BackupPath != "" { + raw, rerr := os.ReadFile(plan.ConfigPath) + if rerr != nil { + return nil, output.IOErr(plan.ConfigPath, "read-for-backup", rerr) + } + if werr := os.WriteFile(plan.BackupPath, raw, 0o600); werr != nil { + return nil, output.IOErr(plan.BackupPath, "write-backup", werr) + } + wroteBackup = plan.BackupPath + } + if cfg == nil { + cfg = map[string]interface{}{} + } + + entry := buildRavenEntry(params.APIBaseURL, params.AgentID, params.AgentToken) + if upErr := upsertRavenEntry(cfg, entry); upErr != nil { + return nil, output.Invalid( + fmt.Sprintf("config at %s has a shape collision under memory.*/plugins.*: %v", plan.ConfigPath, upErr), + "Fix the config file's shape manually (a memory.* or plugins.* path collides with an unexpected non-object value), then retry install", + ) + } + + // config.json is Raven's only credential store: it now holds the + // freshly minted agent_token, and Raven creates it 0644. + if err := writeConfigAtomic(plan.ConfigPath, cfg, configCarriesToken); err != nil { + return nil, err + } + + return &WriteResult{ + Platform: PlatformRaven, + ConfigPath: plan.ConfigPath, + BackupPath: wroteBackup, + WroteNewEntry: !plan.WillReplace, + }, nil +} + +// buildRavenEntry produces the plugins.config. dict Raven hands to +// the plugin factory verbatim. Keys are snake_case (Raven's plugin +// config convention — mirrors the bundled everos-memory seed) and the +// manifest's defaults are inlined so a fresh install yields a +// fully-specified entry instead of relying on the plugin's runtime +// defaults — auditors reading the JSON should see the entire effective +// config. +func buildRavenEntry(apiBaseURL, agentID, agentToken string) map[string]interface{} { + return map[string]interface{}{ + "api_base": apiBaseURL, + "agent_id": agentID, + "agent_token": agentToken, + "flush_every_turns": 1, + "timeout_s": 30.0, + } +} + +// upsertRavenEntry writes: +// +// memory.backend = "everme" (select THE backend; single slot) +// plugins.config. = entry (replace, preserving siblings) +// plugins.disabled drops (a leftover opt-out would make +// the registry skip the plugin +// while install looks successful) +// +// Any path element that exists with a non-object type is left alone and +// the call returns an error — the user must fix the config manually +// instead of having us silently destroy whatever they had there. +func upsertRavenEntry(cfg map[string]interface{}, entry map[string]interface{}) error { + memory, err := ensureObjectAt(cfg, "memory") + if err != nil { + return err + } + memory["backend"] = ravenBackendName + + plugins, err := ensureObjectAt(cfg, "plugins") + if err != nil { + return err + } + pluginCfg, err := ensureObjectAt(plugins, "config") + if err != nil { + return err + } + pluginCfg[RavenPluginID] = entry + + disabled, err := ensureStringSlice(plugins, "disabled") + if err != nil { + return err + } + if containsString(disabled, RavenPluginID) { + kept := make([]interface{}, 0, len(disabled)) + for _, v := range disabled { + if s, ok := v.(string); ok && s == RavenPluginID { + continue + } + kept = append(kept, v) + } + plugins["disabled"] = kept + } + return nil +} + +// Verify re-reads the on-disk JSON and asserts the backend mode is +// correctly installed: plugins/everme-memory/raven-plugin.toml exists +// and memory.backend=everme is set in config.json. +func (*ravenWriter) Verify(_ context.Context, result *WriteResult) error { + if result == nil { + return output.Internal(fmt.Errorf("nil result")) + } + home := filepath.Dir(result.ConfigPath) + cfg, exists, err := readConfig(result.ConfigPath) + if err != nil { + return err + } + if !exists { + return output.IOErr(result.ConfigPath, "verify", fmt.Errorf("config file missing after Commit")) + } + if !ravenBackendInstalled(home, cfg) { + return output.IOErr(result.ConfigPath, "verify", + fmt.Errorf("backend not installed: plugins/%s or memory.backend=%s missing", RavenPluginID, ravenBackendName)) + } + return nil +} + +// ---- helpers -------------------------------------------------------- + +// ravenBackendInstalled reports whether the EverMe backend is wired: +// the plugin manifest exists AND config.json selects +// memory.backend=everme. +func ravenBackendInstalled(home string, cfg map[string]interface{}) bool { + if _, err := os.Stat(filepath.Join(home, "plugins", RavenPluginID, "raven-plugin.toml")); err != nil { + return false + } + mem, ok := cfg["memory"].(map[string]interface{}) + if !ok { + return false + } + backend, _ := mem["backend"].(string) + return backend == ravenBackendName +} diff --git a/cli/internal/plugin/raven_embed.go b/cli/internal/plugin/raven_embed.go new file mode 100644 index 0000000..ce0680f --- /dev/null +++ b/cli/internal/plugin/raven_embed.go @@ -0,0 +1,50 @@ +package plugin + +import ( + "embed" + "os" + "path/filepath" + + "evercli/internal/output" +) + +// ravenFS embeds the Python MemoryBackend source-of-truth. go:embed +// cannot reach outside the package dir, so the plugin files live under +// ravenassets/everme-memory/ in this package. Tests in +// ravenassets/tests/ are deliberately NOT embedded. +// +//go:embed ravenassets/everme-memory/raven-plugin.toml ravenassets/everme-memory/README.md ravenassets/everme-memory/everme_raven/__init__.py ravenassets/everme-memory/everme_raven/backend.py ravenassets/everme-memory/everme_raven/client.py ravenassets/everme-memory/everme_raven/config.py +var ravenFS embed.FS + +// ravenFileNames is the set written into +// ~/.raven/plugins/everme-memory/, relative to the plugin dir. +var ravenFileNames = []string{ + "raven-plugin.toml", + "README.md", + "everme_raven/__init__.py", + "everme_raven/backend.py", + "everme_raven/client.py", + "everme_raven/config.py", +} + +// writeRavenPluginFiles materializes the embedded everme-memory/ plugin +// into destDir/everme-memory/. destDir is typically ~/.raven/plugins. +// The subdirectory name equals RavenPluginID — Raven's discovery scans +// //raven-plugin.toml and warns on a mismatch. +func writeRavenPluginFiles(destDir string) error { + pkgDir := filepath.Join(destDir, RavenPluginID) + if err := os.MkdirAll(filepath.Join(pkgDir, "everme_raven"), 0o755); err != nil { + return output.IOErr(pkgDir, "mkdir-plugin", err) + } + for _, name := range ravenFileNames { + data, err := ravenFS.ReadFile("ravenassets/everme-memory/" + name) + if err != nil { + return output.Internal(err) + } + dst := filepath.Join(pkgDir, filepath.FromSlash(name)) + if err := writeFileAtomic(dst, data, 0o644); err != nil { + return output.IOErr(dst, "write-plugin-file", err) + } + } + return nil +} diff --git a/cli/internal/plugin/raven_embed_test.go b/cli/internal/plugin/raven_embed_test.go new file mode 100644 index 0000000..f9c9c45 --- /dev/null +++ b/cli/internal/plugin/raven_embed_test.go @@ -0,0 +1,36 @@ +package plugin + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteRavenPluginFiles(t *testing.T) { + dest := t.TempDir() + require.NoError(t, writeRavenPluginFiles(dest)) + + pkgDir := filepath.Join(dest, RavenPluginID) + for _, name := range ravenFileNames { + p := filepath.Join(pkgDir, filepath.FromSlash(name)) + info, err := os.Stat(p) + require.NoError(t, err, name) + assert.Equal(t, os.FileMode(0o644), info.Mode().Perm(), name) + assert.Greater(t, info.Size(), int64(0), name) + } + + // The manifest must reference the python package we just wrote so + // Raven's factory import resolves relative to the plugin dir. + raw, err := os.ReadFile(filepath.Join(pkgDir, "raven-plugin.toml")) + require.NoError(t, err) + assert.True(t, strings.Contains(string(raw), "everme_raven.backend:make_backend")) + _, err = os.Stat(filepath.Join(pkgDir, "everme_raven", "backend.py")) + assert.NoError(t, err) + + // Idempotent: a second write (re-install / upgrade) must succeed. + require.NoError(t, writeRavenPluginFiles(dest)) +} diff --git a/cli/internal/plugin/raven_test.go b/cli/internal/plugin/raven_test.go new file mode 100644 index 0000000..6a62952 --- /dev/null +++ b/cli/internal/plugin/raven_test.go @@ -0,0 +1,331 @@ +package plugin + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRavenPluginIDConsistency guards the id contract between this +// writer and the embedded plugin: raven-plugin.toml's [plugin] id, the +// memory_backends contribution name, and the factory module must match +// RavenPluginID / ravenBackendName / the embedded package layout. +// Raven uses the id as the plugins.config key and warns when the +// install directory name differs from it. +func TestRavenPluginIDConsistency(t *testing.T) { + raw, err := ravenFS.ReadFile("ravenassets/everme-memory/raven-plugin.toml") + require.NoError(t, err) + manifest := string(raw) + + assert.Contains(t, manifest, `id = "`+RavenPluginID+`"`, + "manifest [plugin] id must equal RavenPluginID") + assert.Contains(t, manifest, `name = "`+ravenBackendName+`"`, + "memory_backends contribution name must equal ravenBackendName") + assert.Contains(t, manifest, `factory = "everme_raven.backend:make_backend"`, + "factory must reference the embedded everme_raven package") + + // The embedded asset dir name doubles as the on-disk install dir; + // both must equal the plugin id. + _, err = ravenFS.ReadFile("ravenassets/" + RavenPluginID + "/raven-plugin.toml") + assert.NoError(t, err, "embedded asset dir must be named after RavenPluginID") +} + +// TestRavenDetector_NoConfig_NotInstalled covers the "Raven not on this +// machine" path. EVERCLI_RAVEN_CONFIG_DIR points at a nonexistent dir +// (unlike hermesHome there is no multi-layer chain — Raven hardcodes +// ~/.raven — so the override IS the honest simulation) and +// EVERCLI_RAVEN_CMD at a nonexistent binary so the test doesn't depend +// on the host's PATH. +func TestRavenDetector_NoConfig_NotInstalled(t *testing.T) { + t.Setenv("EVERCLI_RAVEN_CONFIG_DIR", filepath.Join(t.TempDir(), "no-such-raven")) + t.Setenv("EVERCLI_RAVEN_CMD", "/nonexistent/raven-not-on-this-box") + + d, err := ravenDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.Equal(t, PlatformRaven, d.Platform) + assert.Equal(t, "Raven", d.DisplayName) + assert.True(t, strings.HasSuffix(d.ConfigPath, "config.json")) + assert.False(t, d.Installed) + assert.False(t, d.ConfigExists) + assert.False(t, d.HasEverMeEntry) +} + +// TestRavenDetector_InstalledFromHomeDir confirms that presence of the +// Raven home dir alone (without a `raven` CLI on PATH) flags Raven as +// installed. +func TestRavenDetector_InstalledFromHomeDir(t *testing.T) { + home := t.TempDir() + t.Setenv("EVERCLI_RAVEN_CONFIG_DIR", home) + t.Setenv("EVERCLI_RAVEN_CMD", "/nonexistent/raven") + + d, err := ravenDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.True(t, d.Installed, "raven home presence must flag installed") + assert.False(t, d.HasEverMeEntry) +} + +// TestRavenDetector_EntryRequiresFilesAndConfig verifies the dual +// condition: manifest on disk AND memory.backend=everme. Either alone +// is a half-install and must report HasEverMeEntry=false so install +// re-runs repair it. +func TestRavenDetector_EntryRequiresFilesAndConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("EVERCLI_RAVEN_CONFIG_DIR", home) + t.Setenv("EVERCLI_RAVEN_CMD", "/nonexistent/raven") + + // Config selects everme but no plugin files yet. + writeRavenTestConfig(t, home, map[string]interface{}{ + "memory": map[string]interface{}{"backend": "everme"}, + }) + d, err := ravenDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.False(t, d.HasEverMeEntry, "config key without plugin files is a half-install") + + // Plugin files land; entry now complete. + require.NoError(t, writeRavenPluginFiles(filepath.Join(home, "plugins"))) + d, err = ravenDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.True(t, d.HasEverMeEntry) + + // Backend flipped away from everme → not installed anymore. + writeRavenTestConfig(t, home, map[string]interface{}{ + "memory": map[string]interface{}{"backend": "everos"}, + }) + d, err = ravenDetector{}.Detect(context.Background()) + require.NoError(t, err) + assert.False(t, d.HasEverMeEntry, "foreign memory.backend means everme is not active") +} + +func TestRavenWriter_LifecycleInterfaces(t *testing.T) { + var w Writer = newRavenWriter() + assert.Equal(t, PlatformRaven, w.Platform()) + _, isVerifier := w.(Verifier) + assert.True(t, isVerifier, "ravenWriter must implement Verifier") + _, isPreparer := w.(Preparer) + assert.False(t, isPreparer, "ravenWriter must NOT implement Preparer") +} + +// TestRavenWriter_Commit_FreshConfig is the happy path on a machine +// where Raven is installed but config.json doesn't exist yet: plugin +// files land, config is created with backend selection + credentials, +// Verify passes. +func TestRavenWriter_Commit_FreshConfig(t *testing.T) { + home := t.TempDir() + cfgPath := filepath.Join(home, "config.json") + w := newRavenWriter() + + plan, err := w.Plan(context.Background(), cfgPath) + require.NoError(t, err) + assert.True(t, plan.WillCreate) + assert.False(t, plan.WillReplace) + assert.Empty(t, plan.BackupPath) + + res, err := w.Commit(context.Background(), plan, WriteParams{ + AgentID: "agt_123", + AgentToken: "evt_" + strings.Repeat("a", 32), + APIBaseURL: "https://api.everme.evermind.ai", + }) + require.NoError(t, err) + assert.True(t, res.WroteNewEntry) + assert.Empty(t, res.BackupPath) + assert.Empty(t, res.NextSteps, "raven install completes headlessly") + + // Plugin files on disk, manifest included. + for _, name := range ravenFileNames { + _, statErr := os.Stat(filepath.Join(home, "plugins", RavenPluginID, filepath.FromSlash(name))) + assert.NoError(t, statErr, name) + } + + cfg := readRavenTestConfig(t, cfgPath) + mem := cfg["memory"].(map[string]interface{}) + assert.Equal(t, "everme", mem["backend"]) + entry := cfg["plugins"].(map[string]interface{})["config"].(map[string]interface{})[RavenPluginID].(map[string]interface{}) + assert.Equal(t, "agt_123", entry["agent_id"]) + assert.Equal(t, "evt_"+strings.Repeat("a", 32), entry["agent_token"]) + assert.Equal(t, "https://api.everme.evermind.ai", entry["api_base"]) + assert.Equal(t, float64(1), entry["flush_every_turns"]) + + // Fresh token-bearing config lands 0600. + info, err := os.Stat(cfgPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + require.NoError(t, w.Verify(context.Background(), res)) +} + +func TestRavenWriter_RemovePreservesOtherConfig(t *testing.T) { + home := t.TempDir() + cfgPath := filepath.Join(home, "config.json") + plugins := filepath.Join(home, "plugins", RavenPluginID) + require.NoError(t, os.MkdirAll(plugins, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(plugins, "raven-plugin.toml"), []byte("id = 'everme-memory'\n"), 0o600)) + cfg := `{"memory":{"backend":"everme","keep":true},"plugins":{"config":{"everme-memory":{"agent_token":"evt_secret"},"other":{"enabled":true}},"disabled":["other"]},"other":"preserve"}` + require.NoError(t, os.WriteFile(cfgPath, []byte(cfg), 0o600)) + + res, err := newRavenWriter().Remove(context.Background(), cfgPath) + require.NoError(t, err) + assert.True(t, res.Removed) + assert.FileExists(t, res.BackupPath) + got, exists, err := readConfig(cfgPath) + require.NoError(t, err) + require.True(t, exists) + assert.Equal(t, true, got["memory"].(map[string]interface{})["keep"]) + assert.NotEqual(t, "everme", got["memory"].(map[string]interface{})["backend"]) + assert.Contains(t, got["plugins"].(map[string]interface{})["config"].(map[string]interface{}), "other") + assert.NotContains(t, got["plugins"].(map[string]interface{})["config"].(map[string]interface{}), RavenPluginID) + assert.Equal(t, "preserve", got["other"]) + assert.NoDirExists(t, plugins) +} + +// TestRavenWriter_Commit_PreservesSiblings ensures the patch is +// surgical: provider keys, sibling plugins.config entries, a user's +// plugins.disabled opt-outs of OTHER plugins, and unrelated top-level +// sections must round-trip untouched. A leftover disabled opt-out of +// everme-memory itself is removed (it would silently veto the install). +func TestRavenWriter_Commit_PreservesSiblings(t *testing.T) { + home := t.TempDir() + cfgPath := filepath.Join(home, "config.json") + writeRavenTestConfig(t, home, map[string]interface{}{ + "providers": map[string]interface{}{"anthropic": map[string]interface{}{"api_key": "sk-x"}}, + "memory": map[string]interface{}{"backend": "everos", "topK": 7}, + "plugins": map[string]interface{}{ + "disabled": []interface{}{"some-other-plugin", RavenPluginID}, + "config": map[string]interface{}{ + "everos-memory": map[string]interface{}{"mode": "embedded"}, + }, + }, + }) + + w := newRavenWriter() + plan, err := w.Plan(context.Background(), cfgPath) + require.NoError(t, err) + assert.False(t, plan.WillCreate) + assert.NotEmpty(t, plan.BackupPath) + + res, err := w.Commit(context.Background(), plan, WriteParams{ + AgentID: "agt_1", AgentToken: "evt_tok", APIBaseURL: "https://api.x", + }) + require.NoError(t, err) + assert.Equal(t, cfgPath+backupSuffix, res.BackupPath) + + cfg := readRavenTestConfig(t, cfgPath) + assert.Equal(t, "sk-x", cfg["providers"].(map[string]interface{})["anthropic"].(map[string]interface{})["api_key"]) + mem := cfg["memory"].(map[string]interface{}) + assert.Equal(t, "everme", mem["backend"], "single slot: everme supersedes everos") + assert.Equal(t, float64(7), mem["topK"], "sibling memory keys preserved") + plugins := cfg["plugins"].(map[string]interface{}) + assert.Equal(t, []interface{}{"some-other-plugin"}, plugins["disabled"], + "our own opt-out removed, others preserved") + pcfg := plugins["config"].(map[string]interface{}) + assert.Contains(t, pcfg, "everos-memory", "sibling plugin config preserved") + assert.Contains(t, pcfg, RavenPluginID) + + // Backup carries the pre-install content. + var backup map[string]interface{} + raw, err := os.ReadFile(res.BackupPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &backup)) + assert.Equal(t, "everos", backup["memory"].(map[string]interface{})["backend"]) +} + +// TestRavenWriter_Commit_RefusesNonObjectPath: a scalar where an object +// is expected must abort with an actionable error, never clobber. +func TestRavenWriter_Commit_RefusesNonObjectPath(t *testing.T) { + home := t.TempDir() + cfgPath := filepath.Join(home, "config.json") + writeRavenTestConfig(t, home, map[string]interface{}{ + "memory": "everos", // scalar, not an object + }) + + w := newRavenWriter() + plan, err := w.Plan(context.Background(), cfgPath) + require.NoError(t, err) + _, err = w.Commit(context.Background(), plan, WriteParams{ + AgentID: "a", AgentToken: "t", APIBaseURL: "https://api.x", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "shape collision") + + // Original config untouched. + raw, rerr := os.ReadFile(cfgPath) + require.NoError(t, rerr) + assert.Contains(t, string(raw), `"memory": "everos"`) +} + +// TestRavenWriter_RejectsMalformedJSON: Plan must surface a parse error +// instead of treating garbage as an empty config. +func TestRavenWriter_RejectsMalformedJSON(t *testing.T) { + home := t.TempDir() + cfgPath := filepath.Join(home, "config.json") + require.NoError(t, os.WriteFile(cfgPath, []byte("{not json"), 0o600)) + + _, err := newRavenWriter().Plan(context.Background(), cfgPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse-json") +} + +// TestRavenWriter_TOCTOU_ConcurrentEdit: a write between Plan and +// Commit must abort the Commit. +func TestRavenWriter_TOCTOU_ConcurrentEdit(t *testing.T) { + home := t.TempDir() + cfgPath := filepath.Join(home, "config.json") + writeRavenTestConfig(t, home, map[string]interface{}{}) + + w := newRavenWriter() + plan, err := w.Plan(context.Background(), cfgPath) + require.NoError(t, err) + + // Simulate Raven itself writing between Plan and Commit. + require.NoError(t, os.WriteFile(cfgPath, []byte(`{"memory":{"backend":"everos"},"pad":"xxxxxxxx"}`), 0o600)) + + _, err = w.Commit(context.Background(), plan, WriteParams{ + AgentID: "a", AgentToken: "t", APIBaseURL: "https://api.x", + }) + require.Error(t, err) +} + +// TestRavenWriter_Verify_HalfInstall: Verify fails when the config key +// is present but plugin files are missing (and vice versa). +func TestRavenWriter_Verify_HalfInstall(t *testing.T) { + home := t.TempDir() + cfgPath := filepath.Join(home, "config.json") + writeRavenTestConfig(t, home, map[string]interface{}{ + "memory": map[string]interface{}{"backend": "everme"}, + }) + w := newRavenWriter() + res := &WriteResult{Platform: PlatformRaven, ConfigPath: cfgPath} + require.Error(t, w.Verify(context.Background(), res), "missing plugin files must fail Verify") + + require.NoError(t, writeRavenPluginFiles(filepath.Join(home, "plugins"))) + require.NoError(t, w.Verify(context.Background(), res)) + + writeRavenTestConfig(t, home, map[string]interface{}{ + "memory": map[string]interface{}{"backend": "everos"}, + }) + require.Error(t, w.Verify(context.Background(), res), "foreign backend must fail Verify") +} + +// ---- helpers --------------------------------------------------------- + +func writeRavenTestConfig(t *testing.T, home string, cfg map[string]interface{}) { + t.Helper() + raw, err := json.MarshalIndent(cfg, "", " ") + require.NoError(t, err) + require.NoError(t, os.MkdirAll(home, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(home, "config.json"), raw, 0o600)) +} + +func readRavenTestConfig(t *testing.T, path string) map[string]interface{} { + t.Helper() + raw, err := os.ReadFile(path) + require.NoError(t, err) + var cfg map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &cfg)) + return cfg +} diff --git a/cli/internal/plugin/ravenassets/.gitignore b/cli/internal/plugin/ravenassets/.gitignore new file mode 100644 index 0000000..3bbe7b6 --- /dev/null +++ b/cli/internal/plugin/ravenassets/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.pyo diff --git a/cli/internal/plugin/ravenassets/SMOKE.md b/cli/internal/plugin/ravenassets/SMOKE.md new file mode 100644 index 0000000..fce504b --- /dev/null +++ b/cli/internal/plugin/ravenassets/SMOKE.md @@ -0,0 +1,27 @@ +# Raven backend — manual smoke test + +Prereq: a real Raven install (`raven doctor` passes), Raven >= the +version that adds user-dir plugins to `sys.path` before factory import, +an EverMe account, evercli built locally. + +1. Build evercli: `cd cli && go build -o /tmp/evercli .` +2. Install: `/tmp/evercli plugin install raven` +3. Verify on disk: + - `ls ~/.raven/plugins/everme-memory/` → `raven-plugin.toml` + + `everme_raven/` (4 files) + `README.md`. + - `python3 -c "import json;c=json.load(open('$HOME/.raven/config.json'));print(c['memory']['backend'], bool(c['plugins']['config']['everme-memory']['agent_token']))"` + → `everme True`. + - A `~/.raven/config.json.bak` backup exists when the config predated install. +4. Plugin loads: `raven plugins` lists `everme-memory` (source: user) and + the `everme` backend as selected; no factory import error in logs. +5. Auto-write: run `raven agent -m "remember that I like durian"`, then + confirm a POST to `/mem/agent-memory` in EverMe backend logs / the + memory appears on the EverMe web UI after worker extraction. +6. Recall: start a new session and ask about the fact; the `# Memory` + prompt segment should carry the profile block + recall bullets. +7. Failure path: set `api_base` to an unreachable host in + `plugins.config["everme-memory"]`; the session must still run + (recall returns empty, store logs a warning — no turn abort). +8. Exclusivity note: while `memory.backend = "everme"`, the bundled + `everos` backend is inactive. Restore the previous value from the + `.bak` to switch back. diff --git a/cli/internal/plugin/ravenassets/everme-memory/README.md b/cli/internal/plugin/ravenassets/everme-memory/README.md new file mode 100644 index 0000000..d5fa5b4 --- /dev/null +++ b/cli/internal/plugin/ravenassets/everme-memory/README.md @@ -0,0 +1,49 @@ +# EverMe memory backend for Raven + +External user-dir plugin implementing Raven's `MemoryBackend` Protocol +(`raven.memory_engine.backend`) against the EverMe cloud `/mem` BFF. +Installed by `evercli plugin install raven` into +`~/.raven/plugins/everme-memory/` and activated via +`memory.backend = "everme"` in `~/.raven/config.json`. + +## Layout + +- `raven-plugin.toml` — Raven plugin manifest; contributes the `everme` + memory backend via `everme_raven.backend:make_backend`. +- `everme_raven/backend.py` — `MemoryBackend` implementation + (`start` / `stop` / `recall` / `store` / `feedback`). +- `everme_raven/client.py` — stdlib-only HTTP client (Bearer evt auth, + envelope parsing, GET-only retry, token redaction). Verbatim port of + the Hermes provider's client. +- `everme_raven/config.py` — config resolution: plugin config dict > + `EVERME_*` env vars > defaults. + +## Endpoint mapping + +| MemoryBackend | EverMe BFF | +|---|---| +| `recall()` | `POST /api/v1/mem/search`; user track prepends the profile block warmed from `POST /api/v1/mem/context` at `start()` | +| `store()` | `POST /api/v1/mem/agent-memory` (epoch-ms timestamps, `toolCalls` preserved, `flush` every `flush_every_turns`) | +| `feedback()` | no EverMe sink yet — logged once, dropped | + +Selecting `memory.backend = "everme"` replaces the bundled `everos` +backend for the session (Raven's memory slot is single-choice); Raven's +own MEMORY.md / consolidation pipeline is unaffected. + +## Config (`plugins.config["everme-memory"]`) + +| key | default | notes | +|---|---|---| +| `api_base` | `https://api.everme.evermind.ai` | `/api/v1` suffix appended automatically | +| `agent_id` | — | `agt_...`, written by evercli | +| `agent_token` | — | `evt_...` plaintext, minted at install; required | +| `flush_every_turns` | `1` | `0` disables flushing | +| `timeout_s` | `30.0` | per-request timeout | + +## Tests + +```bash +cd cli/internal/plugin/ravenassets/tests && python3 -m unittest discover +``` + +No Raven install needed — `_fakes.py` stubs `raven.memory_engine`. diff --git a/cli/internal/plugin/ravenassets/everme-memory/everme_raven/__init__.py b/cli/internal/plugin/ravenassets/everme-memory/everme_raven/__init__.py new file mode 100644 index 0000000..9fed483 --- /dev/null +++ b/cli/internal/plugin/ravenassets/everme-memory/everme_raven/__init__.py @@ -0,0 +1,13 @@ +"""EverMe memory backend for Raven — external user-dir plugin. + +Implements Raven's MemoryBackend Protocol (raven.memory_engine.backend) +against the EverMe cloud /mem BFF: recall from /mem/search (+ profile +from /mem/context), per-turn trajectories to /mem/agent-memory. The +factory entry point is everme_raven.backend:make_backend, referenced +from raven-plugin.toml. +""" +from __future__ import annotations + +__version__ = "0.1.0" + +from .backend import EverMeBackend, make_backend # noqa: F401 diff --git a/cli/internal/plugin/ravenassets/everme-memory/everme_raven/backend.py b/cli/internal/plugin/ravenassets/everme-memory/everme_raven/backend.py new file mode 100644 index 0000000..93dfaec --- /dev/null +++ b/cli/internal/plugin/ravenassets/everme-memory/everme_raven/backend.py @@ -0,0 +1,396 @@ +"""EverMeBackend — Raven MemoryBackend against the EverMe cloud /mem BFF. + +Endpoint mapping (all POST, Bearer evt auth via client.py): + + recall() -> /mem/search {query, topK} -> {items: [{episode, atomicFacts}]} + user track additionally prepends the profile block warmed + from /mem/context at start() + store() -> /mem/agent-memory {conversationId, messages, flush} + messages carry epoch-ms timestamps and preserved toolCalls + feedback -> no EverMe sink yet; logged once and dropped (same posture + as the bundled everos-memory backend) + +Error posture: recall/store failures are logged truthfully (redacted) +and swallowed — the AgentLoop turn pipeline must not abort because the +memory index is unreachable. There is no no-op "degraded mode": a +missing agent_token fails construction loudly so the operator re-runs +`evercli plugin install raven` instead of running memory-less silently. + +The host hands store() only the current turn's message slice +(all_msgs[turn_start_idx:]), so no last-turn trimming is needed here +(unlike the Hermes provider, which receives the full running history). +""" +from __future__ import annotations + +import asyncio +import json +import logging +import time +from typing import Any, Dict, List, Optional + +from raven.memory_engine import Memory + +from .client import EverMeClient, redact_error +from .config import resolve_config + +logger = logging.getLogger("raven.plugin.everme") + +_QUERY_MAX_CHARS = 1024 +_QUERY_MIN_CHARS = 3 +_MAX_CONTENT_CHARS = 8000 # backend MaxMessageContentRunes — avoid 400 "content too long" + + +def make_backend(ctx) -> "EverMeBackend": + """Factory referenced from raven-plugin.toml.""" + return EverMeBackend(ctx) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _coerce_ts(ts) -> int: + if isinstance(ts, (int, float)): + return int(ts) if ts > 10_000_000_000 else int(ts * 1000) + return _now_ms() + + +def _cap(text) -> str: + s = text if isinstance(text, str) else ("" if text is None else str(text)) + return s[:_MAX_CONTENT_CHARS] + + +def _to_text(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for p in content: + if isinstance(p, str): + parts.append(p) + elif isinstance(p, dict): + parts.append(p.get("text") or p.get("content") or "") + return "\n".join(x for x in parts if x) + if isinstance(content, dict) and isinstance(content.get("text"), str): + return content["text"] + return "" + + +def _convert_messages(messages) -> List[Dict[str, Any]]: + """Convert Raven AgentLoop messages (OpenAI-style dicts) to backend + AgentMemoryMessage dicts. Mirrors the Hermes provider's converter: + system messages are dropped, tool calls are preserved with JSON-string + arguments, and every message carries an epoch-ms timestamp.""" + out: List[Dict[str, Any]] = [] + for m in messages or []: + if not isinstance(m, dict): + continue + role = m.get("role") + ts = _coerce_ts(m.get("timestamp")) + if role == "user": + content = _to_text(m.get("content")) + if content: + out.append({"role": "user", "timestamp": ts, "content": _cap(content)}) + elif role == "assistant": + text_parts = [] + tool_calls = [] + c = m.get("content") + if isinstance(c, str): + text_parts.append(c) + for block in (c if isinstance(c, list) else []): + if not isinstance(block, dict) or not block.get("type"): + continue + if block["type"] == "text" and isinstance(block.get("text"), str): + text_parts.append(block["text"]) + elif block["type"] in ("toolCall", "tool_use"): + args = block.get("arguments", block.get("input", {})) + tool_calls.append({ + "id": block.get("id", ""), + "type": "function", + "name": block.get("name") or "unknown", + "arguments": args if isinstance(args, str) else json.dumps(args), + }) + # merge pre-extracted tool calls passed alongside string content + for tc in m.get("toolCalls") or m.get("tool_calls") or []: + if isinstance(tc, dict): + fn = tc.get("function") if isinstance(tc.get("function"), dict) else tc + args = fn.get("arguments") + tool_calls.append({ + "id": tc.get("id", ""), + "type": "function", + "name": fn.get("name") or "unknown", + "arguments": args if isinstance(args, str) else json.dumps(args or {}), + }) + msg: Dict[str, Any] = {"role": "assistant", "timestamp": ts} + text = _to_text(text_parts) + if text: + msg["content"] = _cap(text) + if tool_calls: + msg["toolCalls"] = tool_calls + if "content" in msg or tool_calls: + out.append(msg) + elif role in ("tool", "tool_result"): + tcid = m.get("toolCallId") or m.get("tool_call_id") + if tcid: + out.append({"role": "tool", "timestamp": ts, "toolCallId": tcid, + "content": _cap(_to_text(m.get("content")))}) + return out + + +def _clamp_score(raw: Any) -> float: + """Coerce a relevanceScore to a float clamped to [0, 1].""" + try: + return max(0.0, min(float(raw or 0.0), 1.0)) + except (TypeError, ValueError): + return 0.0 + + +def _meta(mem_type: str, owner_type: str, mem_id: Any) -> Dict[str, Any]: + m: Dict[str, Any] = {"type": mem_type, "owner_type": owner_type} + if mem_id: + m["id"] = mem_id + return m + + +def _search_to_memories(res: Any, owner_type: str) -> List[Memory]: + """Flatten a /mem/search result into list[Memory], one Memory per hit. + + Three hit kinds are rendered, each as an indented bullet block so the + host's top-k trimming and score ordering stay meaningful: + + - episodic ``items`` — the episode line plus its atomic facts; + - ``agentMemory.cases`` — a trajectory's task intent + approach; + - ``agentMemory.skills`` — a clustered skill's name/description/content. + + Cases and skills are the products of the /mem/agent-memory writes this + backend makes, so recall has to surface them — dropping them (the + original bug) meant an agent could never read back what its own + trajectories produced. Scores read ``relevanceScore`` (the real BFF + field name) across all three kinds.""" + out: List[Memory] = [] + if not isinstance(res, dict): + return out + for it in res.get("items") or []: + if not isinstance(it, dict): + continue + episode = (it.get("episode") or "").strip() + if not episode: + continue + lines = [f"- {episode}"] + for fact in it.get("atomicFacts") or []: + if fact: + lines.append(f" - {fact}") + out.append(Memory(text="\n".join(lines), + score=_clamp_score(it.get("relevanceScore")), + metadata=_meta("episode", owner_type, it.get("id")))) + + agent_memory = res.get("agentMemory") + if isinstance(agent_memory, dict): + for c in agent_memory.get("cases") or []: + if not isinstance(c, dict): + continue + intent = (c.get("taskIntent") or "").strip() + approach = (c.get("approach") or "").strip() + if not intent and not approach: + continue + lines = [] + if intent: + lines.append(f"- Task: {intent}") + if approach: + lines.append(f" - Approach: {approach}") + out.append(Memory(text="\n".join(lines), + score=_clamp_score(c.get("relevanceScore")), + metadata=_meta("case", owner_type, c.get("id")))) + + for s in agent_memory.get("skills") or []: + if not isinstance(s, dict): + continue + name = (s.get("name") or "").strip() + desc = (s.get("description") or "").strip() + content = (s.get("content") or "").strip() + if not (name or desc or content): + continue + head = f"- Skill: {name}" if name else "- Skill" + if desc: + head += f" — {desc}" + lines = [head] + if content: + lines.append(f" - {content}") + out.append(Memory(text="\n".join(lines), + score=_clamp_score(s.get("relevanceScore")), + metadata=_meta("skill", owner_type, s.get("id")))) + return out + + +def _render_profile(res: Any) -> str: + if not isinstance(res, dict): + return "" + profile = res.get("profile") if "profile" in res else res + if not isinstance(profile, dict): + return "" + lines = [] + for row in profile.get("explicit_info") or []: + if not isinstance(row, dict): + continue + desc = (row.get("description") or "").strip() + if desc: + cat = f"[{row['category']}] " if row.get("category") else "" + lines.append(f"- {cat}{desc}") + for row in profile.get("implicit_traits") or []: + if not isinstance(row, dict): + continue + desc = (row.get("description") or "").strip() + if desc: + t = f"{row['trait']}: " if row.get("trait") else "" + lines.append(f"- {t}{desc}") + return "\n".join(lines) + + +class EverMeBackend: + """Raven MemoryBackend implementation for EverMe cloud.""" + + def __init__(self, ctx, *, client: Optional[EverMeClient] = None) -> None: + self._cfg = resolve_config(dict(getattr(ctx, "config", None) or {})) + self._logger = getattr(ctx, "logger", None) or logger + if not self._cfg.get("agent_token"): + # Fail construction loudly: a token-less backend would run the + # whole session memory-less while looking installed. The + # registry logs this and the operator re-runs + # `evercli plugin install raven`. + raise ValueError( + "everme-memory: agent_token missing in plugins.config" + '["everme-memory"] (and EVERME_AGENT_TOKEN unset); ' + "run `evercli plugin install raven`" + ) + self._client = client or EverMeClient(self._cfg, version=_version()) + self._timeout_s = float(self._cfg.get("timeout_s") or 30.0) + self._flush_every_turns = int(self._cfg.get("flush_every_turns") or 1) + self._turn_counts: Dict[str, int] = {} + self._profile_text = "" + self._feedback_noop_logged = False + + # -- lifecycle ----------------------------------------------------- + + async def start(self) -> None: + self._logger.info( + "EverMeBackend.start (api_base=%s, agent_id=%s)", + self._cfg["api_base"], self._cfg.get("agent_id") or "", + ) + # Warm the profile block for user-track recall (best-effort: a + # cold cache costs one turn of profile context, not the boot). + try: + res = await self._request("POST", "/mem/context", {}) + self._profile_text = _render_profile(res) + except Exception as e: + self._logger.warning( + "EverMeBackend.start: profile warm-up failed: %s", redact_error(e), + ) + + async def stop(self) -> None: + self._logger.info("EverMeBackend.stop") + # urllib client holds no pooled sockets; nothing to close. + + # -- MemoryBackend Protocol ----------------------------------------- + + async def recall( + self, + query: str, + *, + user_id: Optional[str] = None, + agent_id: Optional[str] = None, + top_k: int, + ) -> List[Memory]: + """Semantic recall via /mem/search, scoped by the evt identity. + + The EverMe agent token already binds (account, agent), so both + tracks hit the same search endpoint; the track only tags + Memory.metadata.owner_type and decides whether the warmed + profile block is prepended (user track only). Exactly one of + user_id / agent_id must be set (XOR) — same contract as the + bundled everos-memory backend.""" + if (user_id is None) == (agent_id is None): + self._logger.warning( + "EverMeBackend.recall: expected exactly one of user_id / " + "agent_id (got user_id=%r, agent_id=%r); returning empty", + user_id, agent_id, + ) + return [] + owner_type = "user" if user_id is not None else "agent" + + memories: List[Memory] = [] + q = (query or "").strip()[:_QUERY_MAX_CHARS] + if len(q) >= _QUERY_MIN_CHARS: + try: + res = await self._request( + "POST", "/mem/search", {"query": q, "topK": max(1, int(top_k))}, + ) + memories = _search_to_memories(res, owner_type) + except Exception as e: + self._logger.warning( + "EverMeBackend.recall failed (%s); returning empty", + redact_error(e), + ) + + if owner_type == "user" and self._profile_text: + memories.insert(0, Memory( + text=self._profile_text, + score=1.0, + metadata={"type": "profile", "owner_type": "user"}, + )) + return memories + + async def store(self, session_id: str, messages: List[Dict[str, Any]]) -> None: + """Forward the turn's message slice to /mem/agent-memory. + + flush rides every Nth turn (flush_every_turns, default 1 — same + cadence the Hermes provider uses) so short sessions still build + memory. Failures are logged and swallowed: the turn is already + in Raven's session log, only plugin-side indexing is skipped.""" + if not messages: + return + converted = _convert_messages(messages) + if not converted: + return + n = self._turn_counts.get(session_id, 0) + 1 + self._turn_counts[session_id] = n + flush = self._flush_every_turns > 0 and n % self._flush_every_turns == 0 + body = {"conversationId": session_id, "messages": converted, "flush": flush} + try: + await self._request("POST", "/mem/agent-memory", body) + except Exception as e: + self._logger.warning( + "EverMeBackend.store failed (%s); turn not indexed", + redact_error(e), + ) + + async def feedback(self, signals: Dict[str, Any]) -> None: + """No EverMe sink for skill-usage signals yet (V2 Agent Hub + scope); logged once per backend so the pending wiring stays + visible without flooding the after-turn pipeline.""" + if not self._feedback_noop_logged: + self._feedback_noop_logged = True + self._logger.info( + "EverMeBackend.feedback: no EverMe sink yet; signals " + "dropped (keys=%s). Logged once per backend.", + sorted(signals.keys()), + ) + else: + self._logger.debug( + "EverMeBackend.feedback no-op (keys=%s)", sorted(signals.keys()), + ) + + # -- helpers --------------------------------------------------------- + + async def _request(self, method: str, path: str, body: Optional[dict]) -> Any: + """Run the sync stdlib client off the event loop.""" + return await asyncio.to_thread( + self._client.request, method, path, body, timeout=self._timeout_s, + ) + + +def _version() -> str: + try: + from . import __version__ + return __version__ + except Exception: + return "0.0.0" diff --git a/cli/internal/plugin/ravenassets/everme-memory/everme_raven/client.py b/cli/internal/plugin/ravenassets/everme-memory/everme_raven/client.py new file mode 100644 index 0000000..6880a6e --- /dev/null +++ b/cli/internal/plugin/ravenassets/everme-memory/everme_raven/client.py @@ -0,0 +1,129 @@ +"""EverMe HTTP client (stdlib only — no httpx/requests). + +Verbatim port of the Hermes provider's client.py; mirrors the +invariants of @everme/agent-sdk client.js: + - single request() funnel, Bearer auth from cfg["agent_token"] + - envelope {error, requestId, status, result}: status==0 -> result + - 30s timeout; only GET/HEAD retried once (writes never retried) + - redact_error() scrubs evt_/emk_ tokens and S3 signing params + +The client is synchronous on purpose (urllib) — the async backend wraps +calls in asyncio.to_thread so we carry zero third-party dependencies +into the host process. +""" +from __future__ import annotations + +import json +import re +import time +import urllib.error +import urllib.request +from typing import Any, Callable, Dict, Optional +from urllib.parse import urlencode + +TIMEOUT_S = 30.0 +RETRY_SLEEP_S = 0.15 + +_evt_re = re.compile(r"evt_[A-Za-z0-9]{32,}") +_emk_re = re.compile(r"emk_[A-Za-z0-9]{32,}") +_s3_re = re.compile( + r"(X-Amz-Signature|X-Amz-Security-Token|X-Amz-Credential)=[^&\"\s]+", + re.IGNORECASE, +) + + +def redact_error(msg: Any) -> str: + text = msg.message if isinstance(msg, Exception) and hasattr(msg, "message") else str(msg) + text = _evt_re.sub(lambda m: m.group(0)[:8] + "_REDACTED", text) + text = _emk_re.sub(lambda m: m.group(0)[:8] + "_REDACTED", text) + text = _s3_re.sub(lambda m: m.group(1) + "=[REDACTED]", text) + return text + + +class EvermeError(Exception): + def __init__(self, message, status=0, code=0, request_id="", error_type="upstream"): + safe = redact_error(message) + super().__init__(safe) + self.message = safe + self.http_status = status + self.code = code + self.request_id = request_id + self.type = error_type + + +def _default_opener(req, timeout): + return urllib.request.urlopen(req, timeout=timeout) + + +class EverMeClient: + def __init__(self, cfg: Dict[str, str], opener: Optional[Callable] = None, version: str = "0.1.0"): + self._base = cfg["api_base"] + self._token = cfg.get("agent_token", "") + self._agent_id = cfg.get("agent_id", "") + self._version = version + self._opener = opener or _default_opener + + def _headers(self) -> Dict[str, str]: + return { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Bearer {self._token}", + "User-Agent": f"everme-raven-plugin/{self._version} (agentId={self._agent_id})", + } + + def request(self, method: str, path: str, body: Optional[dict] = None, + *, timeout: float = TIMEOUT_S, query: Optional[dict] = None) -> Any: + url = self._base + path + if query: + clean = {k: v for k, v in query.items() if v not in (None, "")} + if clean: + url += "?" + urlencode(clean, doseq=True) + data = None if body is None else json.dumps(body).encode("utf-8") + method = method.upper() + + def _once(): + req = urllib.request.Request(url, data=data, method=method, headers=self._headers()) + try: + with self._opener(req, timeout) as resp: + raw = resp.read() + status = getattr(resp, "status", 200) + except urllib.error.HTTPError as e: + raw = e.read() + status = e.code + # Other transport exceptions (URLError, socket timeout, OSError) + # propagate raw so the retry/wrap logic below owns them. + return self._parse(raw, status) + + try: + return _once() + except EvermeError: + raise # application error — never retried + except Exception as e: + if method in ("GET", "HEAD"): + time.sleep(RETRY_SLEEP_S) + try: + return _once() + except EvermeError: + raise + except Exception as e2: + raise EvermeError(f"transport error: {e2}", error_type="upstream") from None + raise EvermeError(f"transport error: {e}", error_type="upstream") from None + + @staticmethod + def _parse(raw: bytes, http_status: int) -> Any: + text = raw.decode("utf-8", errors="replace") if raw else "" + try: + env = json.loads(text) if text else {} + except Exception: + etype = "auth" if http_status in (401, 403) else "upstream" + raise EvermeError(f"HTTP {http_status} — {text[:200]}", status=http_status, error_type=etype) + if isinstance(env, dict) and env.get("status") == 0: + return env.get("result") + code = int(env.get("status") or 0) if isinstance(env, dict) else 0 + etype = "auth" if 30000 <= code < 30300 and code != 30104 else "upstream" + raise EvermeError( + (env.get("error") if isinstance(env, dict) else None) or f"HTTP {http_status}", + status=http_status, code=code, + request_id=(env.get("requestId") if isinstance(env, dict) else "") or "", + error_type=etype, + ) diff --git a/cli/internal/plugin/ravenassets/everme-memory/everme_raven/config.py b/cli/internal/plugin/ravenassets/everme-memory/everme_raven/config.py new file mode 100644 index 0000000..5ca86ad --- /dev/null +++ b/cli/internal/plugin/ravenassets/everme-memory/everme_raven/config.py @@ -0,0 +1,73 @@ +"""Config resolution for the EverMe Raven backend. + +Priority (highest first): + 1. the plugin config dict — plugins.config["everme-memory"] from + ~/.raven/config.json, handed to make_backend(ctx) verbatim by + Raven's plugin registry (this is what evercli writes at install) + 2. process env (EVERME_API_BASE / EVERME_AGENT_ID / EVERME_AGENT_TOKEN) + 3. compiled defaults + +No network calls. api_base always carries the /api/v1 suffix so callers +don't have to think about it (mirrors the Hermes provider's config.py). +Unlike Hermes there is no everme.env file: config.json is Raven's +canonical credential store (same posture as OpenClaw's plugins.entries). +""" +from __future__ import annotations + +import os +from typing import Any, Dict + +DEFAULT_API_BASE = "https://api.everme.evermind.ai" +API_PATH_PREFIX = "/api/v1" +DEFAULT_FLUSH_EVERY_TURNS = 1 +DEFAULT_TIMEOUT_S = 30.0 + +_ENV_BY_KEY = { + "api_base": "EVERME_API_BASE", + "agent_id": "EVERME_AGENT_ID", + "agent_token": "EVERME_AGENT_TOKEN", +} + + +def _pick(key: str, cfg: Dict[str, Any]) -> str: + raw = cfg.get(key) + if raw: + return str(raw) + env = os.environ.get(_ENV_BY_KEY[key]) + if env: + return env + return "" + + +def _normalize_base(raw: str) -> str: + base = (raw or DEFAULT_API_BASE).rstrip("/") + if base.endswith(API_PATH_PREFIX): + return base + return base + API_PATH_PREFIX + + +def _coerce_int(raw: Any, default: int) -> int: + try: + return int(raw) + except (TypeError, ValueError): + return default + + +def _coerce_float(raw: Any, default: float) -> float: + try: + return float(raw) + except (TypeError, ValueError): + return default + + +def resolve_config(cfg: Dict[str, Any] | None) -> Dict[str, Any]: + cfg = cfg or {} + return { + "api_base": _normalize_base(_pick("api_base", cfg)), + "agent_id": _pick("agent_id", cfg), + "agent_token": _pick("agent_token", cfg), + "flush_every_turns": _coerce_int( + cfg.get("flush_every_turns"), DEFAULT_FLUSH_EVERY_TURNS + ), + "timeout_s": _coerce_float(cfg.get("timeout_s"), DEFAULT_TIMEOUT_S), + } diff --git a/cli/internal/plugin/ravenassets/everme-memory/raven-plugin.toml b/cli/internal/plugin/ravenassets/everme-memory/raven-plugin.toml new file mode 100644 index 0000000..94c0829 --- /dev/null +++ b/cli/internal/plugin/ravenassets/everme-memory/raven-plugin.toml @@ -0,0 +1,31 @@ +# EverMe memory backend for Raven (user-dir plugin). +# +# Installed by `evercli plugin install raven` into +# ~/.raven/plugins/everme-memory/ — the directory name MUST equal the +# plugin id below (Raven's discovery scans // +# raven-plugin.toml and logs a mismatch). +# +# The id is also the config key under plugins.config. in +# ~/.raven/config.json and the value evercli's raven.go writer keeps in +# sync (RavenPluginID). Change one, change all. +[plugin] +id = "everme-memory" +version = "0.1.0" +display_name = "EverMe memory" +raven = ">=0.1" +enabled_by_default = true + +[[plugin.contributes.memory_backends]] +name = "everme" +factory = "everme_raven.backend:make_backend" + +# Config schema sketch — the registry passes the user's +# plugins.config["everme-memory"] dict to make_backend(ctx) verbatim +# (same contract as the bundled everos-memory plugin). agent_token is +# minted by the EverMe backend at install time; evercli writes it here. +[plugin.config_schema] +api_base = { type = "string", default = "https://api.everme.evermind.ai" } +agent_id = { type = "string" } +agent_token = { type = "string", secret = true } +flush_every_turns = { type = "integer", default = 1 } +timeout_s = { type = "number", default = 30.0 } diff --git a/cli/internal/plugin/ravenassets/tests/_fakes.py b/cli/internal/plugin/ravenassets/tests/_fakes.py new file mode 100644 index 0000000..9699e47 --- /dev/null +++ b/cli/internal/plugin/ravenassets/tests/_fakes.py @@ -0,0 +1,43 @@ +"""Inject a minimal fake raven.memory_engine so the backend imports +without a real Raven install. Call install_fakes() at the top of every +test module, before importing everme_raven.*""" +import sys +import types +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict + + +def install_fakes(): + # raven.memory_engine.Memory — the frozen dataclass the backend returns. + if "raven.memory_engine" not in sys.modules: + raven_pkg = types.ModuleType("raven") + raven_pkg.__path__ = [] + me_mod = types.ModuleType("raven.memory_engine") + + @dataclass(frozen=True) + class Memory: + text: str + score: float = 0.0 + metadata: Dict[str, Any] = field(default_factory=dict) + + me_mod.Memory = Memory + sys.modules["raven"] = raven_pkg + sys.modules["raven.memory_engine"] = me_mod + + +def make_plugin_importable(): + """Put the plugin dir (the one holding everme_raven/) on sys.path — + the same thing Raven's registry does for user-dir plugins.""" + plugin_dir = Path(__file__).resolve().parent.parent / "everme-memory" + p = str(plugin_dir) + if p not in sys.path: + sys.path.insert(0, p) + + +class FakeContext: + """Duck-typed PluginContext: the backend only reads .config and .logger.""" + + def __init__(self, config=None, logger=None): + self.config = config or {} + self.logger = logger diff --git a/cli/internal/plugin/ravenassets/tests/test_backend.py b/cli/internal/plugin/ravenassets/tests/test_backend.py new file mode 100644 index 0000000..cc2a3da --- /dev/null +++ b/cli/internal/plugin/ravenassets/tests/test_backend.py @@ -0,0 +1,267 @@ +import asyncio +import unittest + +from _fakes import FakeContext, install_fakes, make_plugin_importable + +install_fakes() +make_plugin_importable() + +from everme_raven import backend as backendmod # noqa: E402 +from everme_raven.client import EvermeError # noqa: E402 + +TOKEN = "evt_" + "a" * 32 + + +class FakeClient: + """Records request() calls; responses are canned per path.""" + + def __init__(self, responses=None, error=None): + self.calls = [] + self.responses = responses or {} + self.error = error + + def request(self, method, path, body=None, *, timeout=30.0, query=None): + self.calls.append({"method": method, "path": path, "body": body}) + if self.error is not None: + raise self.error + return self.responses.get(path) + + +def make_backend(config=None, client=None): + cfg = {"agent_token": TOKEN, "agent_id": "agt_1"} + cfg.update(config or {}) + return backendmod.EverMeBackend(FakeContext(config=cfg), client=client) + + +def run(coro): + return asyncio.run(coro) + + +class TestConstruction(unittest.TestCase): + def test_missing_token_fails_loudly(self): + with self.assertRaises(ValueError) as ctx: + backendmod.EverMeBackend(FakeContext(config={})) + self.assertIn("evercli plugin install raven", str(ctx.exception)) + + def test_make_backend_factory(self): + b = backendmod.make_backend(FakeContext(config={"agent_token": TOKEN})) + self.assertIsInstance(b, backendmod.EverMeBackend) + + +class TestRecall(unittest.TestCase): + def _search_result(self): + # relevanceScore is the real BFF field name (SearchResultItem json + # tag). The earlier "score" fake masked a field-name bug that left + # every recalled episode at score 0 in production. + return {"items": [ + {"episode": "User likes durian", "atomicFacts": ["likes durian"], + "relevanceScore": 0.8, "id": "mem_1"}, + {"episode": "", "atomicFacts": ["orphan"]}, # dropped: no episode + {"episode": "Summer is the favorite season", "relevanceScore": 7}, # clamped + ]} + + def test_xor_violation_returns_empty(self): + fc = FakeClient() + b = make_backend(client=fc) + self.assertEqual(run(b.recall("query", top_k=5)), []) + self.assertEqual( + run(b.recall("query", user_id="u", agent_id="a", top_k=5)), []) + self.assertEqual(fc.calls, []) + + def test_user_track_maps_items_to_memories(self): + fc = FakeClient(responses={"/mem/search": self._search_result()}) + b = make_backend(client=fc) + out = run(b.recall("what fruit", user_id="u1", top_k=5)) + self.assertEqual(len(out), 2) + self.assertEqual(out[0].text, "- User likes durian\n - likes durian") + self.assertAlmostEqual(out[0].score, 0.8) + self.assertEqual(out[0].metadata["id"], "mem_1") + self.assertEqual(out[0].metadata["owner_type"], "user") + self.assertEqual(out[1].score, 1.0) # 7 clamped to [0, 1] + self.assertEqual(fc.calls[0]["path"], "/mem/search") + self.assertEqual(fc.calls[0]["body"], {"query": "what fruit", "topK": 5}) + + def test_agent_memory_cases_and_skills_surface(self): + # /mem/search returns data.agent_memory.{cases,skills} alongside the + # episodic items. recall() must render them too — an agent that wrote + # trajectories via /mem/agent-memory has to be able to recall the + # cases/skills those writes produced, not just episodes. + res = { + "items": [{"episode": "Wrote and ran factorial.py -> 720", + "relevanceScore": 0.5, "id": "e1"}], + "agentMemory": { + "cases": [{ + "id": "case_1", + "taskIntent": "Write and run a Python script that computes a value", + "approach": "created the file, ran python3, reported the output", + "relevanceScore": 0.6, + }], + "skills": [{ + "id": "skill_1", + "name": "write-and-run-python-script", + "description": "Author a small Python script and execute it", + "content": "1. write file 2. run python3 file 3. report output", + "relevanceScore": 0.7, + }], + }, + } + fc = FakeClient(responses={"/mem/search": res}) + b = make_backend(client=fc) + out = run(b.recall("python task", agent_id="agt_1", top_k=10)) + + by_type = {m.metadata["type"]: m for m in out} + self.assertIn("episode", by_type) + self.assertIn("case", by_type) + self.assertIn("skill", by_type) + + case = by_type["case"] + self.assertIn("Write and run a Python script", case.text) + self.assertIn("created the file", case.text) + self.assertEqual(case.metadata["id"], "case_1") + self.assertEqual(case.metadata["owner_type"], "agent") + self.assertAlmostEqual(case.score, 0.6) + + skill = by_type["skill"] + self.assertIn("write-and-run-python-script", skill.text) + self.assertIn("run python3 file", skill.text) + self.assertEqual(skill.metadata["id"], "skill_1") + self.assertAlmostEqual(skill.score, 0.7) + + def test_agent_memory_absent_or_empty_is_safe(self): + # No agentMemory key, and an empty one, must both render cleanly. + for res in ({"items": []}, {"items": [], "agentMemory": {}}, + {"items": [], "agentMemory": {"cases": [], "skills": []}}): + fc = FakeClient(responses={"/mem/search": res}) + b = make_backend(client=fc) + self.assertEqual(run(b.recall("q", agent_id="agt_1", top_k=5)), []) + + def test_profile_prepended_on_user_track_after_start(self): + fc = FakeClient(responses={ + "/mem/context": {"profile": {"explicit_info": [ + {"category": "food", "description": "likes durian"}]}}, + "/mem/search": self._search_result(), + }) + b = make_backend(client=fc) + run(b.start()) + out = run(b.recall("what fruit", user_id="u1", top_k=5)) + self.assertEqual(out[0].metadata["type"], "profile") + self.assertEqual(out[0].text, "- [food] likes durian") + self.assertEqual(out[0].score, 1.0) + # agent track never gets the profile block + out_agent = run(b.recall("what fruit", agent_id="agt_1", top_k=5)) + self.assertTrue(all(m.metadata["type"] != "profile" for m in out_agent)) + + def test_short_query_skips_search_but_keeps_profile(self): + fc = FakeClient(responses={ + "/mem/context": {"profile": {"explicit_info": [ + {"description": "likes durian"}]}}, + }) + b = make_backend(client=fc) + run(b.start()) + out = run(b.recall("hi", user_id="u1", top_k=5)) + self.assertEqual(len(out), 1) + self.assertEqual(out[0].metadata["type"], "profile") + self.assertEqual([c["path"] for c in fc.calls], ["/mem/context"]) + + def test_search_failure_swallowed(self): + fc = FakeClient(error=EvermeError("boom")) + b = make_backend(client=fc) + self.assertEqual(run(b.recall("what fruit", user_id="u1", top_k=5)), []) + + def test_start_profile_failure_swallowed(self): + fc = FakeClient(error=EvermeError("boom")) + b = make_backend(client=fc) + run(b.start()) # must not raise + + +class TestStore(unittest.TestCase): + def test_converts_and_posts_turn_slice(self): + fc = FakeClient() + b = make_backend(client=fc) + run(b.store("sess-1", [ + {"role": "system", "content": "ignored"}, + {"role": "user", "content": "hello", "timestamp": 1751500000000}, + {"role": "assistant", "content": "hi", "tool_calls": [ + {"id": "tc1", "function": {"name": "grep", "arguments": '{"q":"x"}'}}]}, + {"role": "tool", "tool_call_id": "tc1", "content": "match"}, + ])) + self.assertEqual(len(fc.calls), 1) + body = fc.calls[0]["body"] + self.assertEqual(fc.calls[0]["path"], "/mem/agent-memory") + self.assertEqual(body["conversationId"], "sess-1") + self.assertTrue(body["flush"]) + msgs = body["messages"] + self.assertEqual([m["role"] for m in msgs], ["user", "assistant", "tool"]) + self.assertEqual(msgs[0]["timestamp"], 1751500000000) + self.assertEqual(msgs[1]["toolCalls"][0]["name"], "grep") + self.assertEqual(msgs[1]["toolCalls"][0]["arguments"], '{"q":"x"}') + self.assertEqual(msgs[2]["toolCallId"], "tc1") + for m in msgs: + self.assertIsInstance(m["timestamp"], int) + self.assertGreater(m["timestamp"], 10_000_000_000) + + def test_flush_cadence_follows_config(self): + fc = FakeClient() + b = make_backend(config={"flush_every_turns": 2}, client=fc) + turn = [{"role": "user", "content": "hello"}] + run(b.store("sess-1", turn)) + run(b.store("sess-1", turn)) + run(b.store("sess-2", turn)) # independent per-session counter + self.assertEqual([c["body"]["flush"] for c in fc.calls], + [False, True, False]) + + def test_empty_or_unconvertible_slice_skips_post(self): + fc = FakeClient() + b = make_backend(client=fc) + run(b.store("sess-1", [])) + run(b.store("sess-1", [{"role": "system", "content": "x"}])) + self.assertEqual(fc.calls, []) + + def test_store_failure_swallowed(self): + fc = FakeClient(error=EvermeError("boom")) + b = make_backend(client=fc) + run(b.store("sess-1", [{"role": "user", "content": "hello"}])) # must not raise + + def test_content_capped(self): + fc = FakeClient() + b = make_backend(client=fc) + run(b.store("sess-1", [{"role": "user", "content": "x" * 10000}])) + self.assertEqual( + len(fc.calls[0]["body"]["messages"][0]["content"]), 8000) + + +class TestConvert(unittest.TestCase): + def test_seconds_timestamp_coerced_to_ms(self): + out = backendmod._convert_messages( + [{"role": "user", "content": "x", "timestamp": 1751500000.5}]) + self.assertEqual(out[0]["timestamp"], 1751500000500) + + def test_content_block_tool_use_preserved(self): + out = backendmod._convert_messages([{ + "role": "assistant", + "content": [ + {"type": "text", "text": "let me check"}, + {"type": "tool_use", "id": "tc9", "name": "read", + "input": {"path": "/tmp/x"}}, + ], + }]) + self.assertEqual(out[0]["content"], "let me check") + self.assertEqual(out[0]["toolCalls"][0]["id"], "tc9") + self.assertIn('"path"', out[0]["toolCalls"][0]["arguments"]) + + def test_tool_result_without_call_id_dropped(self): + out = backendmod._convert_messages( + [{"role": "tool", "content": "orphan"}]) + self.assertEqual(out, []) + + +class TestFeedback(unittest.TestCase): + def test_noop_does_not_raise(self): + b = make_backend(client=FakeClient()) + run(b.feedback({"kind": "skill_usage"})) + run(b.feedback({"kind": "skill_usage"})) + self.assertTrue(b._feedback_noop_logged) + + +if __name__ == "__main__": + unittest.main() diff --git a/cli/internal/plugin/ravenassets/tests/test_client.py b/cli/internal/plugin/ravenassets/tests/test_client.py new file mode 100644 index 0000000..fab2420 --- /dev/null +++ b/cli/internal/plugin/ravenassets/tests/test_client.py @@ -0,0 +1,100 @@ +import json +import unittest + +from _fakes import install_fakes, make_plugin_importable + +install_fakes() +make_plugin_importable() + +from everme_raven import client as clientmod # noqa: E402 + + +class FakeResponse: + def __init__(self, body, status=200): + self._body = body.encode() if isinstance(body, str) else body + self.status = status + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +class TestRedact(unittest.TestCase): + def test_redacts_evt_token(self): + msg = "boom evt_" + "a" * 32 + " tail" + self.assertNotIn("a" * 32, clientmod.redact_error(msg)) + self.assertIn("evt_", clientmod.redact_error(msg)) + + def test_redacts_emk_token(self): + msg = "emk_" + "b" * 32 + self.assertNotIn("b" * 32, clientmod.redact_error(msg)) + + +class TestEnvelope(unittest.TestCase): + def _client(self, opener): + cfg = {"api_base": "https://x/api/v1", "agent_id": "agt", "agent_token": "evt_tok"} + return clientmod.EverMeClient(cfg, opener=opener) + + def test_status_zero_returns_result(self): + def opener(req, timeout): + return FakeResponse(json.dumps({"status": 0, "result": {"ok": True}})) + c = self._client(opener) + self.assertEqual(c.request("POST", "/mem/search", {"query": "q"}), {"ok": True}) + + def test_nonzero_status_raises_typed_error(self): + def opener(req, timeout): + return FakeResponse(json.dumps({"status": 30001, "error": "nope", "requestId": "r1"})) + c = self._client(opener) + with self.assertRaises(clientmod.EvermeError) as ctx: + c.request("POST", "/mem/search", {"query": "q"}) + self.assertEqual(ctx.exception.code, 30001) + self.assertEqual(ctx.exception.type, "auth") + + def test_bearer_and_user_agent_headers_set(self): + captured = {} + + def opener(req, timeout): + captured["auth"] = req.get_header("Authorization") + captured["ua"] = req.get_header("User-agent") + return FakeResponse(json.dumps({"status": 0, "result": None})) + self._client(opener).request("POST", "/mem/context", {}) + self.assertEqual(captured["auth"], "Bearer evt_tok") + self.assertIn("everme-raven-plugin/", captured["ua"]) + + +class TestRetry(unittest.TestCase): + def _client(self, opener): + cfg = {"api_base": "https://x/api/v1", "agent_token": "evt_tok"} + return clientmod.EverMeClient(cfg, opener=opener) + + def test_get_retries_once_on_transport_error(self): + calls = {"n": 0} + + def opener(req, timeout): + calls["n"] += 1 + if calls["n"] == 1: + raise OSError("conn reset") + return FakeResponse(json.dumps({"status": 0, "result": "ok"})) + c = self._client(opener) + self.assertEqual(c.request("GET", "/health"), "ok") + self.assertEqual(calls["n"], 2) + + def test_post_never_retried(self): + calls = {"n": 0} + + def opener(req, timeout): + calls["n"] += 1 + raise OSError("conn reset") + c = self._client(opener) + with self.assertRaises(clientmod.EvermeError): + c.request("POST", "/mem/agent-memory", {"messages": []}) + self.assertEqual(calls["n"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/cli/internal/plugin/ravenassets/tests/test_config.py b/cli/internal/plugin/ravenassets/tests/test_config.py new file mode 100644 index 0000000..7fae22d --- /dev/null +++ b/cli/internal/plugin/ravenassets/tests/test_config.py @@ -0,0 +1,53 @@ +import os +import unittest + +from _fakes import install_fakes, make_plugin_importable + +install_fakes() +make_plugin_importable() + +from everme_raven import config as cfgmod # noqa: E402 + + +class TestConfig(unittest.TestCase): + def setUp(self): + for k in ("EVERME_API_BASE", "EVERME_AGENT_ID", "EVERME_AGENT_TOKEN"): + os.environ.pop(k, None) + + def test_default_api_base_gets_v1_prefix(self): + c = cfgmod.resolve_config({}) + self.assertEqual(c["api_base"], "https://api.everme.evermind.ai/api/v1") + + def test_config_dict_wins_over_env(self): + os.environ["EVERME_AGENT_TOKEN"] = "evt_env" + "a" * 32 + c = cfgmod.resolve_config({"agent_token": "evt_cfg" + "b" * 32}) + self.assertEqual(c["agent_token"], "evt_cfg" + "b" * 32) + + def test_env_fills_missing_config_keys(self): + os.environ["EVERME_AGENT_TOKEN"] = "evt_" + "a" * 32 + os.environ["EVERME_AGENT_ID"] = "agt_x" + c = cfgmod.resolve_config({"api_base": "https://custom.example"}) + self.assertEqual(c["agent_token"], "evt_" + "a" * 32) + self.assertEqual(c["agent_id"], "agt_x") + self.assertEqual(c["api_base"], "https://custom.example/api/v1") + + def test_api_base_existing_v1_suffix_not_doubled(self): + c = cfgmod.resolve_config({"api_base": "https://x/api/v1/"}) + self.assertEqual(c["api_base"], "https://x/api/v1") + + def test_none_config_ok(self): + c = cfgmod.resolve_config(None) + self.assertEqual(c["flush_every_turns"], 1) + self.assertEqual(c["timeout_s"], 30.0) + + def test_numeric_coercion_and_bad_values(self): + c = cfgmod.resolve_config({"flush_every_turns": "5", "timeout_s": "2.5"}) + self.assertEqual(c["flush_every_turns"], 5) + self.assertEqual(c["timeout_s"], 2.5) + c = cfgmod.resolve_config({"flush_every_turns": "x", "timeout_s": {}}) + self.assertEqual(c["flush_every_turns"], 1) + self.assertEqual(c["timeout_s"], 30.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/cli/internal/plugin/registry.go b/cli/internal/plugin/registry.go index 8e8167e..71291cd 100644 --- a/cli/internal/plugin/registry.go +++ b/cli/internal/plugin/registry.go @@ -8,8 +8,8 @@ import ( // registry is the central catalogue of supported platforms. Tests inject // their own via NewServiceWithRegistry, but production code goes through // DefaultRegistry which carries the V1 install matrix (Claude Code + -// OpenClaw + Cursor + Claude Desktop + Codex + Hermes + Gemini CLI + -// opencode). Windsurf / VS Code Copilot / Cline are V1.1 candidates. +// OpenClaw + Cursor + Claude Desktop + Codex + Hermes + +// Devin + WorkBuddy + opencode). VS Code Copilot / Cline remain future candidates. type registry struct { detectors map[Platform]Detector writers map[Platform]Writer @@ -35,11 +35,12 @@ type registry struct { // The plugin source is installed separately via // `openclaw plugins install @everme/openclaw`. // -// - PlatformCursor / PlatformClaudeDesktop / PlatformGemini → mcpWriter -// All three hosts read MCP servers from a top-level `mcpServers.` -// JSON map, so the writer is the shared mcpWriter parameterised by -// platform. Only the config file location is host-specific (see -// cursor.go / claude_desktop.go / gemini.go). +// - PlatformCursor / PlatformDevin → nativeHookWriter +// Each host receives the shared MCP entry plus native lifecycle hooks and +// a protected everme.env file; Cursor and Devin use separate hook configs. +// +// - PlatformClaudeDesktop / PlatformWorkBuddy → mcpWriter +// These MCP-only hosts use the shared JSON writer. // // - PlatformOpenCode → opencodeWriter // opencode reads MCP servers from a top-level `mcp.` map in @@ -63,6 +64,25 @@ type registry struct { // entry. Memory capture is hook-driven (sync_turn / on_session_end), // not dependent on model-initiated tool calls. Implements Verifier // (provider files + memory.provider) but not Preparer. See hermes.go. +// +// - PlatformRaven → ravenWriter +// Raven (Python) discovers external plugins from +// ~/.raven/plugins//raven-plugin.toml and binds one memory +// backend via config.json memory.backend (single slot). ravenWriter +// drops the embedded EverMe MemoryBackend there and patches +// config.json (memory.backend=everme + plugins.config credentials — +// Raven's config.json is its canonical credential store, so no env +// file). Memory capture is host-driven (recall before turn / store +// after turn), not dependent on model-initiated tool calls. +// Implements Verifier (plugin manifest + memory.backend) but not +// Preparer. See raven.go. +// +// - PlatformDSH → dshWriter +// DeepSeek Harness loads @deepseek-ai/dsh-mcp-client from +// ~/.dsh/cordis.patch.yml. dshWriter owns a reversible patch block and +// a protected ~/.dsh/.env credential block because DSH scrubs inherited +// credential-shaped variables before spawning stdio MCP servers. +// Implements Preparer, Verifier and Remover. See dsh.go. func DefaultRegistry() *registry { return ®istry{ detectors: map[Platform]Detector{ @@ -72,8 +92,12 @@ func DefaultRegistry() *registry { PlatformClaudeDesktop: claudeDesktopDetector{}, PlatformCodex: codexDetector{}, PlatformHermes: hermesDetector{}, - PlatformGemini: geminiDetector{}, + PlatformDevin: devinDetector{}, + PlatformWorkBuddy: workBuddyDetector{}, PlatformOpenCode: opencodeDetector{}, + PlatformKimiCode: kimiCodeDetector{}, + PlatformRaven: ravenDetector{}, + PlatformDSH: dshDetector{}, }, writers: map[Platform]Writer{ PlatformClaudeCode: newClaudeCodeWriter(), @@ -82,8 +106,12 @@ func DefaultRegistry() *registry { PlatformClaudeDesktop: newClaudeDesktopWriter(), PlatformCodex: newCodexWriter(), PlatformHermes: newHermesWriter(), - PlatformGemini: newGeminiWriter(), + PlatformDevin: newDevinWriter(), + PlatformWorkBuddy: newWorkBuddyWriter(), PlatformOpenCode: newOpenCodeWriter(), + PlatformKimiCode: newKimiCodeWriter(), + PlatformRaven: newRavenWriter(), + PlatformDSH: newDSHWriter(), }, } } diff --git a/cli/internal/plugin/registry_test.go b/cli/internal/plugin/registry_test.go index 0b030d9..9141b64 100644 --- a/cli/internal/plugin/registry_test.go +++ b/cli/internal/plugin/registry_test.go @@ -9,15 +9,17 @@ import ( // New platforms must be registered in BOTH the detectors and writers // maps, and SupportedPlatforms must list them. Missing either side would // make install hand back a nil writer/detector at runtime. -func TestDefaultRegistry_GeminiAndOpenCodeRegistered(t *testing.T) { +func TestDefaultRegistry_NewHostsRegistered(t *testing.T) { r := DefaultRegistry() - for _, p := range []Platform{PlatformGemini, PlatformOpenCode} { + for _, p := range []Platform{PlatformDSH, PlatformDevin, PlatformOpenCode, PlatformWorkBuddy} { assert.True(t, r.Has(p), "detector missing for %s", p) assert.NotNil(t, r.writer(p), "writer missing for %s", p) assert.NotNil(t, r.detector(p), "detector nil for %s", p) } supported := r.SupportedPlatforms() - assert.Contains(t, supported, PlatformGemini) + assert.Contains(t, supported, PlatformDSH) + assert.Contains(t, supported, PlatformDevin) assert.Contains(t, supported, PlatformOpenCode) + assert.Contains(t, supported, PlatformWorkBuddy) } diff --git a/cli/internal/plugin/service.go b/cli/internal/plugin/service.go index edf6687..f753163 100644 --- a/cli/internal/plugin/service.go +++ b/cli/internal/plugin/service.go @@ -5,12 +5,17 @@ import ( "fmt" "os" "sync" + "time" "evercli/internal/client" + "evercli/internal/core" "evercli/internal/machineid" "evercli/internal/output" + "evercli/internal/runctx" ) +const dshOperationTimeoutFloor = 5 * time.Minute + // defaultMachineFn adapts machineid.Fingerprint to the per-platform // signature used internally. Kept as a function so tests can swap it via // SetMachineFingerprintFn. @@ -216,6 +221,9 @@ type InstallEntry struct { ConfigPath string `json:"configPath"` BackupPath string `json:"backupPath,omitempty"` Warnings []string `json:"warnings,omitempty"` + // NextSteps are required manual follow-ups carried up from + // WriteResult.NextSteps (e.g. Kimi Code's TUI `/plugins install`). + NextSteps []string `json:"nextSteps,omitempty"` } // SkipEntry covers --no-prompt + not-detected and similar voluntary @@ -255,6 +263,9 @@ func (s *Service) Install(ctx context.Context, platforms []Platform, opts Instal if len(platforms) == 0 { return nil, output.Invalid("at least one platform is required", "Pass platform names, e.g. `evercli plugin install claude-code`") } + ctx, cancel := installOperationContext(ctx, platforms) + defer cancel() + rep := &InstallReport{} for _, p := range platforms { if !s.reg.Has(p) { @@ -359,9 +370,8 @@ func (s *Service) installOne(ctx context.Context, p Platform, opts InstallOption // the previous evt. The retry path is "rerun install": same-platform // + same-fingerprint upsert on /agents auto-rotates the token, so // a stranded server-side token from a failed Commit self-heals on - // the next install attempt. See H.4 in - // docs/mcp-codex-hermes-iteration-plan-2026-05-26.md for why V1 - // doesn't restore Client.DisconnectAgent. + // the next install attempt. V1 deliberately doesn't restore + // Client.DisconnectAgent. res, err := wr.Commit(ctx, plan, WriteParams{ AgentID: regResp.AgentID, AgentToken: regResp.AgentToken, @@ -397,6 +407,7 @@ func (s *Service) installOne(ctx context.Context, p Platform, opts InstallOption TokenPrefix: regResp.TokenPrefix, ConfigPath: res.ConfigPath, BackupPath: res.BackupPath, + NextSteps: res.NextSteps, } if vr, ok := wr.(Verifier); ok { if err := vr.Verify(ctx, res); err != nil { @@ -444,12 +455,107 @@ func failedFromWithHint(p Platform, err error, extraHint string) FailedEntry { } } -// (Service.Uninstall / findCloudAgent / classifyDisconnectErr and the -// associated UninstallResult / UninstallOptions / DisconnectErrorDetail -// types were retired in the slimming pass. The plugin lifecycle is now -// "install-only"; users disconnect agents from the EverMe web UI and -// remove local MCP entries by hand if needed. Writer.Remove is also -// gone — see types.go and the per-writer files.) +func (s *Service) Uninstall(ctx context.Context, p Platform, opts UninstallOptions) (*UninstallResult, error) { + if !s.reg.Has(p) { + return nil, output.Invalid(fmt.Sprintf("unknown platform %q", p), "") + } + if p == PlatformDSH { + var cancel context.CancelFunc + ctx, cancel = dshOperationContext(ctx) + defer cancel() + } + detection, detErr := s.reg.detector(p).Detect(ctx) + if detection == nil { + detection = &Detection{Platform: p, DisplayName: string(p)} + } + wr := s.reg.writer(p) + rm, ok := wr.(Remover) + if !ok { + return nil, output.Invalid(fmt.Sprintf("platform %s does not support uninstall", p), "Upgrade evercli or remove the EverMe entry manually") + } + res, err := rm.Remove(ctx, detection.ConfigPath) + if err != nil { + return nil, err + } + out := &UninstallResult{Platform: p, Removed: res.Removed, ConfigPath: res.ConfigPath, BackupPath: res.BackupPath} + if advisor, ok := wr.(UninstallAdvisor); ok { + out.NextSteps = append([]string(nil), advisor.UninstallNextSteps()...) + } + if detErr != nil { + ce := output.ClassifyError(detErr) + out.LocalDetectError = &DetectErrorItem{Type: string(ce.Type), Code: ce.Code, Message: ce.Message, Hint: ce.Hint} + } + // Local cleanup is idempotent. Even when the entry was already removed + // manually, uninstall must still converge the cloud state and revoke the + // matching agent; otherwise a successful local retry leaves an orphaned + // evt_* token behind. + if opts.KeepAgent { + return out, nil + } + agents, err := s.cli.ListAgents(ctx, client.AgentFilter{Platform: string(p)}) + if err != nil { + out.DisconnectError = classifyDisconnectErr(err, "") + return out, nil + } + want := s.machineFn(p) + var candidate *client.Agent + for i := range agents { + a := &agents[i] + if a.Platform == string(p) && a.MachineFingerprint == want { + candidate = a + break + } + } + if candidate == nil { + // Only an exact fingerprint match may be disconnected. An older + // fallback picked a lone fingerprint-less same-platform agent, + // which could revoke ANOTHER machine's token. Not an error — + // local cleanup already succeeded; surface the fact so JSON and + // text consumers both see nothing was disconnected. + out.NoMatchingCloudAgent = true + out.NextSteps = append(out.NextSteps, fmt.Sprintf( + "no cloud agent matched this machine's fingerprint for %s, so none was disconnected — if an agent for this machine still appears in the EverMe web UI, disconnect it there", + p)) + return out, nil + } + if err := s.cli.DisconnectAgent(ctx, candidate.ID); err != nil { + out.DisconnectError = classifyDisconnectErr(err, candidate.ID) + return out, nil + } + out.AgentDisconnected = true + return out, nil +} + +func installOperationContext(parent context.Context, platforms []Platform) (context.Context, context.CancelFunc) { + for _, platform := range platforms { + if platform == PlatformDSH { + return dshOperationContext(parent) + } + } + return parent, func() {} +} + +func dshOperationContext(parent context.Context) (context.Context, context.CancelFunc) { + base := runctx.BaseContext(parent) + deadline, ok := parent.Deadline() + if !ok { + return context.WithCancel(base) + } + timeout := time.Until(deadline) + if timeout < dshOperationTimeoutFloor { + timeout = dshOperationTimeoutFloor + } + return context.WithTimeout(base, timeout) +} + +func classifyDisconnectErr(err error, agentID string) *DisconnectErrorDetail { + ce := output.ClassifyError(err) + hint := ce.Hint + if hint == "" { + hint = "Disconnect this agent in the EverMe Web UI, or rerun with --keep-agent" + } + return &DisconnectErrorDetail{Type: ce.Type, Code: ce.Code, Message: ce.Message, Hint: hint, AgentID: agentID} +} // buildRegisterReq composes the RegisterAgent request body for Install. // The display label comes from the live Detector — `evercli plugin @@ -462,13 +568,12 @@ func (s *Service) buildRegisterReq(p Platform, displayName string) client.Regist Platform: string(p), Name: displayName + " @ " + shortHost(hostname), MachineFingerprint: s.machineFn(p), - ClientVersion: "evercli/" + osTag(), + ClientVersion: core.TruncateClientVersion("evercli/" + osTag()), } } // (Service.Register / RegisterResult / displayFallback were retired in V1 -// alongside the `evercli plugin register` cobra command — see -// docs/mcp-codex-hermes-iteration-plan-2026-05-26.md §D.3. The backend +// alongside the `evercli plugin register` cobra command. The backend // /agents endpoint stays; Install drives it through the path above.) // osTag is a short string ("darwin"/"linux"/"windows") for the backend's diff --git a/cli/internal/plugin/service_lifecycle_test.go b/cli/internal/plugin/service_lifecycle_test.go index 124b334..9e92b7e 100644 --- a/cli/internal/plugin/service_lifecycle_test.go +++ b/cli/internal/plugin/service_lifecycle_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -15,6 +16,7 @@ import ( "evercli/internal/client" "evercli/internal/credential" "evercli/internal/httpmock" + "evercli/internal/runctx" ) // Service-level integration tests for the lifecycle hooks (Preparer / @@ -34,11 +36,12 @@ import ( // whether each hook fired so we can assert call ordering against the // httpmock request log. type lifecycleStubWriter struct { - inner *mcpWriter - prepareErr error - verifyErr error - prepareCount *int - verifyCount *int + inner *mcpWriter + prepareErr error + prepareCtxErr *error + verifyErr error + prepareCount *int + verifyCount *int } func (w *lifecycleStubWriter) Platform() Platform { return w.inner.Platform() } @@ -51,10 +54,13 @@ func (w *lifecycleStubWriter) Commit(ctx context.Context, plan *WritePlan, param return w.inner.Commit(ctx, plan, params) } -func (w *lifecycleStubWriter) Prepare(_ context.Context, _ *Detection) error { +func (w *lifecycleStubWriter) Prepare(ctx context.Context, _ *Detection) error { if w.prepareCount != nil { *w.prepareCount++ } + if w.prepareCtxErr != nil { + *w.prepareCtxErr = ctx.Err() + } return w.prepareErr } @@ -136,6 +142,45 @@ func TestInstall_RunsPrepareBeforeRegisterAgent(t *testing.T) { "Prepare must complete before /agents fires — Prepare-failure-no-token-mint invariant depends on this ordering") } +func TestInstall_DSHDetachesExpiredGlobalTimeout(t *testing.T) { + var prepareCtxErr error + lwriter := &lifecycleStubWriter{ + inner: newMCPWriter(PlatformDSH), + prepareCtxErr: &prepareCtxErr, + } + srv, svc, _ := newLifecycleFixture(t, lwriter) + srv.HandleEnvelope("POST /agents", client.RegisterAgentResp{ + AgentID: "agt_dsh", SourceID: "src_dsh", + AgentToken: "evt_freshly_minted", TokenPrefix: "evt_dsh1", + }) + + base, cancelBase := context.WithCancel(context.Background()) + defer cancelBase() + expired, cancelExpired := context.WithTimeout(base, 0) + defer cancelExpired() + <-expired.Done() + ctx := runctx.WithBaseContext(expired, base) + + rep, err := svc.Install(ctx, []Platform{PlatformDSH}, InstallOptions{}, nil) + require.NoError(t, err) + require.Empty(t, rep.Failed) + require.Len(t, rep.Installed, 1) + assert.NoError(t, prepareCtxErr) +} + +func TestDSHOperationContextFloorsShortDeadline(t *testing.T) { + base := context.Background() + parent, cancelParent := context.WithTimeout(base, time.Second) + defer cancelParent() + parent = runctx.WithBaseContext(parent, base) + + ctx, cancel := dshOperationContext(parent) + defer cancel() + deadline, ok := ctx.Deadline() + require.True(t, ok) + assert.Greater(t, time.Until(deadline), 4*time.Minute+50*time.Second) +} + // TestInstall_PrepareFailure_DoesNotCallRegisterAgent pins the harder // half of H.4: when Prepare fails, the backend must never see a // /agents request. Otherwise we leak a stranded cloud token that diff --git a/cli/internal/plugin/service_test.go b/cli/internal/plugin/service_test.go index 5e32028..cf82645 100644 --- a/cli/internal/plugin/service_test.go +++ b/cli/internal/plugin/service_test.go @@ -312,9 +312,25 @@ func TestInstall_EmptyPlatforms_ReturnsInvalidArgs(t *testing.T) { // ---- Uninstall ----------------------------------------------------- +func TestUninstall_DisconnectsCloudAgentWhenLocalStateAlreadyMissing(t *testing.T) { + tmp := t.TempDir() + configPath := filepath.Join(tmp, "claude.json") + srv, svc, _ := newServiceFixture(t, PlatformClaudeCode, configPath, true) + handleAgentsListFiltered(t, srv, []map[string]any{{ + "id": "agt_orphan", "platform": "claude-code", "machineFingerprint": "test-fingerprint", + }}) + srv.HandleEnvelope("POST /agents/disconnect", map[string]bool{"ok": true}) + + res, err := svc.Uninstall(context.Background(), PlatformClaudeCode, UninstallOptions{}) + require.NoError(t, err) + assert.False(t, res.Removed) + assert.True(t, res.AgentDisconnected) + assert.NotNil(t, srv.LastRequest("POST /agents/disconnect"), + "idempotent local cleanup must still revoke the matching cloud agent") +} + // withMcpEntry pre-seeds a config file with our everme-memory entry, so -// uninstall has something to remove. Used by every test below that -// exercises the cloud-disconnect path. +// uninstall has something to remove. func withMcpEntry(t *testing.T, dir string) string { t.Helper() configPath := filepath.Join(dir, "claude.json") @@ -330,15 +346,110 @@ func withMcpEntry(t *testing.T, dir string) string { } // Backend serves /agents/disconnect via MemAuth + plugin:manage, so -// emk-driven uninstall is the happy path. A 401 on disconnect means a -// genuine auth failure (revoked emk or scope mismatch) — we -// surface it as TypeAuth and let local removal complete regardless. +// emk-driven uninstall is the happy path. An auth errno on disconnect +// means a genuine auth failure (revoked emk or scope mismatch) — we +// surface it as DisconnectError and let local removal complete +// regardless: the user must not be locked out of local cleanup. +func TestUninstall_DisconnectAuthFailure_SurfacesDisconnectError(t *testing.T) { + tmp := t.TempDir() + configPath := withMcpEntry(t, tmp) + srv, svc, _ := newServiceFixture(t, PlatformClaudeCode, configPath, true) + handleAgentsListFiltered(t, srv, []map[string]any{{ + "id": "agt_mine", "platform": "claude-code", "machineFingerprint": "test-fingerprint", + }}) + srv.HandleEnvelopeError("POST /agents/disconnect", 30001, "ErrUnauthorized") + + res, err := svc.Uninstall(context.Background(), PlatformClaudeCode, UninstallOptions{}) + require.NoError(t, err, "disconnect failure must not fail the whole uninstall") + assert.True(t, res.Removed, "local cleanup must complete despite the disconnect failure") + assert.False(t, res.AgentDisconnected) + require.NotNil(t, res.DisconnectError) + assert.Equal(t, 30001, res.DisconnectError.Code, "upstream errno must survive into the result") + assert.Equal(t, output.TypeAuth, res.DisconnectError.Type) + assert.Equal(t, "agt_mine", res.DisconnectError.AgentID) + assert.NotEmpty(t, res.DisconnectError.Hint, "user needs the web-UI fallback hint") +} -// TestUninstall_DetectorError_SurfacesLocalDetectError verifies the +// TestUninstall_DetectorError_SurfacesLocalDetectError verifies a // detector failure (e.g. permissions denied / malformed JSON) is // captured on the result instead of being silently swallowed. The // uninstall still proceeds — we don't want a busted local config to // leave the user without a way to clean up. +func TestUninstall_DetectorError_SurfacesLocalDetectError(t *testing.T) { + tmp := t.TempDir() + configPath := withMcpEntry(t, tmp) + + srv := httpmock.NewServer(t) + mem := credential.NewMem() + require.NoError(t, mem.Set(context.Background(), credential.APIKey(), + "emk_0123456789abcdef0123456789abcdef")) + cli := client.NewWithHTTP(srv.URL(), mem, srv.HTTPClient()) + detErr := &output.CLIError{Type: output.TypeIO, Code: 4001, Message: "config unreadable"} + reg := ®istry{ + detectors: map[Platform]Detector{PlatformClaudeCode: stubDetector{ + platform: PlatformClaudeCode, display: "Claude Code", + configPath: configPath, detectErr: detErr, + }}, + writers: map[Platform]Writer{PlatformClaudeCode: newMCPWriter(PlatformClaudeCode)}, + } + svc := NewServiceWithRegistry(cli, reg, "https://api.test") + svc.SetMachineFingerprintFn(func(_ Platform) string { return "test-fingerprint" }) + handleAgentsListFiltered(t, srv, []map[string]any{{ + "id": "agt_mine", "platform": "claude-code", "machineFingerprint": "test-fingerprint", + }}) + srv.HandleEnvelope("POST /agents/disconnect", map[string]bool{"ok": true}) + + res, err := svc.Uninstall(context.Background(), PlatformClaudeCode, UninstallOptions{}) + require.NoError(t, err, "detector failure must not abort uninstall") + assert.True(t, res.Removed, "local cleanup must still run") + assert.True(t, res.AgentDisconnected, "cloud disconnect must still run") + require.NotNil(t, res.LocalDetectError) + assert.Equal(t, string(output.TypeIO), res.LocalDetectError.Type) + assert.Equal(t, 4001, res.LocalDetectError.Code, "detector error code must propagate") +} + +// TestUninstall_NoFingerprintMatch_DoesNotDisconnect pins the removal of +// the fingerprint-less fallback: when no cloud agent carries THIS +// machine's fingerprint, nothing may be disconnected — the lone +// unpinned agent could belong to another machine. The result surfaces +// the fact instead of failing. +func TestUninstall_NoFingerprintMatch_DoesNotDisconnect(t *testing.T) { + tmp := t.TempDir() + configPath := withMcpEntry(t, tmp) + srv, svc, _ := newServiceFixture(t, PlatformClaudeCode, configPath, true) + handleAgentsListFiltered(t, srv, []map[string]any{ + {"id": "agt_other", "platform": "claude-code", "machineFingerprint": "fp-other-machine"}, + {"id": "agt_unpinned", "platform": "claude-code", "machineFingerprint": ""}, + }) + srv.HandleEnvelope("POST /agents/disconnect", map[string]bool{"ok": true}) + + res, err := svc.Uninstall(context.Background(), PlatformClaudeCode, UninstallOptions{}) + require.NoError(t, err) + assert.True(t, res.Removed) + assert.False(t, res.AgentDisconnected) + assert.True(t, res.NoMatchingCloudAgent, + "result must say no cloud agent matched this machine") + assert.NotEmpty(t, res.NextSteps, + "user needs the manual web-UI disconnect pointer") + assert.Nil(t, srv.LastRequest("POST /agents/disconnect"), + "a lone fingerprint-less agent must NOT be revoked — it may be another machine's") +} + +// TestUninstall_KeepAgent_SkipsCloudCalls pins --keep-agent: local +// cleanup only, zero backend traffic. +func TestUninstall_KeepAgent_SkipsCloudCalls(t *testing.T) { + tmp := t.TempDir() + configPath := withMcpEntry(t, tmp) + srv, svc, _ := newServiceFixture(t, PlatformClaudeCode, configPath, true) + + res, err := svc.Uninstall(context.Background(), PlatformClaudeCode, UninstallOptions{KeepAgent: true}) + require.NoError(t, err) + assert.True(t, res.Removed) + assert.False(t, res.AgentDisconnected) + assert.False(t, res.NoMatchingCloudAgent) + assert.Nil(t, srv.LastRequest("POST /agents/list"), "--keep-agent must not list agents") + assert.Nil(t, srv.LastRequest("POST /agents/disconnect"), "--keep-agent must not disconnect") +} // ---- List ---------------------------------------------------------- @@ -508,6 +619,5 @@ func TestList_RejectsMismatchedAgent(t *testing.T) { } // (Register / displayFallback tests retired in V1 alongside the -// `evercli plugin register` cobra command — see -// docs/mcp-codex-hermes-iteration-plan-2026-05-26.md §D.3. Install-path +// `evercli plugin register` cobra command. Install-path // /agents calls are covered by TestInstall_* below.) diff --git a/cli/internal/plugin/types.go b/cli/internal/plugin/types.go index 6ee4ef7..bbc8654 100644 --- a/cli/internal/plugin/types.go +++ b/cli/internal/plugin/types.go @@ -13,7 +13,10 @@ // registry.go — no other code change required. package plugin -import "context" +import ( + "context" + "evercli/internal/output" +) // Platform is a stable enum-like string identifying an Agent. Values // here are part of the AI-Agent ABI (used as `--platform` arg and in @@ -27,8 +30,12 @@ const ( PlatformClaudeDesktop Platform = "claude-desktop" PlatformCodex Platform = "codex" PlatformHermes Platform = "hermes" - PlatformGemini Platform = "gemini" + PlatformDevin Platform = "devin" + PlatformWorkBuddy Platform = "workbuddy" PlatformOpenCode Platform = "opencode" + PlatformKimiCode Platform = "kimicode" + PlatformRaven Platform = "raven" + PlatformDSH Platform = "dsh" ) // Detection is the result of inspecting the local filesystem for one @@ -70,6 +77,8 @@ type WritePlan struct { // rejects when it finds the file has appeared (concurrent-create). SnapshotModTime int64 // unix nanoseconds SnapshotSize int64 + + auxiliaryFiles []fileSnapshot } // WriteParams carries the data Commit needs to materialize the entry — @@ -87,16 +96,27 @@ type WriteResult struct { ConfigPath string `json:"configPath"` BackupPath string `json:"backupPath,omitempty"` WroteNewEntry bool `json:"wroteNewEntry"` + + // NextSteps are required manual follow-ups the user must perform after + // a successful Commit (distinct from Warnings, which flag a tripped + // sanity check). Kimi Code uses this for the TUI `/plugins install` + // registration it cannot do headlessly. Empty for hosts that finish + // entirely within Commit. + NextSteps []string `json:"nextSteps,omitempty"` +} + +// RemoveResult describes local cleanup. Removal is idempotent: a missing +// config or missing EverMe-owned entry is a successful no-op. +type RemoveResult struct { + Platform Platform `json:"platform"` + ConfigPath string `json:"configPath"` + BackupPath string `json:"backupPath,omitempty"` + Removed bool `json:"removed"` } // Writer mutates the local MCP config. The Plan / Commit split lets // install run a local pre-flight before triggering the backend rotate // (which immediately invalidates the old evt) — see 04-plugin.md §4.6.1. -// -// Remove was retired in the slimming pass alongside `evercli plugin -// uninstall`. The MVP plugin lifecycle is install-only; users -// disconnect agents from the EverMe web UI and clear local MCP -// entries by hand. type Writer interface { Platform() Platform @@ -112,6 +132,47 @@ type Writer interface { Commit(ctx context.Context, plan *WritePlan, params WriteParams) (*WriteResult, error) } +// Remover is implemented by writers that can remove only EverMe-owned +// content while preserving the host's other configuration. +type Remover interface { + Remove(ctx context.Context, configPath string) (*RemoveResult, error) +} + +// UninstallAdvisor lets a writer report host-specific cleanup that cannot be +// performed safely by evercli. Kimi Code's managed plugin registry is owned by +// the host TUI, so local staging cleanup succeeds while the user still needs +// to unregister the entry there. +type UninstallAdvisor interface { + UninstallNextSteps() []string +} + +type UninstallOptions struct{ KeepAgent bool } + +type DisconnectErrorDetail struct { + Type output.ErrorType `json:"type"` + Code int `json:"code,omitempty"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + AgentID string `json:"agentId,omitempty"` +} + +type UninstallResult struct { + Platform Platform `json:"platform"` + Removed bool `json:"removed"` + AgentDisconnected bool `json:"agentDisconnected"` + // NoMatchingCloudAgent is true when no cloud agent carried this + // machine's fingerprint for the platform, so nothing was + // disconnected. Only an exact fingerprint match is ever revoked — + // disconnecting anything else risks killing another machine's + // agent. A matching NextSteps entry points at the web UI fallback. + NoMatchingCloudAgent bool `json:"noMatchingCloudAgent,omitempty"` + ConfigPath string `json:"configPath,omitempty"` + BackupPath string `json:"backupPath,omitempty"` + DisconnectError *DisconnectErrorDetail `json:"disconnectError,omitempty"` + LocalDetectError *DetectErrorItem `json:"localDetectError,omitempty"` + NextSteps []string `json:"nextSteps,omitempty"` +} + // Preparer is an optional Writer extension for hosts that need a // side-effecting setup step BEFORE the backend mints a fresh token. // Service.installOne calls Prepare immediately after Detect and before diff --git a/cli/internal/plugin/workbuddy.go b/cli/internal/plugin/workbuddy.go new file mode 100644 index 0000000..95b70ee --- /dev/null +++ b/cli/internal/plugin/workbuddy.go @@ -0,0 +1,112 @@ +package plugin + +import ( + "context" + "os" + "os/exec" + "path/filepath" + + "evercli/internal/output" +) + +type workBuddyDetector struct{} + +func (workBuddyDetector) Platform() Platform { return PlatformWorkBuddy } + +func (workBuddyDetector) DisplayName() string { return "WorkBuddy" } + +func (workBuddyDetector) Detect(_ context.Context) (*Detection, error) { + dir, err := workBuddyConfigDir() + if err != nil { + return &Detection{Platform: PlatformWorkBuddy, DisplayName: "WorkBuddy"}, nil + } + detection := &Detection{ + Platform: PlatformWorkBuddy, + DisplayName: "WorkBuddy", + ConfigPath: workBuddyConfigPathInDir(dir), + Installed: workBuddyInstalled(dir), + } + + config, exists, err := readConfig(detection.ConfigPath) + if err != nil { + return detection, err + } + detection.ConfigExists = exists + if exists { + detection.HasEverMeEntry = nestedMcpServersHasEntry(config, claudeCodeServersPath, mcpEntryName) + } + return detection, nil +} + +func workBuddyConfigPath() (string, error) { + dir, err := workBuddyConfigDir() + if err != nil { + return "", err + } + return workBuddyConfigPathInDir(dir), nil +} + +// workBuddyConfigPathInDir returns the canonical WorkBuddy MCP config. +// This is the only file WorkBuddy reads user MCP servers from; do not +// probe alternatives — `.mcp.json` is WorkBuddy's generated +// connector-proxy aggregate and `connectors/default/mcp.json` is the +// app-shipped connector marketplace, both app-owned. +func workBuddyConfigPathInDir(dir string) string { + return filepath.Join(dir, "mcp.json") +} + +func workBuddyConfigDir() (string, error) { + if dir := os.Getenv("EVERCLI_WORKBUDDY_CONFIG_DIR"); dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", output.IOErr("workbuddy", "resolve-home", err) + } + return filepath.Join(home, ".workbuddy"), nil +} + +func workBuddyInstalled(configDir string) bool { + if _, err := os.Stat(configDir); err == nil { + return true + } + if appPath := os.Getenv("EVERCLI_WORKBUDDY_APP_PATH"); appPath != "" { + if _, err := os.Stat(appPath); err == nil { + return true + } + // Stale override: fall through to the PATH / darwin probes + // instead of declaring WorkBuddy absent outright. + } + if _, err := exec.LookPath("workbuddy"); err == nil { + return true + } + if runtimeGOOS() == "darwin" { + for _, path := range []string{"/Applications/WorkBuddy.app", filepath.Join(os.Getenv("HOME"), "Applications", "WorkBuddy.app")} { + if _, err := os.Stat(path); err == nil { + return true + } + } + } + return false +} + +// workBuddyWriter wraps the shared mcpWriter to surface the manual +// trust step: WorkBuddy keeps a newly added MCP server disabled (shown +// as failed) until the user trusts it in the MCP management dialog, so +// a successful config write alone does not make the plugin usable. +type workBuddyWriter struct { + *mcpWriter +} + +func newWorkBuddyWriter() Writer { + return workBuddyWriter{newMCPWriter(PlatformWorkBuddy)} +} + +func (w workBuddyWriter) Commit(ctx context.Context, plan *WritePlan, params WriteParams) (*WriteResult, error) { + res, err := w.mcpWriter.Commit(ctx, plan, params) + if res != nil { + res.NextSteps = append(res.NextSteps, + "open WorkBuddy's MCP management dialog and trust the everme-memory server — it stays disabled until the first-connection trust prompt is confirmed") + } + return res, err +} diff --git a/cli/internal/plugin/workbuddy_test.go b/cli/internal/plugin/workbuddy_test.go new file mode 100644 index 0000000..de05f40 --- /dev/null +++ b/cli/internal/plugin/workbuddy_test.go @@ -0,0 +1,136 @@ +package plugin + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkBuddyConfigPathIsCanonical(t *testing.T) { + tests := []struct { + name string + create []string + }{ + {name: "defaults to desktop mcp on a fresh install"}, + {name: "ignores cli dot mcp", create: []string{".mcp.json"}}, + {name: "ignores app-shipped connector marketplace", create: []string{"connectors/default/mcp.json"}}, + {name: "ignores every app-owned file at once", create: []string{".mcp.json", "connectors/default/mcp.json"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("EVERCLI_WORKBUDDY_CONFIG_DIR", dir) + for _, relative := range test.create { + path := filepath.Join(dir, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(`{"mcpServers":{}}`), 0o600)) + } + + got, err := workBuddyConfigPath() + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "mcp.json"), got) + }) + } +} + +func TestWorkBuddyDetectorUsesDirectoryOrAppAndFindsEverMe(t *testing.T) { + t.Run("config directory", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("EVERCLI_WORKBUDDY_CONFIG_DIR", dir) + path := filepath.Join(dir, "mcp.json") + require.NoError(t, os.WriteFile(path, []byte(`{"mcpServers":{"everme-memory":{}}}`), 0o600)) + + detection, err := (workBuddyDetector{}).Detect(t.Context()) + require.NoError(t, err) + assert.Equal(t, PlatformWorkBuddy, detection.Platform) + assert.Equal(t, "WorkBuddy", detection.DisplayName) + assert.True(t, detection.Installed) + assert.True(t, detection.ConfigExists) + assert.True(t, detection.HasEverMeEntry) + assert.Equal(t, path, detection.ConfigPath) + }) + + t.Run("application", func(t *testing.T) { + root := t.TempDir() + configDir := filepath.Join(root, "missing-config") + appPath := filepath.Join(root, "WorkBuddy.app") + require.NoError(t, os.MkdirAll(appPath, 0o700)) + t.Setenv("EVERCLI_WORKBUDDY_CONFIG_DIR", configDir) + t.Setenv("EVERCLI_WORKBUDDY_APP_PATH", appPath) + + detection, err := (workBuddyDetector{}).Detect(t.Context()) + require.NoError(t, err) + assert.True(t, detection.Installed) + assert.False(t, detection.ConfigExists) + assert.Equal(t, filepath.Join(configDir, "mcp.json"), detection.ConfigPath) + }) +} + +func TestWorkBuddyWriterPreservesConnectorProxy(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mcp.json") + require.NoError(t, os.WriteFile(path, []byte(`{ + "mcpServers": { + "connector-proxy": {"command":"node","args":["proxy.js"]} + } +}`), 0o600)) + + writer := newWorkBuddyWriter() + plan, err := writer.Plan(context.Background(), path) + require.NoError(t, err) + _, err = writer.Commit(context.Background(), plan, WriteParams{ + AgentID: "agt_workbuddy", + AgentToken: "test-token", + APIBaseURL: "https://api.test", + }) + require.NoError(t, err) + + raw, err := os.ReadFile(path) + require.NoError(t, err) + var config struct { + MCPServers map[string]json.RawMessage `json:"mcpServers"` + } + require.NoError(t, json.Unmarshal(raw, &config)) + assert.Contains(t, config.MCPServers, "connector-proxy") + assert.Contains(t, config.MCPServers, mcpEntryName) +} + +// WorkBuddy refuses to start an untrusted MCP server, so the install +// result must carry the manual trust follow-up for the CLI to print. +func TestWorkBuddyWriterCommitSurfacesTrustNextStep(t *testing.T) { + path := filepath.Join(t.TempDir(), "mcp.json") + writer := newWorkBuddyWriter() + plan, err := writer.Plan(context.Background(), path) + require.NoError(t, err) + res, err := writer.Commit(context.Background(), plan, WriteParams{ + AgentID: "agt_workbuddy", + AgentToken: "test-token", + APIBaseURL: "https://api.test", + }) + require.NoError(t, err) + require.Len(t, res.NextSteps, 1) + assert.Contains(t, res.NextSteps[0], "trust the everme-memory server") +} + +func TestWorkBuddyWriterCreatesSecureConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "mcp.json") + writer := newWorkBuddyWriter() + plan, err := writer.Plan(context.Background(), path) + require.NoError(t, err) + _, err = writer.Commit(context.Background(), plan, WriteParams{ + AgentID: "agt_workbuddy", + AgentToken: "test-token", + APIBaseURL: "https://api.test", + }) + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} diff --git a/cli/internal/runctx/runctx.go b/cli/internal/runctx/runctx.go new file mode 100644 index 0000000..622bf06 --- /dev/null +++ b/cli/internal/runctx/runctx.go @@ -0,0 +1,39 @@ +// Package runctx carries the process-level run context across the +// command-deadline boundary. +// +// cmdctx wraps each command's context with the global --timeout deadline +// before handing it to a command's RunE. A few long-blocking flows (the +// Device Flow human-approval wait) must detach that inherited deadline +// yet still honour a genuine cancellation (SIGINT). Once a deadline has +// elapsed a context's Err freezes to DeadlineExceeded, masking any later +// Canceled — so the only reliable way to observe a post-deadline cancel +// is to hold the un-deadlined cancellation source itself. cmdctx stashes +// that source here via WithBaseContext; detaching flows recover it with +// BaseContext. +package runctx + +import "context" + +// baseContextKey is the unexported key under which the un-deadlined +// cancellation source is stored. Unexported so only this package's +// accessors can read or write it. +type baseContextKey struct{} + +// WithBaseContext returns a child of ctx that also carries base — the +// un-deadlined cancellation source (typically the signal.NotifyContext +// at the process root). cmdctx calls this right before layering the +// global --timeout deadline onto ctx. +func WithBaseContext(ctx, base context.Context) context.Context { + return context.WithValue(ctx, baseContextKey{}, base) +} + +// BaseContext returns the un-deadlined cancellation source previously +// stashed by WithBaseContext, or ctx itself when none was stashed. The +// fallback keeps callers correct in tests and code paths that never went +// through cmdctx's timeout wrapping. +func BaseContext(ctx context.Context) context.Context { + if base, ok := ctx.Value(baseContextKey{}).(context.Context); ok && base != nil { + return base + } + return ctx +} diff --git a/cli/internal/runctx/runctx_test.go b/cli/internal/runctx/runctx_test.go new file mode 100644 index 0000000..4e96e1c --- /dev/null +++ b/cli/internal/runctx/runctx_test.go @@ -0,0 +1,28 @@ +package runctx_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "evercli/internal/runctx" +) + +func TestBaseContext_ReturnsStashedSource(t *testing.T) { + type marker struct{} + base := context.WithValue(context.Background(), marker{}, "signal") + wrapped, cancel := context.WithTimeout(base, 0) // deadline already elapsed + defer cancel() + wrapped = runctx.WithBaseContext(wrapped, base) + + got := runctx.BaseContext(wrapped) + assert.Same(t, base, got, "must recover the exact un-deadlined source") + assert.Nil(t, got.Err(), "the source carries no elapsed deadline") +} + +func TestBaseContext_FallsBackToCtxWhenNoneStashed(t *testing.T) { + ctx := context.Background() + assert.True(t, ctx == runctx.BaseContext(ctx), + "with no stashed source BaseContext returns the context unchanged") +} diff --git a/cli/internal/skill/agents.go b/cli/internal/skill/agents.go new file mode 100644 index 0000000..c41dea0 --- /dev/null +++ b/cli/internal/skill/agents.go @@ -0,0 +1,66 @@ +package skill + +import ( + "os" + "path/filepath" +) + +// KnownAgent describes a supported agent and how to find its skills directory. +type KnownAgent struct { + Name string // e.g. "claude-code" + DisplayName string // e.g. "Claude Code" + // GlobalSkillsDir returns the global (user-level) skills directory for this agent. + // Returns "" if the agent is not supported on the current OS. + GlobalSkillsDir func() string +} + +// KnownAgents is the canonical list of agents that skills can be linked into. +// Universal agents (Cursor/Codex/Hermes/opencode) all share ~/.agents/skills/ +// as their skills directory — consistent with the skills.sh universal agent convention. +var KnownAgents = []KnownAgent{ + { + Name: "claude-code", + DisplayName: "Claude Code", + GlobalSkillsDir: func() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".claude", "skills") + }, + }, + { + Name: "universal", + DisplayName: "Universal Agents (Cursor/Codex/Hermes/opencode)", + GlobalSkillsDir: func() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".agents", "skills") + }, + }, +} + +// AgentByName returns the KnownAgent with the given name, or (zero, false). +func AgentByName(name string) (KnownAgent, bool) { + for _, a := range KnownAgents { + if a.Name == name { + return a, true + } + } + return KnownAgent{}, false +} + +// ProjectSkillsDir returns the project-level skills directory for an agent +// relative to projectRoot, or "" if the agent isn't recognised. +func ProjectSkillsDir(agentName, projectRoot string) string { + switch agentName { + case "claude-code": + return filepath.Join(projectRoot, ".claude", "skills") + case "universal": + return filepath.Join(projectRoot, ".agents", "skills") + default: + return "" + } +} diff --git a/cli/internal/skill/everme_sync.go b/cli/internal/skill/everme_sync.go new file mode 100644 index 0000000..72d6f10 --- /dev/null +++ b/cli/internal/skill/everme_sync.go @@ -0,0 +1,125 @@ +package skill + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "evercli/internal/credential" + "evercli/internal/logger" + "evercli/internal/output" +) + +// InstallRecord describes a skill install event to sync to the everme backend. +type InstallRecord struct { + SkillID string `json:"skillId"` + SkillName string `json:"skillName"` + Agents []string `json:"agents"` + Scope string `json:"scope"` // "project" | "global" + InstalledAt time.Time `json:"installedAt"` +} + +// EvermeSync records skill install/remove events on the everme backend so +// the web skill management page can display them. +// +// All methods are best-effort: failures are logged at warn level and never +// propagate to callers. Local skill operations must never block on sync. +// +// NOTE: the backing endpoints (POST /api/v1/skills/installs and +// DELETE /api/v1/skills/installs/:skill_id) are backend work — the CLI +// pre-wires the calls and they become active once the backend ships them. +type EvermeSync struct { + apiBaseURL string + cred credential.Provider + http *http.Client + ua string +} + +// NewEvermeSync returns a sync helper. Pass a nil cred to get a no-op helper +// for unauthenticated sessions. +func NewEvermeSync(apiBaseURL string, cred credential.Provider, userAgent string) *EvermeSync { + if cred == nil { + return nil + } + return &EvermeSync{ + apiBaseURL: apiBaseURL, + cred: cred, + http: &http.Client{Timeout: 15 * time.Second}, + ua: userAgent, + } +} + +// RecordInstall notifies the everme backend that a skill was installed. +// Runs synchronously so the CLI process does not exit before the request completes. +// Errors are swallowed — local install already succeeded. +func (s *EvermeSync) RecordInstall(r InstallRecord) { + if s == nil { + return + } + s.post(context.Background(), "/api/v1/skills/installs", r) +} + +// RecordRemove marks connect_local_agent=false on the everme backend. +// Runs synchronously so the CLI process does not exit before the request completes. +func (s *EvermeSync) RecordRemove(skillID string) { + if s == nil { + return + } + s.patch(context.Background(), "/api/v1/skills/installs", map[string]any{ + "skillId": skillID, + "connectLocalAgent": false, + }) +} + +func (s *EvermeSync) post(ctx context.Context, path string, body any) { + if err := s.do(ctx, http.MethodPost, path, body); err != nil { + logger.L().Warnw("everme skill sync failed", "method", "POST", "path", path, "err", err) + } +} + +func (s *EvermeSync) patch(ctx context.Context, path string, body any) { + if err := s.do(ctx, http.MethodPatch, path, body); err != nil { + logger.L().Warnw("everme skill sync failed", "method", "PATCH", "path", path, "err", err) + } +} + +func (s *EvermeSync) do(ctx context.Context, method, path string, body any) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + emk, err := s.cred.Get(ctx, credential.APIKey()) + if err != nil { + return fmt.Errorf("read credential: %w", err) + } + + var bodyReader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal body: %w", err) + } + bodyReader = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(ctx, method, s.apiBaseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+emk) + req.Header.Set("User-Agent", s.ua) + if bodyReader != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := s.http.Do(req) + if err != nil { + return output.Network(s.apiBaseURL, err) + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) //nolint:errcheck + return nil +} diff --git a/cli/internal/skill/firstuse.go b/cli/internal/skill/firstuse.go new file mode 100644 index 0000000..c8f20d6 --- /dev/null +++ b/cli/internal/skill/firstuse.go @@ -0,0 +1,177 @@ +package skill + +import ( + "fmt" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-isatty" +) + +// FirstUseResult carries what the user chose during the first-use prompts. +type FirstUseResult struct { + SkipLogin bool + LoginAction string // "login" | "snooze" | "dismiss" +} + +// SkillFirstUseConfig is the input to RunFirstUsePrompts. +type SkillFirstUseConfig struct { + LoginPrompt string // current config value +} + +// RunFirstUsePrompts runs the login nudge if needed. +// Returns false if the caller should abort. +func RunFirstUsePrompts(cfg *SkillFirstUseConfig) (*FirstUseResult, bool) { + isTTY := isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) + result := &FirstUseResult{} + + if shouldShowLoginPrompt(cfg.LoginPrompt) { + if !isTTY { + result.SkipLogin = true + result.LoginAction = "snooze" + } else { + action := promptLogin() + result.LoginAction = action + result.SkipLogin = action != "login" + } + } + + return result, true +} + +// shouldShowLoginPrompt returns true when the login prompt should appear. +func shouldShowLoginPrompt(loginPrompt string) bool { + switch { + case loginPrompt == "dismissed": + return false + case loginPrompt == "pending": + return true + case strings.HasPrefix(loginPrompt, "snoozed:"): + ts := strings.TrimPrefix(loginPrompt, "snoozed:") + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return true + } + return time.Now().After(t) + default: + return true + } +} + +// SnoozeTimestamp returns the RFC3339 timestamp for "now + 7 days". +func SnoozeTimestamp() string { + return time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339) +} + +// ---- login TUI -------------------------------------------------------------- + +var ( + loginHeaderStyle = lipgloss.NewStyle().Bold(true) + loginSubtitleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "250"}) + loginSelectedMark = lipgloss.NewStyle().Foreground(lipgloss.Color("87")).Render("◉") + loginUnselectedMark = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "246"}).Render("○") + loginCursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("87")) + loginDimStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "250"}) + loginHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) +) + +type loginOption struct { + label string + hint string + action string // "login" | "snooze" | "dismiss" +} + +type loginSelectModel struct { + options []loginOption + cursor int + confirmed bool + aborted bool +} + +func newLoginSelectModel() loginSelectModel { + return loginSelectModel{ + options: []loginOption{ + {label: "Log in now", action: "login"}, + {label: "Remind me later", hint: "snooze 7 days", action: "snooze"}, + {label: "Don't ask again", action: "dismiss"}, + }, + } +} + +func (m loginSelectModel) Init() tea.Cmd { return nil } + +func (m loginSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.aborted = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case "enter": + m.confirmed = true + return m, tea.Quit + } + } + return m, nil +} + +func (m loginSelectModel) View() string { + if m.aborted { + return "" + } + header := loginHeaderStyle.Render("Connect to EverMe?") + subtitle := loginSubtitleStyle.Render("Log in to sync skill installs across devices and teams.") + if m.confirmed { + return header + "\n" + + fmt.Sprintf(" %s %s\n\n", loginSelectedMark, m.options[m.cursor].label) + } + + var sb strings.Builder + sb.WriteString(header + "\n") + sb.WriteString(" " + subtitle + "\n\n") + for i, o := range m.options { + mark := loginUnselectedMark + if i == m.cursor { + mark = loginSelectedMark + } + hint := "" + if o.hint != "" { + hint = loginDimStyle.Render(" " + o.hint) + } + line := fmt.Sprintf(" %s %s%s", mark, o.label, hint) + if i == m.cursor { + sb.WriteString(loginCursorStyle.Render(line)) + } else { + sb.WriteString(line) + } + sb.WriteString("\n") + } + sb.WriteString("\n" + loginHelpStyle.Render(" ↑↓ move Enter confirm Esc skip") + "\n") + return sb.String() +} + +func promptLogin() string { + fmt.Fprintln(os.Stderr) + m := newLoginSelectModel() + p := tea.NewProgram(m, tea.WithOutput(os.Stderr)) + final, err := p.Run() + if err != nil { + return "snooze" + } + fm, _ := final.(loginSelectModel) + if fm.aborted { + return "snooze" + } + return fm.options[fm.cursor].action +} diff --git a/cli/internal/skill/hub_client.go b/cli/internal/skill/hub_client.go new file mode 100644 index 0000000..e760fbe --- /dev/null +++ b/cli/internal/skill/hub_client.go @@ -0,0 +1,255 @@ +package skill + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "evercli/internal/output" +) + +const ( + hubErrNotFound = 60001 + hubErrInvalidParams = 60002 + hubErrDownloadFail = 60003 + hubErrRateLimit = 60005 +) + +// HubClient is the skill-hub-base API surface used by skill commands. +type HubClient interface { + SearchSkills(ctx context.Context, q string, page, limit int) (*SkillListResult, error) + GetSkill(ctx context.Context, idOrName string) (*SkillDetail, error) + // DownloadSkill streams the zip bytes for a skill and passes them to w. + // It automatically appends ?source=cli to record the install event hub-side. + DownloadSkill(ctx context.Context, idOrName string, w io.Writer) error +} + +// SkillSummary matches skill-hub-base's SkillSummary schema. +type SkillSummary struct { + ID string `json:"id"` + SkillID string `json:"skill_id"` + Name string `json:"name"` + Description string `json:"description"` + Source string `json:"source"` + Category string `json:"category"` + QualityScore float64 `json:"quality_score"` + Tags []string `json:"tags"` + BodyTokens int `json:"body_tokens"` + License string `json:"license"` + InstallCount int `json:"install_count"` + DownloadURL string `json:"download_url"` +} + +// SkillDetail extends SkillSummary with full content. +type SkillDetail struct { + SkillSummary + SafetyFlags []string `json:"safety_flags"` + Subscores map[string]interface{} `json:"subscores"` + AddedAt string `json:"added_at"` + Files []string `json:"files"` + SkillMD string `json:"skill_md"` +} + +// SkillListResult is returned by SearchSkills. +type SkillListResult struct { + Items []SkillSummary `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// hubEnvelope is the wire format for all skill-hub-base responses. +type hubEnvelope struct { + Error string `json:"error"` + RequestID string `json:"requestId"` + Status int `json:"status"` + Result json.RawMessage `json:"result"` +} + +type hubClient struct { + baseURL string + http *http.Client + ua string +} + +// NewHubClient returns a HubClient pointed at baseURL. +func NewHubClient(baseURL, userAgent string) HubClient { + return &hubClient{ + baseURL: baseURL, + http: &http.Client{ + Timeout: 60 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 20, + MaxIdleConnsPerHost: 5, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + ForceAttemptHTTP2: true, + }, + }, + ua: userAgent, + } +} + +func (c *hubClient) SearchSkills(ctx context.Context, q string, page, limit int) (*SkillListResult, error) { + params := url.Values{} + if q != "" { + params.Set("q", q) + } + params.Set("page", strconv.Itoa(page)) + params.Set("limit", strconv.Itoa(limit)) + params.Set("sort", "relevance") + + var out SkillListResult + if err := c.get(ctx, "/openapi/v1/skills/search", params, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *hubClient) GetSkill(ctx context.Context, idOrName string) (*SkillDetail, error) { + var out SkillDetail + if err := c.get(ctx, "/openapi/v1/skills/"+url.PathEscape(idOrName), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *hubClient) DownloadSkill(ctx context.Context, idOrName string, w io.Writer) error { + target := c.baseURL + "/openapi/v1/skills/" + url.PathEscape(idOrName) + "/download?source=cli" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return output.Internal(fmt.Errorf("build download request: %w", err)) + } + req.Header.Set("User-Agent", c.ua) + req.Header.Set("Accept", "application/zip") + + resp, err := c.http.Do(req) + if err != nil { + return output.Network(c.baseURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return output.NotFound("skill", idOrName) + } + if resp.StatusCode == http.StatusTooManyRequests { + return output.RateLimit(0) + } + if resp.StatusCode != http.StatusOK { + return output.Upstream(resp.StatusCode, fmt.Sprintf("download returned HTTP %d", resp.StatusCode), "") + } + + if _, err := io.Copy(w, io.LimitReader(resp.Body, 256<<20)); err != nil { + return output.IOErr("download", "stream-zip", err) + } + return nil +} + +// get performs a GET request against the hub and decodes the envelope result into out. +func (c *hubClient) get(ctx context.Context, path string, params url.Values, out any) error { + target := c.baseURL + path + if len(params) > 0 { + target += "?" + params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return output.Internal(fmt.Errorf("build request: %w", err)) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.ua) + + resp, err := c.http.Do(req) + if err != nil { + return output.Network(c.baseURL, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return output.Network(c.baseURL, err) + } + + if resp.StatusCode == http.StatusTooManyRequests { + return output.RateLimit(0) + } + if resp.StatusCode == http.StatusNotFound { + return output.NotFound("skill", path) + } + + var env hubEnvelope + if err := json.Unmarshal(body, &env); err != nil { + return output.Upstream(resp.StatusCode, "unexpected response from skill hub", "") + } + + if env.Status != 0 { + return classifyHubError(env.Status, env.Error, "") + } + + if out != nil { + if err := json.Unmarshal(env.Result, out); err != nil { + return output.Internal(fmt.Errorf("decode hub result: %w", err)) + } + } + return nil +} + +// post performs an authenticated POST request against the hub (no body expected in result). +func (c *hubClient) post(ctx context.Context, path string, in any) error { + var bodyReader io.Reader + if in != nil { + raw, err := json.Marshal(in) + if err != nil { + return output.Internal(fmt.Errorf("marshal request: %w", err)) + } + bodyReader = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bodyReader) + if err != nil { + return output.Internal(fmt.Errorf("build request: %w", err)) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.ua) + if bodyReader != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + return output.Network(c.baseURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusTooManyRequests { + return output.RateLimit(0) + } + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + var env hubEnvelope + if err := json.Unmarshal(body, &env); err != nil || env.Status != 0 { + // non-critical path; caller can ignore + return classifyHubError(env.Status, env.Error, "") + } + return nil +} + +func classifyHubError(status int, msg, requestID string) *output.CLIError { + switch status { + case hubErrNotFound: + return output.NotFound("skill", "") + case hubErrInvalidParams: + return output.Invalid(msg, "") + case hubErrRateLimit: + return output.RateLimit(0) + default: + return output.Upstream(status, msg, requestID) + } +} diff --git a/cli/internal/skill/hub_client_test.go b/cli/internal/skill/hub_client_test.go new file mode 100644 index 0000000..3cc27c1 --- /dev/null +++ b/cli/internal/skill/hub_client_test.go @@ -0,0 +1,194 @@ +package skill_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "evercli/internal/skill" +) + +// hubEnvelope mirrors the skill-hub-base response shape used in tests. +type hubEnvelope struct { + Error string `json:"error"` + RequestID string `json:"requestId"` + Status int `json:"status"` + Result interface{} `json:"result,omitempty"` +} + +// hubFixture is a lightweight mock for skill-hub-base. +type hubFixture struct { + t *testing.T + mux *http.ServeMux + server *httptest.Server + client skill.HubClient +} + +func newHubFixture(t *testing.T) *hubFixture { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + cli := skill.NewHubClient(srv.URL, "evercli/test") + return &hubFixture{t: t, mux: mux, server: srv, client: cli} +} + +// envelope replies with a success envelope wrapping result. +func (f *hubFixture) envelope(route string, result interface{}) { + f.mux.HandleFunc(route, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(hubEnvelope{Error: "success", RequestID: "req-mock", Status: 0, Result: result}) + }) +} + +// envelopeError replies with a non-zero skill-hub-base status code. +func (f *hubFixture) envelopeError(route string, status int, msg string) { + f.mux.HandleFunc(route, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(hubEnvelope{Error: msg, RequestID: "req-mock", Status: status}) + }) +} + +// httpStatus replies with a bare HTTP status code (no envelope). +func (f *hubFixture) httpStatus(route string, code int) { + f.mux.HandleFunc(route, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(code) + }) +} + +// ---- SearchSkills --------------------------------------------------------- + +func TestHubClient_SearchSkills_Happy(t *testing.T) { + f := newHubFixture(t) + f.envelope("GET /openapi/v1/skills/search", map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "id": "11111111-1111-1111-1111-111111111111", + "skill_id": "awesome:user/code-reviewer", + "name": "code-reviewer", + "description": "Reviews code", + "quality_score": 0.92, + "install_count": 12300, + "tags": []string{"review"}, + }, + }, + "total": 1, + "page": 1, + "limit": 20, + }) + + result, err := f.client.SearchSkills(context.Background(), "code review", 1, 20) + require.NoError(t, err) + require.Len(t, result.Items, 1) + assert.Equal(t, "code-reviewer", result.Items[0].Name) + assert.Equal(t, "awesome:user/code-reviewer", result.Items[0].SkillID) + assert.InDelta(t, 0.92, result.Items[0].QualityScore, 0.001) + assert.Equal(t, 1, result.Total) +} + +func TestHubClient_SearchSkills_EmptyQuery(t *testing.T) { + f := newHubFixture(t) + f.envelope("GET /openapi/v1/skills/search", map[string]interface{}{ + "items": []interface{}{}, + "total": 0, "page": 1, "limit": 20, + }) + + result, err := f.client.SearchSkills(context.Background(), "", 1, 20) + require.NoError(t, err) + assert.Empty(t, result.Items) +} + +func TestHubClient_SearchSkills_RateLimit(t *testing.T) { + f := newHubFixture(t) + f.httpStatus("GET /openapi/v1/skills/search", http.StatusTooManyRequests) + + _, err := f.client.SearchSkills(context.Background(), "x", 1, 20) + require.Error(t, err) + assert.Contains(t, err.Error(), "rate_limit") +} + +// ---- GetSkill ------------------------------------------------------------- + +func TestHubClient_GetSkill_Happy(t *testing.T) { + f := newHubFixture(t) + f.envelope("GET /openapi/v1/skills/code-reviewer", map[string]interface{}{ + "id": "11111111-1111-1111-1111-111111111111", + "skill_id": "awesome:user/code-reviewer", + "name": "code-reviewer", + "description": "Reviews your code with AI", + "quality_score": 0.92, + "install_count": 5000, + "tags": []string{"coding", "review"}, + "skill_md": "# Code Reviewer\nThis skill reviews your code.", + "files": []string{"SKILL.md"}, + }) + + detail, err := f.client.GetSkill(context.Background(), "code-reviewer") + require.NoError(t, err) + assert.Equal(t, "code-reviewer", detail.Name) + assert.Equal(t, "awesome:user/code-reviewer", detail.SkillID) + assert.Contains(t, detail.SkillMD, "Code Reviewer") + assert.Equal(t, []string{"SKILL.md"}, detail.Files) +} + +func TestHubClient_GetSkill_NotFound(t *testing.T) { + f := newHubFixture(t) + f.envelopeError("GET /openapi/v1/skills/nonexistent", 60001, "not_found") + + _, err := f.client.GetSkill(context.Background(), "nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), "not_found") +} + +func TestHubClient_GetSkill_HTTP404(t *testing.T) { + f := newHubFixture(t) + f.httpStatus("GET /openapi/v1/skills/gone", http.StatusNotFound) + + _, err := f.client.GetSkill(context.Background(), "gone") + require.Error(t, err) + assert.Contains(t, err.Error(), "not_found") +} + +// ---- DownloadSkill -------------------------------------------------------- + +func TestHubClient_DownloadSkill_Happy(t *testing.T) { + f := newHubFixture(t) + fakeZip := []byte("PK\x03\x04fake-zip-content") + f.mux.HandleFunc("GET /openapi/v1/skills/code-reviewer/download", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "cli", r.URL.Query().Get("source"), "source=cli must be appended") + w.Header().Set("Content-Type", "application/zip") + w.Write(fakeZip) + }) + + var buf bytes.Buffer + err := f.client.DownloadSkill(context.Background(), "code-reviewer", &buf) + require.NoError(t, err) + assert.Equal(t, fakeZip, buf.Bytes()) +} + +func TestHubClient_DownloadSkill_NotFound(t *testing.T) { + f := newHubFixture(t) + f.httpStatus("GET /openapi/v1/skills/gone/download", http.StatusNotFound) + + var buf bytes.Buffer + err := f.client.DownloadSkill(context.Background(), "gone", &buf) + require.Error(t, err) + assert.Contains(t, err.Error(), "not_found") +} + +func TestHubClient_DownloadSkill_RateLimit(t *testing.T) { + f := newHubFixture(t) + f.httpStatus("GET /openapi/v1/skills/x/download", http.StatusTooManyRequests) + + var buf bytes.Buffer + err := f.client.DownloadSkill(context.Background(), "x", &buf) + require.Error(t, err) + assert.Contains(t, err.Error(), "rate_limit") +} diff --git a/cli/internal/skill/service.go b/cli/internal/skill/service.go new file mode 100644 index 0000000..868bfde --- /dev/null +++ b/cli/internal/skill/service.go @@ -0,0 +1,272 @@ +package skill + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + "time" +) + +// InstallOpts configures a single Install call. +type InstallOpts struct { + Global bool // install to ~/.everme/skills instead of .everme/skills + DryRun bool // print what would happen without doing it +} + +// InstallResult describes a completed install. +type InstallResult struct { + Name string `json:"name"` + SkillID string `json:"skillId"` + Version string `json:"contentHash"` + Central string `json:"-"` + LinkedAgents []string `json:"linkedAgents"` + LinkedPaths []string `json:"-"` // actual filesystem paths of each agent copy + DryRun bool `json:"dryRun,omitempty"` +} + +// UpdateFailure records a failed update with the reason. +type UpdateFailure struct { + Name string `json:"name"` + Reason string `json:"reason"` +} + +// UpdateReport summarises a batch update. +type UpdateReport struct { + Updated []string `json:"updated"` + UpToDate []string `json:"upToDate"` + Failed []string `json:"failed"` // names only, kept for compat + FailedDetails []UpdateFailure `json:"failedDetails,omitempty"` +} + +// Service orchestrates all skill operations. +type Service struct { + hub HubClient + store *Store + sync *EvermeSync // nil when user is not logged in +} + +// NewService constructs a Service. sync may be nil for unauthenticated sessions. +func NewService(hub HubClient, store *Store, sync *EvermeSync) *Service { + return &Service{hub: hub, store: store, sync: sync} +} + +// Browse searches the hub and returns a paginated result. +func (s *Service) Browse(ctx context.Context, q string, page, limit int) (*SkillListResult, error) { + if limit <= 0 { + limit = 20 + } + if page <= 0 { + page = 1 + } + return s.hub.SearchSkills(ctx, q, page, limit) +} + +// Info fetches full details for a single skill. +func (s *Service) Info(ctx context.Context, idOrName string) (*SkillDetail, error) { + return s.hub.GetSkill(ctx, idOrName) +} + +// Install downloads and installs a skill identified by id or name. +func (s *Service) Install(ctx context.Context, idOrName string, opts InstallOpts) (*InstallResult, error) { + detail, err := s.hub.GetSkill(ctx, idOrName) + if err != nil { + return nil, err + } + + name := detail.Name + if name == "" { + name = idOrName + } + contentHash := ContentHash(detail.SkillMD) + + if opts.DryRun { + return &InstallResult{ + Name: name, + SkillID: detail.SkillID, + Version: contentHash, + LinkedAgents: agentNames(s.store.agents), + DryRun: true, + }, nil + } + + // Download the zip. + var buf bytes.Buffer + if err := s.hub.DownloadSkill(ctx, detail.ID, &buf); err != nil { + return nil, fmt.Errorf("download skill %q: %w", name, err) + } + + zipData := buf.Bytes() + if err := s.store.Install(name, zipData); err != nil { + return nil, err + } + + if err := s.store.Link(name); err != nil { + return nil, err + } + + // Async sync — never blocks the user. + s.sync.RecordInstall(InstallRecord{ + SkillID: detail.SkillID, + SkillName: name, + Agents: agentNames(s.store.agents), + Scope: scope(opts.Global), + InstalledAt: time.Now().UTC(), + }) + + centralDir := s.store.skillDir(name) + linkedPaths := make([]string, len(s.store.agents)) + for i, a := range s.store.agents { + linkedPaths[i] = filepath.Join(a.SkillsDir, name) + } + return &InstallResult{ + Name: name, + SkillID: detail.SkillID, + Version: contentHash, + Central: centralDir, + LinkedAgents: agentNames(s.store.agents), + LinkedPaths: linkedPaths, + }, nil +} + +// List returns all locally installed skills. +func (s *Service) List(ctx context.Context) ([]InstalledSkill, error) { + return s.store.List() +} + +// Remove unlinks and deletes a skill by name. +func (s *Service) Remove(ctx context.Context, name string) error { + meta, _ := s.store.GetMeta(name) + if err := s.store.Remove(name); err != nil { + return err + } + if meta != nil { + s.sync.RecordRemove(meta.SkillID) + } + return nil +} + +// Unlink removes the skill copies from the configured agent dirs without +// deleting the central store — other projects that copied the same skill are unaffected. +func (s *Service) Unlink(ctx context.Context, name string) error { + if err := s.store.Unlink(name); err != nil { + return err + } + return nil +} + +// Update checks all (or the specified) installed skills against the hub and +// re-installs any whose content hash has changed. +func (s *Service) Update(ctx context.Context, names ...string) (*UpdateReport, error) { + if len(names) == 0 { + installed, err := s.store.List() + if err != nil { + return nil, err + } + for _, sk := range installed { + names = append(names, sk.Name) + } + } + + report := &UpdateReport{} + + addFailure := func(name, reason string) { + report.Failed = append(report.Failed, name) + report.FailedDetails = append(report.FailedDetails, UpdateFailure{Name: name, Reason: reason}) + } + for _, name := range names { + meta, err := s.store.GetMeta(name) + if err != nil { + addFailure(name, "not installed") + continue + } + if meta.SkillID == "" { + addFailure(name, "no skill_id in metadata") + continue + } + + detail, err := s.hub.GetSkill(ctx, meta.SkillID) + if err != nil { + addFailure(name, err.Error()) + continue + } + + remoteHash := ContentHash(detail.SkillMD) + if remoteHash == meta.ContentHash { + report.UpToDate = append(report.UpToDate, name) + continue + } + + if _, err := s.Install(ctx, meta.SkillID, InstallOpts{}); err != nil { + addFailure(name, err.Error()) + continue + } + report.Updated = append(report.Updated, name) + } + return report, nil +} + +// Link copies a single skill to a single additional agent. +// Used by `evercli skill config agents add` to retroactively wire existing skills. +func (s *Service) Link(agentName, skillName string) error { + ka, ok := AgentByName(agentName) + if !ok { + return nil + } + dir := ka.GlobalSkillsDir() + if dir == "" { + return nil + } + tmpStore := NewStore(s.store.root, []AgentTarget{{Name: agentName, SkillsDir: dir}}) + return tmpStore.Link(skillName) +} + +// UnlinkAgent removes all skill copies for a single agent. +// Used by `evercli skill config agents remove`. +func (s *Service) UnlinkAgent(agentName string) error { + ka, ok := AgentByName(agentName) + if !ok { + return nil + } + dir := ka.GlobalSkillsDir() + if dir == "" { + return nil + } + installed, err := s.store.List() + if err != nil { + return err + } + tmpStore := NewStore(s.store.root, []AgentTarget{{Name: agentName, SkillsDir: dir}}) + for _, sk := range installed { + _ = tmpStore.Unlink(sk.Name) // best-effort + } + return nil +} + +// AgentInstallPrompt returns the canonical text block shown at the bottom of +// `evercli skill info` output — a ready-to-paste instruction for AI agents. +func AgentInstallPrompt(skillID string) string { + return fmt.Sprintf(`── Agent Install Prompt ─────────────────────────────────── +To install this skill, send this prompt to your agent: + + Run `+"`evercli skill install %s`"+` to install this skill. + If evercli is not installed, run: + curl -fsSL https://cli.evermind.ai/install.sh | sh + Once installed, the skill will be active in your next session. +──────────────────────────────────────────────────────────`, skillID) +} + +func agentNames(agents []AgentTarget) []string { + names := make([]string, len(agents)) + for i, a := range agents { + names[i] = a.Name + } + return names +} + +func scope(global bool) string { + if global { + return "global" + } + return "project" +} diff --git a/cli/internal/skill/store.go b/cli/internal/skill/store.go new file mode 100644 index 0000000..9a039e6 --- /dev/null +++ b/cli/internal/skill/store.go @@ -0,0 +1,382 @@ +package skill + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "evercli/internal/output" +) + +// AgentTarget is a configured agent and its skills directory. +type AgentTarget struct { + Name string // e.g. "claude-code" + SkillsDir string // absolute path, e.g. "/home/user/.claude/skills" +} + +// Store manages the central skill store and agent copies. +type Store struct { + root string // absolute path to /.everme/skills or ~/.everme/skills + agents []AgentTarget // configured agent targets +} + +// InstalledSkill describes a locally installed skill. +type InstalledSkill struct { + Name string + Description string + SkillID string // from frontmatter metadata.skill_id + ContentHash string // SHA256 of skill_md at install time; from metadata.content_hash + InstalledAt time.Time + LinkedAgents []string +} + +// SkillMeta holds the parsed frontmatter from an installed SKILL.md. +type SkillMeta struct { + Name string + Description string + SkillID string + ContentHash string +} + +// NewStore creates a Store with the given central root and agent targets. +func NewStore(root string, agents []AgentTarget) *Store { + return &Store{root: root, agents: agents} +} + +// skillDir returns the central directory for a skill by name. +func (s *Store) skillDir(name string) string { + return filepath.Join(s.root, name) +} + +// Install extracts a zip (provided as raw bytes in zipData) into the central store. +// It overwrites any existing installation for the same name. +// If all zip entries share a common top-level directory prefix it is stripped so +// that the skill contents land directly in destDir (no extra nesting). +func (s *Store) Install(name string, zipData []byte) error { + destDir := s.skillDir(name) + + // Remove existing installation before re-extracting. + if err := os.RemoveAll(destDir); err != nil { + return output.IOErr(destDir, "remove-existing", err) + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return output.IOErr(destDir, "mkdir", err) + } + + zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + return output.IOErr(name, "open-zip", err) + } + + prefix := zipTopLevelPrefix(zr.File) + + for _, f := range zr.File { + if err := extractZipEntry(f, destDir, prefix); err != nil { + return err + } + } + return nil +} + +// zipTopLevelPrefix returns the common top-level directory prefix shared by all +// zip entries (e.g. "code-review/"), or "" if entries have no common prefix. +func zipTopLevelPrefix(files []*zip.File) string { + if len(files) == 0 { + return "" + } + // Collect the first path segment of each entry. + prefix := "" + for _, f := range files { + name := filepath.ToSlash(f.Name) + idx := strings.Index(name, "/") + if idx < 0 { + // Entry sits at the root — no common prefix to strip. + return "" + } + seg := name[:idx+1] // includes trailing slash + if prefix == "" { + prefix = seg + } else if prefix != seg { + return "" + } + } + return prefix +} + +// extractZipEntry safely extracts one zip entry into destDir, +// rejecting zip-slip paths (entries escaping destDir). +// prefix is stripped from the front of f.Name before computing the target path. +func extractZipEntry(f *zip.File, destDir, prefix string) error { + name := strings.TrimPrefix(filepath.ToSlash(f.Name), prefix) + if name == "" { + return nil // was the prefix directory itself + } + target := filepath.Join(destDir, filepath.Clean("/"+name)) + if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) && target != filepath.Clean(destDir) { + return fmt.Errorf("zip-slip: unsafe path %q", f.Name) + } + if f.FileInfo().IsDir() { + return os.MkdirAll(target, 0o755) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return output.IOErr(filepath.Dir(target), "mkdir", err) + } + rc, err := f.Open() + if err != nil { + return output.IOErr(f.Name, "open-zip-entry", err) + } + defer rc.Close() + + dst, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return output.IOErr(target, "create", err) + } + defer dst.Close() + + if _, err := io.Copy(dst, io.LimitReader(rc, 32<<20)); err != nil { + return output.IOErr(target, "write", err) + } + return nil +} + +// WriteSkillMD writes the SKILL.md for a skill that was installed from +// raw markdown (not a zip), annotating the frontmatter with hub metadata. +func (s *Store) WriteSkillMD(name, skillID, contentHash, markdown string) error { + dir := s.skillDir(name) + if err := os.MkdirAll(dir, 0o755); err != nil { + return output.IOErr(dir, "mkdir", err) + } + + annotated := injectFrontmatterMeta(markdown, skillID, contentHash) + path := filepath.Join(dir, "SKILL.md") + if err := os.WriteFile(path, []byte(annotated), 0o644); err != nil { + return output.IOErr(path, "write", err) + } + return nil +} + +// Link copies the skill from the central store into each agent's skills directory. +// Idempotent: removes any existing copy at the target before creating. +func (s *Store) Link(name string) error { + centralDir := s.skillDir(name) + for _, agent := range s.agents { + target := filepath.Join(agent.SkillsDir, name) + if err := os.MkdirAll(agent.SkillsDir, 0o755); err != nil { + return output.IOErr(agent.SkillsDir, "mkdir", err) + } + if err := os.RemoveAll(target); err != nil { + return output.IOErr(target, "remove-old-copy", err) + } + if err := copyDir(centralDir, target); err != nil { + return err + } + } + return nil +} + +// Unlink removes the agent-side copy for a skill. +func (s *Store) Unlink(name string) error { + for _, agent := range s.agents { + target := filepath.Join(agent.SkillsDir, name) + if err := os.RemoveAll(target); err != nil { + return output.IOErr(target, "remove-link", err) + } + } + return nil +} + +// Remove unlinks and deletes the central store directory for a skill. +func (s *Store) Remove(name string) error { + if err := s.Unlink(name); err != nil { + return err + } + dir := s.skillDir(name) + if err := os.RemoveAll(dir); err != nil { + return output.IOErr(dir, "remove", err) + } + return nil +} + +// List returns all locally installed skills. +func (s *Store) List() ([]InstalledSkill, error) { + entries, err := os.ReadDir(s.root) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, output.IOErr(s.root, "read-dir", err) + } + + var skills []InstalledSkill + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + meta, err := s.GetMeta(name) + if err != nil { + // Best-effort: include entry with just the name. + skills = append(skills, InstalledSkill{Name: name}) + continue + } + info, _ := e.Info() + var installedAt time.Time + if info != nil { + installedAt = info.ModTime() + } + skills = append(skills, InstalledSkill{ + Name: name, + Description: meta.Description, + SkillID: meta.SkillID, + ContentHash: meta.ContentHash, + InstalledAt: installedAt, + LinkedAgents: s.linkedAgents(name), + }) + } + return skills, nil +} + +// GetMeta reads and parses the frontmatter from the installed SKILL.md. +func (s *Store) GetMeta(name string) (*SkillMeta, error) { + mdPath := filepath.Join(s.skillDir(name), "SKILL.md") + data, err := os.ReadFile(mdPath) + if os.IsNotExist(err) { + return nil, output.NotFound("skill", name) + } + if err != nil { + return nil, output.IOErr(mdPath, "read", err) + } + return parseSkillMDFrontmatter(string(data), name) +} + +// linkedAgents returns the names of agents that currently have a link to this skill. +func (s *Store) linkedAgents(name string) []string { + var linked []string + for _, agent := range s.agents { + target := filepath.Join(agent.SkillsDir, name) + if _, err := os.Lstat(target); err == nil { + linked = append(linked, agent.Name) + } + } + return linked +} + +// ContentHash computes the SHA256 of the skill_md string, used for update detection. +func ContentHash(skillMD string) string { + sum := sha256.Sum256([]byte(skillMD)) + return hex.EncodeToString(sum[:]) +} + +// parseSkillMDFrontmatter extracts name, description, and metadata fields from SKILL.md. +func parseSkillMDFrontmatter(content, fallbackName string) (*SkillMeta, error) { + content = strings.TrimPrefix(content, "\xef\xbb\xbf") // strip BOM + if !strings.HasPrefix(content, "---") { + return &SkillMeta{Name: fallbackName}, nil + } + + rest := content[3:] + end := strings.Index(rest, "\n---") + if end == -1 { + return &SkillMeta{Name: fallbackName}, nil + } + block := rest[:end] + + var fm struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Metadata struct { + SkillID string `yaml:"skill_id"` + ContentHash string `yaml:"content_hash"` + } `yaml:"metadata"` + } + if err := yaml.Unmarshal([]byte(block), &fm); err != nil { + return &SkillMeta{Name: fallbackName}, nil + } + + name := fm.Name + if name == "" { + name = fallbackName + } + return &SkillMeta{ + Name: name, + Description: fm.Description, + SkillID: fm.Metadata.SkillID, + ContentHash: fm.Metadata.ContentHash, + }, nil +} + +// injectFrontmatterMeta inserts or updates skill_id and content_hash inside +// the SKILL.md frontmatter block. If no frontmatter exists, one is prepended. +func injectFrontmatterMeta(markdown, skillID, contentHash string) string { + hasFrontmatter := strings.HasPrefix(strings.TrimPrefix(markdown, "\xef\xbb\xbf"), "---") + + metaBlock := fmt.Sprintf("metadata:\n skill_id: %q\n content_hash: %q", skillID, contentHash) + + if !hasFrontmatter { + return "---\n" + metaBlock + "\n---\n\n" + markdown + } + + rest := markdown[3:] + end := strings.Index(rest, "\n---") + if end == -1 { + return "---\n" + metaBlock + "\n---\n\n" + markdown + } + + fmBlock := rest[:end] + after := rest[end+4:] + + // Remove existing metadata block to avoid duplication. + var lines []string + for _, line := range strings.Split(fmBlock, "\n") { + if strings.HasPrefix(line, "metadata:") { + continue + } + lines = append(lines, line) + } + cleaned := strings.Join(lines, "\n") + return "---\n" + cleaned + "\n" + metaBlock + "\n---" + after +} + +// copyDir copies src directory tree to dst. +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return output.IOErr(path, "walk", err) + } + rel, _ := filepath.Rel(src, path) + target := filepath.Join(dst, rel) + + if info.IsDir() { + return os.MkdirAll(target, info.Mode()) + } + return copyFile(path, target, info.Mode()) + }) +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return output.IOErr(src, "open", err) + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return output.IOErr(dst, "create", err) + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return output.IOErr(dst, "copy", err) + } + return nil +} diff --git a/cli/internal/skill/store_test.go b/cli/internal/skill/store_test.go new file mode 100644 index 0000000..2c0cc86 --- /dev/null +++ b/cli/internal/skill/store_test.go @@ -0,0 +1,189 @@ +package skill_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "evercli/internal/skill" +) + +// buildStore creates an isolated Store in a temp directory. +// Returns the store and the central root path. +func buildStore(t *testing.T, agents []skill.AgentTarget) (*skill.Store, string) { + t.Helper() + tmp := t.TempDir() + root := filepath.Join(tmp, ".everme", "skills") + return skill.NewStore(root, agents), root +} + +// agentTarget returns an AgentTarget wired into a temp directory. +func agentTarget(t *testing.T, name string) skill.AgentTarget { + t.Helper() + dir := filepath.Join(t.TempDir(), name, "skills") + require.NoError(t, os.MkdirAll(dir, 0o755)) + return skill.AgentTarget{Name: name, SkillsDir: dir} +} + +// ---- WriteSkillMD / GetMeta ----------------------------------------------- + +func TestStore_WriteAndGetMeta(t *testing.T) { + store, _ := buildStore(t, nil) + md := "---\nname: my-skill\ndescription: Does something useful.\n---\n\n# My Skill\n" + + require.NoError(t, store.WriteSkillMD("my-skill", "awesome:user/my-skill", "abc123", md)) + + meta, err := store.GetMeta("my-skill") + require.NoError(t, err) + assert.Equal(t, "my-skill", meta.Name) + assert.Equal(t, "Does something useful.", meta.Description) + assert.Equal(t, "awesome:user/my-skill", meta.SkillID) + assert.Equal(t, "abc123", meta.ContentHash) +} + +func TestStore_GetMeta_NotInstalled(t *testing.T) { + store, _ := buildStore(t, nil) + _, err := store.GetMeta("nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), "not_found") +} + +// ---- Link / Unlink -------------------------------------------------------- + +func TestStore_Link_CreatesCopy(t *testing.T) { + agent := agentTarget(t, "claude-code") + store, _ := buildStore(t, []skill.AgentTarget{agent}) + require.NoError(t, store.WriteSkillMD("my-skill", "id-1", "hash-1", "---\nname: my-skill\n---\n")) + + require.NoError(t, store.Link("my-skill")) + + targetPath := filepath.Join(agent.SkillsDir, "my-skill") + fi, err := os.Lstat(targetPath) + require.NoError(t, err, "copy must exist at agent skills dir") + assert.True(t, fi.IsDir(), "must be a real directory, not a symlink") + assert.True(t, fi.Mode()&os.ModeSymlink == 0, "must NOT be a symlink") +} + +func TestStore_Link_Idempotent(t *testing.T) { + agent := agentTarget(t, "claude-code") + store, _ := buildStore(t, []skill.AgentTarget{agent}) + require.NoError(t, store.WriteSkillMD("skill-a", "id-1", "h1", "---\nname: skill-a\n---\n")) + + // Link twice — must not error on the second call. + require.NoError(t, store.Link("skill-a")) + require.NoError(t, store.Link("skill-a"), "re-linking must be idempotent") +} + +func TestStore_Unlink_RemovesCopy(t *testing.T) { + agent := agentTarget(t, "claude-code") + store, _ := buildStore(t, []skill.AgentTarget{agent}) + require.NoError(t, store.WriteSkillMD("my-skill", "id-1", "h1", "---\nname: my-skill\n---\n")) + require.NoError(t, store.Link("my-skill")) + + require.NoError(t, store.Unlink("my-skill")) + + _, err := os.Lstat(filepath.Join(agent.SkillsDir, "my-skill")) + assert.True(t, os.IsNotExist(err), "copy must be gone after Unlink") +} + +// ---- Remove --------------------------------------------------------------- + +func TestStore_Remove_DeletesCentralAndLinks(t *testing.T) { + agent := agentTarget(t, "claude-code") + store, root := buildStore(t, []skill.AgentTarget{agent}) + require.NoError(t, store.WriteSkillMD("my-skill", "id-1", "h1", "---\nname: my-skill\n---\n")) + require.NoError(t, store.Link("my-skill")) + + require.NoError(t, store.Remove("my-skill")) + + // Central dir must be gone. + _, err := os.Stat(filepath.Join(root, "my-skill")) + assert.True(t, os.IsNotExist(err), "central store dir must be deleted") + + // Agent copy must be gone. + _, err = os.Lstat(filepath.Join(agent.SkillsDir, "my-skill")) + assert.True(t, os.IsNotExist(err), "agent copy must be removed") +} + +// ---- List ----------------------------------------------------------------- + +func TestStore_List_Empty(t *testing.T) { + store, _ := buildStore(t, nil) + skills, err := store.List() + require.NoError(t, err) + assert.Empty(t, skills, "fresh store must return empty list") +} + +func TestStore_List_MultipleSkills(t *testing.T) { + agent := agentTarget(t, "claude-code") + store, _ := buildStore(t, []skill.AgentTarget{agent}) + + skills := []struct{ name, id string }{ + {"code-reviewer", "id-1"}, + {"pr-summary", "id-2"}, + } + for _, sk := range skills { + md := "---\nname: " + sk.name + "\ndescription: Desc for " + sk.name + "\n---\n" + require.NoError(t, store.WriteSkillMD(sk.name, sk.id, "hash", md)) + require.NoError(t, store.Link(sk.name)) + } + + list, err := store.List() + require.NoError(t, err) + require.Len(t, list, 2) + + names := make(map[string]bool) + for _, s := range list { + names[s.Name] = true + assert.Contains(t, s.LinkedAgents, "claude-code", "linked agents must be populated") + } + assert.True(t, names["code-reviewer"]) + assert.True(t, names["pr-summary"]) +} + +// ---- ContentHash ---------------------------------------------------------- + +func TestContentHash_Deterministic(t *testing.T) { + md := "# Hello\nThis is a skill." + h1 := skill.ContentHash(md) + h2 := skill.ContentHash(md) + assert.Equal(t, h1, h2, "ContentHash must be deterministic") + assert.Len(t, h1, 64, "SHA-256 hex is 64 chars") +} + +func TestContentHash_DifferentContent(t *testing.T) { + assert.NotEqual(t, + skill.ContentHash("version A"), + skill.ContentHash("version B"), + "different content must produce different hashes", + ) +} + +// ---- injectFrontmatterMeta (via WriteSkillMD round-trip) ------------------ + +func TestStore_WriteSkillMD_InjectsMetaIntoExistingFrontmatter(t *testing.T) { + store, _ := buildStore(t, nil) + md := "---\nname: my-skill\ndescription: Does things.\n---\n\n# Body" + require.NoError(t, store.WriteSkillMD("my-skill", "my:skill/id", "sha256abc", md)) + + meta, err := store.GetMeta("my-skill") + require.NoError(t, err) + assert.Equal(t, "my:skill/id", meta.SkillID) + assert.Equal(t, "sha256abc", meta.ContentHash) + assert.Equal(t, "my-skill", meta.Name) + assert.Equal(t, "Does things.", meta.Description, "existing frontmatter fields must be preserved") +} + +func TestStore_WriteSkillMD_InjectsMetaWhenNoFrontmatter(t *testing.T) { + store, _ := buildStore(t, nil) + md := "# My Skill\nNo frontmatter." + require.NoError(t, store.WriteSkillMD("bare-skill", "bare:id", "hash99", md)) + + meta, err := store.GetMeta("bare-skill") + require.NoError(t, err) + assert.Equal(t, "bare:id", meta.SkillID) + assert.Equal(t, "hash99", meta.ContentHash) +} diff --git a/cli/internal/skill/tui/browse.go b/cli/internal/skill/tui/browse.go new file mode 100644 index 0000000..422e853 --- /dev/null +++ b/cli/internal/skill/tui/browse.go @@ -0,0 +1,562 @@ +// Package tui provides the bubbletea TUI for `evercli skill browse`. +package tui + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "evercli/internal/skill" +) + +// PendingInstall is set when the user presses Enter on a skill. +// The caller should check this after p.Run() and trigger the install flow. + +// ---- styles --------------------------------------------------------------- + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("87")) + selectedLine = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("87")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "250"}) + scoreStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + divStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "238"}) + previewHdr = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("87")) + labelStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "246"}) + installStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "243"}) +) + +var bSpinFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} + +// ---- messages ------------------------------------------------------------- + +type searchResultMsg struct { + results *skill.SkillListResult + err error + page int +} + +// searchTriggerMsg carries a generation counter to discard stale debounce events. +type searchTriggerMsg struct { + q string + gen int +} + +type browseTick struct{} + +// ---- model ---------------------------------------------------------------- + +// Model is the bubbletea model for the skill browser. +type Model struct { + hub skill.HubClient + + input textinput.Model + results []skill.SkillSummary + total int + page int + cursor int + + loading bool + spinFrame int + + err string + + width int + height int + + lastQuery string + debounceGen int // incremented on each keystroke; stale events are ignored + + // PendingInstall is set to the skill_id when the user presses Enter. + // Non-empty means the TUI exited with an install request. + PendingInstall string +} + +// New creates a browse Model with an empty initial query. +func New(hub skill.HubClient) Model { + return NewWithQuery(hub, "") +} + +// NewWithQuery creates a browse Model with a pre-filled search query. +func NewWithQuery(hub skill.HubClient, initialQuery string) Model { + ti := textinput.New() + ti.Placeholder = "Search skills…" + ti.Focus() + ti.CharLimit = 200 + if initialQuery != "" { + ti.SetValue(initialQuery) + } + + return Model{ + hub: hub, + input: ti, + lastQuery: initialQuery, + loading: initialQuery != "", + } +} + +// ---- Init / Update / View ------------------------------------------------- + +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{textinput.Blink, m.searchCmd(m.lastQuery, 1)} + if m.loading { + cmds = append(cmds, doTick()) + } + return tea.Batch(cmds...) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.results)-1 { + m.cursor++ + } + case "tab": + if len(m.results) < m.total && !m.loading { + m.loading = true + cmds = append(cmds, m.searchCmd(m.lastQuery, m.page+1), doTick()) + } + case "/": + m.input.Focus() + case "enter": + if len(m.results) > 0 { + sk := m.results[m.cursor] + m.PendingInstall = sk.SkillID + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + cmds = append(cmds, cmd) + + q := m.input.Value() + if q != m.lastQuery { + m.lastQuery = q + cmds = append(cmds, m.debounceSearch(q)) + } + + case searchResultMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err.Error() + } else { + m.err = "" + if msg.page == 1 { + m.results = msg.results.Items + m.cursor = 0 + } else { + m.results = append(m.results, msg.results.Items...) + } + m.total = msg.results.Total + m.page = msg.page + } + + case searchTriggerMsg: + // Discard stale debounce events — only the latest generation fires. + if msg.gen != m.debounceGen { + return m, nil + } + m.loading = true + cmds = append(cmds, m.searchCmd(msg.q, 1), doTick()) + + case browseTick: + if m.loading { + m.spinFrame++ + cmds = append(cmds, doTick()) + } + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if m.width == 0 { + return "Loading…" + } + + var out strings.Builder + + // ---- Header ---- + out.WriteString(titleStyle.Render(" EverMe Skills") + "\n") + out.WriteString(" " + m.input.View() + "\n") + + if m.loading { + frame := bSpinFrames[m.spinFrame%len(bSpinFrames)] + out.WriteString(dimStyle.Render(fmt.Sprintf(" %s searching…", frame)) + "\n") + } else if m.err != "" { + out.WriteString(errorStyle.Render(" ✗ "+m.err) + "\n") + } else if m.total > 0 { + out.WriteString(dimStyle.Render(fmt.Sprintf(" %d result(s)", m.total)) + "\n") + } else { + out.WriteString("\n") + } + headerLines := strings.Count(out.String(), "\n") + + // ---- Footer ---- + footerLines := 1 + + // ---- Body height ---- + bodyHeight := m.height - headerLines - footerLines - 1 + if bodyHeight < 3 { + bodyHeight = 3 + } + + // ---- Split layout ---- + usePreview := m.width >= 80 + listWidth := m.width + previewWidth := 0 + if usePreview { + listWidth = m.width / 2 + if listWidth < 30 { + listWidth = 30 + } + previewWidth = m.width - listWidth - 1 + } + + listLines := m.renderListLines(listWidth-2, bodyHeight) + + var body string + if usePreview { + previewLines := m.renderPreviewLines(previewWidth-2, bodyHeight) + var sb strings.Builder + for i := 0; i < bodyHeight; i++ { + ll := "" + if i < len(listLines) { + ll = listLines[i] + } + pl := "" + if i < len(previewLines) { + pl = previewLines[i] + } + sb.WriteString(padToVisible(ll, listWidth)) + sb.WriteString(divStyle.Render("│")) + sb.WriteString(pl) + sb.WriteString("\n") + } + body = sb.String() + } else { + body = strings.Join(listLines, "\n") + "\n" + } + + return out.String() + body + helpStyle.Render(m.buildHelpLine()) + "\n" +} + +// renderListLines renders the results list as a fixed-height slice of lines. +// Each line: name (fixed 26 chars) · description (fills) · install count (right, muted) +func (m Model) renderListLines(width, maxLines int) []string { + var lines []string + + visibleRows := maxLines - 2 + start := 0 + if m.cursor >= visibleRows { + start = m.cursor - visibleRows + 1 + } + end := start + visibleRows + if end > len(m.results) { + end = len(m.results) + } + + for i := start; i < end; i++ { + sk := m.results[i] + line := formatBrowseSkillLine(sk, width-3) + if i == m.cursor { + lines = append(lines, selectedLine.Render("▶ ")+selectedLine.Render(line)) + } else { + lines = append(lines, dimStyle.Render(" ")+dimStyle.Render(line)) + } + } + + for len(lines) < visibleRows { + lines = append(lines, "") + } + + if len(m.results) == 0 && !m.loading { + if m.lastQuery == "" { + lines[0] = dimStyle.Render(" Showing top results · type to search") + } else { + lines[0] = dimStyle.Render(" No skills found for \"" + m.lastQuery + "\"") + } + } + + if m.total > len(m.results) { + lines = append(lines, dimStyle.Render(fmt.Sprintf(" Showing %d / %d · Tab for more", len(m.results), m.total))) + } else { + lines = append(lines, "") + } + + return lines +} + +// renderPreviewLines renders the right-hand preview panel at a fixed height. +// +// Layout (fixed 5 lines of metadata): +// +// name +// (blank) +// Quality ★★★★☆ 4.6 / 5 +// Source source/path +// (blank) +// description (fills remaining height) +func (m Model) renderPreviewLines(width, maxLines int) []string { + pad := func(lines []string) []string { + for len(lines) < maxLines { + lines = append(lines, "") + } + return lines[:maxLines] + } + + if len(m.results) == 0 || width < 10 { + return pad(nil) + } + sk := m.results[m.cursor] + + var lines []string + + // Name + lines = append(lines, previewHdr.Render(truncate(sk.Name, width))) + lines = append(lines, "") + + // Quality score with label + stars := qualityStars(sk.QualityScore) + score5 := sk.QualityScore * 5 + lines = append(lines, labelStyle.Render("Quality")+" "+scoreStyle.Render(fmt.Sprintf("%s %.1f / 5", stars, score5))) + + // Source with label + if sk.Source != "" { + lines = append(lines, labelStyle.Render("Source ")+" "+dimStyle.Render(truncate(sk.Source, width-10))) + } else { + lines = append(lines, "") + } + lines = append(lines, "") + + // Description — fills all remaining lines (adaptive to terminal height) + // Fixed overhead: name(1) + blank(1) + quality(1) + source(1) + blank(1) = 5 + descMaxLines := maxLines - 5 + if descMaxLines < 1 { + descMaxLines = 1 + } + descLines := wrapTextLines(sk.Description, width, descMaxLines) + for _, dl := range descLines { + lines = append(lines, dimStyle.Render(dl)) + } + + return pad(lines) +} + +func (m Model) buildHelpLine() string { + return " ↑↓/jk navigate ↵ install Tab more / search q quit" +} + +// ---- commands ------------------------------------------------------------- + +// debounceSearch schedules a search after 300ms. A generation counter ensures +// only the most-recent keystroke's search fires; earlier ones are discarded. +func (m *Model) debounceSearch(q string) tea.Cmd { + m.debounceGen++ + gen := m.debounceGen + return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg { + return searchTriggerMsg{q: q, gen: gen} + }) +} + +func (m Model) searchCmd(q string, page int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + results, err := m.hub.SearchSkills(ctx, q, page, 20) + return searchResultMsg{results: results, err: err, page: page} + } +} + +func doTick() tea.Cmd { + return tea.Tick(80*time.Millisecond, func(_ time.Time) tea.Msg { + return browseTick{} + }) +} + +// ---- formatters ----------------------------------------------------------- + +// formatBrowseSkillLine renders one list row: name (left) + install count with suffix (right). +func formatBrowseSkillLine(sk skill.SkillSummary, width int) string { + installStr := formatInstallCount(sk.InstallCount) + " installs" + installVisible := stringWidth(installStr) + + nameMax := width - installVisible - 1 + if nameMax < 4 { + nameMax = 4 + } + name := truncate(sk.Name, nameMax) + nameVisible := stringWidth(name) + + pad := width - nameVisible - installVisible + if pad < 1 { + pad = 1 + } + return name + strings.Repeat(" ", pad) + installStr +} + +// qualityStars converts a 0–1 quality score to a 5-star string (e.g. ★★★★☆). +func qualityStars(score float64) string { + stars := int(math.Round(score * 5)) + if stars > 5 { + stars = 5 + } + if stars < 0 { + stars = 0 + } + return strings.Repeat("★", stars) + strings.Repeat("☆", 5-stars) +} + +func formatInstallCount(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%.1fk", float64(n)/1_000) + default: + return fmt.Sprintf("%d", n) + } +} + +// runeWidth returns the terminal column width of a rune (1 for ASCII, 2 for CJK/fullwidth). +func runeWidth(r rune) int { + if r < 0x1100 { + return 1 + } + if (r >= 0x1100 && r <= 0x115F) || + r == 0x2329 || r == 0x232A || + (r >= 0x2E80 && r <= 0x303E) || + (r >= 0x3040 && r <= 0x33FF) || + (r >= 0x3400 && r <= 0x4DBF) || + (r >= 0x4E00 && r <= 0x9FFF) || + (r >= 0xA000 && r <= 0xA4CF) || + (r >= 0xAC00 && r <= 0xD7AF) || + (r >= 0xF900 && r <= 0xFAFF) || + (r >= 0xFE10 && r <= 0xFE19) || + (r >= 0xFE30 && r <= 0xFE6F) || + (r >= 0xFF00 && r <= 0xFF60) || + (r >= 0xFFE0 && r <= 0xFFE6) || + (r >= 0x1F300 && r <= 0x1F64F) || + (r >= 0x20000 && r <= 0x2FA1F) { + return 2 + } + return 1 +} + +// stringWidth returns the visible terminal column width of s. +func stringWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// wrapTextLines wraps text to width columns (CJK-aware), returning at most maxLines lines. +func wrapTextLines(text string, width, maxLines int) []string { + if width <= 0 { + return []string{truncate(text, 20)} + } + words := strings.Fields(text) + var lines []string + var line strings.Builder + lineW := 0 + for _, w := range words { + if len(lines) >= maxLines { + break + } + ww := stringWidth(w) + if lineW > 0 && lineW+1+ww > width { + lines = append(lines, line.String()) + line.Reset() + lineW = 0 + } + if lineW > 0 { + line.WriteByte(' ') + lineW++ + } + line.WriteString(w) + lineW += ww + } + if line.Len() > 0 && len(lines) < maxLines { + lines = append(lines, line.String()) + } + return lines +} + +// truncate shortens s to at most max terminal columns (CJK-aware). +func truncate(s string, max int) string { + if max <= 0 { + return "" + } + w := 0 + for i, r := range s { + rw := runeWidth(r) + if w+rw > max { + if max > 1 { + return s[:i] + "…" + } + return s[:i] + } + w += rw + } + return s +} + +func firstLine(s string) string { + if nl := strings.Index(s, "\n"); nl >= 0 { + return s[:nl] + } + return s +} + +// padToVisible pads s with spaces until its visible column width equals width. +func padToVisible(s string, width int) string { + visible := stringWidth(stripANSICodes(s)) + if visible >= width { + return s + } + return s + strings.Repeat(" ", width-visible) +} + +// stripANSICodes removes ANSI escape sequences for accurate visible-length measurement. +func stripANSICodes(s string) string { + var b strings.Builder + inEsc := false + for i := 0; i < len(s); i++ { + if s[i] == '\x1b' { + inEsc = true + continue + } + if inEsc { + if (s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') { + inEsc = false + } + continue + } + b.WriteByte(s[i]) + } + return b.String() +} diff --git a/cli/internal/skill/tui/install_prompt.go b/cli/internal/skill/tui/install_prompt.go new file mode 100644 index 0000000..d2c9bdb --- /dev/null +++ b/cli/internal/skill/tui/install_prompt.go @@ -0,0 +1,120 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ---- shared styles ---------------------------------------------------------- + +var ( + ipHeaderStyle = lipgloss.NewStyle().Bold(true) + ipSelectedMark = lipgloss.NewStyle().Foreground(lipgloss.Color("87")).Render("◉") + ipUnselectedMark = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "246"}).Render("○") + ipCursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("87")) + ipDimStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "250"}) + ipSummaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("82")) + ipHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) +) + +// ---- Scope single-select ---------------------------------------------------- + +type scopeOption struct { + label string + hint string + global bool +} + +type scopeSelectModel struct { + options []scopeOption + cursor int + confirmed bool + aborted bool +} + +func newScopeSelectModel(projectRoot string) scopeSelectModel { + projHint := projectRoot + "/.agents/skills/ .claude/skills/" + if projectRoot == "" { + projHint = "./.agents/skills/ .claude/skills/" + } + return scopeSelectModel{ + options: []scopeOption{ + {label: "Project", hint: projHint, global: false}, + {label: "Global", hint: "~/.agents/skills/ ~/.claude/skills/", global: true}, + }, + } +} + +func (m scopeSelectModel) Init() tea.Cmd { return nil } + +func (m scopeSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.aborted = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case "enter": + m.confirmed = true + return m, tea.Quit + } + } + return m, nil +} + +func (m scopeSelectModel) View() string { + if m.aborted { + return "" + } + header := ipHeaderStyle.Render("Scope?") + if m.confirmed { + opt := m.options[m.cursor] + return header + "\n" + + fmt.Sprintf(" %s %s\n\n", ipSelectedMark, ipSummaryStyle.Render(opt.label)) + } + + var sb strings.Builder + sb.WriteString(header + "\n") + for i, o := range m.options { + mark := ipUnselectedMark + if i == m.cursor { + mark = ipSelectedMark + } + line := fmt.Sprintf(" %s %-12s%s", mark, o.label, ipDimStyle.Render(o.hint)) + if i == m.cursor { + sb.WriteString(ipCursorStyle.Render(line)) + } else { + sb.WriteString(line) + } + sb.WriteString("\n") + } + sb.WriteString(ipHelpStyle.Render(" ↑↓ move Enter confirm") + "\n") + return sb.String() +} + +// RunScopeSelect runs the scope single-select TUI inline. +// Returns (global, ok). global=false means project scope. +func RunScopeSelect(projectRoot string) (bool, bool) { + m := newScopeSelectModel(projectRoot) + p := tea.NewProgram(m) + final, err := p.Run() + if err != nil { + return false, false + } + fm, _ := final.(scopeSelectModel) + if fm.aborted { + return false, false + } + return fm.options[fm.cursor].global, true +} diff --git a/cli/internal/skill/tui/skill_select.go b/cli/internal/skill/tui/skill_select.go new file mode 100644 index 0000000..f046376 --- /dev/null +++ b/cli/internal/skill/tui/skill_select.go @@ -0,0 +1,120 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "evercli/internal/skill" +) + +// ---- Skill multi-select ----------------------------------------------------- + +type skillSelectModel struct { + skills []skill.InstalledSkill + selected map[string]bool + cursor int + confirmed bool + aborted bool +} + +func newSkillSelectModel(skills []skill.InstalledSkill) skillSelectModel { + return skillSelectModel{ + skills: skills, + selected: make(map[string]bool), + } +} + +func (m skillSelectModel) Init() tea.Cmd { return nil } + +func (m skillSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.aborted = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.skills)-1 { + m.cursor++ + } + case " ": + name := m.skills[m.cursor].Name + m.selected[name] = !m.selected[name] + case "enter": + m.confirmed = true + return m, tea.Quit + } + } + return m, nil +} + +func (m skillSelectModel) View() string { + if m.aborted { + return "" + } + header := ipHeaderStyle.Render("Select skills to remove:") + if m.confirmed { + var names []string + for _, sk := range m.skills { + if m.selected[sk.Name] { + names = append(names, sk.Name) + } + } + summary := "(none)" + if len(names) > 0 { + summary = strings.Join(names, ", ") + } + return header + "\n" + + fmt.Sprintf(" %s %s\n\n", ipSelectedMark, ipSummaryStyle.Render(summary)) + } + + var sb strings.Builder + sb.WriteString(header + "\n") + for i, sk := range m.skills { + mark := ipUnselectedMark + if m.selected[sk.Name] { + mark = ipSelectedMark + } + agents := strings.Join(sk.LinkedAgents, ", ") + if agents == "" { + agents = "—" + } + line := fmt.Sprintf(" %s %-30s%s", mark, sk.Name, ipDimStyle.Render(agents)) + if i == m.cursor { + sb.WriteString(ipCursorStyle.Render(line)) + } else { + sb.WriteString(line) + } + sb.WriteString("\n") + } + sb.WriteString(ipHelpStyle.Render(" ↑↓ move Space toggle Enter confirm Esc cancel") + "\n") + return sb.String() +} + +// RunSkillSelect runs an inline multi-select TUI for choosing installed skills to remove. +// Returns the selected skill names and ok=false if the user aborted or pressed Esc. +func RunSkillSelect(skills []skill.InstalledSkill) ([]string, bool) { + m := newSkillSelectModel(skills) + p := tea.NewProgram(m) + final, err := p.Run() + if err != nil { + return nil, false + } + fm, _ := final.(skillSelectModel) + if fm.aborted { + return nil, false + } + var result []string + for _, sk := range skills { + if fm.selected[sk.Name] { + result = append(result, sk.Name) + } + } + return result, true +} diff --git a/cli/main.go b/cli/main.go index bfb8fb5..3cabb1b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -12,7 +12,7 @@ import ( "evercli/internal/output" ) -// Build-time variables injected via -ldflags. +// Build-time variables injected via -ldflags by goreleaser. var ( version = "dev" commit = "none" diff --git a/plugins/README.md b/plugins/README.md index 9142ecc..421ac98 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,16 +1,27 @@ # EverMe agent plugins — monorepo -This directory ships **one EverMe plugin per AI-agent host**, plus a shared SDK that owns the EverMe gateway wire protocol. Each plugin can evolve independently so host-specific changes stay scoped. +This directory ships **one EverMe plugin per AI-agent host**, plus a shared SDK that owns the EverMe gateway wire protocol. Each plugin iterates independently — release a Claude Code-only fix without re-publishing OpenClaw, ship a new MCP host without touching anything else. ``` plugins/ -├── agent-sdk/ ← shared core (HTTP client, presign+S3 upload, buffer, redaction, prompt helpers) -├── memory-mcp/ ← generic MCP server (Cursor / Cline / any MCP host) +├── agent-sdk/ ← shared core (HTTP client, hook runtime, redaction, prompt helpers) +├── memory-mcp/ ← generic MCP server (local stdio + hosted Streamable HTTP) ├── openclaw/ ← OpenClaw ContextEngine plugin ├── claude-code/ ← Claude Code native plugin (hooks + commands + skill + bundled MCP) -└── package.json ← npm workspaces root +├── kimicode/ ← Kimi Code native plugin (hooks + skills + bundled MCP; single kimi.plugin.json) +├── codex/ ← Codex native lifecycle hook runner (marketplace-distributed commands) +├── cursor/ ← Cursor native lifecycle hook runner +├── devin/ ← Devin transcript-save hook runner +├── dsh/ ← DeepSeek Harness native Cordis hooks +├── cli/ ← npm wrapper that downloads + runs the native evercli binary (independent version line) +├── package.json ← npm workspaces root +└── scripts/ + ├── release.sh ← topological publish (agent-sdk first, then the host plugins) + └── bump.sh ← keep the nine plugin packages' versions in sync (cli wrapper bumps separately) ``` +The nine protocol packages (`agent-sdk` + eight host plugins) form the gateway-protocol stack described below and release together. `cli/` is a separate distribution artifact — an npm shim over the Go `evercli` binary — versioned and published on its own line; it does not depend on the SDK and is not part of the topological plugin release. + ## Why one package per host Different AI-agent hosts expose **fundamentally different plugin contracts**, not just different config files: @@ -18,10 +29,13 @@ Different AI-agent hosts expose **fundamentally different plugin contracts**, no | Host | Plugin contract | |---|---| | **Claude Code** | Native plugins with hooks (`SessionStart`, `UserPromptSubmit`, `Stop`, `SessionEnd`), slash commands, skills, marketplace | +| **Kimi Code** | Native plugin in a single self-contained `kimi.plugin.json`: `SessionStart` + `UserPromptSubmit` recall hooks, a `SessionEnd` whole-session write, skills, and a bundled MCP | | **OpenClaw** | In-process ContextEngine module: `bootstrap → afterTurn → assemble → compact → dispose` lifecycle | +| **DeepSeek Harness** | Native Cordis events (`agent/pre-step`, `session/event`, `session/flush`) plus MCP tools | | **Cursor / Cline / generic MCP** | External MCP server over stdio/JSON-RPC, host calls tools | +| **Cloud MCP agents** | Hosted stateless Streamable HTTP endpoint, host calls tools over HTTPS | -A single multi-host package would couple unrelated host integrations, and a Claude Code-specific hook bug would force OpenClaw users to absorb an unrelated package update. Splitting per host is what Feishu's CLI ecosystem (`lark-im`, `lark-doc`, `lark-base`, ...) does and it scales. +A single multi-host package would be a Frankenstein where every release re-versions code that didn't change, and where a Claude Code-specific hook bug forces an OpenClaw publish. Splitting per host is what飞书's CLI ecosystem (`lark-im`, `lark-doc`, `lark-base`, …) does and it scales. ## Architecture @@ -51,22 +65,27 @@ A single multi-host package would couple unrelated host integrations, and a Clau / generic MCP (in-process) (hooks + cmds + MCP) ``` +*(`@everme/kimicode`, `@everme/codex`, `@everme/cursor`, `@everme/devin`, and `@everme/dsh` are additional native-hook packages over the same shared runtime; omitted from the diagram above for width.)* + ## Host support matrix | Host | Package | Status | Recall trigger | Save trigger | |---|---|---|---|---| | Claude Code | `@everme/claude-code` | ✅ | UserPromptSubmit hook (auto) | Stop + SessionEnd hooks (auto) | +| Kimi Code | `@everme/kimicode` | ✅ | UserPromptSubmit hook (auto) | SessionEnd whole-session flush (auto) | | OpenClaw | `@everme/openclaw` | ✅ | `assemble` lifecycle (auto) | `afterTurn` lifecycle (auto) | -| Cursor | `@everme/memory-mcp` | ✅ via MCP | model-driven (`mem_search` tool) | model-driven (`mem_save_turn` tool; SDK-side `buffer.flush()` on session end) | +| Cursor | `@everme/cursor` + `@everme/memory-mcp` | ✅ native save + MCP | SessionStart profile; per-prompt MCP fallback | Stop + PreCompact hooks (auto); postToolUse spools tool calls for the Stop upload | | Claude Desktop | `@everme/memory-mcp` | ✅ via MCP | same as Cursor | same as Cursor | -| Codex | `@everme/memory-mcp` | ✅ via MCP (CLI) / Resources (App) | model-driven (`mem_search` tool / `mem://search`) | model-driven (`mem_save_turn`; Codex App is recall-only) | +| Codex | `@everme/codex` + `@everme/memory-mcp` | ✅ native hooks + MCP | SessionStart/UserPromptSubmit hooks (auto) | Stop + PreCompact hooks (auto) | +| DeepSeek Harness | `@everme/dsh` + `@everme/memory-mcp` | ✅ native hooks + MCP | `agent/pre-step` (auto) | `turn/end` + `session/flush` (auto) | | Hermes | native `MemoryProvider` (evercli-embedded, no npm) | ✅ native provider | `prefetch` hook (auto) | `sync_turn` + `on_session_end` / `on_pre_compress` hooks (auto) | | Cline | `@everme/memory-mcp` | ✅ via MCP | same as Cursor | same as Cursor | -| Generic MCP host | `@everme/memory-mcp` | ✅ via MCP | same as Cursor | same as Cursor | -| Gemini CLI | `@everme/memory-mcp` | ✅ via MCP | same as Cursor | same as Cursor | +| Generic local MCP host | `@everme/memory-mcp` | ✅ via stdio MCP | same as Cursor | same as Cursor | +| Cloud MCP agent | hosted `@everme/memory-mcp` | ✅ endpoint; host smoke required | MCP tool call | MCP tool call | +| Devin | `@everme/devin` + `@everme/memory-mcp` | ✅ native save + MCP | MCP fallback | Transcript hook (auto) | | opencode | `@everme/memory-mcp` | ✅ via MCP | same as Cursor | same as Cursor | -> One-command `evercli plugin install ` covers **claude-code, openclaw, cursor, claude-desktop, codex, hermes, gemini, opencode**. Cline and generic MCP hosts also work but need manual `mcpServers` wiring — no dedicated `evercli` installer for them yet. +> One-command `evercli plugin install ` covers **claude-code, openclaw, cursor, claude-desktop, codex, dsh, hermes, devin, opencode**. **Kimi Code** is two-step: `evercli plugin install kimicode` stages the bundle + credentials, then you finish registration inside the TUI with `/plugins install ~/.kimi-code/everme`. Cline and generic MCP hosts also work but need manual `mcpServers` wiring — no dedicated `evercli` installer for them yet. ### Roadmap @@ -84,21 +103,42 @@ When a new host's plugin contract justifies its own package, we add a sibling he ```bash cd everme/plugins npm install # symlinks @everme/agent-sdk into all dependents -npm test --workspaces # runs all 5 workspaces' tests (137 total) +npm test --workspaces # runs all 10 workspaces' tests (nine protocol packages + CLI wrapper) npm test --workspace @everme/agent-sdk # just the SDK ``` The workspace setup means edits to `agent-sdk/src/` are immediately reflected in the dependents — no `npm pack` round-trip during development. +## Release + +```bash +./scripts/bump.sh patch # 0.1.0 → 0.1.1 across the nine protocol packages +git diff && git commit -am "chore: bump to 0.1.1" +./scripts/release.sh # dry-run preview +./scripts/release.sh --execute # publish, agent-sdk first then dependents +``` + +`release.sh` enforces: + +1. clean working tree (no uncommitted changes) +2. all nine protocol package versions match (the `cli` wrapper is released separately and is not checked here) +3. agent-sdk publishes BEFORE anyone who depends on it (otherwise a fresh `npm install @everme/openclaw` would 404 on the missing dep) +4. waits for the registry to acknowledge each version before moving on + ## Per-host README Each package has its own README with installation, configuration, and lifecycle: -- [`../docs/contracts.md`](../docs/contracts.md) — public CLI/MCP/token redaction contract - [`agent-sdk/README.md`](agent-sdk/README.md) — wire protocol + concurrency contracts - [`memory-mcp/README.md`](memory-mcp/README.md) — MCP tools + host config snippet - [`openclaw/README.md`](openclaw/README.md) — OpenClaw lifecycle + config - [`claude-code/README.md`](claude-code/README.md) — Claude Code hooks + slash commands + skill +- [`kimicode/README.md`](kimicode/README.md) — Kimi Code hooks + skills + wire.jsonl transcript locator +- [`codex/README.md`](codex/README.md) — Codex lifecycle hook runner + rollout parser +- [`cursor/README.md`](cursor/README.md) — Cursor lifecycle hooks + recall boundary +- [`devin/README.md`](devin/README.md) — transcript-save hook +- [`dsh/README.md`](dsh/README.md) — native Cordis recall/save hooks + MCP coexistence +- [`cli/README.md`](cli/README.md) — npm wrapper install + native `evercli` binary download ## License diff --git a/plugins/agent-sdk/README.md b/plugins/agent-sdk/README.md index 669c69e..9e9c790 100644 --- a/plugins/agent-sdk/README.md +++ b/plugins/agent-sdk/README.md @@ -49,6 +49,24 @@ The envelope every endpoint follows: { error, requestId, status, result } // status === 0 → success, return result ``` +Write sync contract: `/mem/agent-memory` accepts `flush` (synchronous add on +every batch + extraction flush) and `sync` (synchronous add only, no flush). +`saveAgentMemory` sets `sync: true` on the leading requests of a multi-request +upload whose flush rides the final request, so earlier batches keep the +synchronous-add guarantee; servers without the field ignore it. `flushed` on +the result means the flush was issued — `status` / `extracted` are the +materialisation signals, and `status: "no_extraction"` means the upstream +queued the session without extracting yet. + +Request id contract: `createClient` generates a UUID per request and sends it +as the `requestId` header; the gateway reuses a valid inbound value, so plugin +stderr, EverMe ELK, and the cloud platform's log search all join on one id. +`client.requestWithMeta(...)` resolves to `{ result, requestId }`; the wrapper +helpers (`searchMemory` / `getContext` / `saveAgentMemory` / +`savePersonalMemory`) surface the same id on their results, and `EvermeError` +carries it (`err.requestId`, rendered by `err.describe()` / +`describeError(err)`). + ## Env-file precedence The shared hook runtime merges the host's `everme.env` file into the process env. File values never override variables already set in the shell — **except** `EVERME_AGENT_TOKEN` and `EVERME_AGENT_ID`, where the file wins. This asymmetry is deliberate: `evercli` rotates the per-machine evt token by rewriting `everme.env`, and a stale token exported in the shell must not shadow the rotated credential. diff --git a/plugins/agent-sdk/index.js b/plugins/agent-sdk/index.js index 2673bed..b2c176f 100644 --- a/plugins/agent-sdk/index.js +++ b/plugins/agent-sdk/index.js @@ -26,7 +26,7 @@ * dedicated module rather than reviving the multi-layer buffer. */ -export { createClient, EvermeError, redactError } from "./src/client.js"; +export { createClient, EvermeError, redactError, describeError, requestMeta } from "./src/client.js"; export { saveAgentMemory, flushAgentMemory, @@ -42,7 +42,17 @@ export { resolveConfig, assertConfigUsable, TIMEOUT_MS, UPLOAD_TIMEOUT_MS } from export { buildMemoryPrompt, MEMORY_TYPES, MEMORY_TYPE_LABELS } from "./src/prompt.js"; export { toText, stripChannelMetadata, isSessionResetPrompt } from "./src/messages.js"; export { resolveHookKnobs } from "./src/hooks/knobs.js"; +export { + boundedTimeoutMs, + HOOK_SAFETY_MARGIN_MS, + HOST_HOOK_TIMEOUT_S, + hookBudgetMs, + MIN_REQUEST_BUDGET_MS, + startHookWatchdog, +} from "./src/hooks/deadline.js"; export { sanitizeRecallQuery, extractUserIntent, formatQueryStats, QUERY_MAX_CHARS } from "./src/hooks/query.js"; export { createSessionState, createTurnCounter } from "./src/hooks/state.js"; +export { createToolEventBuffer } from "./src/hooks/toolbuffer.js"; export { runHook } from "./src/hooks/runtime.js"; +export { runInject } from "./src/hooks/inject.js"; export { renderProfileBlock, profileItemCount } from "./src/hooks/session-start.js"; diff --git a/plugins/agent-sdk/package.json b/plugins/agent-sdk/package.json index 89b4325..c0e0659 100644 --- a/plugins/agent-sdk/package.json +++ b/plugins/agent-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@everme/agent-sdk", - "version": "0.4.2", + "version": "0.6.1", "type": "module", "description": "Shared core for EverMe AI-agent plugins. HTTP client, search/context, agent-memory writes, redaction, prompt helpers. Host-agnostic — paired with @everme/openclaw, @everme/claude-code, @everme/memory-mcp, …", "license": "Apache-2.0", @@ -18,7 +18,7 @@ "node": ">=18.0.0" }, "scripts": { - "test": "node --test tests/client.test.js tests/config.test.js tests/messages.test.js tests/agent-memory.test.js tests/personal-memory.test.js tests/search.test.js tests/truncate.test.js tests/hooks.test.js tests/runtime.test.js tests/query.test.js tests/recall-query-e2e.test.js" + "test": "node --test tests/client.test.js tests/config.test.js tests/messages.test.js tests/agent-memory.test.js tests/personal-memory.test.js tests/search.test.js tests/truncate.test.js tests/hooks.test.js tests/runtime.test.js tests/query.test.js tests/recall-query-e2e.test.js tests/deadline.test.js tests/toolbuffer.test.js" }, "keywords": [ "evermind", diff --git a/plugins/agent-sdk/src/agent-memory.js b/plugins/agent-sdk/src/agent-memory.js index b78c3f4..4292ac4 100644 --- a/plugins/agent-sdk/src/agent-memory.js +++ b/plugins/agent-sdk/src/agent-memory.js @@ -5,6 +5,7 @@ * shape while preserving assistant tool calls and tool results. */ +import { requestMeta } from "./client.js"; import { toText, stripChannelMetadata } from "./messages.js"; import { capRunes } from "./truncate.js"; @@ -41,17 +42,25 @@ export async function saveAgentMemory(client, { conversationId, messages = [], f // caller instead of pretending the upload succeeded. const batches = Math.max(1, Math.ceil(converted.length / MAX_MESSAGES_PER_REQUEST)); let res = null; + const requestIds = []; for (let batch = 0; batch < batches; batch += 1) { const slice = converted.slice(batch * MAX_MESSAGES_PER_REQUEST, (batch + 1) * MAX_MESSAGES_PER_REQUEST); const isLast = batch === batches - 1; - res = await client.request("POST", "/mem/agent-memory", { + const { result, requestId } = await requestMeta(client, "POST", "/mem/agent-memory", { conversationId, messages: slice, flush: isLast ? flush : false, + // Leading batches of a flushing upload must keep the server's + // synchronous-add guarantee: an async leading batch can still be + // invisible to the final request's flush (first-flush data loss, + // one request boundary later). Servers without the field ignore it. + ...(!isLast && flush === true ? { sync: true } : {}), }); + res = result; + requestIds.push(requestId); } - log.info?.(`[everme] saveAgentMemory ok: messages=${converted.length} batches=${batches} flushed=${Boolean(res?.flushed)}`); - return res; + log.info?.(`[everme] saveAgentMemory ok: messages=${converted.length} batches=${batches} flushed=${Boolean(res?.flushed)} status=${res?.status ?? ""} requestId=${requestIds.join(",")}`); + return res == null ? res : { ...res, requestId: requestIds[requestIds.length - 1], requestIds }; } export async function flushAgentMemory(client, { conversationId } = {}, log) { diff --git a/plugins/agent-sdk/src/client.js b/plugins/agent-sdk/src/client.js index 9f108df..81093e4 100644 --- a/plugins/agent-sdk/src/client.js +++ b/plugins/agent-sdk/src/client.js @@ -13,8 +13,10 @@ * / save / search) decides whether to surface the error or degrade. */ +import { randomUUID } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; import { TIMEOUT_MS } from "./config.js"; +import { boundedTimeoutMs } from "./hooks/deadline.js"; const noop = { info() {}, warn() {} }; @@ -79,6 +81,40 @@ export class EvermeError extends Error { this.requestId = requestId; this.type = type; } + + /** + * Support-friendly one-liner: message plus the errno and requestId a user + * can quote to correlate with server-side logs. Every user-facing error + * sink (MCP errResp, hook diagnostics, engine warns) should prefer this + * over .message. + */ + describe() { + const parts = []; + if (this.code) parts.push(`errno=${this.code}`); + if (this.requestId) parts.push(`requestId=${this.requestId}`); + return parts.length ? `${this.message} (${parts.join(", ")})` : this.message; + } +} + +/** + * Render any error for user-facing sinks: EvermeError gets its describe() + * form (errno + requestId), everything else falls back to redactError. + */ +export function describeError(err) { + if (err instanceof EvermeError) return err.describe(); + return redactError(err?.message || String(err)); +} + +/** + * Meta-aware call helper for SDK wrappers. Real clients expose + * requestWithMeta; test stubs and host-provided fakes may only implement + * request — fall back gracefully with an empty requestId. + */ +export async function requestMeta(client, method, path, body, opts) { + if (typeof client?.requestWithMeta === "function") { + return client.requestWithMeta(method, path, body, opts); + } + return { result: await client.request(method, path, body, opts), requestId: "" }; } /** @@ -86,26 +122,43 @@ export class EvermeError extends Error { * individual methods (e.g. inject a fake fetch via cfg). */ export function createClient(cfg, log = noop) { - const headers = () => ({ + const headers = (requestId) => ({ "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${cfg.agentToken}`, "User-Agent": `everme-memory-mcp/0.1 (agentId=${cfg.agentId})`, + // Client-generated trace id. The gateway reuses a valid inbound value, + // so plugin logs, EverMe ELK, and the cloud platform all join on it — + // even when the request times out before any response arrives. + requestId, }); /** * Single-funnel request helper. Path is appended to cfg.baseUrl. - * `body` may be undefined for GET. Returns the envelope's result or - * throws an EvermeError. + * `body` may be undefined for GET. Resolves to { result, requestId } or + * throws an EvermeError (which carries the same requestId). */ - async function request(method, path, body, { timeoutMs = TIMEOUT_MS, query } = {}) { + async function requestWithMeta(method, path, body, { timeoutMs = TIMEOUT_MS, query } = {}) { + const requestId = randomUUID(); const url = buildUrl(cfg.baseUrl, path, query); const init = { method, - headers: headers(), + headers: headers(requestId), body: body == null ? undefined : JSON.stringify(body), }; - return execWithRetry(url, init, timeoutMs, log); + // cfg.deadlineAt is set when the caller runs under a host deadline (a + // native hook). Clamping here is what makes sequential requests share + // one budget instead of each taking a fresh timeoutMs. + return execWithRetry(url, init, boundedTimeoutMs(timeoutMs, cfg.deadlineAt), log, requestId); + } + + /** + * Back-compat helper: same as requestWithMeta but resolves to the bare + * envelope result. Callers that need the trace id use requestWithMeta. + */ + async function request(method, path, body, opts) { + const { result } = await requestWithMeta(method, path, body, opts); + return result; } /** @@ -118,6 +171,7 @@ export function createClient(cfg, log = noop) { * reject the upload as malformed. */ async function rawPost(uploadUrl, body, contentType, { timeoutMs = TIMEOUT_MS } = {}) { + timeoutMs = boundedTimeoutMs(timeoutMs, cfg.deadlineAt); const ac = new AbortController(); const t = setTimeout(() => ac.abort(), timeoutMs); try { @@ -187,7 +241,7 @@ export function createClient(cfg, log = noop) { } } - return { request, rawPost }; + return { request, requestWithMeta, rawPost }; } function buildUrl(base, path, query) { @@ -203,9 +257,9 @@ function buildUrl(base, path, query) { return q ? `${base}${path}?${q}` : `${base}${path}`; } -async function execWithRetry(url, init, timeoutMs, log) { +async function execWithRetry(url, init, timeoutMs, log, requestId) { try { - return await execOnce(url, init, timeoutMs); + return await execOnce(url, init, timeoutMs, requestId); } catch (err) { if (err instanceof EvermeError) { // Application-level errors don't get retried — only transport. @@ -220,13 +274,13 @@ async function execWithRetry(url, init, timeoutMs, log) { if (method !== "GET" && method !== "HEAD") { throw err; } - log.warn?.(`[everme] ${method} failed, retrying once: ${redactError(err?.message)}`); + log.warn?.(`[everme] ${method} failed, retrying once (requestId=${requestId}): ${redactError(err?.message)}`); await sleep(150); - return execOnce(url, init, timeoutMs); + return execOnce(url, init, timeoutMs, requestId); } } -async function execOnce(url, init, timeoutMs) { +async function execOnce(url, init, timeoutMs, requestId = "") { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), timeoutMs); let res; @@ -246,6 +300,7 @@ async function execOnce(url, init, timeoutMs) { message: aborted ? `timed out after ${timeoutMs}ms` : redactError(err?.message || String(err)), + requestId, type: aborted ? "timeout" : "upstream", }); } @@ -262,6 +317,7 @@ async function execOnce(url, init, timeoutMs) { message: aborted ? `timed out reading body after ${timeoutMs}ms` : redactError(`body read failed: ${err?.message || String(err)}`), + requestId, type: aborted ? "timeout" : "upstream", }); } @@ -273,16 +329,19 @@ async function execOnce(url, init, timeoutMs) { try { env = text ? JSON.parse(text) : {}; } catch { - // Non-JSON response (load shedder, proxy 502 page, etc). + // Non-JSON response (load shedder, proxy 502 page, etc). The gateway + // echoes the id on the response header even when a proxy mangles the + // body, so prefer that before falling back to our own id. throw new EvermeError({ message: `HTTP ${res.status}${text ? " — " + text.slice(0, 200) : ""}`, status: res.status, + requestId: res.headers?.get?.("requestId") || requestId, type: res.status === 401 || res.status === 403 ? "auth" : "upstream", }); } if (env && env.status === 0) { - return env.result ?? null; + return { result: env.result ?? null, requestId: env.requestId || requestId }; } // Envelope-encoded failure or missing status. const code = Number(env?.status) || 0; @@ -291,7 +350,7 @@ async function execOnce(url, init, timeoutMs) { message: env?.error || `HTTP ${res.status}`, status: res.status, code, - requestId: env?.requestId, + requestId: env?.requestId || requestId, type: errType, }); } diff --git a/plugins/agent-sdk/src/hooks/deadline.js b/plugins/agent-sdk/src/hooks/deadline.js new file mode 100644 index 0000000..73486ba --- /dev/null +++ b/plugins/agent-sdk/src/hooks/deadline.js @@ -0,0 +1,103 @@ +/** + * Hook time budget. + * + * A native hook runs as a short-lived process the host kills at the + * timeout declared in its manifest. Until this module existed the SDK's + * own request timeout was 30s and Claude Code's Stop timeout was also + * 30s, so the abort path could never win: the process was killed + * mid-request, the turn counter never committed, no diagnostic reached + * stderr, and the turn was lost for good — the next Stop reads only the + * new last turn, and nothing retries the old one. + * + * Two numbers meaning the same thing lived in two files and drifted. + * They live here now, and the host manifests are asserted against this + * table in tests. + */ + +/** + * Per-event host kill deadline in seconds, as declared in the manifests + * we own (claude-code/hooks/hooks.json, kimicode/kimi.plugin.json — both + * asserted against this table in tests). Hosts whose timeout we do not + * declare (Codex, Cursor, Devin) inherit these values as a conservative + * default: budgeting less time than the host allows costs a retry at + * worst, budgeting more is the bug this module exists to fix. + */ +export const HOST_HOOK_TIMEOUT_S = Object.freeze({ + SessionStart: 30, + UserPromptSubmit: 10, + Stop: 30, + SessionEnd: 30, + PreCompact: 30, +}); + +/** + * Headroom the hook keeps for itself: node startup, reading the + * transcript, committing the turn counter, and writing a diagnostic. The + * whole point is that we finish and report rather than get killed. + */ +export const HOOK_SAFETY_MARGIN_MS = 3_000; + +/** + * Floor for a single request. Handing fetch a zero or negative timeout + * aborts it instantly and reports a timeout we caused ourselves; better + * to make one honest attempt with what little is left. + */ +export const MIN_REQUEST_BUDGET_MS = 1_000; + +/** + * Milliseconds this hook process may spend before the host kills it, or + * null for an event with no declared host timeout (no budget is better + * than an invented one). + */ +export function hookBudgetMs(event) { + const seconds = HOST_HOOK_TIMEOUT_S[event]; + if (!seconds) return null; + return seconds * 1_000 - HOOK_SAFETY_MARGIN_MS; +} + +/** + * Clamp a request timeout to what is left before deadlineAt. Sequential + * requests in one hook therefore share a single budget: a flush turn + * sends enqueue then flush, and the second gets what the first left over + * instead of a fresh full timeout. + */ +export function boundedTimeoutMs(configuredMs, deadlineAt, now = Date.now()) { + if (!deadlineAt) return configuredMs; + const remaining = deadlineAt - now; + if (remaining < MIN_REQUEST_BUDGET_MS) return MIN_REQUEST_BUDGET_MS; + return Math.min(configuredMs, remaining); +} + +/** + * Arm a timer that reports the hook ran out of time. It is the backstop + * for a hook wedged past its own abort path — never a competitor to it — + * so it fires AFTER the request deadline (requests abort themselves at + * budgetMs, and even a last-gasp request granted past the deadline gets + * only MIN_REQUEST_BUDGET_MS more) and before the host kill at + * budgetMs + HOOK_SAFETY_MARGIN_MS, so a truly stuck hook still leaves a + * line behind rather than dying silently — the symptom that made this + * bug invisible. Firing earlier would kill a request that was about to + * finish and preempt the fail-open handling that commits the turn + * counter and writes the diagnostic. + * + * Returns a stop() to disarm it; the timers are injectable so the + * behaviour is testable without waiting on wall-clock time. + */ +export function startHookWatchdog({ + event = "", + budgetMs, + onExpire, + setTimer = setTimeout, + clearTimer = clearTimeout, +} = {}) { + if (!budgetMs || budgetMs <= 0) return () => {}; + const fireAt = budgetMs + HOOK_SAFETY_MARGIN_MS / 2; + const handle = setTimer(() => { + onExpire?.( + `EverMe ${event || "hook"} hook gave up after ${fireAt}ms to stay inside the host timeout`, + ); + }, fireAt); + // A hook process must not be held open by its own watchdog. + handle?.unref?.(); + return () => clearTimer(handle); +} diff --git a/plugins/agent-sdk/src/hooks/inject.js b/plugins/agent-sdk/src/hooks/inject.js index d22ed07..2da0d99 100644 --- a/plugins/agent-sdk/src/hooks/inject.js +++ b/plugins/agent-sdk/src/hooks/inject.js @@ -12,7 +12,7 @@ export async function runInject({ input, client, config, search = searchMemory, writeQueryStats(log, stats); if (countTokens(query) < MIN_PROMPT_TOKENS) return { block: "", count: 0 }; - const result = await search(client, { query, topK: config.injectTopK }); + const result = await search(client, { query, topK: config.injectTopK }, log); const memories = (result?.memories || []).filter((memory) => { const score = memory?.score ?? memory?.relevanceScore; return score == null || score === 0 || score >= config.injectMinScore; diff --git a/plugins/agent-sdk/src/hooks/runtime-core.js b/plugins/agent-sdk/src/hooks/runtime-core.js index e876796..2b7298f 100644 --- a/plugins/agent-sdk/src/hooks/runtime-core.js +++ b/plugins/agent-sdk/src/hooks/runtime-core.js @@ -1,4 +1,4 @@ -import { redactError } from "../client.js"; +import { describeError } from "../client.js"; /** * Transport-independent enqueue/flush primitive shared by host stores. @@ -14,7 +14,7 @@ export function createHookRuntime({ enqueue, flush, diagnostic = () => {}, rethr return await operation(); } catch (error) { try { - diagnostic(`EverMe ${label} degraded: ${redactError(error)}`); + diagnostic(`EverMe ${label} degraded: ${describeError(error)}`); } catch { // Diagnostics are best effort; never let a host hook fail closed. } diff --git a/plugins/agent-sdk/src/hooks/runtime.js b/plugins/agent-sdk/src/hooks/runtime.js index 6397ce7..0458b53 100644 --- a/plugins/agent-sdk/src/hooks/runtime.js +++ b/plugins/agent-sdk/src/hooks/runtime.js @@ -7,33 +7,70 @@ */ import { readFile } from "node:fs/promises"; -import { createClient, redactError } from "../client.js"; +import { createClient, describeError, redactError } from "../client.js"; import { resolveConfig } from "../config.js"; import { resolveHookKnobs } from "./knobs.js"; +import { hookBudgetMs, startHookWatchdog } from "./deadline.js"; import { createSessionState, createTurnCounter } from "./state.js"; import { runInject } from "./inject.js"; import { runSessionStart } from "./session-start.js"; import { runBoundaryFlush, runStore } from "./store.js"; export { createHookRuntime } from "./runtime-core.js"; -const WRITE_EVENTS = new Set(["Stop", "SessionEnd", "PreCompact"]); +const WRITE_EVENTS = new Set(["Stop", "SessionEnd", "PreCompact", "PostToolUse"]); const ROTATED_KEYS = new Set(["EVERME_AGENT_TOKEN", "EVERME_AGENT_ID"]); export async function runHook(event, rawInput, adapter, deps = {}) { - return runHostHook(event, rawInput, adapter, { - ...deps, - resolveConfig: resolveRuntimeConfig, - createClient, - createTurnCounter, - createSessionState, - runSessionStart, - runInject, - runStore, - runBoundaryFlush, - redactError, + // Process-level backstop: runHostHook already fails open on errors, but + // nothing inside the process hears the host's SIGKILL. The watchdog + // fires after the request deadline (so the abort and fail-open path get + // to finish first) and before the host kill, leaving a line on stderr + // so a hook that runs out of time is visible instead of just missing. + const stopWatchdog = startHookWatchdog({ + event: adapter?.mapEvent?.(event) || event, + budgetMs: hookBudgetMs(adapter?.mapEvent?.(event) || event), + onExpire: (line) => { + try { + process.stderr.write(`${line}\n`); + } catch { + // A closed stderr must never break a hook. + } + process.exit(0); + }, }); + try { + return await runHostHook(event, rawInput, adapter, { + ...deps, + resolveConfig: resolveRuntimeConfig, + createClient, + createTurnCounter, + createSessionState, + runSessionStart, + runInject, + runStore, + runBoundaryFlush, + redactError, + }); + } finally { + stopWatchdog(); + } } +// Hooks own stdout (it is the host ABI), so HTTP-level lines — including the +// per-request requestId — go to stderr where hosts collect diagnostics. +const stderrLog = { + info(line) { + try { + process.stderr.write(`${line}\n`); + } catch { + // A closed stderr must never break a hook. + } + }, + warn(line) { + this.info(line); + }, +}; + export async function runHostHook(event, rawInput, adapter, deps = {}) { const hostEvent = event; let result = { block: "", count: 0 }; @@ -41,17 +78,25 @@ export async function runHostHook(event, rawInput, adapter, deps = {}) { const canonicalEvent = adapter.mapEvent?.(hostEvent) || hostEvent; const input = await adapter.normalizeInput(rawInput || {}, hostEvent); const env = deps.env || await loadRuntimeEnv(adapter, deps.baseEnv || process.env); - const config = deps.config || requireOperation(deps.resolveConfig, "resolveConfig")(env); - if (!config.isConfigured) return formatOutput(adapter, hostEvent, result); - if (WRITE_EVENTS.has(canonicalEvent) && (config.authMode !== "evt" || !config.agentId)) { + const baseConfig = deps.config || requireOperation(deps.resolveConfig, "resolveConfig")(env); + if (!baseConfig.isConfigured) return formatOutput(adapter, hostEvent, result); + if (WRITE_EVENTS.has(canonicalEvent) && (baseConfig.authMode !== "evt" || !baseConfig.agentId)) { return formatOutput(adapter, hostEvent, result); } - const client = deps.client || requireOperation(deps.createClient, "createClient")(config); + // Every request this hook makes shares one deadline, set inside the + // host's kill timeout. Without it the SDK's own 30s timeout equalled + // (or, for UserPromptSubmit, tripled) the host's, so the host always + // won the race and killed us mid-request with nothing written down. + const budgetMs = deps.budgetMs === undefined ? hookBudgetMs(canonicalEvent) : deps.budgetMs; + const config = budgetMs ? { ...baseConfig, deadlineAt: Date.now() + budgetMs } : baseConfig; + + const log = deps.log || stderrLog; + const client = deps.client || requireOperation(deps.createClient, "createClient")(config, log); if (canonicalEvent === "SessionStart") { - result = await requireOperation(deps.runSessionStart, "runSessionStart")({ input, client, config }); + result = await requireOperation(deps.runSessionStart, "runSessionStart")({ input, client, config, log }); } else if (canonicalEvent === "UserPromptSubmit") { - result = await requireOperation(deps.runInject, "runInject")({ input, client, config, search: deps.searchMemory, log: deps.log }); + result = await requireOperation(deps.runInject, "runInject")({ input, client, config, search: deps.searchMemory, log }); } else if (canonicalEvent === "Stop") { const counter = deps.counter || requireOperation(deps.createTurnCounter, "createTurnCounter")({ stateDir: env.EVERME_STATE_DIR }); result = await requireOperation(deps.runStore, "runStore")({ @@ -60,8 +105,18 @@ export async function runHostHook(event, rawInput, adapter, deps = {}) { client, config, counter, + stateDir: env.EVERME_STATE_DIR, + log, diagnostic: (line) => { throw new Error(line); }, }); + } else if (canonicalEvent === "PostToolUse") { + // Local spool only — hosts whose transcript omits tool outputs + // (Cursor) buffer each call here and the Stop hook uploads them + // with the turn. Adapters without the capability keep the old + // no-op behavior for this event. + if (typeof adapter.bufferToolUse === "function") { + result = await adapter.bufferToolUse(input, { stateDir: env.EVERME_STATE_DIR }); + } } else if (canonicalEvent === "SessionEnd" || canonicalEvent === "PreCompact") { // Optional: without a session state store the boundary flush still // works, it just loses re-fire idempotency (no high-water mark). @@ -74,6 +129,7 @@ export async function runHostHook(event, rawInput, adapter, deps = {}) { adapter, client, sessionState, + log, diagnostic: (line) => { throw new Error(line); }, }); } @@ -134,7 +190,9 @@ function requireOperation(fn, name) { } function writeDiagnostic(event, error, redact = redactError, writer = (line) => process.stderr.write(line)) { - const redacted = redact(error); + // describeError appends errno + requestId for EvermeError — the line the + // user quotes to support, so it must carry the correlation id. + const redacted = error?.name === "EvermeError" ? describeError(error) : redact(error); const reason = String(redacted).replace(/\s+/g, " ").trim(); const label = { SessionStart: "start", diff --git a/plugins/agent-sdk/src/hooks/session-start.js b/plugins/agent-sdk/src/hooks/session-start.js index 781cc10..aa53239 100644 --- a/plugins/agent-sdk/src/hooks/session-start.js +++ b/plugins/agent-sdk/src/hooks/session-start.js @@ -1,9 +1,16 @@ -export async function runSessionStart({ client }) { - const result = await client.request("POST", "/mem/context", {}); +import { requestMeta } from "../client.js"; + +export async function runSessionStart({ client, log }) { + const { result, requestId } = await requestMeta(client, "POST", "/mem/context", {}); const profile = result?.profile; + const count = profileItemCount(profile); + // One line per profile injection so a SessionStart can be located in ELK + // by its trace id — errors already carry it via the degraded diagnostic. + log?.info?.(`[everme] SessionStart profile: items=${count} requestId=${requestId}`); return { block: renderProfileBlock(profile), - count: profileItemCount(profile), + count, + requestId, }; } diff --git a/plugins/agent-sdk/src/hooks/state.js b/plugins/agent-sdk/src/hooks/state.js index eadfe73..eb234c7 100644 --- a/plugins/agent-sdk/src/hooks/state.js +++ b/plugins/agent-sdk/src/hooks/state.js @@ -2,7 +2,7 @@ import { mkdir, readFile, readdir, rename, stat, unlink, writeFile, chmod } from import os from "node:os"; import path from "node:path"; -const DEFAULT_STATE_DIR = path.join(os.homedir(), ".everme", "state"); +export const DEFAULT_STATE_DIR = path.join(os.homedir(), ".everme", "state"); // Session state files are keyed by session id and become garbage once the // host session is gone; prune anything untouched for this long on the next @@ -99,7 +99,9 @@ async function pruneStaleStateFiles(stateDir, keepFile) { try { const cutoff = Date.now() - STATE_MAX_AGE_MS; for (const name of await readdir(stateDir)) { - if (!name.endsWith(".json")) continue; + // .json = session counters, .toolbuf.jsonl = tool event buffers + // whose turn never reached a Stop drain (aborted sessions). + if (!name.endsWith(".json") && !name.endsWith(".toolbuf.jsonl")) continue; const file = path.join(stateDir, name); if (file === keepFile) continue; try { @@ -114,7 +116,7 @@ async function pruneStaleStateFiles(stateDir, keepFile) { } } -function sanitizeSessionId(sessionId) { +export function sanitizeSessionId(sessionId) { const sanitized = String(sessionId || "default") .replace(/[^a-zA-Z0-9._-]+/g, "_") .replace(/^\.+/, "") diff --git a/plugins/agent-sdk/src/hooks/store.js b/plugins/agent-sdk/src/hooks/store.js index 949efed..19f3aba 100644 --- a/plugins/agent-sdk/src/hooks/store.js +++ b/plugins/agent-sdk/src/hooks/store.js @@ -1,18 +1,18 @@ import { flushAgentMemory, saveAgentMemory } from "../agent-memory.js"; import { createHookRuntime } from "./runtime-core.js"; -export async function runStore({ input, adapter, client, config, counter, diagnostic }) { +export async function runStore({ input, adapter, client, config, counter, stateDir, log, diagnostic }) { const sessionId = input?.sessionId; if (!sessionId) return { block: "", count: 0 }; - const messages = await adapter.readLastTurn(input); + const messages = await adapter.readLastTurn(input, { stateDir }); if (!Array.isArray(messages) || !messages.length) return { block: "", count: 0 }; const turnId = await resolveTurnId(adapter, input); const state = await counter.peek(sessionId, turnId); if (state.duplicate) return { block: "", count: 0, duplicate: true }; const runtime = createHookRuntime({ - enqueue: (turn) => saveAgentMemory(client, turn), - flush: (conversationId) => flushAgentMemory(client, { conversationId }), + enqueue: (turn) => saveAgentMemory(client, turn, log), + flush: (conversationId) => flushAgentMemory(client, { conversationId }, log), diagnostic, rethrowOnError: true, }); @@ -22,17 +22,21 @@ export async function runStore({ input, adapter, client, config, counter, diagno // separate empty flush is NOT equivalent — a flush ACK does not // guarantee the extraction was enqueued upstream, so the messages must // ride on the flushing request itself. - await runtime.flushSession({ conversationId: sessionId, messages }); + const saved = await runtime.flushSession({ conversationId: sessionId, messages }); await counter.commit(sessionId, turnId); - return { block: "", count: messages.length, flushed: true }; + return { block: "", count: messages.length, flushed: true, status: saved?.status, requestId: saved?.requestId }; } - await runtime.enqueueTurn({ conversationId: sessionId, messages }); + const saved = await runtime.enqueueTurn({ conversationId: sessionId, messages }); // Commit only after the gateway accepted the turn: committing first would // consume the turn id on a failed enqueue and dedupe away the retry. const committed = await counter.commit(sessionId, turnId); const flushed = config.flushEveryTurns > 0 && committed.count % config.flushEveryTurns === 0; - if (flushed) await runtime.flush(sessionId); - return { block: "", count: messages.length, flushed }; + let requestId = saved?.requestId; + if (flushed) { + const flushRes = await runtime.flush(sessionId); + requestId = flushRes?.requestId || requestId; + } + return { block: "", count: messages.length, flushed, requestId }; } // Hosts whose Stop payload carries no native turn id (Claude Code's @@ -46,11 +50,11 @@ async function resolveTurnId(adapter, input) { return (await adapter.resolveTurnId(input)) || ""; } -export async function runBoundaryFlush({ input, adapter, client, sessionState, diagnostic }) { +export async function runBoundaryFlush({ input, adapter, client, sessionState, log, diagnostic }) { if (!input?.sessionId) return { block: "", count: 0 }; const runtime = createHookRuntime({ - enqueue: (turn) => saveAgentMemory(client, turn), - flush: (conversationId) => flushAgentMemory(client, { conversationId }), + enqueue: (turn) => saveAgentMemory(client, turn, log), + flush: (conversationId) => flushAgentMemory(client, { conversationId }, log), diagnostic, rethrowOnError: true, }); @@ -69,10 +73,15 @@ export async function runBoundaryFlush({ input, adapter, client, sessionState, d const uploadedCount = sessionState ? (await sessionState.read(input.sessionId)).uploadedCount : 0; const delta = uploadedCount > 0 ? messages.slice(uploadedCount) : messages; if (!delta.length) return { block: "", count: 0, skipped: true }; - await runtime.flushSession({ conversationId: input.sessionId, messages: delta }); + const saved = await runtime.flushSession({ conversationId: input.sessionId, messages: delta }); + // The high-water mark tracks UPLOAD dedup, not extraction: the gateway + // accepted these messages, so re-sending them next boundary would + // double-write. Extraction state travels separately via `status` — + // "no_extraction" here means the upstream queued the session without + // materialising it yet (v2 first-flush gap). if (sessionState) await sessionState.patch(input.sessionId, { uploadedCount: messages.length }); - return { block: "", count: delta.length, flushed: true }; + return { block: "", count: delta.length, flushed: true, status: saved?.status, requestId: saved?.requestId }; } - await runtime.onSessionEnd(input.sessionId); - return { block: "", count: 0, flushed: true }; + const flushRes = await runtime.onSessionEnd(input.sessionId); + return { block: "", count: 0, flushed: true, requestId: flushRes?.requestId }; } diff --git a/plugins/agent-sdk/src/hooks/toolbuffer.js b/plugins/agent-sdk/src/hooks/toolbuffer.js new file mode 100644 index 0000000..04fa892 --- /dev/null +++ b/plugins/agent-sdk/src/hooks/toolbuffer.js @@ -0,0 +1,84 @@ +import { appendFile, mkdir, readFile, stat, unlink } from "node:fs/promises"; +import path from "node:path"; +import { capRunes } from "../truncate.js"; +import { DEFAULT_STATE_DIR, sanitizeSessionId } from "./state.js"; + +const DEFAULT_MAX_BUFFER_BYTES = 2_000_000; + +/** + * Per-conversation tool-event spool. Hosts whose transcript omits tool + * outputs (Cursor) buffer each postToolUse payload here from a separate + * short-lived hook process, and the Stop hook drains the spool to attach + * the calls to the turn it uploads. + * + * Drain removes the file unconditionally: leaving events behind on a + * failed upload would attach them to the NEXT turn of the conversation, + * which is worse than losing them. Spools for turns whose Stop never + * fired are pruned by the shared stale-state sweep. + */ +export function createToolEventBuffer({ stateDir = DEFAULT_STATE_DIR, maxBytes = DEFAULT_MAX_BUFFER_BYTES } = {}) { + const fileFor = (sessionId) => path.join(stateDir, `${sanitizeSessionId(sessionId)}.toolbuf.jsonl`); + return { + async append(sessionId, event) { + const file = fileFor(sessionId); + await mkdir(stateDir, { recursive: true, mode: 0o700 }); + try { + if ((await stat(file)).size >= maxBytes) return { dropped: true }; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + const line = JSON.stringify({ + ts: Date.now(), + generationId: textField(event?.generationId), + id: textField(event?.id), + name: textField(event?.name) || "unknown", + input: capField(event?.input), + output: capField(event?.output), + }); + await appendFile(file, `${line}\n`, { encoding: "utf8", mode: 0o600 }); + return { dropped: false }; + }, + + async drain(sessionId, { generationId = "" } = {}) { + const file = fileFor(sessionId); + let raw; + try { + raw = await readFile(file, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + await unlink(file).catch(() => {}); + const events = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let event; + try { + event = JSON.parse(line); + } catch { + continue; + } + // Events stamped with a different generation belong to an earlier + // aborted turn; unstamped events are kept (lenient by design — + // Cursor's payload docs do not promise generation_id everywhere). + if (generationId && event?.generationId && event.generationId !== generationId) continue; + events.push(event); + } + return events; + }, + }; +} + +function textField(value) { + return typeof value === "string" ? value : ""; +} + +function capField(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return capRunes(value); + try { + return capRunes(JSON.stringify(value)); + } catch { + return ""; + } +} diff --git a/plugins/agent-sdk/src/personal-memory.js b/plugins/agent-sdk/src/personal-memory.js index 227d32b..0a00384 100644 --- a/plugins/agent-sdk/src/personal-memory.js +++ b/plugins/agent-sdk/src/personal-memory.js @@ -9,6 +9,7 @@ * — the personal endpoint ignores them. */ +import { requestMeta } from "./client.js"; import { toText, stripChannelMetadata } from "./messages.js"; import { capRunes } from "./truncate.js"; @@ -22,13 +23,13 @@ export async function savePersonalMemory(client, { conversationId, messages = [] .filter(Boolean); if (!converted.length) return null; - const res = await client.request("POST", "/mem/personal", { + const { result: res, requestId } = await requestMeta(client, "POST", "/mem/personal", { conversationId, messages: converted, flush, }); - log.info?.(`[everme] savePersonalMemory ok: messages=${converted.length} flushed=${Boolean(res?.flushed)}`); - return res; + log.info?.(`[everme] savePersonalMemory ok: messages=${converted.length} flushed=${Boolean(res?.flushed)} requestId=${requestId}`); + return res == null ? res : { ...res, requestId }; } export function convertPersonalMessage(msg, fallbackTimestamp) { diff --git a/plugins/agent-sdk/src/search.js b/plugins/agent-sdk/src/search.js index b12cc73..41d5c02 100644 --- a/plugins/agent-sdk/src/search.js +++ b/plugins/agent-sdk/src/search.js @@ -17,6 +17,8 @@ * Both endpoints accept evt_ via Bearer (MemAuth + mem:search/mem:read). */ +import { requestMeta } from "./client.js"; + const noop = { info() {}, warn() {} }; // Mirror the backend's MaxSearchQueryRunes (server/internal/biz/memory/ @@ -61,14 +63,14 @@ export async function searchMemory(client, params, log = noop) { ? { memoryTypes: params.memoryTypes } : {}), }; - log.info?.(`[everme] POST /mem/search topK=${body.topK} q="${truncate(body.query, 60)}"`); - const res = await client.request("POST", "/mem/search", body); + const { result: res, requestId } = await requestMeta(client, "POST", "/mem/search", body); + log.info?.(`[everme] POST /mem/search topK=${body.topK} q="${truncate(body.query, 60)}" requestId=${requestId}`); return { memories: res?.items ?? [], profiles: res?.profiles ?? [], rawMessages: res?.rawMessages ?? [], agentMemory: res?.agentMemory ?? { cases: [], skills: [] }, - requestId: res?.requestId, + requestId, }; } @@ -82,10 +84,10 @@ export async function searchMemory(client, params, log = noop) { */ export async function getContext(client, _query, opts = {}, log = noop) { const body = opts.forceRefresh ? { forceRefresh: true } : {}; - log.info?.(`[everme] POST /mem/context forceRefresh=${!!opts.forceRefresh}`); - const res = await client.request("POST", "/mem/context", body); + const { result: res, requestId } = await requestMeta(client, "POST", "/mem/context", body); + log.info?.(`[everme] POST /mem/context forceRefresh=${!!opts.forceRefresh} requestId=${requestId}`); if (typeof res?.context === "string" && res.context) { - return { context: res.context, memoryCount: res.memoryCount ?? estimateCount(res) }; + return { context: res.context, memoryCount: res.memoryCount ?? estimateCount(res), requestId }; } // EverMe /mem/context returns a structured `profile` (explicit_info + // implicit_traits) rather than a pre-rendered block; render it here so @@ -94,10 +96,10 @@ export async function getContext(client, _query, opts = {}, log = noop) { if (res?.profile) { const rendered = renderProfile(res.profile); if (rendered) { - return { context: rendered, memoryCount: estimateProfileCount(res.profile) }; + return { context: rendered, memoryCount: estimateProfileCount(res.profile), requestId }; } } - return { context: "", memoryCount: estimateCount(res) }; + return { context: "", memoryCount: estimateCount(res), requestId }; } function renderProfile(profile) { diff --git a/plugins/agent-sdk/tests/agent-memory.test.js b/plugins/agent-sdk/tests/agent-memory.test.js index de68639..326023d 100644 --- a/plugins/agent-sdk/tests/agent-memory.test.js +++ b/plugins/agent-sdk/tests/agent-memory.test.js @@ -120,6 +120,10 @@ describe("agent memory", () => { assert.equal(calls.length, 3); assert.deepEqual(calls.map((call) => call.body.messages.length), [500, 500, 203]); assert.deepEqual(calls.map((call) => call.body.flush), [false, false, true]); + // Leading batches must keep the synchronous-add guarantee (sync=true): + // async leading batches can still be invisible to the final request's + // flush — the first-flush data-loss shape, per request this time. + assert.deepEqual(calls.map((call) => call.body.sync), [true, true, undefined]); assert.ok(calls.every((call) => call.path === "/mem/agent-memory" && call.body.conversationId === "sess-big")); assert.equal(calls[0].body.messages[0].content, "message 0"); assert.equal(calls[2].body.messages[202].content, "message 1202"); @@ -144,6 +148,8 @@ describe("agent memory", () => { await saveAgentMemory(client, { conversationId: "sess-nf", messages, flush: false }); assert.deepEqual(calls.map((body) => body.flush), [false, false]); + assert.ok(calls.every((body) => body.sync === undefined), + "append-only uploads must not force the sync path"); assert.deepEqual(calls.map((body) => body.messages.length), [500, 1]); }); diff --git a/plugins/agent-sdk/tests/client.test.js b/plugins/agent-sdk/tests/client.test.js index e6d3fdb..aafe09a 100644 --- a/plugins/agent-sdk/tests/client.test.js +++ b/plugins/agent-sdk/tests/client.test.js @@ -117,3 +117,56 @@ describe("client", () => { assert.equal(s.getLastRequest().url, "/agents?platform=claude-code"); }); }); + +describe("request id propagation", () => { + test("sends a requestId header and returns it via requestWithMeta", async () => { + const srv = await startServer(); + try { + srv.setReply({ status: 200, body: { error: "ok", status: 0, result: { ok: 1 }, requestId: "" } }); + const client = createClient({ baseUrl: srv.baseUrl, agentToken: "evt_x", agentId: "agt_1" }); + const { result, requestId } = await client.requestWithMeta("POST", "/mem/search", { query: "q" }); + assert.deepEqual(result, { ok: 1 }); + const sent = srv.getLastRequest().headers.requestid; + assert.ok(sent, "requestId header must be sent"); + assert.equal(requestId, sent, "with no envelope id, the client-generated id is returned"); + } finally { + await srv.close(); + } + }); + + test("prefers the envelope requestId and echoes it on errors and describe()", async () => { + const srv = await startServer(); + try { + srv.setReply({ status: 503, body: { error: "Memory profile temporarily unavailable", status: 50301, requestId: "srv-9" } }); + const client = createClient({ baseUrl: srv.baseUrl, agentToken: "evt_x", agentId: "agt_1" }); + await assert.rejects( + () => client.requestWithMeta("POST", "/mem/agent-memory", {}), + (err) => { + assert.ok(err instanceof EvermeError); + assert.equal(err.code, 50301); + assert.equal(err.requestId, "srv-9"); + assert.match(err.describe(), /errno=50301/); + assert.match(err.describe(), /requestId=srv-9/); + return true; + }, + ); + } finally { + await srv.close(); + } + }); + + test("transport-level failures still carry the client-generated id", async () => { + // Point at a port that is not listening: fetch rejects before any + // response exists, so only the locally generated id can identify the + // attempt in logs. + const client = createClient({ baseUrl: "http://127.0.0.1:1", agentToken: "evt_x", agentId: "agt_1" }); + await assert.rejects( + () => client.requestWithMeta("POST", "/mem/search", {}), + (err) => { + assert.ok(err instanceof EvermeError); + assert.ok(err.requestId, "requestId must be set even without a response"); + return true; + }, + ); + }); +}); diff --git a/plugins/agent-sdk/tests/deadline.test.js b/plugins/agent-sdk/tests/deadline.test.js new file mode 100644 index 0000000..17e644c --- /dev/null +++ b/plugins/agent-sdk/tests/deadline.test.js @@ -0,0 +1,285 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createClient } from "../index.js"; +import { runHostHook } from "../src/hooks/runtime.js"; +import { + boundedTimeoutMs, + HOOK_SAFETY_MARGIN_MS, + HOST_HOOK_TIMEOUT_S, + hookBudgetMs, + MIN_REQUEST_BUDGET_MS, + startHookWatchdog, + TIMEOUT_MS, +} from "../index.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pluginsRoot = path.resolve(here, "..", ".."); + +// Regression for the 2026-08-17 review item 1.1.7. The host kills a hook +// at its manifest timeout; the SDK's own request timeout was set to the +// same 30s, so the abort path could never win the race - the process was +// killed mid-flight, the turn counter never committed, no diagnostic was +// written, and the turn was gone for good (the next Stop reads only the +// new last turn). The in-process budget must always finish first. +describe("hook deadline budget", () => { + test("every hook budget leaves the host room to hear back", () => { + for (const [event, seconds] of Object.entries(HOST_HOOK_TIMEOUT_S)) { + const budget = hookBudgetMs(event); + assert.ok(budget > 0, `${event} must have a positive budget`); + assert.ok( + budget < seconds * 1000, + `${event}: budget ${budget}ms must be strictly under the host's ${seconds}s kill deadline`, + ); + assert.equal(budget, seconds * 1000 - HOOK_SAFETY_MARGIN_MS); + } + }); + + test("UserPromptSubmit is the tightest budget, and the default request timeout blows it", () => { + // The recall hook gets 10s from the host while the SDK default is 30s: + // a 3:1 mismatch, worse than Stop's 1:1. + assert.ok(hookBudgetMs("UserPromptSubmit") < TIMEOUT_MS); + }); + + test("an unknown event has no budget rather than a made-up one", () => { + assert.equal(hookBudgetMs("NotAHostEvent"), null); + }); + + test("a request never outlives the remaining budget", () => { + const now = 1_000_000; + // Plenty of budget left: the configured timeout still applies. + assert.equal(boundedTimeoutMs(TIMEOUT_MS, now + 60_000, now), TIMEOUT_MS); + // Less budget than the configured timeout: the deadline wins. + assert.equal(boundedTimeoutMs(TIMEOUT_MS, now + 5_000, now), 5_000); + // Nearly out of budget: never go to zero or negative, or fetch would + // abort instantly and we would report a timeout we caused ourselves. + assert.equal(boundedTimeoutMs(TIMEOUT_MS, now + 10, now), MIN_REQUEST_BUDGET_MS); + assert.equal(boundedTimeoutMs(TIMEOUT_MS, now - 5_000, now), MIN_REQUEST_BUDGET_MS); + // No deadline configured: unchanged. + assert.equal(boundedTimeoutMs(TIMEOUT_MS, null, now), TIMEOUT_MS); + }); + + test("two sequential requests share one budget instead of each getting a fresh one", () => { + // A flush turn issues enqueue then flush. With a shared deadline the + // second request gets what the first left, so the pair cannot exceed + // the host's budget the way two independent 30s timeouts could. + const now = 1_000_000; + const deadlineAt = now + hookBudgetMs("Stop"); + const first = boundedTimeoutMs(TIMEOUT_MS, deadlineAt, now); + const afterFirst = now + first; + const second = boundedTimeoutMs(TIMEOUT_MS, deadlineAt, afterFirst); + assert.ok(first + second <= hookBudgetMs("Stop") + MIN_REQUEST_BUDGET_MS); + }); +}); + +describe("hook watchdog", () => { + // The watchdog is the backstop for a hook wedged past its own abort + // path, not a competitor to it: it must fire AFTER the request deadline + // (or it kills a request that was about to finish and preempts the + // fail-open handling that commits state and writes the diagnostic) and + // before the host kill (or it never fires at all). Even the last-gasp + // request boundedTimeoutMs grants past the deadline gets + // MIN_REQUEST_BUDGET_MS, so the watchdog must sit beyond that too. + test("fires between the request deadline and the host kill", () => { + let scheduled = null; + const budgetMs = hookBudgetMs("Stop"); + const stop = startHookWatchdog({ + budgetMs, + onExpire: () => {}, + setTimer: (fn, ms) => { + scheduled = ms; + return 1; + }, + clearTimer: () => {}, + }); + assert.ok( + scheduled > budgetMs + MIN_REQUEST_BUDGET_MS, + `watchdog at ${scheduled}ms would preempt a request still allowed until ${budgetMs + MIN_REQUEST_BUDGET_MS}ms`, + ); + assert.ok( + scheduled < budgetMs + HOOK_SAFETY_MARGIN_MS, + `watchdog at ${scheduled}ms fires after the host kill at ${budgetMs + HOOK_SAFETY_MARGIN_MS}ms`, + ); + stop(); + }); + + test("holds that ordering for the tightest budget too", () => { + let scheduled = null; + const budgetMs = hookBudgetMs("UserPromptSubmit"); + startHookWatchdog({ + budgetMs, + onExpire: () => {}, + setTimer: (fn, ms) => { + scheduled = ms; + return 1; + }, + clearTimer: () => {}, + }); + assert.ok(scheduled > budgetMs + MIN_REQUEST_BUDGET_MS, `watchdog at ${scheduled}ms`); + assert.ok(scheduled < budgetMs + HOOK_SAFETY_MARGIN_MS, `watchdog at ${scheduled}ms`); + }); + + test("reports which hook ran out of time, without a token", () => { + let reported = ""; + let fire = null; + startHookWatchdog({ + event: "Stop", + budgetMs: 27_000, + onExpire: (line) => { + reported = line; + }, + setTimer: (fn) => { + fire = fn; + return 1; + }, + clearTimer: () => {}, + }); + fire(); + assert.match(reported, /Stop/); + assert.doesNotMatch(reported, /evt_/); + }); + + test("no budget means no watchdog", () => { + let scheduled = false; + startHookWatchdog({ + budgetMs: null, + onExpire: () => {}, + setTimer: () => { + scheduled = true; + return 1; + }, + clearTimer: () => {}, + }); + assert.equal(scheduled, false); + }); + + test("stopping it clears the timer so a fast hook exits immediately", () => { + let cleared = null; + const stop = startHookWatchdog({ + budgetMs: 27_000, + onExpire: () => {}, + setTimer: () => 42, + clearTimer: (handle) => { + cleared = handle; + }, + }); + stop(); + assert.equal(cleared, 42); + }); +}); + +// The bug was two numbers meaning the same thing living in two files. A +// manifest that drifts from the SDK table puts them back out of sync, so +// assert them against each other. +describe("host manifests match the SDK timeout table", () => { + test("claude-code hooks.json", async () => { + const raw = await readFile(path.join(pluginsRoot, "claude-code", "hooks", "hooks.json"), "utf8"); + const { hooks } = JSON.parse(raw); + for (const [event, matchers] of Object.entries(hooks)) { + for (const matcher of matchers) { + for (const entry of matcher.hooks) { + assert.equal( + entry.timeout, + HOST_HOOK_TIMEOUT_S[event], + `${event} manifest timeout must match HOST_HOOK_TIMEOUT_S`, + ); + } + } + } + }); + + test("kimicode kimi.plugin.json", async () => { + const raw = await readFile(path.join(pluginsRoot, "kimicode", "kimi.plugin.json"), "utf8"); + const { hooks } = JSON.parse(raw); + for (const entry of hooks) { + assert.equal( + entry.timeout, + HOST_HOOK_TIMEOUT_S[entry.event], + `${entry.event} manifest timeout must match HOST_HOOK_TIMEOUT_S`, + ); + } + }); +}); + +// The wiring, not just the arithmetic: a hook must hand the client a +// deadline and the client must honour it. Without this, each request +// still gets its own full 30s and a flush turn can spend 60s against a +// 30s host budget. +describe("runtime hands the client a deadline", () => { + test("Stop config carries a deadline inside the host budget", async () => { + let seenConfig = null; + const before = Date.now(); + await runHostHook( + "Stop", + { sessionId: "s1", turnId: "t1" }, + { + envFile: () => undefined, + normalizeInput: async (raw) => raw, + formatOutput: (event, result) => ({ event, ...result }), + }, + { + baseEnv: {}, + resolveConfig: () => ({ isConfigured: true, authMode: "evt", agentId: "agt_test" }), + createClient: (cfg) => { + seenConfig = cfg; + return { id: "client" }; + }, + createTurnCounter: () => ({ + peek: async () => ({ duplicate: false }), + commit: async () => ({ count: 1 }), + }), + runStore: async () => ({ block: "", count: 0 }), + }, + ); + assert.ok(seenConfig?.deadlineAt, "createClient must receive a deadlineAt"); + // Measured from before the call, so the deadline is at least the + // budget away and — the property that matters — still comfortably + // inside the host's kill deadline. + const budget = seenConfig.deadlineAt - before; + assert.ok(budget >= hookBudgetMs("Stop"), `deadline is short: ${budget}ms`); + assert.ok( + budget < HOST_HOOK_TIMEOUT_S.Stop * 1000, + `deadline must land inside the host's ${HOST_HOOK_TIMEOUT_S.Stop}s kill window, got ${budget}ms`, + ); + }); + + test("the client aborts on the deadline instead of waiting out its own 30s", async () => { + const budgetMs = 1_500; + const client = createClient( + { + baseUrl: "https://example.invalid/api/v1", + agentToken: "evt_test", + agentId: "agt_test", + deadlineAt: Date.now() + budgetMs, + }, + { info() {}, warn() {} }, + ); + + // A request that never answers: only the abort can end it. + const realFetch = globalThis.fetch; + globalThis.fetch = (_url, init) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => reject(init.signal.reason ?? new Error("aborted"))); + }); + + const started = Date.now(); + let error; + try { + await client.request("POST", "/mem/agent-memory", { conversationId: "s1" }); + } catch (err) { + error = err; + } finally { + globalThis.fetch = realFetch; + } + const waited = Date.now() - started; + + assert.ok(error, "a hung request must surface an error, not hang the hook"); + assert.match(String(error.message), /timed out/); + assert.ok( + waited < TIMEOUT_MS / 2, + `must abort on the ${budgetMs}ms deadline, waited ${waited}ms`, + ); + }); +}); diff --git a/plugins/agent-sdk/tests/search.test.js b/plugins/agent-sdk/tests/search.test.js index c950940..78e6df1 100644 --- a/plugins/agent-sdk/tests/search.test.js +++ b/plugins/agent-sdk/tests/search.test.js @@ -5,10 +5,13 @@ import { searchMemory, QUERY_MAX_CHARS } from "../src/search.js"; describe("searchMemory", () => { test("posts query + topK to /mem/search and renames items → memories", async () => { const calls = []; + // requestWithMeta mirrors the real client contract: the trace id lives on + // the envelope, NOT inside result — a result-level requestId used to mask + // the fact that searchMemory read a field the backend never sent there. const client = { - async request(method, path, body) { + async requestWithMeta(method, path, body) { calls.push({ method, path, body }); - return { items: [{ summary: "hit" }], profiles: [], requestId: "req-1" }; + return { result: { items: [{ summary: "hit" }], profiles: [] }, requestId: "req-1" }; }, }; diff --git a/plugins/agent-sdk/tests/toolbuffer.test.js b/plugins/agent-sdk/tests/toolbuffer.test.js new file mode 100644 index 0000000..ce613ca --- /dev/null +++ b/plugins/agent-sdk/tests/toolbuffer.test.js @@ -0,0 +1,72 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createToolEventBuffer } from "../src/hooks/toolbuffer.js"; + +const tempDirs = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function newBuffer() { + const dir = await mkdtemp(path.join(os.tmpdir(), "everme-toolbuffer-")); + tempDirs.push(dir); + return { buffer: createToolEventBuffer({ stateDir: dir }), dir }; +} + +describe("tool event buffer", () => { + test("drain returns appended events in order and clears the file", async () => { + const { buffer, dir } = await newBuffer(); + await buffer.append("session-a", { name: "shell", input: { command: "ls" }, output: "a.js" }); + await buffer.append("session-a", { name: "read_file", input: { path: "a.js" }, output: "body" }); + + const events = await buffer.drain("session-a"); + assert.equal(events.length, 2); + assert.equal(events[0].name, "shell"); + assert.equal(events[1].name, "read_file"); + assert.ok(events[0].ts <= events[1].ts, "events must carry monotonic timestamps"); + + assert.deepEqual(await buffer.drain("session-a"), []); + const leftovers = (await readdir(dir)).filter((name) => name.includes("session-a")); + assert.deepEqual(leftovers, [], "drain must remove the buffer file"); + }); + + test("drain keeps events for the requested generation and lenient ones without a generation", async () => { + const { buffer } = await newBuffer(); + await buffer.append("session-b", { name: "stale", generationId: "gen-old" }); + await buffer.append("session-b", { name: "current", generationId: "gen-new" }); + await buffer.append("session-b", { name: "unstamped" }); + + const events = await buffer.drain("session-b", { generationId: "gen-new" }); + assert.deepEqual(events.map((event) => event.name), ["current", "unstamped"]); + }); + + test("drain of an unknown session returns an empty list", async () => { + const { buffer } = await newBuffer(); + assert.deepEqual(await buffer.drain("never-seen"), []); + }); + + test("append caps the buffer file instead of growing without bound", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "everme-toolbuffer-")); + tempDirs.push(dir); + const buffer = createToolEventBuffer({ stateDir: dir, maxBytes: 2048 }); + const bulk = "x".repeat(500); + for (let i = 0; i < 12; i += 1) { + await buffer.append("session-c", { name: `tool_${i}`, output: bulk }); + } + const events = await buffer.drain("session-c"); + assert.ok(events.length < 12, "the cap must drop appends past the byte limit"); + assert.ok(events.length > 0, "events under the cap must survive"); + }); + + test("buffer files are private to the user", async () => { + const { buffer, dir } = await newBuffer(); + await buffer.append("session-d", { name: "shell" }); + const [file] = (await readdir(dir)).filter((name) => name.includes("session-d")); + const info = await stat(path.join(dir, file)); + assert.equal(info.mode & 0o777, 0o600); + }); +}); diff --git a/plugins/claude-code/.claude-plugin/marketplace.json b/plugins/claude-code/.claude-plugin/marketplace.json index 3df03fa..e5afb4a 100644 --- a/plugins/claude-code/.claude-plugin/marketplace.json +++ b/plugins/claude-code/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "everme", "source": "./", "description": "Automatic memory recall for Claude Code through the EverMe gateway. Saves and recalls per-session context using your EverMe account credentials.", - "version": "0.4.2", + "version": "0.6.1", "homepage": "https://everme.evermind.ai", "license": "Apache-2.0" } diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 2bdc9da..e1bcdde 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "everme", - "version": "0.4.2", + "version": "0.6.1", "description": "EverMe — automatic memory recall for Claude Code. Recalls relevant context from past sessions before each prompt and saves new turns through the EverMe gateway.", "author": { "name": "EverMind AI", diff --git a/plugins/claude-code/hooks/scripts/mcp-server.js b/plugins/claude-code/hooks/scripts/mcp-server.js index c24e8ef..0961450 100755 --- a/plugins/claude-code/hooks/scripts/mcp-server.js +++ b/plugins/claude-code/hooks/scripts/mcp-server.js @@ -25,6 +25,7 @@ import { savePersonalMemory, AGENT_MEMORY_ROLES, redactError, + describeError, EvermeError, } from "@everme/agent-sdk"; import { getConfig, isConfigured } from "./lib/config.js"; @@ -48,8 +49,23 @@ const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26"]); const LATEST_PROTOCOL_VERSION = "2025-03-26"; let client; +// stdout carries the JSON-RPC stream; HTTP diagnostics (per-request +// requestId lines) go to stderr like every other hook surface. +const stderrLog = { + info(line) { + try { + process.stderr.write(`${line}\n`); + } catch { + // A closed stderr must never break the MCP stream. + } + }, + warn(line) { + this.info(line); + }, +}; + function getClient() { - if (!client) client = createClient(getConfig()); + if (!client) client = createClient(getConfig(), stderrLog); return client; } @@ -305,14 +321,14 @@ const handlers = { switch (name) { case "mem_search": { const topK = Math.min(Number(args.topK) || 10, 50); - const res = await searchMemory(getClient(), { query: String(args.query || ""), topK }); + const res = await searchMemory(getClient(), { query: String(args.query || ""), topK }, stderrLog); const body = buildMemoryPrompt(res, { wrapInCodeBlock: false }); const header = `## EverMe search results for "${String(args.query || "")}"`; const trimmed = body.replace(/^## Relevant memory\n\n?/, ""); const text = trimmed ? `${header}\n\n${trimmed}` : `${header}\n\n_(no matching memories)_`; - return ok(redactError(text)); + return ok(appendRequestID(redactError(text), res?.requestId)); } case "mem_context": { // Profile-only: `query` is accepted for compat but ignored. @@ -320,9 +336,10 @@ const handlers = { getClient(), "", { forceRefresh: args.forceRefresh === true }, + stderrLog, ); const text = ctx?.context || "_(no profile available — your EverMe account has no extracted memories yet)_"; - return ok(redactError(text)); + return ok(appendRequestID(redactError(text), ctx?.requestId)); } case "mem_save_turn": { let messages; @@ -341,7 +358,7 @@ const handlers = { conversationId: args.sessionKey || "default", messages, flush: args.flush !== false, - }); + }, stderrLog); return okJson({ saved: !!res, accepted: !!res, @@ -350,6 +367,7 @@ const handlers = { flushed: !!res?.flushed, profileStatus: res?.personalStatus || null, profileUpdated: !!res?.personalExtracted, + requestId: res?.requestId || null, }); } case "mem_save_fact": { @@ -378,7 +396,7 @@ const handlers = { conversationId: args.sessionKey || "default", messages, flush: args.flush !== false, - }); + }, stderrLog); if (!res) { return errResp("mem_save_fact wrote nothing — every message had empty content after normalization"); } @@ -392,13 +410,14 @@ const handlers = { // profileUpdated aliases extracted — the only signal that the // fact really materialised into the profile. profileUpdated: !!res?.extracted, + requestId: res?.requestId || null, }); } default: return errResp(`unknown tool: ${name}`); } } catch (err) { - const safe = redactError(err instanceof EvermeError ? err.message : err?.message || String(err)); + const safe = describeError(err); return errResp(safe); } }, @@ -414,6 +433,13 @@ function errResp(msg) { return { isError: true, content: [{ type: "text", text: `error: ${msg}` }] }; } +// appendRequestID mirrors @everme/memory-mcp: tack the trace id onto a +// markdown payload so a user can quote it to support. +function appendRequestID(text, requestId) { + if (!requestId) return text; + return `${text}\n\n_(requestId: ${requestId})_`; +} + // normaliseTurnMessage coerces an LLM-provided message into the SDK // agent-memory shape — accepts both legacy {role, text} and canonical // {role, content, toolCalls, toolCallId} forms. Mirrors the equivalent diff --git a/plugins/claude-code/package.json b/plugins/claude-code/package.json index 8d70ab5..6289334 100644 --- a/plugins/claude-code/package.json +++ b/plugins/claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@everme/claude-code", - "version": "0.4.2", + "version": "0.6.1", "type": "module", "description": "EverMe native plugin for Claude Code — automatic memory recall via SessionStart/UserPromptSubmit/Stop/SessionEnd hooks, plus /recall slash + bundled MCP server.", "license": "Apache-2.0", @@ -21,7 +21,7 @@ "README.md" ], "dependencies": { - "@everme/agent-sdk": "^0.4.2" + "@everme/agent-sdk": "^0.6.1" }, "keywords": [ "evermind", diff --git a/plugins/cli/CHANGELOG.md b/plugins/cli/CHANGELOG.md index 8498231..0a16033 100644 --- a/plugins/cli/CHANGELOG.md +++ b/plugins/cli/CHANGELOG.md @@ -3,12 +3,53 @@ All notable changes to `@everme/cli` are documented here. The version of this package matches the `evercli` Go binary version it downloads. -## [Unreleased] +## [0.1.0-beta.3] - 2026-05-12 -- Canonical binary downloads come from `EverMind-AI/EverMe` GitHub Releases - using bare semver tags such as `v0.2.2`. -- The npm wrapper exposes the platform-native binary as `evercli`. +### Changed + +- `evercli import run ` now sends `originPlatform: ` in + the source-create request. Cold-start imports attribute correctly to the + target AI agent platform (claude-code, openclaw, …) in the EverMe UI + instead of appearing under "EverCli". Pairs with server-side + `source.origin_platform` column (migration 000005) + BFF view-layer + attribution rewrite. Backward compatible with older servers — they + silently ignore the extra field. Older beta.1/beta.2 CLIs continue to + work but their cold-start imports surface as the EverCli placeholder + until the user upgrades. + +## [0.1.0-beta.2] - 2026-05-12 + +### Fixed + +- `evercli plugin install claude-code` now resolves the Claude Code plugin + source via `npm install -g @everme/claude-code` (a globally-installed npm + package) instead of a stale GitHub URL fallback that was never reachable in + production. The hardcoded `https://github.com/EverMind-AI/everme.git` + fallback (which 404'd because the org was wrong, and would have 404'd at the + next step anyway because the mirror repo has no plugin source) is removed. +- The mono-repo `devPluginSourcePath()` auto-fallback is also removed so dev + and prod take the same code path. Developers wanting to test local plugin + changes should set `EVERCLI_CLAUDE_PLUGIN_SOURCE=/path/to/plugins/claude-code/` + explicitly. +- The error hint surfaced when `claude plugin marketplace add` fails now + explicitly rules out `gh auth login` as a remediation — earlier AI agents + inferred it was a GitHub auth issue and went hunting for credentials. + +### Changed (BREAKING) + +- **CLI command renamed from `everme` to `evercli`.** The Go binary has always + self-identified as `evercli` (in `--version` output, `EVERCLI_*` env vars, + log tags, HTTP User-Agent, JSON `resumeCommand` field, and the `platform` + field sent to the backend); only the npm bin alias was `everme`, creating a + confusing split. The npm bin is now `evercli` to match. + - **Migration**: `npm uninstall -g @everme/cli && npm install -g @everme/cli@beta`, + then replace `everme` with `evercli` in any shell scripts or aliases. + +## [0.1.0-beta.1] - 2026-05-11 + +- Initial release: npm wrapper that downloads platform-matched `evercli` binary + from GitHub Releases and exposes it (at the time, as the `everme` command; + renamed to `evercli` in 0.1.0-beta.2). - SHA256 checksum verification against `sha256sums.txt` shipped in the package. -- Mirror chain: GitHub → npm_config_registry-derived binary mirror → - registry.npmmirror.com. +- Mirror chain: GitHub → npm_config_registry-derived → registry.npmmirror.com. - Lazy install fallback when `postinstall` is skipped (npx, restricted CI). diff --git a/plugins/cli/README.md b/plugins/cli/README.md index 3c6a368..abc7e27 100644 --- a/plugins/cli/README.md +++ b/plugins/cli/README.md @@ -12,7 +12,7 @@ npx @everme/cli --version ## What this package does -`@everme/cli` is a thin Node-side installer + runner; the actual CLI is a pre-compiled binary downloaded from `EverMind-AI/EverMe` GitHub Releases on first install. On `npm install`: +`@everme/cli` is a thin Node-side installer + runner; the actual CLI is a pre-compiled binary downloaded on first install. On `npm install`: 1. Detect platform / arch (darwin/linux/windows × amd64/arm64) 2. Download the matching archive from `https://github.com/EverMind-AI/EverMe/releases/download/v/evercli__.{tar.gz|zip}` @@ -27,7 +27,7 @@ If `postinstall` was skipped (some `npx` flows, restricted CI), the installer ru The installer tries download sources in this order: 1. `https://github.com/EverMind-AI/EverMe/releases/...` (canonical) -2. The npm registry's binary mirror path (`/-/binary/everme-cli/...`), if your `npm_config_registry` is non-default. `everme-cli` here is only the binary mirror namespace. +2. The npm registry's binary mirror path (`/-/binary/everme-cli/...`), if your `npm_config_registry` is non-default 3. `https://registry.npmmirror.com/-/binary/everme-cli/...` (always tried as final fallback) The first source to succeed wins. SHA-256 verification runs regardless of source. diff --git a/plugins/cli/package.json b/plugins/cli/package.json index cd57a91..80fbbc0 100644 --- a/plugins/cli/package.json +++ b/plugins/cli/package.json @@ -1,6 +1,6 @@ { "name": "@everme/cli", - "version": "0.2.4", + "version": "0.32.0", "description": "EverMe CLI — npm wrapper that downloads and runs the platform-native evercli binary on first use.", "license": "Apache-2.0", "bin": { @@ -8,7 +8,7 @@ }, "scripts": { "postinstall": "node scripts/install.js", - "test": "node --test tests/install.test.js" + "test": "node --test tests/install.test.js tests/upgrade-plugins.test.js" }, "os": [ "darwin", @@ -26,6 +26,7 @@ "scripts/install.js", "scripts/install-wizard.js", "scripts/run.js", + "scripts/upgrade-plugins.js", "sha256sums.txt", "LICENSE", "README.md" @@ -50,6 +51,5 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" - }, - "dependencies": {} + } } diff --git a/plugins/cli/scripts/install.js b/plugins/cli/scripts/install.js index 46df57a..c72a92f 100755 --- a/plugins/cli/scripts/install.js +++ b/plugins/cli/scripts/install.js @@ -6,10 +6,10 @@ * postinstall hook for @everme/cli — downloads, verifies, and extracts the * platform-native evercli binary into /bin. * - * Expected binary archive conventions: + * Conventions match this monorepo's existing goreleaser config + * (cli/.goreleaser.yml): * - * repo EverMind-AI/EverMe - * tag vX.Y.Z + * tag cli/vX.Y.Z * archive evercli__.tar.gz (Unix) * evercli__.zip (Windows) * checksum sha256sums.txt @@ -24,6 +24,7 @@ const path = require("path"); const os = require("os"); const crypto = require("crypto"); const { execFileSync } = require("child_process"); +const { refreshPlugins } = require("./upgrade-plugins.js"); const VERSION = require("../package.json").version; const REPO = "EverMind-AI/EverMe"; @@ -57,7 +58,10 @@ const isWindows = process.platform === "win32"; const ext = isWindows ? ".zip" : ".tar.gz"; const archiveName = `${NAME}_${platform}_${arch}${ext}`; -// Tag scheme is bare semver `vX.Y.Z`. +// Tag scheme is bare semver `vX.Y.Z`. The original design used a monorepo +// prefix `cli/vX.Y.Z`, but goreleaser OSS rejects prefixed tags as invalid +// semver (the `monorepo.tag_prefix` setting is Pro-only). Since server is +// deployed separately (not tagged), the cli/ prefix carried no real value. const TAG = `v${VERSION}`; const GITHUB_URL = `https://github.com/${REPO}/releases/download/${TAG}/${archiveName}`; @@ -91,9 +95,8 @@ function isDefaultNpmjsRegistry(url) { * * 1. npm_config_registry — when the user has set a non-default registry * (npmmirror clone, corp Verdaccio, Artifactory), we prepend the - * derived path. The `everme-cli` segment is a binary mirror namespace, - * not a GitHub repository name. Many proxies don't host /-/binary//..., - * so we always append the public npmmirror as a final fallback. + * derived path. Many proxies don't host /-/binary//..., so we + * always append the public npmmirror as a final fallback. * 2. registry.npmmirror.com — public China mirror, always tried last. * * The default public npmjs registry is skipped because it doesn't host @@ -357,6 +360,14 @@ if (require.main === module) { try { install(); + // One-shot plugin refresh: bring already-installed hosts to the latest + // plugin line. Never fatal — guards + per-host try/catch live in the + // module; we belt-and-suspenders wrap here so nothing fails `npm i -g`. + try { + refreshPlugins({ binPath: dest, env: process.env }); + } catch (e) { + console.error(`[@everme/cli] plugin refresh skipped: ${e.message}`); + } } catch (err) { console.error(`Failed to install ${NAME}:`, err.message); console.error( diff --git a/plugins/cli/scripts/upgrade-plugins.js b/plugins/cli/scripts/upgrade-plugins.js new file mode 100644 index 0000000..139a11c --- /dev/null +++ b/plugins/cli/scripts/upgrade-plugins.js @@ -0,0 +1,274 @@ +// Copyright 2026 Evermind AI +// SPDX-License-Identifier: Apache-2.0 + +/** + * Plugin refresh invoked from @everme/cli postinstall. After the new binary + * is on disk, bring already-installed plugin hosts up to the latest plugin + * line — without any new evercli subcommand. Discovery reuses the read-only + * `plugin list`; refresh is per host class (see spec + * .work_context/specs/2026-06-23-cli-postinstall-one-shot-upgrade-design.md). + */ + +"use strict"; + +const fs = require("fs"); +const { execFileSync } = require("child_process"); + +// Quote-delimited bare/latest spec pairs. JSON host configs (cursor, +// claude-desktop, opencode) use double quotes; codex's config.toml uses single +// quotes. Each pair is inherently idempotent — once `@latest` is present the +// bare quoted token no longer exists (the closing quote sits after `@latest`). +const MCP_SPEC_PAIRS = [ + ['"@everme/memory-mcp"', '"@everme/memory-mcp@latest"'], + ["'@everme/memory-mcp'", "'@everme/memory-mcp@latest'"], +]; + +const NPX_PLATFORMS = new Set(["cursor", "claude-desktop", "codex", "opencode"]); + +const LIST_TIMEOUT_MS = 15000; +const REFRESH_TIMEOUT_MS = 60000; + +function npmCommand() { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function defaultRunner(file, args, opts) { + return execFileSync(file, args, opts); +} + +// discoverHosts runs the read-only `plugin list --format json` against the +// freshly-downloaded binary and returns the platforms array. Throws if the +// envelope is missing or not ok so the caller treats discovery as failed. +function discoverHosts(binPath, runner) { + const run = runner || defaultRunner; + const out = run(binPath, ["plugin", "list", "--format", "json"], { + encoding: "utf8", + timeout: LIST_TIMEOUT_MS, + stdio: ["ignore", "pipe", "ignore"], + }); + const env = JSON.parse(out); + if (!env || env.ok !== true || !env.data || !Array.isArray(env.data.platforms)) { + throw new Error("unexpected `plugin list` envelope shape"); + } + return env.data.platforms; +} + +// patchNpxSpec adds @latest to the memory-mcp spec in a host config file by a +// literal, format-agnostic replace of the quoted bare token. Idempotent: the +// quoted bare token no longer exists once @latest is present. Touches only the +// package spec — never credential fields. +// +// The replace is atomic: write a sibling temp file, fsync, then rename over the +// original. These configs carry credentials, so an interrupted in-place +// truncate/write (crash, disk full) must never leave a corrupted file. The +// original mode is preserved (host configs are 0600). +function patchNpxSpec(configPath) { + const before = fs.readFileSync(configPath, "utf8"); + let after = before; + for (const [bare, latest] of MCP_SPEC_PAIRS) { + if (after.includes(bare)) after = after.split(bare).join(latest); + } + if (after === before) return false; + const mode = fs.statSync(configPath).mode & 0o777; + const tmp = `${configPath}.everme-tmp-${process.pid}`; + try { + const fd = fs.openSync(tmp, "w", mode); + try { + fs.writeFileSync(fd, after); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.chmodSync(tmp, mode); + fs.renameSync(tmp, configPath); + } catch (e) { + try { + fs.unlinkSync(tmp); + } catch (_) { + // temp file may not exist if openSync failed — ignore. + } + throw e; + } + return true; +} + +// refreshClaudeCode brings Claude Code to the latest plugin payload. Two steps +// are required: `evercli plugin install claude-code` reuses an already-present +// global npm package without upgrading it (pluginSourceSpec short-circuits on +// the existing `npm root -g` path), so the @latest bump must happen first; the +// re-install then runs marketplace add-or-update + plugin install-or-update so +// Claude Code actually loads the fresh hooks/commands/MCP, and asserts the +// cached version matches the payload. Step 2 rotates the local agent token +// (per-(platform, fingerprint) — other devices unaffected). +function refreshClaudeCode(binPath, runner) { + const run = runner || defaultRunner; + run(npmCommand(), ["install", "-g", "@everme/claude-code@latest"], { + encoding: "utf8", + timeout: REFRESH_TIMEOUT_MS, + stdio: ["ignore", "ignore", "inherit"], + }); + run(binPath, ["plugin", "install", "claude-code"], { + encoding: "utf8", + timeout: REFRESH_TIMEOUT_MS, + stdio: ["ignore", "ignore", "inherit"], + }); +} + +// `plugins update ` is the refresh verb: it bumps an already-tracked plugin +// to latest by id (not an npm spec) and leaves openclaw.json alone, unlike +// `install --force`, which reruns the full install-time config scaffolding and +// rewrites the credential-bearing config. The host is already installed in the +// refresh context; if it somehow isn't tracked, update errors and the caller +// degrades it to a warning. +function refreshOpenClaw(runner) { + const run = runner || defaultRunner; + run("openclaw", ["plugins", "update", "@everme/openclaw"], { + encoding: "utf8", + timeout: REFRESH_TIMEOUT_MS, + stdio: ["ignore", "ignore", "inherit"], + }); +} + +function refreshHermes(binPath, runner) { + const run = runner || defaultRunner; + run(binPath, ["plugin", "install", "hermes"], { + encoding: "utf8", + timeout: REFRESH_TIMEOUT_MS, + stdio: ["ignore", "ignore", "inherit"], + }); +} + +// Codex needs BOTH refresh paths and used to get only the npx one: its +// memory-mcp spec lives in config.toml (patchNpxSpec), but its hook bundle +// ships through the marketplace cache, which Codex refreshes only when a +// `marketplace upgrade` runs against a changed manifest version. That +// upgrade lives inside `evercli plugin install codex`, so without this the +// hooks stayed pinned to whatever version first installed them. +function refreshCodex(binPath, runner) { + const run = runner || defaultRunner; + run(binPath, ["plugin", "install", "codex"], { + encoding: "utf8", + timeout: REFRESH_TIMEOUT_MS, + stdio: ["ignore", "ignore", "inherit"], + }); +} + +// Kimi Code mirrors claude-code's npm-first shape: evercli stages the +// bundle from the global npm package and short-circuits on an existing +// directory, so the @latest bump must happen first. Registration is the +// one step evercli cannot do headlessly (`/plugins install` is TUI-only), +// so the staged bundle only reaches Kimi Code after the user re-registers. +function refreshKimicode(binPath, runner) { + const run = runner || defaultRunner; + run(npmCommand(), ["install", "-g", "@everme/kimicode@latest"], { + encoding: "utf8", + timeout: REFRESH_TIMEOUT_MS, + stdio: ["ignore", "ignore", "inherit"], + }); + run(binPath, ["plugin", "install", "kimicode"], { + encoding: "utf8", + timeout: REFRESH_TIMEOUT_MS, + stdio: ["ignore", "ignore", "inherit"], + }); +} + +// Raven's Python backend is embedded in the evercli binary, so the freshly +// downloaded binary is the new payload — re-running install is what +// rewrites ~/.raven/plugins/everme-memory/. Nothing to bump on npm. +function refreshRaven(binPath, runner) { + const run = runner || defaultRunner; + run(binPath, ["plugin", "install", "raven"], { + encoding: "utf8", + timeout: REFRESH_TIMEOUT_MS, + stdio: ["ignore", "ignore", "inherit"], + }); +} + +function skipReason(env) { + if (env.EVERME_SKIP_PLUGIN_UPGRADE) return "EVERME_SKIP_PLUGIN_UPGRADE is set"; + if (env.CI) return "CI environment"; + if (typeof process.getuid === "function" && process.getuid() === 0) return "running as root"; + if (env.SUDO_USER) return "running under sudo"; + return null; +} + +// refreshPlugins is the postinstall entrypoint. It NEVER throws: every failure +// degrades to a stderr warning so `npm i -g` always succeeds. +function refreshPlugins(opts) { + const { binPath, env, runner } = opts; + const log = opts.logger || ((m) => console.error(m)); + + const skip = skipReason(env); + if (skip) { + log(`[@everme/cli] Skipping plugin refresh (${skip}). ` + + `Re-run \`npm i -g @everme/cli@latest\` as your normal user to refresh plugins.`); + return; + } + + let hosts; + try { + hosts = discoverHosts(binPath, runner); + } catch (e) { + log(`[@everme/cli] Could not enumerate plugin hosts; skipping plugin refresh: ${e.message}`); + return; + } + + // The npx patch and the plugin refresh are not alternatives: codex needs + // both (npx spec in config.toml + marketplace-shipped hook bundle), so + // the two dispatches run in sequence rather than as one if/else chain. + for (const h of hosts) { + if (!h || h.hasEverMeEntry !== true) continue; + try { + if (NPX_PLATFORMS.has(h.platform) && h.configPath && patchNpxSpec(h.configPath)) { + log(`[@everme/cli] ${h.platform}: pinned memory-mcp to @latest`); + } + if (h.platform === "claude-code") { + refreshClaudeCode(binPath, runner); + } else if (h.platform === "codex") { + refreshCodex(binPath, runner); + } else if (h.platform === "openclaw") { + refreshOpenClaw(runner); + } else if (h.platform === "hermes") { + refreshHermes(binPath, runner); + } else if (h.platform === "raven") { + refreshRaven(binPath, runner); + } else if (h.platform === "kimicode") { + refreshKimicode(binPath, runner); + log("[@everme/cli] kimicode: bundle staged — run `/plugins install ~/.kimi-code/everme` in the Kimi Code TUI to load it"); + } + } catch (e) { + log(`[@everme/cli] ${h.platform}: refresh failed (continuing): ${e.message}`); + } + } + + logUnconnectedHosts(hosts, log); +} + +// logUnconnectedHosts prints one hint line per agent that is present on this +// machine but has no EverMe entry yet. This replaces the retired +// `evercli plugin scan` reminder flow: discovery already happened via +// `plugin list` above, so an update is the natural moment to surface it. +function logUnconnectedHosts(hosts, log) { + for (const h of hosts) { + if (!h || h.installed !== true || h.hasEverMeEntry === true) continue; + log(`[@everme/cli] Detected ${h.displayName || h.platform} on this machine without EverMe — connect it with: evercli plugin install ${h.platform}`); + } +} + +module.exports = { + NPX_PLATFORMS, + LIST_TIMEOUT_MS, + REFRESH_TIMEOUT_MS, + npmCommand, + discoverHosts, + patchNpxSpec, + refreshClaudeCode, + refreshCodex, + refreshOpenClaw, + refreshHermes, + refreshKimicode, + refreshRaven, + skipReason, + refreshPlugins, + logUnconnectedHosts, +}; diff --git a/plugins/cli/sha256sums.txt b/plugins/cli/sha256sums.txt index 8e4e98d..5129e3d 100644 --- a/plugins/cli/sha256sums.txt +++ b/plugins/cli/sha256sums.txt @@ -1,6 +1,6 @@ -f38531c8944606da514d5752ef5e4f191cf35e66020928bfcf3c543b655b5f00 evercli_darwin_amd64.tar.gz -9c8d4b6992f0a3dd849d2f88b485c6e3e2cee391ac49444f2b93cd4cd9d3d38f evercli_darwin_arm64.tar.gz -2db1dbf945099c12ba6f0db9e2b2d6563549db238dca04601aa250e0c0aee970 evercli_linux_amd64.tar.gz -d2424274ec7b31ae8850c7855964158c4c96e889c30b4e040059d04d70742de2 evercli_linux_arm64.tar.gz -d83c3c5291787ac4e9d2d05e34eebd697754fdbc4835fd5da5635ecb4bed411e evercli_windows_amd64.zip -a80032d6a50b14f719e93afb0407c2ccbf840b3e3929c702317f374606bfaab1 evercli_windows_arm64.zip +9b87a85b4dc0a10d2fc80813d4bcb70831383f758f9763eb7799c4ff2c27e5b1 evercli_darwin_amd64.tar.gz +95d7bd3d97c0e58d38d905d7ac922a3c81b26089533a0411b3b645dc40b33d98 evercli_darwin_arm64.tar.gz +5ebd99e58c70d21e22cc23964ec0658ff1b1e960be28a2ac005f87fbb76aa704 evercli_linux_amd64.tar.gz +2be43559fd7804cee94bef8d3eff0f4f2b9117bd05ee5dcc472306905256e7ee evercli_linux_arm64.tar.gz +23e78bbf7a7471a411cbdcb0ad7a77d0f8255cfc47135c8b02f5738ada7c1c42 evercli_windows_amd64.zip +e3bf531be8b9bb4cb83abdbac62023d545397717f73ac08469a068209d2fc53b evercli_windows_arm64.zip diff --git a/plugins/cli/tests/install.test.js b/plugins/cli/tests/install.test.js index bab37c2..3affe6f 100644 --- a/plugins/cli/tests/install.test.js +++ b/plugins/cli/tests/install.test.js @@ -45,7 +45,7 @@ test("resolveMirrorUrls ignores http-only / malformed registries", () => { test("assertAllowedHost passes for github.com", () => { assert.doesNotThrow(() => - assertAllowedHost("https://github.com/EverMind-AI/EverMe/releases/download/v0.1.0/x"), + assertAllowedHost("https://github.com/EverMind-AI/EverMe/releases/download/cli/v0.1.0/x"), ); }); diff --git a/plugins/cli/tests/upgrade-plugins.test.js b/plugins/cli/tests/upgrade-plugins.test.js new file mode 100644 index 0000000..a4ba94f --- /dev/null +++ b/plugins/cli/tests/upgrade-plugins.test.js @@ -0,0 +1,216 @@ +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const mod = require("../scripts/upgrade-plugins.js"); + +function tmpFile(contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "everme-up-")); + const p = path.join(dir, "config.json"); + fs.writeFileSync(p, contents); + return p; +} + +test("patchNpxSpec rewrites a bare spec and is idempotent", () => { + const p = tmpFile('{"args":["-y","@everme/memory-mcp"]}'); + assert.equal(mod.patchNpxSpec(p), true); + assert.match(fs.readFileSync(p, "utf8"), /@everme\/memory-mcp@latest/); + // second run: nothing to change + assert.equal(mod.patchNpxSpec(p), false); + // exactly one @latest, no double-suffix + const after = fs.readFileSync(p, "utf8"); + assert.equal((after.match(/@latest/g) || []).length, 1); +}); + +test("patchNpxSpec leaves a config without the bare spec untouched", () => { + const p = tmpFile('{"args":["-y","@everme/memory-mcp@latest"]}'); + assert.equal(mod.patchNpxSpec(p), false); +}); + +test("patchNpxSpec rewrites a TOML single-quoted spec and is idempotent", () => { + // codex's config.toml uses single quotes: args = ['-y', '@everme/memory-mcp'] + const p = tmpFile("args = ['-y', '@everme/memory-mcp']\n"); + assert.equal(mod.patchNpxSpec(p), true); + assert.match(fs.readFileSync(p, "utf8"), /'@everme\/memory-mcp@latest'/); + // second run: nothing to change + assert.equal(mod.patchNpxSpec(p), false); + // exactly one @latest, no double-suffix + const after = fs.readFileSync(p, "utf8"); + assert.equal((after.match(/@latest/g) || []).length, 1); +}); + +test("patchNpxSpec preserves the file mode and leaves no temp file", () => { + const p = tmpFile('{"args":["-y","@everme/memory-mcp"]}'); + fs.chmodSync(p, 0o600); + assert.equal(mod.patchNpxSpec(p), true); + assert.equal(fs.statSync(p).mode & 0o777, 0o600); + // atomic rename leaves no sibling temp artifact behind + const siblings = fs.readdirSync(path.dirname(p)); + assert.deepEqual(siblings, ["config.json"]); +}); + +test("discoverHosts parses the plugin list envelope", () => { + const fakeRunner = (file, args) => { + assert.deepEqual(args, ["plugin", "list", "--format", "json"]); + return JSON.stringify({ + ok: true, + data: { platforms: [{ platform: "codex", configPath: "/c", hasEverMeEntry: true }] }, + }); + }; + const hosts = mod.discoverHosts("/bin/evercli", fakeRunner); + assert.equal(hosts.length, 1); + assert.equal(hosts[0].platform, "codex"); +}); + +test("discoverHosts throws on a non-ok envelope", () => { + const fakeRunner = () => JSON.stringify({ ok: false, error: { message: "boom" } }); + assert.throws(() => mod.discoverHosts("/bin/evercli", fakeRunner)); +}); + +test("refreshClaudeCode upgrades the npm package then re-registers the plugin", () => { + const calls = []; + const runner = (file, args) => { calls.push([file, args]); return ""; }; + mod.refreshClaudeCode("/bin/evercli", runner); + assert.equal(calls.length, 2); + // 1. force-upgrade the global npm package + assert.match(calls[0][0], /^npm(\.cmd)?$/); + assert.deepEqual(calls[0][1], ["install", "-g", "@everme/claude-code@latest"]); + // 2. re-register so Claude Code loads the fresh payload + assert.equal(calls[1][0], "/bin/evercli"); + assert.deepEqual(calls[1][1], ["plugin", "install", "claude-code"]); +}); + +test("refreshOpenClaw updates the tracked plugin by id", () => { + // `plugins update ` is the refresh verb: it bumps an already-tracked + // plugin to latest without the full config-overwrite that `install` runs. + const calls = []; + const runner = (file, args) => { calls.push([file, args]); return ""; }; + mod.refreshOpenClaw(runner); + assert.equal(calls[0][0], "openclaw"); + assert.deepEqual(calls[0][1], ["plugins", "update", "@everme/openclaw"]); +}); + +test("refreshHermes runs plugin install hermes", () => { + const calls = []; + const runner = (file, args) => { calls.push([file, args]); return ""; }; + mod.refreshHermes("/bin/evercli", runner); + assert.equal(calls[0][0], "/bin/evercli"); + assert.deepEqual(calls[0][1], ["plugin", "install", "hermes"]); +}); + +test("refreshCodex re-registers so the marketplace hook bundle is upgraded", () => { + // The npx patch alone never refreshes codex's marketplace cache; the + // `marketplace upgrade` lives inside `plugin install codex`. + const calls = []; + const runner = (file, args) => { calls.push([file, args]); return ""; }; + mod.refreshCodex("/bin/evercli", runner); + assert.equal(calls[0][0], "/bin/evercli"); + assert.deepEqual(calls[0][1], ["plugin", "install", "codex"]); +}); + +test("refreshKimicode upgrades the npm bundle then re-stages it", () => { + const calls = []; + const runner = (file, args) => { calls.push([file, args]); return ""; }; + mod.refreshKimicode("/bin/evercli", runner); + assert.equal(calls.length, 2); + assert.match(calls[0][0], /^npm(\.cmd)?$/); + assert.deepEqual(calls[0][1], ["install", "-g", "@everme/kimicode@latest"]); + assert.equal(calls[1][0], "/bin/evercli"); + assert.deepEqual(calls[1][1], ["plugin", "install", "kimicode"]); +}); + +test("refreshRaven re-installs the embedded backend without touching npm", () => { + const calls = []; + const runner = (file, args) => { calls.push([file, args]); return ""; }; + mod.refreshRaven("/bin/evercli", runner); + assert.equal(calls.length, 1, "the payload ships inside the binary — nothing to npm-install"); + assert.equal(calls[0][0], "/bin/evercli"); + assert.deepEqual(calls[0][1], ["plugin", "install", "raven"]); +}); + +test("skipReason fires for CI, sudo, root, and opt-out", () => { + assert.ok(mod.skipReason({ CI: "true" })); + assert.ok(mod.skipReason({ SUDO_USER: "bob" })); + assert.ok(mod.skipReason({ EVERME_SKIP_PLUGIN_UPGRADE: "1" })); + assert.equal(mod.skipReason({}), null); +}); + +test("refreshPlugins dispatches per host class and skips entries without everme", () => { + const platforms = [ + { platform: "codex", configPath: tmpFile('{"args":["-y","@everme/memory-mcp"]}'), hasEverMeEntry: true }, + { platform: "cursor", configPath: "/nope", hasEverMeEntry: false }, // skipped: no entry + { platform: "claude-code", hasEverMeEntry: true }, + { platform: "openclaw", hasEverMeEntry: true }, + { platform: "hermes", hasEverMeEntry: true }, + { platform: "raven", hasEverMeEntry: true }, + { platform: "kimicode", hasEverMeEntry: true }, + ]; + const seen = []; + const runner = (file, args) => { + if (args[0] === "plugin" && args[1] === "list") { + return JSON.stringify({ ok: true, data: { platforms } }); + } + seen.push(`${file} ${args.join(" ")}`); + return ""; + }; + mod.refreshPlugins({ binPath: "/bin/evercli", env: {}, runner, logger: () => {} }); + + // codex npx config got @latest + assert.match(fs.readFileSync(platforms[0].configPath, "utf8"), /@everme\/memory-mcp@latest/); + // codex ALSO gets a plugin refresh: the npx patch leaves its + // marketplace-shipped hook bundle stale on its own. + assert.ok(seen.some((c) => /\/bin\/evercli plugin install codex/.test(c))); + // claude-code / kimicode: npm upgrade + binary re-register + assert.ok(seen.some((c) => /@everme\/claude-code@latest/.test(c))); + assert.ok(seen.some((c) => /\/bin\/evercli plugin install claude-code/.test(c))); + assert.ok(seen.some((c) => /@everme\/kimicode@latest/.test(c))); + assert.ok(seen.some((c) => /\/bin\/evercli plugin install kimicode/.test(c))); + // openclaw / hermes / raven once each + assert.ok(seen.some((c) => /openclaw plugins update @everme\/openclaw/.test(c))); + assert.ok(seen.some((c) => /\/bin\/evercli plugin install hermes/.test(c))); + assert.ok(seen.some((c) => /\/bin\/evercli plugin install raven/.test(c))); +}); + +test("refreshPlugins never throws when an action fails", () => { + const platforms = [{ platform: "openclaw", hasEverMeEntry: true }]; + const runner = (file, args) => { + if (args[1] === "list") return JSON.stringify({ ok: true, data: { platforms } }); + throw new Error("openclaw not on PATH"); + }; + const warnings = []; + assert.doesNotThrow(() => + mod.refreshPlugins({ binPath: "/bin/evercli", env: {}, runner, logger: (m) => warnings.push(m) }), + ); + assert.ok(warnings.some((m) => /openclaw/.test(m))); +}); + +test("refreshPlugins is a no-op under a guard", () => { + let called = false; + const runner = () => { called = true; return ""; }; + mod.refreshPlugins({ binPath: "/bin/evercli", env: { CI: "1" }, runner, logger: () => {} }); + assert.equal(called, false); +}); + +test("refreshPlugins hints at detected hosts without an EverMe entry", () => { + const platforms = [ + { platform: "cursor", displayName: "Cursor", installed: true, hasEverMeEntry: false }, + { platform: "devin", displayName: "Devin", installed: false, hasEverMeEntry: false }, + { platform: "hermes", displayName: "Hermes", installed: true, hasEverMeEntry: true }, + ]; + const runner = (file, args) => { + if (args[0] === "plugin" && args[1] === "list") { + return JSON.stringify({ ok: true, data: { platforms } }); + } + return ""; + }; + const lines = []; + mod.refreshPlugins({ binPath: "/bin/evercli", env: {}, runner, logger: (m) => lines.push(m) }); + + const hints = lines.filter((m) => /without EverMe/.test(m)); + assert.equal(hints.length, 1, "only the installed-but-unconnected host gets a hint"); + assert.match(hints[0], /Cursor/); + assert.match(hints[0], /evercli plugin install cursor/); +}); diff --git a/plugins/codex/package.json b/plugins/codex/package.json index 9cf6476..94b1474 100644 --- a/plugins/codex/package.json +++ b/plugins/codex/package.json @@ -1,6 +1,6 @@ { "name": "@everme/codex", - "version": "0.4.2", + "version": "0.6.1", "type": "module", "description": "Native EverMe lifecycle hooks for Codex.", "license": "Apache-2.0", @@ -40,7 +40,7 @@ "registry": "https://registry.npmjs.org" }, "dependencies": { - "@everme/agent-sdk": "^0.4.2" + "@everme/agent-sdk": "^0.6.1" }, "devDependencies": { "esbuild": "^0.28.1" diff --git a/plugins/codex/tests/hook.test.js b/plugins/codex/tests/hook.test.js index f0c27b2..6738366 100644 --- a/plugins/codex/tests/hook.test.js +++ b/plugins/codex/tests/hook.test.js @@ -47,7 +47,8 @@ describe("everme-codex hook CLI", () => { }, runtime.env); assert.equal(result.code, 0); - assert.equal(result.stderr, ""); + assert.match(result.stderr, /requestId=[0-9a-f-]{36}/, + "stderr must carry the save line with its trace id"); }); // Codex runs a hook command through a shell with the SESSION cwd as the @@ -108,7 +109,8 @@ describe("everme-codex hook CLI", () => { assert.equal(first.code, 0); assert.equal(first.stdout, ""); - assert.equal(first.stderr, ""); + assert.match(first.stderr, /saveAgentMemory ok: .*requestId=[0-9a-f-]{36}/, + "stderr must carry the save line with its trace id"); assert.equal(duplicate.code, 0); assert.equal(requests.length, 1); assert.equal(requests[0].path, "/api/v1/mem/agent-memory"); diff --git a/plugins/cursor/README.md b/plugins/cursor/README.md new file mode 100644 index 0000000..112980a --- /dev/null +++ b/plugins/cursor/README.md @@ -0,0 +1,41 @@ +# @everme/cursor + +Native EverMe lifecycle hook runner for Cursor. It keeps memory traffic on +EverMe's stable `/api/v1/mem/*` BFF contract. + +## Lifecycle + +- `sessionStart`: inject the EverMe profile snapshot through + `additional_context`. +- `postToolUse`: spool the tool call (name, input, output) to a local + per-conversation buffer under the state dir. No network happens here. +- `stop`: stream the Cursor transcript, attach the spooled tool calls of + this turn, and save the latest user turn. +- `preCompact`: send a flush-only request before context compaction. + +Cursor's transcript intentionally omits tool outputs, and its 3.16+ typed +event stream may summarize native-tool arguments, so the spooled +`postToolUse` payloads are the authoritative tool trace. When the spool is +empty (hook not registered, older install) the transcript's own +`tool_call` / `tool_use` records are kept as an inputs-only fallback, and +unknown transcript record types are reported on stderr instead of being +silently dropped. + +Cursor's current `beforeSubmitPrompt` output supports allow/block decisions and +a user-visible message, but not hidden model context. The package therefore +does not register that event as automatic recall. Use the `everme-memory` MCP +tools for per-prompt recall. + +All hook failures are fail-open and credentials are redacted from diagnostics. +`evercli plugin install cursor` writes credentials to +`~/.cursor/everme.env` with mode `0600`. + +## Configuration + +The shared hook knobs are `EVERME_INJECT_TOPK`, `EVERME_INJECT_PROFILE`, +`EVERME_INJECT_MIN_SCORE`, `EVERME_FLUSH_EVERY_TURNS`, +`EVERME_FLUSH_MODE`, and `EVERME_STATE_DIR`. + +## License + +Apache-2.0. diff --git a/plugins/cursor/bin/hook.js b/plugins/cursor/bin/hook.js new file mode 100755 index 0000000..24d2352 --- /dev/null +++ b/plugins/cursor/bin/hook.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { redactError, runHook } from "@everme/agent-sdk"; +import { cursorAdapter } from "../src/adapter.js"; + +main().catch((error) => { + const reason = redactError(error).replace(/\s+/g, " ").trim(); + process.stderr.write(`EverMe Cursor hook degraded: ${reason}\n`); + process.exitCode = 0; +}); + +async function main() { + const [, , command, event] = process.argv; + if (command !== "hook" || !event) return; + const input = await readStdinJSON(); + const output = await runHook(event, input, cursorAdapter); + if (output && Object.keys(output).length) { + process.stdout.write(JSON.stringify(output)); + } +} + +async function readStdinJSON() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + return {}; + } +} diff --git a/plugins/cursor/package.json b/plugins/cursor/package.json new file mode 100644 index 0000000..a1a8455 --- /dev/null +++ b/plugins/cursor/package.json @@ -0,0 +1,43 @@ +{ + "name": "@everme/cursor", + "version": "0.6.1", + "type": "module", + "description": "Native EverMe lifecycle hooks for Cursor.", + "license": "Apache-2.0", + "bin": { + "everme-cursor": "./bin/hook.js" + }, + "files": [ + "bin/", + "src/" + ], + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "test": "node --test tests/transcript.test.js tests/adapter.test.js tests/hook.test.js" + }, + "keywords": [ + "evermind", + "everme", + "cursor", + "memory", + "hooks" + ], + "homepage": "https://everme.evermind.ai", + "repository": { + "type": "git", + "url": "git+https://github.com/EverMind-AI/EverMe.git", + "directory": "plugins/cursor" + }, + "bugs": { + "url": "https://github.com/EverMind-AI/EverMe/issues" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "dependencies": { + "@everme/agent-sdk": "^0.6.1" + } +} diff --git a/plugins/cursor/src/adapter.js b/plugins/cursor/src/adapter.js new file mode 100644 index 0000000..40a9ebb --- /dev/null +++ b/plugins/cursor/src/adapter.js @@ -0,0 +1,143 @@ +import os from "node:os"; +import path from "node:path"; +import { createToolEventBuffer } from "@everme/agent-sdk"; +import { readLastTurn } from "./transcript.js"; + +const EVENT_MAP = { + sessionStart: "SessionStart", + stop: "Stop", + preCompact: "PreCompact", + postToolUse: "PostToolUse", +}; + +export const cursorAdapter = { + platform: "cursor", + + envFile() { + return process.env.EVERME_ENV_FILE_PATH || path.join(os.homedir(), ".cursor", "everme.env"); + }, + + mapEvent(event) { + return EVENT_MAP[event] || event; + }, + + normalizeInput(rawInput, hostEvent) { + const workspaceRoots = Array.isArray(rawInput?.workspace_roots) + ? rawInput.workspace_roots.filter((root) => typeof root === "string") + : []; + const sessionId = rawInput?.conversation_id || rawInput?.session_id || ""; + const input = { + sessionId, + // No generation_id → empty turn key (dedup disabled), NOT the + // conversation id: that constant would mark every turn after the + // first as a duplicate and silently drop the whole session's writes. + turnId: rawInput?.generation_id || "", + transcriptPath: rawInput?.transcript_path || process.env.CURSOR_TRANSCRIPT_PATH || "", + workspaceRoots, + cwd: workspaceRoots[0] || process.env.CURSOR_PROJECT_DIR || "", + cursorVersion: rawInput?.cursor_version || process.env.CURSOR_VERSION || "", + }; + if (hostEvent === "postToolUse") { + input.tool = { + name: rawInput?.tool_name || rawInput?.name || "", + input: rawInput?.tool_input ?? rawInput?.input ?? "", + output: rawInput?.tool_output ?? rawInput?.output ?? "", + }; + } + return input; + }, + + // Cursor's transcript intentionally omits tool outputs, so postToolUse + // spools each call locally and the Stop hook attaches the spool to the + // turn it uploads. No network happens here. + async bufferToolUse(input, { stateDir, maxBytes, warn = warnSpoolFull } = {}) { + if (!input?.sessionId || !input.tool) return { block: "", count: 0 }; + const buffer = createToolEventBuffer({ stateDir, maxBytes }); + const { dropped } = await buffer.append(input.sessionId, { + generationId: input.turnId, + name: input.tool.name, + input: input.tool.input, + output: input.tool.output, + }); + if (dropped) { + // The cap protects the disk from a runaway turn, but a capped call + // must not vanish silently — that is the failure mode this spool + // exists to fix. + warn(`EverMe Cursor tool call spool is full for ${input.sessionId}; this call will be missing from the saved turn`); + return { block: "", count: 0 }; + } + return { block: "", count: 1 }; + }, + + async readLastTurn(input, { stateDir } = {}) { + const transcript = await readLastTurn(input?.transcriptPath, { + warn: (type) => warnUnknownRecordType(type), + }); + if (!input?.sessionId) return transcript; + const buffer = createToolEventBuffer({ stateDir }); + const events = await buffer.drain(input.sessionId, { generationId: input?.turnId || "" }); + if (!events.length) return transcript; + + // The spool carries inputs AND outputs while transcript tool records + // carry inputs only, and the two sides share no reliable call id to + // dedupe on — so a non-empty spool supersedes the transcript's tool + // data entirely. Text messages always come from the transcript. + const text = transcript + .filter((message) => message.role !== "tool" && (message.content || !message.toolCalls)) + .map(({ toolCalls, ...message }) => message); + const pairs = events.flatMap((event, index) => { + const id = event.id || `cursor_tool_${event.ts || "untimed"}_${index}`; + const stamp = Number.isFinite(event.ts) ? { timestamp: event.ts } : {}; + return [ + { + role: "assistant", + ...stamp, + toolCalls: [{ id, type: "function", name: event.name || "unknown", arguments: argumentText(event.input) }], + }, + { role: "tool", ...stamp, toolCallId: id, content: contentText(event.output) }, + ]; + }); + const head = text.length && text[0].role === "user" ? [text[0]] : []; + return [...head, ...pairs, ...text.slice(head.length)]; + }, + + formatOutput(event, { block = "" } = {}) { + if (event !== "sessionStart" || !block) return {}; + return { additional_context: block }; + }, +}; + +function argumentText(value) { + if (typeof value === "string") return value || "{}"; + try { + return JSON.stringify(value ?? {}); + } catch { + return "{}"; + } +} + +function contentText(value) { + if (typeof value === "string") return value || "tool result"; + if (value == null) return "tool result"; + try { + return JSON.stringify(value) || "tool result"; + } catch { + return "tool result"; + } +} + +function warnUnknownRecordType(type) { + try { + process.stderr.write(`EverMe Cursor transcript: unknown record type ${type}\n`); + } catch { + // A closed stderr must never break a hook. + } +} + +function warnSpoolFull(line) { + try { + process.stderr.write(`${line}\n`); + } catch { + // A closed stderr must never break a hook. + } +} diff --git a/plugins/cursor/src/transcript.js b/plugins/cursor/src/transcript.js new file mode 100644 index 0000000..b160136 --- /dev/null +++ b/plugins/cursor/src/transcript.js @@ -0,0 +1,164 @@ +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; +import { capRunes } from "@everme/agent-sdk"; + +// Record types Cursor writes that intentionally carry nothing we save. +// Anything else without a recognisable role or tool payload is reported +// through `warn` so the next transcript-schema drift is visible instead +// of silently dropped (the failure mode that hid the 3.16 rewrite). +const IGNORED_RECORD_TYPES = new Set([ + "reasoning", + "thinking", + "system", + "turn_started", + "turn_ended", +]); + +export async function readLastTurn(transcriptPath, { warn } = {}) { + if (!transcriptPath) return []; + const lines = createInterface({ + input: createReadStream(transcriptPath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + let delta = []; + let foundUser = false; + let lineNo = 0; + const unknownTypes = new Set(); + + for await (const line of lines) { + lineNo += 1; + let record; + try { + record = JSON.parse(line); + } catch { + continue; + } + const message = mapRecord(record, lineNo, unknownTypes); + if (!message) continue; + if (message.role === "user") { + delta = [message]; + foundUser = true; + } else if (foundUser) { + delta.push(message); + } + } + + if (typeof warn === "function") { + for (const type of unknownTypes) warn(type); + } + return foundUser ? delta : []; +} + +function mapRecord(record, lineNo, unknownTypes) { + const type = typeof record?.type === "string" ? record.type : ""; + if (type === "tool_call" || type === "toolCall") return mapToolCall(record, lineNo); + if (type === "tool_result" || type === "toolResult") return mapToolResult(record); + + const value = record?.message && typeof record.message === "object" ? record.message : record; + // Cursor has written three role layouts so far: nested message.role, + // role on the record itself, and role carried as the record type. Try + // all of them so an older or future layout still parses. + const role = pickRole(value?.role) || pickRole(record?.role) || pickRole(record?.type); + if (!role) { + if (type && !IGNORED_RECORD_TYPES.has(type)) unknownTypes.add(type); + return null; + } + const { text, toolCalls } = readContent(value?.content ?? value?.text ?? record?.content ?? record?.text, lineNo); + if (role === "user") return text ? { role, content: text } : null; + if (!text && !toolCalls.length) return null; + return { + role, + ...(text ? { content: text } : {}), + ...(toolCalls.length ? { toolCalls } : {}), + }; +} + +// No verified full sample of the 3.16 event stream exists yet, so field +// names are matched leniently across the layouts Cursor and its CLI have +// been reported to write. A wrong guess degrades to a synthesized id or a +// dropped optional field, never a dropped call. +function mapToolCall(record, lineNo) { + const payload = firstObject(record.tool_call, record.toolCall, record.message, record); + const name = firstString(payload.name, payload.tool_name, payload.tool, record.tool_name); + const id = firstString(payload.id, payload.call_id, payload.tool_call_id, record.tool_call_id) + || `cursor_tool_${lineNo}`; + const args = payload.arguments ?? payload.args ?? payload.input ?? payload.tool_input + ?? record.tool_input ?? {}; + return { + role: "assistant", + toolCalls: [{ id, type: "function", name: name || "unknown", arguments: argumentText(args) }], + }; +} + +function mapToolResult(record) { + const payload = firstObject(record.tool_result, record.toolResult, record); + const toolCallId = firstString( + payload.tool_call_id, + payload.call_id, + payload.toolCallId, + record.tool_call_id, + ); + // A result that names no call cannot be paired downstream (the SDK + // requires toolCallId on tool roles) — drop it rather than guess. + if (!toolCallId) return null; + const { text } = readContent( + record.message?.content ?? payload.content ?? payload.output ?? payload.result, + 0, + ); + return { role: "tool", toolCallId, content: text || "tool result" }; +} + +function pickRole(candidate) { + return candidate === "user" || candidate === "assistant" ? candidate : ""; +} + +function readContent(content, lineNo) { + if (typeof content === "string") return { text: capText(content), toolCalls: [] }; + if (!Array.isArray(content)) return { text: "", toolCalls: [] }; + const parts = []; + const toolCalls = []; + for (const part of content) { + if (typeof part === "string") { + parts.push(part); + } else if (typeof part?.text === "string" && (!part.type || part.type === "text")) { + parts.push(part.text); + } else if (part?.type === "tool_use" || part?.type === "toolCall" || part?.type === "tool_call") { + const id = firstString(part.id, part.call_id, part.tool_call_id) + || `cursor_tool_${lineNo}_${toolCalls.length}`; + toolCalls.push({ + id, + type: "function", + name: firstString(part.name, part.tool_name) || "unknown", + arguments: argumentText(part.input ?? part.arguments ?? part.args ?? {}), + }); + } + } + return { text: capText(parts.filter(Boolean).join("\n")), toolCalls }; +} + +function firstObject(...candidates) { + for (const candidate of candidates) { + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) return candidate; + } + return {}; +} + +function firstString(...candidates) { + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate) return candidate; + } + return ""; +} + +function argumentText(value) { + if (typeof value === "string") return capText(value); + try { + return capText(JSON.stringify(value ?? {})); + } catch { + return "{}"; + } +} + +function capText(value) { + return capRunes(String(value || "").trim()); +} diff --git a/plugins/cursor/tests/adapter.test.js b/plugins/cursor/tests/adapter.test.js new file mode 100644 index 0000000..cdc3f84 --- /dev/null +++ b/plugins/cursor/tests/adapter.test.js @@ -0,0 +1,173 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { cursorAdapter } from "../src/adapter.js"; + +const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +const transcriptPath = path.join(fixtures, "cursor-transcript.jsonl"); +const toolEventsPath = path.join(fixtures, "cursor-transcript-tool-events.jsonl"); +const tempDirs = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function newStateDir() { + const dir = await mkdtemp(path.join(os.tmpdir(), "everme-cursor-adapter-")); + tempDirs.push(dir); + return dir; +} + +describe("Cursor hook adapter", () => { + test("maps only supported native lifecycle events", () => { + assert.equal(cursorAdapter.mapEvent("sessionStart"), "SessionStart"); + assert.equal(cursorAdapter.mapEvent("stop"), "Stop"); + assert.equal(cursorAdapter.mapEvent("preCompact"), "PreCompact"); + assert.equal(cursorAdapter.mapEvent("postToolUse"), "PostToolUse"); + assert.equal(cursorAdapter.mapEvent("beforeSubmitPrompt"), "beforeSubmitPrompt"); + }); + + test("normalizes the tool payload for postToolUse", async () => { + const input = await cursorAdapter.normalizeInput({ + conversation_id: "cursor-conversation", + generation_id: "cursor-generation", + tool_name: "shell", + tool_input: { command: "npm test" }, + tool_output: "all green", + }, "postToolUse"); + assert.deepEqual(input.tool, { + name: "shell", + input: { command: "npm test" }, + output: "all green", + }); + }); + + test("normalizes official common input fields", async () => { + assert.deepEqual(await cursorAdapter.normalizeInput({ + conversation_id: "cursor-conversation", + generation_id: "cursor-generation", + transcript_path: transcriptPath, + workspace_roots: ["/repo", "/shared"], + hook_event_name: "stop", + cursor_version: "1.7.2", + }, "stop"), { + sessionId: "cursor-conversation", + turnId: "cursor-generation", + transcriptPath, + workspaceRoots: ["/repo", "/shared"], + cwd: "/repo", + cursorVersion: "1.7.2", + }); + }); + + test("leaves the turn key empty when generation id is absent so dedup is disabled", async () => { + const input = await cursorAdapter.normalizeInput({ conversation_id: "cursor-conversation" }, "stop"); + assert.equal(input.turnId, ""); + }); + + test("reads the latest turn from the transcript", async () => { + assert.deepEqual(await cursorAdapter.readLastTurn({ transcriptPath }), [ + { role: "user", content: "latest question" }, + { role: "assistant", content: "latest answer" }, + ]); + }); + + test("buffered tool events join the turn as call/result pairs after the user message", async () => { + const stateDir = await newStateDir(); + const input = { sessionId: "cursor-conversation", turnId: "gen-1", transcriptPath }; + await cursorAdapter.bufferToolUse( + { ...input, tool: { name: "shell", input: { command: "npm test" }, output: "all green" } }, + { stateDir }, + ); + + const messages = await cursorAdapter.readLastTurn(input, { stateDir }); + assert.equal(messages[0].role, "user"); + const call = messages[1]; + assert.equal(call.role, "assistant"); + assert.equal(call.toolCalls.length, 1); + assert.equal(call.toolCalls[0].name, "shell"); + assert.equal(call.toolCalls[0].arguments, "{\"command\":\"npm test\"}"); + const result = messages[2]; + assert.equal(result.role, "tool"); + assert.equal(result.toolCallId, call.toolCalls[0].id); + assert.equal(result.content, "all green"); + assert.deepEqual(messages[3], { role: "assistant", content: "latest answer" }); + + const again = await cursorAdapter.readLastTurn(input, { stateDir }); + assert.ok(!again.some((message) => message.role === "tool"), "drain must consume the buffer"); + }); + + // The spool is capped so a runaway turn cannot fill the disk, but a + // capped call must leave a line behind: silently losing tool calls is + // the exact failure mode this plugin exists to fix. + test("a full spool drops the call with a warning, not in silence", async () => { + const stateDir = await newStateDir(); + const warnings = []; + const options = { stateDir, maxBytes: 1, warn: (line) => warnings.push(line) }; + const tool = { name: "shell", input: { command: "npm test" }, output: "all green" }; + + const first = await cursorAdapter.bufferToolUse( + { sessionId: "cursor-conversation", turnId: "gen-1", tool }, + options, + ); + assert.equal(first.count, 1, "the first append lands before the cap is hit"); + + const second = await cursorAdapter.bufferToolUse( + { sessionId: "cursor-conversation", turnId: "gen-1", tool }, + options, + ); + assert.equal(second.count, 0); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /tool call spool/i); + }); + + test("buffered tool events supersede tool records parsed from the transcript", async () => { + const stateDir = await newStateDir(); + const input = { sessionId: "cursor-conversation", turnId: "gen-2", transcriptPath: toolEventsPath }; + await cursorAdapter.bufferToolUse( + { ...input, tool: { name: "shell", input: { command: "npm test" }, output: "exit 0" } }, + { stateDir }, + ); + + const messages = await cursorAdapter.readLastTurn(input, { stateDir }); + const toolCalls = messages.flatMap((message) => message.toolCalls || []); + assert.deepEqual(toolCalls.map((call) => call.name), ["shell"], + "transcript tool records carry no outputs and must yield to the buffered pairs"); + assert.equal(messages.filter((message) => message.role === "tool").length, 1); + assert.deepEqual(messages.filter((message) => message.role === "assistant" && message.content) + .map((message) => message.content), ["running now", "all green"]); + }); + + test("without buffered events the transcript tool records are kept as fallback", async () => { + const stateDir = await newStateDir(); + const messages = await cursorAdapter.readLastTurn( + { sessionId: "cursor-conversation", transcriptPath: toolEventsPath }, + { stateDir }, + ); + const toolCalls = messages.flatMap((message) => message.toolCalls || []); + assert.deepEqual(toolCalls.map((call) => call.name), ["shell", "read_file"]); + }); + + test("emits initial context only for sessionStart", () => { + const block = "facts"; + assert.deepEqual(cursorAdapter.formatOutput("sessionStart", { block }), { additional_context: block }); + assert.deepEqual(cursorAdapter.formatOutput("stop", { block }), {}); + assert.deepEqual(cursorAdapter.formatOutput("preCompact", { block }), {}); + assert.deepEqual(cursorAdapter.formatOutput("beforeSubmitPrompt", { block }), {}); + assert.deepEqual(cursorAdapter.formatOutput("sessionStart", { block: "" }), {}); + }); + + test("uses the Cursor credential file by default", () => { + const previous = process.env.EVERME_ENV_FILE_PATH; + delete process.env.EVERME_ENV_FILE_PATH; + try { + assert.equal(cursorAdapter.envFile(), path.join(os.homedir(), ".cursor", "everme.env")); + } finally { + if (previous === undefined) delete process.env.EVERME_ENV_FILE_PATH; + else process.env.EVERME_ENV_FILE_PATH = previous; + } + }); +}); diff --git a/plugins/cursor/tests/fixtures/cursor-transcript-host-shape.jsonl b/plugins/cursor/tests/fixtures/cursor-transcript-host-shape.jsonl new file mode 100644 index 0000000..839009f --- /dev/null +++ b/plugins/cursor/tests/fixtures/cursor-transcript-host-shape.jsonl @@ -0,0 +1,4 @@ +{"role":"user","message":{"content":[{"type":"text","text":"old question"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"old answer"}]}} +{"role":"user","message":{"content":[{"type":"text","text":"latest question"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"latest answer"}]}} diff --git a/plugins/cursor/tests/fixtures/cursor-transcript-tool-events.jsonl b/plugins/cursor/tests/fixtures/cursor-transcript-tool-events.jsonl new file mode 100644 index 0000000..46de881 --- /dev/null +++ b/plugins/cursor/tests/fixtures/cursor-transcript-tool-events.jsonl @@ -0,0 +1,12 @@ +{"type":"user","message":{"content":[{"type":"text","text":"old question"}]}} +{"type":"assistant","message":{"content":[{"type":"text","text":"old answer"}]}} +{"type":"turn_ended","status":"completed"} +{"type":"user","message":{"content":"run the tests"}} +{"type":"assistant","message":{"content":[{"type":"text","text":"running now"},{"type":"tool_use","id":"call_inline","name":"shell","input":{"command":"npm test"}}]}} +{"type":"tool_call","tool_call":{"id":"call_standalone","name":"read_file","arguments":{"path":"a.js"}}} +{"type":"tool_result","tool_call_id":"call_standalone","message":{"content":"file body"}} +{"type":"tool_result","message":{"content":"orphan result without id"}} +{"type":"reasoning","message":{"content":"private reasoning"}} +{"type":"future_record_kind","payload":{"anything":true}} +{"type":"assistant","message":{"content":[{"type":"text","text":"all green"}]}} +{"type":"turn_ended","status":"completed"} diff --git a/plugins/cursor/tests/fixtures/cursor-transcript.jsonl b/plugins/cursor/tests/fixtures/cursor-transcript.jsonl new file mode 100644 index 0000000..98073ca --- /dev/null +++ b/plugins/cursor/tests/fixtures/cursor-transcript.jsonl @@ -0,0 +1,8 @@ +{"type":"user","message":{"role":"user","content":[{"type":"text","text":"old question"}]}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"old answer"}]}} +this is malformed json +{"type":"user","message":{"role":"user","content":"latest question"}} +{"type":"reasoning","message":{"content":"private reasoning"}} +{"kind":"unknown_step","payload":{"anything":true}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"latest answer"}]}} +{"type":"tool_result","message":{"content":"tool details"}} diff --git a/plugins/cursor/tests/hook.test.js b/plugins/cursor/tests/hook.test.js new file mode 100644 index 0000000..1f36ce1 --- /dev/null +++ b/plugins/cursor/tests/hook.test.js @@ -0,0 +1,185 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixture = path.join(packageDir, "tests", "fixtures", "cursor-transcript.jsonl"); +const tempDirs = []; +const servers = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("everme-cursor hook CLI", () => { + test("stop writes the latest turn and preCompact sends a flush-only request", async () => { + const requests = []; + const server = await startServer(async (req, res) => { + requests.push({ path: req.url, body: JSON.parse(await readBody(req)) }); + respond(res, 200, { status: 0, result: { flushed: true } }); + }); + const runtime = await runtimeEnv(server, "test-agent-token"); + const baseInput = { + conversation_id: "cursor-conversation", + generation_id: "cursor-generation", + transcript_path: fixture, + workspace_roots: ["/repo"], + }; + + const stop = await runHookProcess("stop", { ...baseInput, hook_event_name: "stop" }, runtime.env); + const compact = await runHookProcess( + "preCompact", + { ...baseInput, hook_event_name: "preCompact", generation_id: "compact-generation" }, + runtime.env, + ); + + assert.equal(stop.code, 0); + assert.equal(stop.stdout, ""); + assert.match(stop.stderr, /saveAgentMemory ok: .*requestId=[0-9a-f-]{36}/, + "stderr must carry the save line with its trace id"); + assert.equal(compact.code, 0); + assert.equal(compact.stdout, ""); + assert.match(compact.stderr, /requestId=[0-9a-f-]{36}/); + assert.equal(requests.length, 2); + assert.ok(requests.every((request) => request.path === "/api/v1/mem/agent-memory")); + assert.deepEqual(requests[0].body.messages.map(({ role, content }) => ({ role, content })), [ + { role: "user", content: "latest question" }, + { role: "assistant", content: "latest answer" }, + ]); + assert.equal(requests[0].body.flush, false); + assert.deepEqual(requests[1].body, { + conversationId: "cursor-conversation", + messages: [], + flush: true, + }); + }); + + test("postToolUse spools locally without network and stop attaches the spooled calls", async () => { + const requests = []; + const server = await startServer(async (req, res) => { + requests.push({ path: req.url, body: JSON.parse(await readBody(req)) }); + respond(res, 200, { status: 0, result: { flushed: true } }); + }); + const runtime = await runtimeEnv(server, "test-agent-token"); + const baseInput = { + conversation_id: "cursor-tools", + generation_id: "cursor-tools-generation", + transcript_path: fixture, + }; + + const post = await runHookProcess("postToolUse", { + ...baseInput, + hook_event_name: "postToolUse", + tool_name: "shell", + tool_input: { command: "npm test" }, + tool_output: "all green", + }, runtime.env); + assert.equal(post.code, 0); + assert.equal(post.stdout, ""); + assert.equal(requests.length, 0, "postToolUse must not touch the network"); + + const stop = await runHookProcess("stop", { ...baseInput, hook_event_name: "stop" }, runtime.env); + assert.equal(stop.code, 0); + assert.equal(requests.length, 1); + const messages = requests[0].body.messages; + assert.equal(messages[0].role, "user"); + assert.equal(messages[1].role, "assistant"); + assert.equal(messages[1].toolCalls[0].name, "shell"); + assert.equal(messages[1].toolCalls[0].arguments, "{\"command\":\"npm test\"}"); + assert.equal(messages[2].role, "tool"); + assert.equal(messages[2].toolCallId, messages[1].toolCalls[0].id); + assert.equal(messages[2].content, "all green"); + assert.equal(messages[3].role, "assistant"); + assert.equal(messages[3].content, "latest answer"); + }); + + test("backend errors fail open and redact the credential", async () => { + const token = ["ev", "t_", "d".repeat(32)].join(""); + const server = await startServer(async (_req, res) => { + respond(res, 401, { status: 30101, error: `expired ${token}` }); + }); + const runtime = await runtimeEnv(server, token); + + const result = await runHookProcess("stop", { + conversation_id: "cursor-failed", + generation_id: "cursor-failed-generation", + transcript_path: fixture, + }, runtime.env); + + assert.equal(result.code, 0); + assert.equal(result.stdout, ""); + assert.doesNotMatch(result.stderr, new RegExp(token)); + assert.match(result.stderr, /REDACTED/); + assert.equal(result.stderr.match(/\n/g)?.length, 1); + }); +}); + +async function runtimeEnv(server, token) { + const dir = await mkdtemp(path.join(os.tmpdir(), "everme-cursor-")); + tempDirs.push(dir); + const address = server.address(); + const envFile = path.join(dir, "everme.env"); + await writeFile(envFile, [ + `EVERME_API_BASE=http://127.0.0.1:${address.port}`, + "EVERME_AGENT_ID=agt_cursor", + `EVERME_AGENT_TOKEN=${token}`, + `EVERME_STATE_DIR=${path.join(dir, "state")}`, + "", + ].join("\n"), { mode: 0o600 }); + return { + env: { + ...process.env, + EVERME_ENV_FILE_PATH: envFile, + EVERME_AGENT_TOKEN: "", + EVERME_AGENT_ID: "", + EVERME_API_BASE: "", + EVERME_STATE_DIR: "", + }, + }; +} + +function runHookProcess(event, input, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["bin/hook.js", "hook", event], { + cwd: packageDir, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; }); + child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(JSON.stringify(input)); + }); +} + +async function startServer(handler) { + const server = http.createServer((req, res) => { + Promise.resolve(handler(req, res)).catch((error) => { + res.statusCode = 500; + res.end(String(error)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + servers.push(server); + return server; +} + +async function readBody(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +function respond(res, statusCode, body) { + res.writeHead(statusCode, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} diff --git a/plugins/cursor/tests/transcript.test.js b/plugins/cursor/tests/transcript.test.js new file mode 100644 index 0000000..a8d4278 --- /dev/null +++ b/plugins/cursor/tests/transcript.test.js @@ -0,0 +1,103 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readLastTurn } from "../src/transcript.js"; + +const fixture = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "cursor-transcript.jsonl"); + +describe("Cursor transcript parser", () => { + test("returns only the latest user turn and ignores malformed or unknown steps", async () => { + assert.deepEqual(await readLastTurn(fixture), [ + { role: "user", content: "latest question" }, + { role: "assistant", content: "latest answer" }, + ]); + }); + + test("returns an empty delta for unsupported transcript shapes", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "everme-cursor-transcript-")); + const file = path.join(dir, "unsupported.jsonl"); + try { + await writeFile(file, "not-json\n{\"type\":\"reasoning\",\"text\":\"hidden\"}\n", "utf8"); + assert.deepEqual(await readLastTurn(file), []); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +// The shape Cursor actually writes to the transcript it hands the Stop +// hook: role at the TOP level, message carrying only { content }. The +// hand-authored fixture above assumes a nested message.role that Cursor +// does not emit, so the parser passed its tests while extracting nothing +// from real sessions - 38 transcripts on a developer machine yielded 0 +// messages. This is the 2026-08-17 review item 1.1.10, except the loss +// is the whole turn, not only the tool calls. +describe("Cursor transcript parser, host shape", () => { + const hostShape = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures", + "cursor-transcript-host-shape.jsonl", + ); + + test("reads a role declared at the top level of the record", async () => { + assert.deepEqual(await readLastTurn(hostShape), [ + { role: "user", content: "latest question" }, + { role: "assistant", content: "latest answer" }, + ]); + }); +}); + +// Cursor 3.16+ rewrote the transcript as a typed event stream. No full +// real sample with tool records exists yet (capture blocked on a usage +// limit), so the reader is deliberately lenient about field names and +// must warn on record types it does not recognise instead of silently +// dropping them — 2026-08-17 review item 1.1.10. +describe("Cursor transcript parser, tool events", () => { + const toolEvents = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures", + "cursor-transcript-tool-events.jsonl", + ); + + test("keeps tool calls from content blocks and standalone records, paired with results", async () => { + const warned = []; + assert.deepEqual(await readLastTurn(toolEvents, { warn: (type) => warned.push(type) }), [ + { role: "user", content: "run the tests" }, + { + role: "assistant", + content: "running now", + toolCalls: [{ id: "call_inline", type: "function", name: "shell", arguments: "{\"command\":\"npm test\"}" }], + }, + { + role: "assistant", + toolCalls: [{ id: "call_standalone", type: "function", name: "read_file", arguments: "{\"path\":\"a.js\"}" }], + }, + { role: "tool", toolCallId: "call_standalone", content: "file body" }, + { role: "assistant", content: "all green" }, + ]); + assert.deepEqual(warned, ["future_record_kind"]); + }); + + test("synthesizes an id for a tool call record that carries none", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "everme-cursor-transcript-")); + const file = path.join(dir, "no-id.jsonl"); + try { + await writeFile(file, [ + JSON.stringify({ type: "user", message: { content: "question" } }), + JSON.stringify({ type: "tool_call", tool_name: "shell", tool_input: { command: "ls" } }), + "", + ].join("\n"), "utf8"); + const [, call] = await readLastTurn(file); + assert.equal(call.role, "assistant"); + assert.equal(call.toolCalls.length, 1); + assert.equal(call.toolCalls[0].name, "shell"); + assert.equal(call.toolCalls[0].arguments, "{\"command\":\"ls\"}"); + assert.ok(call.toolCalls[0].id, "synthesized id must be non-empty"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/plugins/devin/README.md b/plugins/devin/README.md new file mode 100644 index 0000000..71d95b2 --- /dev/null +++ b/plugins/devin/README.md @@ -0,0 +1,32 @@ +# @everme/devin + +Native EverMe lifecycle hook runner for Devin. It keeps memory +traffic on EverMe's stable `/api/v1/mem/*` BFF contract. + +## Lifecycle + +- `post_cascade_response_with_transcript`: stream the protected JSONL + transcript and save only the latest user input plus following planner + responses. + +The parser ignores code actions, file bodies, command output, unknown steps, +and malformed lines. Devin's current `pre_user_prompt` hook can allow or +block a prompt but cannot inject hidden model context, so per-prompt recall +remains available through the `everme-memory` MCP tools. + +All hook failures are fail-open and credentials are redacted from diagnostics. +`evercli plugin install devin` writes credentials to +`~/.codeium/windsurf/everme.env` with mode `0600`, merges the MCP server into +`~/.codeium/windsurf/mcp_config.json`, and writes the lifecycle hook to +`~/.codeium/windsurf/hooks.json`. Devin Desktop still uses this internal +Cascade directory after the product rename; `~/.config/devin/config.json` +belongs to the separate Devin terminal CLI. + +## Configuration + +The shared hook knobs are `EVERME_FLUSH_EVERY_TURNS`, `EVERME_FLUSH_MODE`, and +`EVERME_STATE_DIR`. + +## License + +Apache-2.0. diff --git a/plugins/devin/bin/hook.js b/plugins/devin/bin/hook.js new file mode 100755 index 0000000..4c742e8 --- /dev/null +++ b/plugins/devin/bin/hook.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +import { redactError, runHook } from "@everme/agent-sdk"; +import { devinAdapter } from "../src/adapter.js"; +import { stashPrompt } from "../src/pending-prompt.js"; + +main().catch((error) => { + const reason = redactError(error).replace(/\s+/g, " ").trim(); + process.stderr.write(`EverMe Devin hook degraded: ${reason}\n`); + process.exitCode = 0; +}); + +async function main() { + const [, , command, event] = process.argv; + if (command !== "hook" || !event) return; + const input = await readStdinJSON(); + // The prompt and the answer arrive as two events in two processes. + // pre_user_prompt only parks the question so the response that follows + // can be stored as a turn instead of an answer to nothing; it uploads + // nothing itself. + if (event === "pre_user_prompt") { + await stashPrompt(input?.trajectory_id, input?.tool_info?.user_prompt); + return; + } + const output = await runHook(event, input, devinAdapter); + if (output && Object.keys(output).length) { + process.stdout.write(JSON.stringify(output)); + } +} + +async function readStdinJSON() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + return {}; + } +} diff --git a/plugins/devin/package.json b/plugins/devin/package.json new file mode 100644 index 0000000..8b5e070 --- /dev/null +++ b/plugins/devin/package.json @@ -0,0 +1,44 @@ +{ + "name": "@everme/devin", + "version": "0.6.1", + "type": "module", + "description": "Native EverMe lifecycle hooks for Devin.", + "license": "Apache-2.0", + "bin": { + "everme-devin": "./bin/hook.js" + }, + "files": [ + "bin/", + "src/" + ], + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "test": "node --test tests/transcript.test.js tests/turn.test.js tests/pending-prompt.test.js tests/adapter.test.js tests/hook.test.js" + }, + "keywords": [ + "evermind", + "everme", + "devin", + "cascade", + "memory", + "hooks" + ], + "homepage": "https://everme.evermind.ai", + "repository": { + "type": "git", + "url": "git+https://github.com/EverMind-AI/EverMe.git", + "directory": "plugins/devin" + }, + "bugs": { + "url": "https://github.com/EverMind-AI/EverMe/issues" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "dependencies": { + "@everme/agent-sdk": "^0.6.1" + } +} diff --git a/plugins/devin/src/adapter.js b/plugins/devin/src/adapter.js new file mode 100644 index 0000000..2eb11f7 --- /dev/null +++ b/plugins/devin/src/adapter.js @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { takePrompt } from "./pending-prompt.js"; +import { readLastTurn } from "./transcript.js"; +import { messagesForEvent } from "./turn.js"; + +// Events Devin emits that close out something worth storing. A real +// session produced post_cascade_response (the answer, inline) and +// post_run_command (the tool call) and never emitted the _with_transcript +// variant — that one is kept only so an older Devin still works. +const TRANSCRIPT_EVENT = "post_cascade_response_with_transcript"; +const WRITE_EVENTS = new Set([TRANSCRIPT_EVENT, "post_cascade_response", "post_run_command", "post_read_code"]); + +export const devinAdapter = { + platform: "devin", + + envFile() { + if (process.env.EVERME_ENV_FILE_PATH) return process.env.EVERME_ENV_FILE_PATH; + // Devin moved its user config from the Windsurf tree to ~/.config/devin + // and asks users whether to copy it over, so credentials live in either + // place depending on when the install happened. Prefer the current + // location, fall back to the old one, and name the current one when + // neither exists so the "not configured" path reports today's layout. + const current = path.join(os.homedir(), ".config", "devin", "everme.env"); + if (existsSync(current)) return current; + const legacy = path.join(os.homedir(), ".codeium", "windsurf", "everme.env"); + return existsSync(legacy) ? legacy : current; + }, + + mapEvent(event) { + return WRITE_EVENTS.has(event) ? "Stop" : event; + }, + + normalizeInput(rawInput, event) { + return { + sessionId: rawInput?.trajectory_id || "", + turnId: rawInput?.execution_id || "", + // The event decides which shape tool_info has, so readLastTurn needs + // to know which one it was handed. + event: rawInput?.agent_action_name || event || "", + toolInfo: rawInput?.tool_info && typeof rawInput.tool_info === "object" ? rawInput.tool_info : {}, + transcriptPath: rawInput?.tool_info?.transcript_path || "", + timestamp: rawInput?.timestamp || "", + modelName: rawInput?.model_name || "", + }; + }, + + async readLastTurn(input) { + // Only the _with_transcript variant names a file; the events Devin + // actually emits carry their content in tool_info. + if (input?.transcriptPath) return readLastTurn(input.transcriptPath); + const pending = input?.event === "post_cascade_response" + ? await takePrompt(input?.sessionId) + : ""; + return messagesForEvent(input?.event, input, pending); + }, + + formatOutput() { + return {}; + }, +}; diff --git a/plugins/devin/src/pending-prompt.js b/plugins/devin/src/pending-prompt.js new file mode 100644 index 0000000..0fd3b46 --- /dev/null +++ b/plugins/devin/src/pending-prompt.js @@ -0,0 +1,56 @@ +/** + * Carry a user prompt from one Devin hook event to the next. + * + * Devin delivers the prompt (pre_user_prompt) and the answer + * (post_cascade_response) as two events, each in its own short-lived + * process. Without a handoff every stored turn would be an answer with no + * question, so the prompt is parked on disk keyed by trajectory and + * consumed by the response that follows it. + */ + +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +function stateDirOf(options = {}) { + return options.stateDir || process.env.EVERME_STATE_DIR || path.join(os.tmpdir(), "everme-devin"); +} + +// The trajectory id comes from the host, so it is not allowed to steer +// the write anywhere but this directory. +function entryPath(trajectoryId, options) { + const safe = String(trajectoryId).replace(/[^A-Za-z0-9._-]/g, "_"); + return path.join(stateDirOf(options), `devin-prompt-${safe}.json`); +} + +export async function stashPrompt(trajectoryId, prompt, options = {}) { + const text = typeof prompt === "string" ? prompt.trim() : ""; + if (!trajectoryId || !text) return; + const dir = stateDirOf(options); + await mkdir(dir, { recursive: true, mode: 0o700 }); + // The prompt is user content: keep it owner-only. + await writeFile(entryPath(trajectoryId, options), JSON.stringify({ prompt: text }), { + encoding: "utf8", + mode: 0o600, + }); +} + +export async function takePrompt(trajectoryId, options = {}) { + if (!trajectoryId) return ""; + const file = entryPath(trajectoryId, options); + let raw; + try { + raw = await readFile(file, "utf8"); + } catch { + return ""; + } + // Consume it either way: a prompt that cannot be parsed must not stay + // behind to mislabel the next answer. + await rm(file, { force: true }).catch(() => {}); + try { + const prompt = JSON.parse(raw)?.prompt; + return typeof prompt === "string" ? prompt : ""; + } catch { + return ""; + } +} diff --git a/plugins/devin/src/transcript.js b/plugins/devin/src/transcript.js new file mode 100644 index 0000000..cc83d6d --- /dev/null +++ b/plugins/devin/src/transcript.js @@ -0,0 +1,39 @@ +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; + +export async function readLastTurn(transcriptPath) { + if (!transcriptPath) return []; + const lines = createInterface({ + input: createReadStream(transcriptPath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + let delta = []; + let foundUser = false; + + for await (const line of lines) { + let step; + try { + step = JSON.parse(line); + } catch { + continue; + } + if (step?.status && step.status !== "done") continue; + if (step?.type === "user_input") { + const content = text(step?.user_input?.user_response); + if (!content) continue; + delta = [{ role: "user", content }]; + foundUser = true; + continue; + } + if (foundUser && step?.type === "planner_response") { + const content = text(step?.planner_response?.response); + if (content) delta.push({ role: "assistant", content }); + } + } + + return foundUser ? delta : []; +} + +function text(value) { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/plugins/devin/src/turn.js b/plugins/devin/src/turn.js new file mode 100644 index 0000000..24b5473 --- /dev/null +++ b/plugins/devin/src/turn.js @@ -0,0 +1,66 @@ +/** + * Build the messages a Devin hook event contributes to a conversation. + * + * Devin hands each hook a `tool_info` shaped for that event rather than a + * transcript file: post_cascade_response carries the answer inline, + * post_run_command carries the command that ran. A real session emitted + * post_cascade_response and never post_cascade_response_with_transcript, + * so there is no transcript to parse — the turn is assembled here. + */ + +export function messagesForEvent(event, input = {}, pendingPrompt = "") { + const toolInfo = input?.toolInfo ?? {}; + switch (event) { + case "post_cascade_response": + return responseMessages(toolInfo, pendingPrompt); + // turnId is the event's execution_id (see adapter.normalizeInput). + case "post_run_command": + return toolCallMessages("run_command", pick(toolInfo, ["command_line", "cwd"]), "command_line", input?.turnId); + case "post_read_code": + return toolCallMessages("read_code", pick(toolInfo, ["file_path"]), "file_path", input?.turnId); + default: + return []; + } +} + +function pick(toolInfo, keys) { + const out = {}; + for (const key of keys) { + const value = text(toolInfo[key]); + if (value) out[key] = value; + } + return out; +} + +function responseMessages(toolInfo, pendingPrompt) { + const response = text(toolInfo.response); + if (!response) return []; + const prompt = text(pendingPrompt); + const messages = []; + // The prompt arrives on a different event (and a different process), so + // it is only here when pre_user_prompt stashed it. Storing the answer + // alone is still better than storing nothing. + if (prompt) messages.push({ role: "user", content: prompt }); + messages.push({ role: "assistant", content: response }); + return messages; +} + +// No paired tool result is emitted: Devin reports what a tool was asked +// to do but not what came back, and a fabricated empty result would claim +// we captured output we never saw. +function toolCallMessages(name, args, requiredKey, executionId) { + if (!args[requiredKey]) return []; + return [{ + role: "assistant", + toolCalls: [{ + id: `devin_${executionId || args[requiredKey]}`, + type: "function", + name, + arguments: JSON.stringify(args), + }], + }]; +} + +function text(value) { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/plugins/devin/tests/adapter.test.js b/plugins/devin/tests/adapter.test.js new file mode 100644 index 0000000..8abd579 --- /dev/null +++ b/plugins/devin/tests/adapter.test.js @@ -0,0 +1,163 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { stashPrompt } from "../src/pending-prompt.js"; +import { fileURLToPath } from "node:url"; +import { devinAdapter } from "../src/adapter.js"; + +const transcriptPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "devin-transcript.jsonl"); + +describe("Devin hook adapter", () => { + test("maps only the transcript response event to Stop", () => { + assert.equal(devinAdapter.mapEvent("post_cascade_response_with_transcript"), "Stop"); + assert.equal(devinAdapter.mapEvent("pre_user_prompt"), "pre_user_prompt"); + }); + + test("normalizes the official common and tool fields", async () => { + assert.deepEqual(await devinAdapter.normalizeInput({ + agent_action_name: "post_cascade_response_with_transcript", + trajectory_id: "devin-trajectory", + execution_id: "devin-execution", + timestamp: "2026-07-14T02:00:00.000Z", + model_name: "Claude Sonnet 4", + tool_info: { transcript_path: transcriptPath }, + }, "post_cascade_response_with_transcript"), { + sessionId: "devin-trajectory", + turnId: "devin-execution", + event: "post_cascade_response_with_transcript", + toolInfo: { transcript_path: transcriptPath }, + transcriptPath, + timestamp: "2026-07-14T02:00:00.000Z", + modelName: "Claude Sonnet 4", + }); + }); + + test("reads the latest conversational turn", async () => { + assert.deepEqual(await devinAdapter.readLastTurn({ transcriptPath }), [ + { role: "user", content: "create a hello world file" }, + { role: "assistant", content: "I'll create a hello world file for you." }, + { role: "assistant", content: "I created the file for you." }, + ]); + }); + + // A real session emitted post_cascade_response and post_run_command and + // never post_cascade_response_with_transcript, so those two are what the + // adapter has to turn into a stored turn. + test("treats the events Devin actually emits as turn boundaries", () => { + assert.equal(devinAdapter.mapEvent("post_cascade_response"), "Stop"); + assert.equal(devinAdapter.mapEvent("post_run_command"), "Stop"); + }); + + test("carries the event and tool_info through normalizeInput", () => { + const input = devinAdapter.normalizeInput({ + agent_action_name: "post_run_command", + trajectory_id: "traj-1", + execution_id: "exec-1", + tool_info: { command_line: "ls", cwd: "/tmp" }, + }, "post_run_command"); + assert.equal(input.sessionId, "traj-1"); + assert.equal(input.turnId, "exec-1"); + assert.equal(input.event, "post_run_command"); + assert.deepEqual(input.toolInfo, { command_line: "ls", cwd: "/tmp" }); + }); + + test("builds the turn from tool_info, pairing the stashed prompt", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "everme-devin-adapter-")); + try { + await stashPrompt("traj-1", "how many lines", { stateDir: dir }); + const previous = process.env.EVERME_STATE_DIR; + process.env.EVERME_STATE_DIR = dir; + try { + const messages = await devinAdapter.readLastTurn({ + event: "post_cascade_response", + sessionId: "traj-1", + toolInfo: { response: "42" }, + }); + assert.deepEqual(messages, [ + { role: "user", content: "how many lines" }, + { role: "assistant", content: "42" }, + ]); + } finally { + if (previous === undefined) delete process.env.EVERME_STATE_DIR; + else process.env.EVERME_STATE_DIR = previous; + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // Regression: turn.js used to read input.executionId, a field + // normalizeInput never produces (it emits turnId), so every tool call + // id silently fell back to the command line. Pipe the real normalized + // input through, exactly as runStore does, to pin the id derivation. + test("tool call ids come from execution_id, not the command line", async () => { + const input = devinAdapter.normalizeInput({ + trajectory_id: "traj-1", + execution_id: "exec-101", + tool_info: { command_line: "echo hi", cwd: "/tmp" }, + }, "post_run_command"); + const messages = await devinAdapter.readLastTurn(input); + assert.equal(messages[0].toolCalls[0].id, "devin_exec-101"); + }); + + test("emits no output for asynchronous post hooks", () => { + assert.deepEqual(devinAdapter.formatOutput("post_cascade_response_with_transcript", { block: "memory" }), {}); + }); + + // Devin moved its user config from the Windsurf tree to ~/.config/devin + // and prompts users to copy it across, so credentials can sit in either + // place: the current location for a fresh install, the old one for an + // install made before the move. Reading only one of them means the hook + // starts up unconfigured and silently writes nothing. + test("prefers Devin's current config directory for credentials", withHome((home) => { + mkdirSync(path.join(home, ".config", "devin"), { recursive: true }); + writeFileSync(path.join(home, ".config", "devin", "everme.env"), "EVERME_AGENT_TOKEN=evt_x\n"); + mkdirSync(path.join(home, ".codeium", "windsurf"), { recursive: true }); + writeFileSync(path.join(home, ".codeium", "windsurf", "everme.env"), "EVERME_AGENT_TOKEN=evt_old\n"); + + assert.equal(devinAdapter.envFile(), path.join(home, ".config", "devin", "everme.env")); + })); + + test("falls back to the pre-move location when that is where the install is", withHome((home) => { + mkdirSync(path.join(home, ".codeium", "windsurf"), { recursive: true }); + writeFileSync(path.join(home, ".codeium", "windsurf", "everme.env"), "EVERME_AGENT_TOKEN=evt_old\n"); + + assert.equal(devinAdapter.envFile(), path.join(home, ".codeium", "windsurf", "everme.env")); + })); + + test("with neither present, names the current location", withHome((home) => { + assert.equal(devinAdapter.envFile(), path.join(home, ".config", "devin", "everme.env")); + })); + + test("an explicit EVERME_ENV_FILE_PATH still wins", withHome((home) => { + const explicit = path.join(home, "elsewhere.env"); + process.env.EVERME_ENV_FILE_PATH = explicit; + try { + assert.equal(devinAdapter.envFile(), explicit); + } finally { + delete process.env.EVERME_ENV_FILE_PATH; + } + })); +}); + +// withHome runs fn against a throwaway HOME so the assertions never +// depend on (or touch) the developer's real Devin install. +function withHome(fn) { + return () => { + const previousHome = process.env.HOME; + const previousEnvFile = process.env.EVERME_ENV_FILE_PATH; + delete process.env.EVERME_ENV_FILE_PATH; + const home = mkdtempSync(path.join(os.tmpdir(), "everme-devin-home-")); + process.env.HOME = home; + try { + fn(home); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousEnvFile !== undefined) process.env.EVERME_ENV_FILE_PATH = previousEnvFile; + rmSync(home, { recursive: true, force: true }); + } + }; +} diff --git a/plugins/devin/tests/fixtures/devin-transcript.jsonl b/plugins/devin/tests/fixtures/devin-transcript.jsonl new file mode 100644 index 0000000..e9c06e1 --- /dev/null +++ b/plugins/devin/tests/fixtures/devin-transcript.jsonl @@ -0,0 +1,8 @@ +{"status":"done","type":"user_input","user_input":{"user_response":"old request"}} +{"status":"done","type":"planner_response","planner_response":{"response":"old response"}} +this is malformed json +{"status":"done","type":"user_input","user_input":{"rules_applied":{"always_on":["my-rule.md"]},"user_response":"create a hello world file"}} +{"status":"done","type":"planner_response","planner_response":{"response":"I'll create a hello world file for you."}} +{"status":"done","type":"code_action","code_action":{"new_content":"print('hello world')\n","path":"/path/to/file.py"}} +{"status":"done","type":"unknown_step","unknown_step":{"payload":"ignore me"}} +{"status":"done","type":"planner_response","planner_response":{"response":"I created the file for you."}} diff --git a/plugins/devin/tests/hook.test.js b/plugins/devin/tests/hook.test.js new file mode 100644 index 0000000..200ab66 --- /dev/null +++ b/plugins/devin/tests/hook.test.js @@ -0,0 +1,145 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixture = path.join(packageDir, "tests", "fixtures", "devin-transcript.jsonl"); +const tempDirs = []; +const servers = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("everme-devin hook CLI", () => { + test("stores the latest conversational turn without code-action bodies", async () => { + const requests = []; + const server = await startServer(async (req, res) => { + requests.push({ path: req.url, body: JSON.parse(await readBody(req)) }); + respond(res, 200, { status: 0, result: { flushed: false } }); + }); + const runtime = await runtimeEnv(server, "test-agent-token"); + + const result = await runHookProcess({ + agent_action_name: "post_cascade_response_with_transcript", + trajectory_id: "devin-trajectory", + execution_id: "devin-execution", + timestamp: "2026-07-14T02:00:00.000Z", + model_name: "Claude Sonnet 4", + tool_info: { transcript_path: fixture }, + }, runtime.env); + + assert.equal(result.code, 0); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /saveAgentMemory ok: .*requestId=[0-9a-f-]{36}/, + "stderr must carry the save line with its trace id"); + assert.equal(requests.length, 1); + assert.equal(requests[0].path, "/api/v1/mem/agent-memory"); + assert.equal(requests[0].body.conversationId, "devin-trajectory"); + assert.equal(requests[0].body.flush, false); + assert.deepEqual( + requests[0].body.messages.map(({ role, content }) => ({ role, content })), + [ + { role: "user", content: "create a hello world file" }, + { role: "assistant", content: "I'll create a hello world file for you." }, + { role: "assistant", content: "I created the file for you." }, + ], + ); + assert.doesNotMatch(JSON.stringify(requests[0].body), /print\('hello world'\)/); + }); + + test("backend errors fail open and redact the credential", async () => { + const token = ["ev", "t_", "e".repeat(32)].join(""); + const server = await startServer(async (_req, res) => { + respond(res, 401, { status: 30101, error: `expired ${token}` }); + }); + const runtime = await runtimeEnv(server, token); + + const result = await runHookProcess({ + agent_action_name: "post_cascade_response_with_transcript", + trajectory_id: "devin-failed", + execution_id: "devin-failed-execution", + tool_info: { transcript_path: fixture }, + }, runtime.env); + + assert.equal(result.code, 0); + assert.equal(result.stdout, ""); + assert.doesNotMatch(result.stderr, new RegExp(token)); + assert.match(result.stderr, /REDACTED/); + assert.equal(result.stderr.match(/\n/g)?.length, 1); + }); +}); + +async function runtimeEnv(server, token) { + const dir = await mkdtemp(path.join(os.tmpdir(), "everme-devin-")); + tempDirs.push(dir); + const address = server.address(); + const envFile = path.join(dir, "everme.env"); + await writeFile(envFile, [ + `EVERME_API_BASE=http://127.0.0.1:${address.port}`, + "EVERME_AGENT_ID=agt_devin", + `EVERME_AGENT_TOKEN=${token}`, + `EVERME_STATE_DIR=${path.join(dir, "state")}`, + "", + ].join("\n"), { mode: 0o600 }); + return { + env: { + ...process.env, + EVERME_ENV_FILE_PATH: envFile, + EVERME_AGENT_TOKEN: "", + EVERME_AGENT_ID: "", + EVERME_API_BASE: "", + EVERME_STATE_DIR: "", + }, + }; +} + +function runHookProcess(input, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ + "bin/hook.js", + "hook", + "post_cascade_response_with_transcript", + ], { + cwd: packageDir, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; }); + child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(JSON.stringify(input)); + }); +} + +async function startServer(handler) { + const server = http.createServer((req, res) => { + Promise.resolve(handler(req, res)).catch((error) => { + res.statusCode = 500; + res.end(String(error)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + servers.push(server); + return server; +} + +async function readBody(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +function respond(res, statusCode, body) { + res.writeHead(statusCode, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} diff --git a/plugins/devin/tests/pending-prompt.test.js b/plugins/devin/tests/pending-prompt.test.js new file mode 100644 index 0000000..eb71e6d --- /dev/null +++ b/plugins/devin/tests/pending-prompt.test.js @@ -0,0 +1,61 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { stashPrompt, takePrompt } from "../src/pending-prompt.js"; + +function withStateDir(fn) { + return async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "everme-devin-state-")); + try { + await fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; +} + +// The prompt and the answer reach us as two separate hook events, in two +// separate processes. Without carrying the prompt across, every stored +// turn would be an answer with no question. +describe("Devin pending prompt", () => { + test("a stashed prompt is returned to the response that follows it", withStateDir(async (dir) => { + await stashPrompt("traj-1", "how many lines", { stateDir: dir }); + assert.equal(await takePrompt("traj-1", { stateDir: dir }), "how many lines"); + })); + + test("taking it consumes it, so one prompt cannot label two answers", withStateDir(async (dir) => { + await stashPrompt("traj-1", "once", { stateDir: dir }); + assert.equal(await takePrompt("traj-1", { stateDir: dir }), "once"); + assert.equal(await takePrompt("traj-1", { stateDir: dir }), ""); + })); + + test("trajectories do not borrow each other's prompts", withStateDir(async (dir) => { + await stashPrompt("traj-1", "first", { stateDir: dir }); + await stashPrompt("traj-2", "second", { stateDir: dir }); + assert.equal(await takePrompt("traj-2", { stateDir: dir }), "second"); + assert.equal(await takePrompt("traj-1", { stateDir: dir }), "first"); + })); + + test("a prompt is user content, so it is not left world-readable", withStateDir(async (dir) => { + await stashPrompt("traj-1", "private question", { stateDir: dir }); + const entry = path.join(dir, "devin-prompt-traj-1.json"); + assert.equal(statSync(entry).mode & 0o777, 0o600); + })); + + test("an id with path separators cannot escape the state directory", withStateDir(async (dir) => { + await stashPrompt("../../escape", "x", { stateDir: dir }); + assert.equal(await takePrompt("../../escape", { stateDir: dir }), "x"); + assert.equal(statSync(dir).isDirectory(), true); + })); + + test("nothing stashed reads as nothing, not as a crash", withStateDir(async (dir) => { + assert.equal(await takePrompt("never-seen", { stateDir: dir }), ""); + })); + + test("a missing trajectory id is not tracked at all", withStateDir(async (dir) => { + await stashPrompt("", "orphan", { stateDir: dir }); + assert.equal(await takePrompt("", { stateDir: dir }), ""); + })); +}); diff --git a/plugins/devin/tests/transcript.test.js b/plugins/devin/tests/transcript.test.js new file mode 100644 index 0000000..1f7f69c --- /dev/null +++ b/plugins/devin/tests/transcript.test.js @@ -0,0 +1,33 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readLastTurn } from "../src/transcript.js"; + +const fixture = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "devin-transcript.jsonl"); + +describe("Devin transcript parser", () => { + test("returns the last user input and following planner responses", async () => { + const messages = await readLastTurn(fixture); + + assert.deepEqual(messages, [ + { role: "user", content: "create a hello world file" }, + { role: "assistant", content: "I'll create a hello world file for you." }, + { role: "assistant", content: "I created the file for you." }, + ]); + assert.doesNotMatch(JSON.stringify(messages), /print\('hello world'\)/); + }); + + test("ignores malformed and unknown transcript steps", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "everme-devin-transcript-")); + const file = path.join(dir, "unsupported.jsonl"); + try { + await writeFile(file, "not-json\n{\"type\":\"code_action\",\"code_action\":{\"new_content\":\"secret\"}}\n", "utf8"); + assert.deepEqual(await readLastTurn(file), []); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/plugins/devin/tests/turn.test.js b/plugins/devin/tests/turn.test.js new file mode 100644 index 0000000..1362ca8 --- /dev/null +++ b/plugins/devin/tests/turn.test.js @@ -0,0 +1,86 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { messagesForEvent } from "../src/turn.js"; + +// Devin never emitted post_cascade_response_with_transcript in a real +// session - only post_cascade_response, which carries the answer inline +// and names no transcript file. So the turn is assembled from tool_info, +// not parsed out of a transcript. Shapes observed on a real machine: +// pre_user_prompt tool_info = { user_prompt } +// post_run_command tool_info = { command_line, cwd } +// post_cascade_response tool_info = { response } +describe("Devin turn assembly", () => { + test("a response is paired with the prompt that triggered it", () => { + const messages = messagesForEvent( + "post_cascade_response", + { toolInfo: { response: "42" } }, + "how many lines", + ); + assert.deepEqual(messages, [ + { role: "user", content: "how many lines" }, + { role: "assistant", content: "42" }, + ]); + }); + + test("a response with no remembered prompt still stores the answer", () => { + assert.deepEqual( + messagesForEvent("post_cascade_response", { toolInfo: { response: "42" } }, ""), + [{ role: "assistant", content: "42" }], + ); + }); + + test("an empty response produces nothing to upload", () => { + assert.deepEqual(messagesForEvent("post_cascade_response", { toolInfo: { response: " " } }, "p"), []); + assert.deepEqual(messagesForEvent("post_cascade_response", { toolInfo: {} }, "p"), []); + }); + + test("a run_command becomes a tool call, which is the whole point", () => { + const messages = messagesForEvent("post_run_command", { + turnId: "exec-1", + toolInfo: { command_line: "wc -l /etc/paths", cwd: "/Users/admin" }, + }); + assert.equal(messages.length, 1); + const [msg] = messages; + assert.equal(msg.role, "assistant"); + assert.equal(msg.toolCalls.length, 1); + assert.equal(msg.toolCalls[0].name, "run_command"); + assert.equal(msg.toolCalls[0].id, "devin_exec-1"); + const args = JSON.parse(msg.toolCalls[0].arguments); + assert.equal(args.command_line, "wc -l /etc/paths"); + assert.equal(args.cwd, "/Users/admin"); + }); + + test("a run_command with no command is not worth a tool call", () => { + assert.deepEqual(messagesForEvent("post_run_command", { toolInfo: {} }), []); + }); + + // Devin reports the command it ran but not what came back, so the tool + // call is deliberately emitted without a paired tool result. Inventing + // an empty result would claim we captured output we never saw. + test("no tool result is fabricated for a command whose output we never get", () => { + const messages = messagesForEvent("post_run_command", { + turnId: "e", + toolInfo: { command_line: "ls" }, + }); + assert.equal(messages.filter((m) => m.role === "tool").length, 0); + }); + + // Observed on a real read: post_read_code carries only { file_path }. + test("a read becomes a tool call naming the file", () => { + const messages = messagesForEvent("post_read_code", { + turnId: "exec-2", + toolInfo: { file_path: "/etc/paths" }, + }); + assert.equal(messages.length, 1); + assert.equal(messages[0].toolCalls[0].name, "read_code"); + assert.equal(JSON.parse(messages[0].toolCalls[0].arguments).file_path, "/etc/paths"); + }); + + test("a read with no path is not worth a tool call", () => { + assert.deepEqual(messagesForEvent("post_read_code", { toolInfo: {} }), []); + }); + + test("an event we do not handle contributes nothing", () => { + assert.deepEqual(messagesForEvent("post_write_code", { toolInfo: { path: "x" } }), []); + }); +}); diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md new file mode 100644 index 0000000..e98cb72 --- /dev/null +++ b/plugins/dsh/README.md @@ -0,0 +1,45 @@ +# @everme/dsh + +Native EverMe lifecycle integration for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). + +The Cordis plugin complements `@everme/memory-mcp`: + +- `agent/pre-step` performs query-specific recall before the first model step of each turn. +- `session/event` captures the completed DSH turn, including tool calls and results, and writes it through `/mem/agent-memory`. +- `session/flush` waits for pending EverMe writes so DSH persistence checkpoints do not race the memory upload. +- Recall and save failures degrade open: DSH continues without blocking the user turn. +- The MCP server remains available through `npx -y @everme/memory-mcp@latest` for explicit `mem_context`, `mem_search`, `mem_save_fact`, and `mem_save_turn` tool calls. + +## Install + +Use EverCLI so the native plugin, MCP server, Cordis patch, and credentials stay in sync: + +```bash +npm install -g @everme/cli +evercli auth login +evercli plugin install dsh +``` + +`@everme/dsh` declares a native DSH bundle. EverCLI prefers the installed `dsh` launcher and falls back to `npx --yes @deepseek-ai/dsh@latest`, refreshes the dependency in both the `web` and `headless` profiles, and configures both profiles to start `@everme/memory-mcp@latest` through `npx` at runtime. The profiles share the credentials in `~/.dsh/.env`, while EverCLI manages separate MCP blocks in `~/.dsh/profiles/web/cordis.patch.yml` and `~/.dsh/profiles/headless/cordis.patch.yml`. The DSH install flow has a five-minute minimum operation budget so the first npm download is not clipped by EverCLI's default command timeout. + +Web sessions recall and save automatically after restart. Headless tasks use the same lifecycle integration and wait for pending memory writes before the one-shot process exits: + +```bash +dsh --profile headless "summarize the decisions from my previous session" +``` + +## Cordis entry + +The package exports the standard Cordis plugin surface: + +```js +export const name = "everme"; +export const inject = ["agents"]; +export function apply(ctx, config) {} +``` + +Credentials are read from DSH's layered environment (`EVERME_API_BASE`, `EVERME_AGENT_ID`, and `EVERME_AGENT_TOKEN`). Do not put tokens directly in `cordis.patch.yml`. + +## License + +Apache-2.0. diff --git a/plugins/dsh/cordis.patch.yml b/plugins/dsh/cordis.patch.yml new file mode 100644 index 0000000..2217b0a --- /dev/null +++ b/plugins/dsh/cordis.patch.yml @@ -0,0 +1,5 @@ +# Native EverMe lifecycle hooks. Credentials come from DSH's layered environment. +- insert: + - id: memory-everme-native + name: '@everme/dsh' + config: {} diff --git a/plugins/dsh/index.js b/plugins/dsh/index.js new file mode 100644 index 0000000..d928d18 --- /dev/null +++ b/plugins/dsh/index.js @@ -0,0 +1,190 @@ +import { createUserMessage } from "@deepseek-ai/dsh-llm"; +import { + assertConfigUsable, + createClient, + redactError, + resolveConfig, + runInject, + saveAgentMemory, + toText, +} from "@everme/agent-sdk"; + +export const name = "everme"; +export const inject = ["agents"]; + +export function apply(ctx, config = {}) { + installEverMeHooks(ctx, config); +} + +export function installEverMeHooks(ctx, config = {}, dependencies = {}) { + const log = createLogger(ctx, dependencies.log); + const resolved = dependencies.config || resolveConfig(config); + try { + assertConfigUsable(resolved); + } catch (error) { + log.warn(`[everme] native hooks disabled: ${safeError(error)}`); + return { enabled: false }; + } + + const client = dependencies.client || createClient(resolved, log); + const recall = dependencies.runInject || runInject; + const save = dependencies.saveAgentMemory || saveAgentMemory; + const makeUserMessage = dependencies.createUserMessage || createUserMessage; + const pending = new WeakMap(); + + ctx.on("agent/pre-step", async ({ messages, step, signal }, next) => { + const decision = await next(); + if (decision.kind !== "enter" || signal.aborted || step !== 1) return decision; + + try { + const prompt = humanPrompt(decision.messages || messages); + if (!prompt) return decision; + const result = await recall({ input: { prompt }, client, config: resolved, log }); + if (!result?.block) return decision; + return { + kind: "enter", + messages: [ + ...decision.messages, + makeUserMessage({ + content: [{ type: "text", text: result.block }], + source: { + kind: "plugin", + plugin: name, + form: "snapshot", + sections: [{ name: "everme-recall", text: result.block }], + }, + }), + ], + }; + } catch (error) { + log.warn(`[everme] recall degraded open: ${safeError(error)}`); + return decision; + } + }, { prepend: true }); + + ctx.on("session/event", (session, event) => { + if (event?.type !== "turn/end") return; + const messages = collectTurnMessages(session, event.data?.turn, event.seq); + if (!messages.length) return; + enqueue(pending, session, async () => { + await save(client, { + conversationId: String(session.id), + messages, + flush: true, + }, log); + }, log); + }); + + ctx.on("session/flush", async (session) => { + await (pending.get(session) || Promise.resolve()); + }); + + return { enabled: true, pending }; +} + +export function collectTurnMessages(session, turn, endSeq = Number.POSITIVE_INFINITY) { + if (!session || !Number.isSafeInteger(turn) || !Array.isArray(session.events)) return []; + const events = session.events; + let startIndex = -1; + let endIndex = events.length; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.seq > endSeq) continue; + if (event?.type === "turn/end" && event.data?.turn === turn) { + endIndex = index + 1; + continue; + } + if (event?.type === "turn/start" && event.data?.turn === turn) { + startIndex = index; + break; + } + } + if (startIndex < 0) return []; + + const messages = []; + for (const event of events.slice(startIndex + 1, endIndex)) { + const converted = convertSessionEvent(event); + if (converted) messages.push(converted); + } + return messages; +} + +function convertSessionEvent(event) { + if (event?.type === "user/message") { + const message = event.data; + if (message?.source?.kind !== "user") return null; + const content = toText(message.content); + return content ? { role: "user", content, timestamp: event.time } : null; + } + + if (event?.type === "assistant/message") { + const content = normalizeAssistantContent(event.data?.message?.content); + return content.length ? { role: "assistant", content, timestamp: event.time } : null; + } + + if (event?.type === "tool/result") { + const block = event.data?.message?.content?.find((item) => item?.type === "tool-result"); + if (!block?.toolCallId) return null; + return { + role: "tool", + toolCallId: String(block.toolCallId), + content: block.content || [], + timestamp: event.time, + }; + } + + return null; +} + +function normalizeAssistantContent(content) { + const normalized = []; + for (const block of Array.isArray(content) ? content : []) { + if (block?.type === "text" && block.text) { + normalized.push({ type: "text", text: block.text }); + } else if (block?.type === "tool-call" && block.id) { + normalized.push({ + type: "toolCall", + id: String(block.id), + name: block.name || "unknown", + arguments: block.arguments || "{}", + }); + } + } + return normalized; +} + +function humanPrompt(messages) { + return (Array.isArray(messages) ? messages : []) + .filter((message) => message?.source?.kind === "user") + .map((message) => toText(message.content)) + .filter(Boolean) + .join("\n\n"); +} + +function enqueue(pending, session, operation, log) { + const previous = pending.get(session) || Promise.resolve(); + const current = previous + .catch(() => {}) + .then(operation) + .catch((error) => { + log.warn(`[everme] save degraded open: ${safeError(error)}`); + }); + pending.set(session, current); + return current; +} + +function createLogger(ctx, override) { + if (override) return override; + return { + info(line) { + ctx?.logger?.info?.(line); + }, + warn(line) { + ctx?.logger?.warn?.(line); + }, + }; +} + +function safeError(error) { + return redactError(error instanceof Error ? error.message : String(error)); +} diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json new file mode 100644 index 0000000..a482e6e --- /dev/null +++ b/plugins/dsh/package.json @@ -0,0 +1,59 @@ +{ + "name": "@everme/dsh", + "version": "0.6.1", + "type": "module", + "description": "Native EverMe lifecycle hooks for DeepSeek Harness.", + "license": "Apache-2.0", + "main": "./index.js", + "exports": { + ".": "./index.js", + "./cordis.patch.yml": "./cordis.patch.yml" + }, + "files": [ + "index.js", + "cordis.patch.yml", + "README.md" + ], + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "test": "node --test tests/plugin.test.js" + }, + "keywords": [ + "evermind", + "everme", + "deepseek", + "dsh", + "memory", + "cordis", + "hooks" + ], + "homepage": "https://everme.evermind.ai", + "repository": { + "type": "git", + "url": "git+https://github.com/EverMind-AI/EverMe.git", + "directory": "plugins/dsh" + }, + "bugs": { + "url": "https://github.com/EverMind-AI/EverMe/issues" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "dependencies": { + "@everme/agent-sdk": "^0.6.1" + }, + "peerDependencies": { + "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "0.1.0-rc.6" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + } +} diff --git a/plugins/dsh/tests/plugin.test.js b/plugins/dsh/tests/plugin.test.js new file mode 100644 index 0000000..8a08bd6 --- /dev/null +++ b/plugins/dsh/tests/plugin.test.js @@ -0,0 +1,165 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { collectTurnMessages, installEverMeHooks } from "../index.js"; + +const usableConfig = { + baseUrl: "https://example.invalid/api/v1", + agentId: "agt_test", + agentToken: "evt_00000000000000000000000000000000", + injectTopK: 10, + injectMinScore: 0, + injectProfile: true, +}; + +function fakeContext() { + const handlers = new Map(); + const warnings = []; + return { + handlers, + warnings, + logger: { info() {}, warn(line) { warnings.push(line); } }, + on(event, handler, options) { + handlers.set(event, { handler, options }); + }, + }; +} + +function userMessage(text, id = "u1") { + return { id, role: "user", content: [{ type: "text", text }], source: { kind: "user" } }; +} + + +test("package declares a DSH bundle for native hook activation", () => { + const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + const patch = readFileSync(new URL("../cordis.patch.yml", import.meta.url), "utf8"); + + assert.equal(manifest.dsh.bundle.patch, "./cordis.patch.yml"); + assert.ok(manifest.files.includes("cordis.patch.yml")); + assert.match(patch, /id: memory-everme-native/); + assert.match(patch, /name: '@everme\/dsh'/); +}); + +test("pre-step appends query-specific recall on the first step", async () => { + const ctx = fakeContext(); + const seen = []; + installEverMeHooks(ctx, {}, { + config: usableConfig, + client: {}, + runInject: async (input) => { + seen.push(input.input.prompt); + return { block: "remember this", count: 1 }; + }, + createUserMessage: (message) => ({ ...message, id: "recall", role: "user" }), + }); + + const original = userMessage("what did we decide?"); + const result = await ctx.handlers.get("agent/pre-step").handler( + { messages: [original], turn: 1, step: 1, signal: { aborted: false } }, + async () => ({ kind: "enter", messages: [original] }), + ); + + assert.deepEqual(seen, ["what did we decide?"]); + assert.equal(result.messages.length, 2); + assert.equal(result.messages[1].source.plugin, "everme"); + assert.equal(result.messages[1].source.sections[0].name, "everme-recall"); + assert.deepEqual(ctx.handlers.get("agent/pre-step").options, { prepend: true }); +}); + +test("pre-step degrades open and never duplicates recall on later steps", async () => { + const ctx = fakeContext(); + let calls = 0; + installEverMeHooks(ctx, {}, { + config: usableConfig, + client: {}, + runInject: async () => { + calls += 1; + throw new Error("evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa unavailable"); + }, + }); + const original = userMessage("hello"); + const handler = ctx.handlers.get("agent/pre-step").handler; + const first = await handler( + { messages: [original], turn: 1, step: 1, signal: { aborted: false } }, + async () => ({ kind: "enter", messages: [original] }), + ); + const second = await handler( + { messages: [], turn: 1, step: 2, signal: { aborted: false } }, + async () => ({ kind: "enter", messages: [] }), + ); + + assert.deepEqual(first.messages, [original]); + assert.deepEqual(second.messages, []); + assert.equal(calls, 1); + assert.match(ctx.warnings[0], /REDACTED/); + assert.doesNotMatch(ctx.warnings[0], /evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/); +}); + +test("turn-end saves one complete user-assistant-tool trajectory and flush waits", async () => { + const ctx = fakeContext(); + let release; + const blocked = new Promise((resolve) => { release = resolve; }); + const saves = []; + installEverMeHooks(ctx, {}, { + config: usableConfig, + client: {}, + saveAgentMemory: async (_client, request) => { + saves.push(request); + await blocked; + }, + }); + + const session = { + id: "session-1", + events: [ + { type: "turn/start", seq: 1, time: 1000, data: { turn: 1 } }, + { type: "user/message", seq: 2, time: 1010, data: userMessage("question") }, + { type: "user/message", seq: 3, time: 1020, data: { id: "p1", role: "user", content: [{ type: "text", text: "plugin context" }], source: { kind: "plugin", plugin: "other" } } }, + { type: "assistant/message", seq: 4, time: 1030, data: { turn: 1, step: 1, message: { content: [{ type: "text", text: "checking" }, { type: "tool-call", id: "call-1", name: "read", arguments: "{\"path\":\"a\"}" }] } } }, + { type: "tool/result", seq: 5, time: 1040, data: { turn: 1, step: 1, message: { content: [{ type: "tool-result", toolCallId: "call-1", content: [{ type: "text", text: "result" }] }] } } }, + { type: "assistant/message", seq: 6, time: 1050, data: { turn: 1, step: 2, message: { content: [{ type: "text", text: "answer" }] } } }, + { type: "turn/end", seq: 7, time: 1060, data: { turn: 1, reason: { kind: "completed" } } }, + ], + }; + + ctx.handlers.get("session/event").handler(session, session.events.at(-1)); + let flushed = false; + const flushing = ctx.handlers.get("session/flush").handler(session).then(() => { flushed = true; }); + await Promise.resolve(); + assert.equal(flushed, false); + release(); + await flushing; + + assert.equal(saves.length, 1); + assert.equal(saves[0].conversationId, "session-1"); + assert.equal(saves[0].flush, true); + assert.deepEqual(saves[0].messages.map((message) => message.role), ["user", "assistant", "tool", "assistant"]); + assert.equal(saves[0].messages[1].content[1].type, "toolCall"); + assert.equal(saves[0].messages[2].toolCallId, "call-1"); +}); + +test("collectTurnMessages returns only the requested turn", () => { + const session = { + events: [ + { type: "turn/start", seq: 1, time: 1, data: { turn: 1 } }, + { type: "user/message", seq: 2, time: 2, data: userMessage("first", "u1") }, + { type: "turn/end", seq: 3, time: 3, data: { turn: 1, reason: { kind: "completed" } } }, + { type: "turn/start", seq: 4, time: 4, data: { turn: 2 } }, + { type: "user/message", seq: 5, time: 5, data: userMessage("second", "u2") }, + { type: "turn/end", seq: 6, time: 6, data: { turn: 2, reason: { kind: "completed" } } }, + ], + }; + + assert.deepEqual(collectTurnMessages(session, 2, 6).map((message) => message.content), ["second"]); +}); + +test("missing credentials disable native hooks without throwing", () => { + const ctx = fakeContext(); + const state = installEverMeHooks(ctx, {}, { + config: { ...usableConfig, agentToken: "" }, + }); + + assert.equal(state.enabled, false); + assert.equal(ctx.handlers.size, 0); + assert.match(ctx.warnings[0], /native hooks disabled/); +}); diff --git a/plugins/everme/.codex-plugin/plugin.json b/plugins/everme/.codex-plugin/plugin.json index 1b99c31..82a2e66 100644 --- a/plugins/everme/.codex-plugin/plugin.json +++ b/plugins/everme/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "everme", - "version": "0.4.2", + "version": "0.6.1", "description": "Persistent memory recall and lifecycle capture for Codex sessions via EverMe.", "author": { "name": "EverMind AI", diff --git a/plugins/everme/bin/hook.mjs b/plugins/everme/bin/hook.mjs index ea0572e..ddfee24 100755 --- a/plugins/everme/bin/hook.mjs +++ b/plugins/everme/bin/hook.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // ../agent-sdk/src/client.js +import { randomUUID } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; // ../agent-sdk/src/hooks/knobs.js @@ -74,6 +75,46 @@ function trimSlash(s) { return String(s || "").replace(/\/+$/, ""); } +// ../agent-sdk/src/hooks/deadline.js +var HOST_HOOK_TIMEOUT_S = Object.freeze({ + SessionStart: 30, + UserPromptSubmit: 10, + Stop: 30, + SessionEnd: 30, + PreCompact: 30 +}); +var HOOK_SAFETY_MARGIN_MS = 3e3; +var MIN_REQUEST_BUDGET_MS = 1e3; +function hookBudgetMs(event) { + const seconds = HOST_HOOK_TIMEOUT_S[event]; + if (!seconds) return null; + return seconds * 1e3 - HOOK_SAFETY_MARGIN_MS; +} +function boundedTimeoutMs(configuredMs, deadlineAt, now = Date.now()) { + if (!deadlineAt) return configuredMs; + const remaining = deadlineAt - now; + if (remaining < MIN_REQUEST_BUDGET_MS) return MIN_REQUEST_BUDGET_MS; + return Math.min(configuredMs, remaining); +} +function startHookWatchdog({ + event = "", + budgetMs, + onExpire, + setTimer = setTimeout, + clearTimer = clearTimeout +} = {}) { + if (!budgetMs || budgetMs <= 0) return () => { + }; + const fireAt = budgetMs + HOOK_SAFETY_MARGIN_MS / 2; + const handle = setTimer(() => { + onExpire?.( + `EverMe ${event || "hook"} hook gave up after ${fireAt}ms to stay inside the host timeout` + ); + }, fireAt); + handle?.unref?.(); + return () => clearTimer(handle); +} + // ../agent-sdk/src/client.js var noop = { info() { }, warn() { @@ -96,24 +137,56 @@ var EvermeError = class extends Error { this.requestId = requestId; this.type = type; } + /** + * Support-friendly one-liner: message plus the errno and requestId a user + * can quote to correlate with server-side logs. Every user-facing error + * sink (MCP errResp, hook diagnostics, engine warns) should prefer this + * over .message. + */ + describe() { + const parts = []; + if (this.code) parts.push(`errno=${this.code}`); + if (this.requestId) parts.push(`requestId=${this.requestId}`); + return parts.length ? `${this.message} (${parts.join(", ")})` : this.message; + } }; +function describeError(err) { + if (err instanceof EvermeError) return err.describe(); + return redactError(err?.message || String(err)); +} +async function requestMeta(client, method, path3, body, opts) { + if (typeof client?.requestWithMeta === "function") { + return client.requestWithMeta(method, path3, body, opts); + } + return { result: await client.request(method, path3, body, opts), requestId: "" }; +} function createClient(cfg, log = noop) { - const headers = () => ({ + const headers = (requestId) => ({ "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${cfg.agentToken}`, - "User-Agent": `everme-memory-mcp/0.1 (agentId=${cfg.agentId})` + "User-Agent": `everme-memory-mcp/0.1 (agentId=${cfg.agentId})`, + // Client-generated trace id. The gateway reuses a valid inbound value, + // so plugin logs, EverMe ELK, and the cloud platform all join on it — + // even when the request times out before any response arrives. + requestId }); - async function request(method, path3, body, { timeoutMs = TIMEOUT_MS, query } = {}) { + async function requestWithMeta(method, path3, body, { timeoutMs = TIMEOUT_MS, query } = {}) { + const requestId = randomUUID(); const url = buildUrl(cfg.baseUrl, path3, query); const init = { method, - headers: headers(), + headers: headers(requestId), body: body == null ? void 0 : JSON.stringify(body) }; - return execWithRetry(url, init, timeoutMs, log); + return execWithRetry(url, init, boundedTimeoutMs(timeoutMs, cfg.deadlineAt), log, requestId); + } + async function request(method, path3, body, opts) { + const { result } = await requestWithMeta(method, path3, body, opts); + return result; } async function rawPost(uploadUrl, body, contentType, { timeoutMs = TIMEOUT_MS } = {}) { + timeoutMs = boundedTimeoutMs(timeoutMs, cfg.deadlineAt); const ac = new AbortController(); const t = setTimeout(() => ac.abort(), timeoutMs); try { @@ -164,7 +237,7 @@ function createClient(cfg, log = noop) { clearTimeout(t); } } - return { request, rawPost }; + return { request, requestWithMeta, rawPost }; } function buildUrl(base, path3, query) { const qs = query ? new URLSearchParams() : null; @@ -178,9 +251,9 @@ function buildUrl(base, path3, query) { const q = qs?.toString(); return q ? `${base}${path3}?${q}` : `${base}${path3}`; } -async function execWithRetry(url, init, timeoutMs, log) { +async function execWithRetry(url, init, timeoutMs, log, requestId) { try { - return await execOnce(url, init, timeoutMs); + return await execOnce(url, init, timeoutMs, requestId); } catch (err) { if (err instanceof EvermeError) { throw err; @@ -189,12 +262,12 @@ async function execWithRetry(url, init, timeoutMs, log) { if (method !== "GET" && method !== "HEAD") { throw err; } - log.warn?.(`[everme] ${method} failed, retrying once: ${redactError(err?.message)}`); + log.warn?.(`[everme] ${method} failed, retrying once (requestId=${requestId}): ${redactError(err?.message)}`); await sleep(150); - return execOnce(url, init, timeoutMs); + return execOnce(url, init, timeoutMs, requestId); } } -async function execOnce(url, init, timeoutMs) { +async function execOnce(url, init, timeoutMs, requestId = "") { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), timeoutMs); let res; @@ -206,6 +279,7 @@ async function execOnce(url, init, timeoutMs) { const aborted = ac.signal.aborted; throw new EvermeError({ message: aborted ? `timed out after ${timeoutMs}ms` : redactError(err?.message || String(err)), + requestId, type: aborted ? "timeout" : "upstream" }); } @@ -215,6 +289,7 @@ async function execOnce(url, init, timeoutMs) { const aborted = ac.signal.aborted; throw new EvermeError({ message: aborted ? `timed out reading body after ${timeoutMs}ms` : redactError(`body read failed: ${err?.message || String(err)}`), + requestId, type: aborted ? "timeout" : "upstream" }); } @@ -228,11 +303,12 @@ async function execOnce(url, init, timeoutMs) { throw new EvermeError({ message: `HTTP ${res.status}${text ? " — " + text.slice(0, 200) : ""}`, status: res.status, + requestId: res.headers?.get?.("requestId") || requestId, type: res.status === 401 || res.status === 403 ? "auth" : "upstream" }); } if (env && env.status === 0) { - return env.result ?? null; + return { result: env.result ?? null, requestId: env.requestId || requestId }; } const code = Number(env?.status) || 0; const errType = code >= 3e4 && code < 30300 && code !== 30104 ? "auth" : "upstream"; @@ -240,7 +316,7 @@ async function execOnce(url, init, timeoutMs) { message: env?.error || `HTTP ${res.status}`, status: res.status, code, - requestId: env?.requestId, + requestId: env?.requestId || requestId, type: errType }); } @@ -322,17 +398,25 @@ async function saveAgentMemory(client, { conversationId, messages = [], flush = if (!converted.length && !flushOnly) return null; const batches = Math.max(1, Math.ceil(converted.length / MAX_MESSAGES_PER_REQUEST)); let res = null; + const requestIds = []; for (let batch = 0; batch < batches; batch += 1) { const slice = converted.slice(batch * MAX_MESSAGES_PER_REQUEST, (batch + 1) * MAX_MESSAGES_PER_REQUEST); const isLast = batch === batches - 1; - res = await client.request("POST", "/mem/agent-memory", { + const { result, requestId } = await requestMeta(client, "POST", "/mem/agent-memory", { conversationId, messages: slice, - flush: isLast ? flush : false + flush: isLast ? flush : false, + // Leading batches of a flushing upload must keep the server's + // synchronous-add guarantee: an async leading batch can still be + // invisible to the final request's flush (first-flush data loss, + // one request boundary later). Servers without the field ignore it. + ...!isLast && flush === true ? { sync: true } : {} }); + res = result; + requestIds.push(requestId); } - log.info?.(`[everme] saveAgentMemory ok: messages=${converted.length} batches=${batches} flushed=${Boolean(res?.flushed)}`); - return res; + log.info?.(`[everme] saveAgentMemory ok: messages=${converted.length} batches=${batches} flushed=${Boolean(res?.flushed)} status=${res?.status ?? ""} requestId=${requestIds.join(",")}`); + return res == null ? res : { ...res, requestId: requestIds[requestIds.length - 1], requestIds }; } async function flushAgentMemory(client, { conversationId } = {}, log) { return saveAgentMemory(client, { conversationId, messages: [], flush: true }, log); @@ -425,14 +509,14 @@ async function searchMemory(client, params, log = noop2) { ...params.filter ? { filter: params.filter } : {}, ...Array.isArray(params.memoryTypes) && params.memoryTypes.length ? { memoryTypes: params.memoryTypes } : {} }; - log.info?.(`[everme] POST /mem/search topK=${body.topK} q="${truncate(body.query, 60)}"`); - const res = await client.request("POST", "/mem/search", body); + const { result: res, requestId } = await requestMeta(client, "POST", "/mem/search", body); + log.info?.(`[everme] POST /mem/search topK=${body.topK} q="${truncate(body.query, 60)}" requestId=${requestId}`); return { memories: res?.items ?? [], profiles: res?.profiles ?? [], rawMessages: res?.rawMessages ?? [], agentMemory: res?.agentMemory ?? { cases: [], skills: [] }, - requestId: res?.requestId + requestId }; } function truncate(s, n) { @@ -678,7 +762,7 @@ async function pruneStaleStateFiles(stateDir, keepFile) { try { const cutoff = Date.now() - STATE_MAX_AGE_MS; for (const name of await readdir(stateDir)) { - if (!name.endsWith(".json")) continue; + if (!name.endsWith(".json") && !name.endsWith(".toolbuf.jsonl")) continue; const file = path.join(stateDir, name); if (file === keepFile) continue; try { @@ -704,7 +788,7 @@ async function runInject({ input, client, config, search = searchMemory, log }) const { query, stats } = extractUserIntent(input?.prompt); writeQueryStats(log, stats); if (countTokens(query) < MIN_PROMPT_TOKENS) return { block: "", count: 0 }; - const result = await search(client, { query, topK: config.injectTopK }); + const result = await search(client, { query, topK: config.injectTopK }, log); const memories = (result?.memories || []).filter((memory) => { const score = memory?.score ?? memory?.relevanceScore; return score == null || score === 0 || score >= config.injectMinScore; @@ -755,12 +839,15 @@ function countBundle(bundle, sections) { } // ../agent-sdk/src/hooks/session-start.js -async function runSessionStart({ client }) { - const result = await client.request("POST", "/mem/context", {}); +async function runSessionStart({ client, log }) { + const { result, requestId } = await requestMeta(client, "POST", "/mem/context", {}); const profile = result?.profile; + const count = profileItemCount(profile); + log?.info?.(`[everme] SessionStart profile: items=${count} requestId=${requestId}`); return { block: renderProfileBlock(profile), - count: profileItemCount(profile) + count, + requestId }; } function renderProfileBlock(profile) { @@ -807,7 +894,7 @@ function createHookRuntime({ enqueue, flush, diagnostic = () => { return await operation(); } catch (error) { try { - diagnostic(`EverMe ${label} degraded: ${redactError(error)}`); + diagnostic(`EverMe ${label} degraded: ${describeError(error)}`); } catch { } if (rethrowOnError) throw error; @@ -834,41 +921,45 @@ function createHookRuntime({ enqueue, flush, diagnostic = () => { } // ../agent-sdk/src/hooks/store.js -async function runStore({ input, adapter, client, config, counter, diagnostic }) { +async function runStore({ input, adapter, client, config, counter, stateDir, log, diagnostic }) { const sessionId = input?.sessionId; if (!sessionId) return { block: "", count: 0 }; - const messages = await adapter.readLastTurn(input); + const messages = await adapter.readLastTurn(input, { stateDir }); if (!Array.isArray(messages) || !messages.length) return { block: "", count: 0 }; const turnId = await resolveTurnId(adapter, input); const state = await counter.peek(sessionId, turnId); if (state.duplicate) return { block: "", count: 0, duplicate: true }; const runtime = createHookRuntime({ - enqueue: (turn) => saveAgentMemory(client, turn), - flush: (conversationId) => flushAgentMemory(client, { conversationId }), + enqueue: (turn) => saveAgentMemory(client, turn, log), + flush: (conversationId) => flushAgentMemory(client, { conversationId }, log), diagnostic, rethrowOnError: true }); if (config.flushMode === "legacy") { - await runtime.flushSession({ conversationId: sessionId, messages }); + const saved2 = await runtime.flushSession({ conversationId: sessionId, messages }); await counter.commit(sessionId, turnId); - return { block: "", count: messages.length, flushed: true }; + return { block: "", count: messages.length, flushed: true, status: saved2?.status, requestId: saved2?.requestId }; } - await runtime.enqueueTurn({ conversationId: sessionId, messages }); + const saved = await runtime.enqueueTurn({ conversationId: sessionId, messages }); const committed = await counter.commit(sessionId, turnId); const flushed = config.flushEveryTurns > 0 && committed.count % config.flushEveryTurns === 0; - if (flushed) await runtime.flush(sessionId); - return { block: "", count: messages.length, flushed }; + let requestId = saved?.requestId; + if (flushed) { + const flushRes = await runtime.flush(sessionId); + requestId = flushRes?.requestId || requestId; + } + return { block: "", count: messages.length, flushed, requestId }; } async function resolveTurnId(adapter, input) { if (input?.turnId) return input.turnId; if (typeof adapter?.resolveTurnId !== "function") return ""; return await adapter.resolveTurnId(input) || ""; } -async function runBoundaryFlush({ input, adapter, client, sessionState, diagnostic }) { +async function runBoundaryFlush({ input, adapter, client, sessionState, log, diagnostic }) { if (!input?.sessionId) return { block: "", count: 0 }; const runtime = createHookRuntime({ - enqueue: (turn) => saveAgentMemory(client, turn), - flush: (conversationId) => flushAgentMemory(client, { conversationId }), + enqueue: (turn) => saveAgentMemory(client, turn, log), + flush: (conversationId) => flushAgentMemory(client, { conversationId }, log), diagnostic, rethrowOnError: true }); @@ -878,31 +969,59 @@ async function runBoundaryFlush({ input, adapter, client, sessionState, diagnost const uploadedCount = sessionState ? (await sessionState.read(input.sessionId)).uploadedCount : 0; const delta = uploadedCount > 0 ? messages.slice(uploadedCount) : messages; if (!delta.length) return { block: "", count: 0, skipped: true }; - await runtime.flushSession({ conversationId: input.sessionId, messages: delta }); + const saved = await runtime.flushSession({ conversationId: input.sessionId, messages: delta }); if (sessionState) await sessionState.patch(input.sessionId, { uploadedCount: messages.length }); - return { block: "", count: delta.length, flushed: true }; + return { block: "", count: delta.length, flushed: true, status: saved?.status, requestId: saved?.requestId }; } - await runtime.onSessionEnd(input.sessionId); - return { block: "", count: 0, flushed: true }; + const flushRes = await runtime.onSessionEnd(input.sessionId); + return { block: "", count: 0, flushed: true, requestId: flushRes?.requestId }; } // ../agent-sdk/src/hooks/runtime.js -var WRITE_EVENTS = /* @__PURE__ */ new Set(["Stop", "SessionEnd", "PreCompact"]); +var WRITE_EVENTS = /* @__PURE__ */ new Set(["Stop", "SessionEnd", "PreCompact", "PostToolUse"]); var ROTATED_KEYS = /* @__PURE__ */ new Set(["EVERME_AGENT_TOKEN", "EVERME_AGENT_ID"]); async function runHook(event, rawInput, adapter, deps = {}) { - return runHostHook(event, rawInput, adapter, { - ...deps, - resolveConfig: resolveRuntimeConfig, - createClient, - createTurnCounter, - createSessionState, - runSessionStart, - runInject, - runStore, - runBoundaryFlush, - redactError + const stopWatchdog = startHookWatchdog({ + event: adapter?.mapEvent?.(event) || event, + budgetMs: hookBudgetMs(adapter?.mapEvent?.(event) || event), + onExpire: (line) => { + try { + process.stderr.write(`${line} +`); + } catch { + } + process.exit(0); + } }); + try { + return await runHostHook(event, rawInput, adapter, { + ...deps, + resolveConfig: resolveRuntimeConfig, + createClient, + createTurnCounter, + createSessionState, + runSessionStart, + runInject, + runStore, + runBoundaryFlush, + redactError + }); + } finally { + stopWatchdog(); + } } +var stderrLog = { + info(line) { + try { + process.stderr.write(`${line} +`); + } catch { + } + }, + warn(line) { + this.info(line); + } +}; async function runHostHook(event, rawInput, adapter, deps = {}) { const hostEvent = event; let result = { block: "", count: 0 }; @@ -910,16 +1029,19 @@ async function runHostHook(event, rawInput, adapter, deps = {}) { const canonicalEvent = adapter.mapEvent?.(hostEvent) || hostEvent; const input = await adapter.normalizeInput(rawInput || {}, hostEvent); const env = deps.env || await loadRuntimeEnv(adapter, deps.baseEnv || process.env); - const config = deps.config || requireOperation(deps.resolveConfig, "resolveConfig")(env); - if (!config.isConfigured) return formatOutput(adapter, hostEvent, result); - if (WRITE_EVENTS.has(canonicalEvent) && (config.authMode !== "evt" || !config.agentId)) { + const baseConfig = deps.config || requireOperation(deps.resolveConfig, "resolveConfig")(env); + if (!baseConfig.isConfigured) return formatOutput(adapter, hostEvent, result); + if (WRITE_EVENTS.has(canonicalEvent) && (baseConfig.authMode !== "evt" || !baseConfig.agentId)) { return formatOutput(adapter, hostEvent, result); } - const client = deps.client || requireOperation(deps.createClient, "createClient")(config); + const budgetMs = deps.budgetMs === void 0 ? hookBudgetMs(canonicalEvent) : deps.budgetMs; + const config = budgetMs ? { ...baseConfig, deadlineAt: Date.now() + budgetMs } : baseConfig; + const log = deps.log || stderrLog; + const client = deps.client || requireOperation(deps.createClient, "createClient")(config, log); if (canonicalEvent === "SessionStart") { - result = await requireOperation(deps.runSessionStart, "runSessionStart")({ input, client, config }); + result = await requireOperation(deps.runSessionStart, "runSessionStart")({ input, client, config, log }); } else if (canonicalEvent === "UserPromptSubmit") { - result = await requireOperation(deps.runInject, "runInject")({ input, client, config, search: deps.searchMemory, log: deps.log }); + result = await requireOperation(deps.runInject, "runInject")({ input, client, config, search: deps.searchMemory, log }); } else if (canonicalEvent === "Stop") { const counter = deps.counter || requireOperation(deps.createTurnCounter, "createTurnCounter")({ stateDir: env.EVERME_STATE_DIR }); result = await requireOperation(deps.runStore, "runStore")({ @@ -928,10 +1050,16 @@ async function runHostHook(event, rawInput, adapter, deps = {}) { client, config, counter, + stateDir: env.EVERME_STATE_DIR, + log, diagnostic: (line) => { throw new Error(line); } }); + } else if (canonicalEvent === "PostToolUse") { + if (typeof adapter.bufferToolUse === "function") { + result = await adapter.bufferToolUse(input, { stateDir: env.EVERME_STATE_DIR }); + } } else if (canonicalEvent === "SessionEnd" || canonicalEvent === "PreCompact") { const sessionState = deps.sessionState || (typeof deps.createSessionState === "function" ? deps.createSessionState({ stateDir: env.EVERME_STATE_DIR }) : void 0); result = await requireOperation(deps.runBoundaryFlush, "runBoundaryFlush")({ @@ -939,6 +1067,7 @@ async function runHostHook(event, rawInput, adapter, deps = {}) { adapter, client, sessionState, + log, diagnostic: (line) => { throw new Error(line); } @@ -996,7 +1125,7 @@ function requireOperation(fn, name) { return fn; } function writeDiagnostic(event, error, redact = redactError, writer = (line) => process.stderr.write(line)) { - const redacted = redact(error); + const redacted = error?.name === "EvermeError" ? describeError(error) : redact(error); const reason = String(redacted).replace(/\s+/g, " ").trim(); const label = { SessionStart: "start", diff --git a/plugins/kimicode/LICENSE b/plugins/kimicode/LICENSE new file mode 100644 index 0000000..667db5d --- /dev/null +++ b/plugins/kimicode/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Evermind AI + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/kimicode/README.md b/plugins/kimicode/README.md new file mode 100644 index 0000000..3b37b6d --- /dev/null +++ b/plugins/kimicode/README.md @@ -0,0 +1,81 @@ +# EverMe — Kimi Code plugin + +Automatic memory recall + persistence for Kimi Code, backed by the EverMe gateway. + +## What it does + +- **SessionStart** → loads the profile snapshot from past sessions and prints it to stdout, which Kimi Code appends to the model context (wrapped in ``). +- **UserPromptSubmit** → searches your memory for content relevant to the prompt you just typed and prints it (wrapped in ``) BEFORE the model sees the prompt. Silent when no relevant hit (no nag). +- **SessionEnd** → the plugin's **sole runtime write** (there is no Stop hook): reads the session's full `wire.jsonl` transcript and flushes the WHOLE session once to `/mem/agent-memory` (never `/mem/sources`). Writing the whole session at once — instead of thin per-turn deltas — gives the backend the coherent multi-turn context episodic-memory extraction needs; a single memory-recall turn yields a case but rarely an episode. The session is extracted exactly once, so there are no duplicate cases/episodes. + +Plus: + +- **MCP server** (`everme-memory`, the standalone `@everme/memory-mcp`) exposing `mem_search` + `mem_context` (explicit recall) and `mem_save_turn` + `mem_save_fact` (explicit write). +- **Skills** `memory-recall` (session-start primer) and `memory-tools` (when/how to use the search tools). + +## Manifest + +Unlike the Claude Code plugin (which splits `plugin.json` + `hooks/hooks.json` + `.mcp.json`), Kimi Code uses a **single self-contained** `kimi.plugin.json` at the plugin root that declares `mcpServers`, `hooks`, `skills`, and `sessionStart` inline. + +## Hook output contract + +The hooks emit a **JSON envelope** on stdout — a single line `{"message":""}` — and Kimi Code injects the `message` field into the model context. So SessionStart / UserPromptSubmit write `JSON.stringify({ message: block })`; the recall/profile text is carried in `message`, not printed as bare stdout. On any error or no-data, the hooks exit 0 with no output and never block the host. + +## Transcript location (SessionEnd hook) + +Kimi Code's SessionEnd hook receives **no transcript path** on stdin. The transcript lives at: + +``` +$KIMI_CODE_HOME/sessions///agents/main/wire.jsonl +``` + +where `` = `wd__` and `` is the stdin `session_id`. The SessionEnd hook derives this from `session_id` + `cwd` + `KIMI_CODE_HOME` (default `~/.kimi-code`). Kimi's slug rule preserves characters (e.g. hyphens) that a naive reconstruction would rewrite, so the bucket dir is located by its `sha256(cwd)[:12]` suffix — identical on both sides — rather than by rebuilding ``. It then reads `wire.jsonl` and persists the whole session. + +## Credentials + +Kimi Code's `mcpServers.env` cannot carry per-user secrets (no `${VAR}` expansion), so credentials are NOT placed in the manifest. Both the MCP server and the hooks read them at runtime from: + +``` +$KIMI_CODE_HOME/everme.env (default ~/.kimi-code/everme.env) +``` + +This file is written by the EverMe Go CLI installer (`evercli`), in `KEY=value` form. + +## Configuration + +| Env var | Purpose | +|---|---| +| `EVERME_API_KEY` | Account-level emk. Supports recall-only mode. | +| `EVERME_AGENT_TOKEN` | Per-machine evt. Required for realtime writes and wins over emk when both are set. | +| `EVERME_AGENT_ID` | Required with `EVERME_AGENT_TOKEN` for realtime writes; also pins recall to a specific cloud agent. | +| `EVERME_API_BASE` | Gateway host. Defaults to `https://api.everme.evermind.ai`. Set to `http://localhost:8080` for local dev. | +| `EVERME_ENV_FILE_PATH` | Override the env-file location (defaults to `$KIMI_CODE_HOME/everme.env`). | +| `EVERME_INJECT_TOPK` | Recall rows, default `10`, clamped to `1..20`. | +| `EVERME_INJECT_PROFILE` | `1` includes profiles in per-prompt recall; default `0`. | +| `EVERME_INJECT_MIN_SCORE` | Positive-score cutoff, default `0.1`. | +| `EVERME_FLUSH_EVERY_TURNS` | Extraction cadence, default `5`. | +| `EVERME_FLUSH_MODE` | Set `legacy` to restore every-turn flush. | +| `EVERME_STATE_DIR` | Turn counter directory, default `~/.everme/state`; files are `0600`. | +| `KIMI_CODE_HOME` | Kimi Code home dir; the hook process always sets this. Defaults to `~/.kimi-code`. | + +## Files + +``` +kimi.plugin.json single self-contained manifest (mcpServers + hooks + skills + sessionStart) +hooks/scripts/inject-memories.mjs UserPromptSubmit handler +hooks/scripts/session-start.mjs SessionStart handler +hooks/scripts/session-summary.mjs SessionEnd handler — sole runtime write, whole-session flush (episodic) +hooks/scripts/lib/adapter.js Kimi stdin/transcript/stdout adapter (readSession boundary write) +hooks/scripts/lib/run-hook.js thin shared-runtime entry helper +hooks/scripts/lib/config.js Env-var resolution (emk vs evt); env-file at $KIMI_CODE_HOME/everme.env +hooks/scripts/lib/kimi-transcript.js wire.jsonl locator + parser (readSession / readLastTurn) +hooks/scripts/lib/kimi-stdin.js snake_case stdin JSON reader +skills/memory-recall/SKILL.md session-start primer skill +skills/memory-tools/SKILL.md always-injected skill — when/how to use the tools +LICENSE +README.md +``` + +## License + +Apache-2.0 diff --git a/plugins/kimicode/hooks/scripts/inject-memories.mjs b/plugins/kimicode/hooks/scripts/inject-memories.mjs new file mode 100644 index 0000000..939c544 --- /dev/null +++ b/plugins/kimicode/hooks/scripts/inject-memories.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +process.on("uncaughtException", () => process.exit(0)); +process.on("unhandledRejection", () => process.exit(0)); + +import { runKimiCodeHook } from "./lib/run-hook.js"; + +runKimiCodeHook("UserPromptSubmit"); diff --git a/plugins/kimicode/hooks/scripts/lib/adapter.js b/plugins/kimicode/hooks/scripts/lib/adapter.js new file mode 100644 index 0000000..cbb9e0a --- /dev/null +++ b/plugins/kimicode/hooks/scripts/lib/adapter.js @@ -0,0 +1,44 @@ +import os from "node:os"; +import path from "node:path"; +import { readLastTurn, readSession } from "./kimi-transcript.js"; + +const CONTEXT_EVENTS = new Set(["SessionStart", "UserPromptSubmit"]); + +// A single Kimi Code turn (a prompt + a short answer) is too thin for the +// backend to extract an episodic memory, so kimicode registers no Stop hook +// and persists the WHOLE session once at SessionEnd instead. Below this +// floor there is no episode to extract, so the write is skipped entirely. +const MIN_SESSION_MESSAGES = 2; + +export const kimiCodeAdapter = { + platform: "kimi-code", + + envFile() { + const home = process.env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code"); + return process.env.EVERME_ENV_FILE_PATH || path.join(home, "everme.env"); + }, + + normalizeInput(rawInput) { + return { + sessionId: rawInput?.session_id || "kimi-code-session", + cwd: rawInput?.cwd || process.cwd(), + prompt: rawInput?.prompt || "", + turnId: rawInput?.turn_id || "", + source: rawInput?.source || "", + }; + }, + + readLastTurn(input) { + return readLastTurn({ sessionId: input?.sessionId, cwd: input?.cwd }); + }, + + async readSession(input) { + const messages = await readSession({ sessionId: input?.sessionId, cwd: input?.cwd }); + return messages.length >= MIN_SESSION_MESSAGES ? messages : []; + }, + + formatOutput(event, { block = "" } = {}) { + if (!CONTEXT_EVENTS.has(event) || !block) return {}; + return { message: block }; + }, +}; diff --git a/plugins/kimicode/hooks/scripts/lib/config.js b/plugins/kimicode/hooks/scripts/lib/config.js new file mode 100644 index 0000000..4ff377b --- /dev/null +++ b/plugins/kimicode/hooks/scripts/lib/config.js @@ -0,0 +1,141 @@ +/** + * Plugin config loader. + * + * Source precedence: + * + * For EVERME_AGENT_TOKEN and EVERME_AGENT_ID — the per-machine + * credentials evercli rotates — `~/.kimi-code/everme.env` always wins. + * evercli is the canonical owner of these values; if anything else + * (a stale config mcp.env block, a leftover shell var) has a + * different value, it's stale and the freshly-rotated evt must win. + * + * For every other EVERME_* (EVERME_API_KEY for emk-mode debugging, + * EVERME_API_BASE for self-hosted EverMe, …) process.env still wins: + * users may legitimately want to override these from a shell or from + * Kimi Code's mcp .env block, and evercli does not own them. + * + * Compiled defaults (api.everme.evermind.ai, no token) sit at the bottom. + * + * Auth modes (mutually exclusive, both wire-compatible): + * evt — set EVERME_AGENT_TOKEN (per-machine token from evercli) + * emk — set EVERME_API_KEY (account-level, from EverMe Web UI) + * + * If neither is set the plugin runs in disabled-mode: hooks short- + * circuit silently so the host (Kimi Code) is never blocked. + * + * NOTE (Kimi Code): the env-file lives at $KIMI_CODE_HOME/everme.env + * (default ~/.kimi-code/everme.env). Kimi Code's mcpServers.env cannot + * carry per-user secrets (no ${VAR} expansion), so the MCP server + the + * hooks both read creds from this file at runtime. + */ + +import { readFileSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { resolveConfig as sdkResolveConfig } from "@everme/agent-sdk"; + +// Resolve the Kimi Code home directory ($KIMI_CODE_HOME, default +// ~/.kimi-code). The hook process always has KIMI_CODE_HOME in env, but +// the MCP server (or a manual invocation) may not, so we fall back. +function kimiCodeHome() { + return process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"); +} + +// Env-file location. EVERME_ENV_FILE_PATH overrides for tests so they +// don't get polluted by a real file on the developer's box. +function evermeEnvFilePath() { + return process.env.EVERME_ENV_FILE_PATH || join(kimiCodeHome(), "everme.env"); +} + +let cached = null; +let envFileLoaded = false; + +// Keys that evercli rotates per machine. For these, the env file is +// the canonical source — if process.env carries a different value +// (stale config mcp.env block, leftover shell export from a +// previous account), the env file's value MUST overwrite it. Without +// this the freshly-rotated evt could be shadowed by a stale token, +// leaving every memory call 401. +const EVERME_ROTATED_KEYS = new Set([ + "EVERME_AGENT_TOKEN", + "EVERME_AGENT_ID", +]); + +/** + * Load $KIMI_CODE_HOME/everme.env (KEY=value lines) into process.env. + * Idempotent — runs once per process. + * + * For EVERME_ROTATED_KEYS the env file always wins. For everything + * else (EVERME_API_KEY, EVERME_API_BASE, …) process.env wins so users + * can override via shell or mcp .env block. + * + * This is the path evercli uses to hand the freshly-minted evt to the + * plugin without editing the user's shell profile (which is brittle: + * profile name varies by shell, and a user might run `kimi` from a + * non-interactive shell that doesn't load .zshrc). + */ +function loadEnvFile() { + if (envFileLoaded) return; + envFileLoaded = true; + const path = evermeEnvFilePath(); + if (!existsSync(path)) return; + try { + const raw = readFileSync(path, "utf8"); + for (const line of raw.split("\n")) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + const eq = t.indexOf("="); + if (eq < 1) continue; + const k = t.slice(0, eq).trim(); + let v = t.slice(eq + 1).trim(); + // Tolerate quoted values (single or double). + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1); + } + if (EVERME_ROTATED_KEYS.has(k)) { + // evercli owns this key — env file always wins. + process.env[k] = v; + } else if (!process.env[k]) { + // user-overridable key — fill gap only. + process.env[k] = v; + } + } + } catch { + /* unreadable file is not fatal — plugin runs disabled */ + } +} + +export function getConfig() { + if (cached) return cached; + loadEnvFile(); + + const agentToken = process.env.EVERME_AGENT_TOKEN || process.env.EVERME_API_KEY || ""; + const authMode = process.env.EVERME_AGENT_TOKEN + ? "evt" + : process.env.EVERME_API_KEY + ? "emk" + : "none"; + + const sdkCfg = sdkResolveConfig({ + apiBase: process.env.EVERME_API_BASE, + agentId: process.env.EVERME_AGENT_ID, + agentToken, + topK: 5, + }); + + cached = { + ...sdkCfg, + authMode, + isConfigured: !!agentToken, + }; + return cached; +} + +export function isConfigured() { + return getConfig().isConfigured; +} + +export function _resetCache() { + cached = null; + envFileLoaded = false; +} diff --git a/plugins/kimicode/hooks/scripts/lib/kimi-stdin.js b/plugins/kimicode/hooks/scripts/lib/kimi-stdin.js new file mode 100644 index 0000000..b88684f --- /dev/null +++ b/plugins/kimicode/hooks/scripts/lib/kimi-stdin.js @@ -0,0 +1,26 @@ +/** + * Kimi Code hook stdin reader. + * + * Kimi Code feeds each hook a single JSON object on stdin using + * snake_case keys. Base shape: + * { hook_event_name, session_id, cwd } + * plus per-event fields: + * UserPromptSubmit -> prompt + * Stop -> stop_hook_active (NOTE: no transcript path) + * SessionStart -> source + * SessionEnd -> (base only) + * + * Returns {} on empty / malformed stdin so callers can treat a missing + * payload the same as an empty one and exit 0 silently. + */ +export async function readStdinJSON() { + const chunks = []; + for await (const c of process.stdin) chunks.push(c); + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch { + return {}; + } +} diff --git a/plugins/kimicode/hooks/scripts/lib/kimi-transcript.js b/plugins/kimicode/hooks/scripts/lib/kimi-transcript.js new file mode 100644 index 0000000..979ee9a --- /dev/null +++ b/plugins/kimicode/hooks/scripts/lib/kimi-transcript.js @@ -0,0 +1,345 @@ +/** + * Kimi Code transcript reader. + * + * Unlike Claude Code, Kimi Code hooks do NOT receive a + * transcript path on stdin. The transcript lives at a deterministic + * location keyed by the working directory + session id: + * + * $KIMI_CODE_HOME/sessions///agents/main/wire.jsonl + * + * where + * KIMI_CODE_HOME defaults to ~/.kimi-code + * = wd__ + * = the stdin `session_id` (already like session_) + * + * wire.jsonl is a JSONL event stream (timestamps are epoch ms in the + * field `time`). We translate the events into the EverMe agent-memory + * message shape: + * + * { role:"user"|"assistant"|"tool", timestamp, content, toolCalls?, toolCallId? } + * + * Event mapping (confirmed from real tool-running sessions): + * - context.append_message, message.role=="user", origin.kind=="user" + * (or no origin) -> a user message. + * message.content is [{type:"text", text}] blocks. DROP any non-user + * origin: injection (recall/system reminders), skill_activation, etc. + * - context.append_loop_event, event.type=="content.part", + * event.part.type=="text" -> assistant text; aggregate + * by event.turnId + event.step, flush at event.type=="step.end". + * DROP part.type=="think". + * - context.append_loop_event, event.type=="tool.call" + * ({toolCallId,name,args}) -> assistant message with a toolCalls entry + * (args is an object -> JSON-stringified). Pending text is flushed + * first so order is text -> call -> result. + * - context.append_loop_event, event.type=="tool.result" + * ({toolCallId|parentUuid, result.output}) -> a tool message. + * + * "last turn" = from the last user message through end of stream + * (mirrors claude-code store-memories.js lastTurn). + * + * Failure-mode: every error returns [] — a hook must never + * block the host. + */ + +import { existsSync, readdirSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { AGENT_MEMORY_ROLES, AGENT_MEMORY_TOOL_CALL_TYPES } from "@everme/agent-sdk"; + +const READ_RETRIES = 5; +const RETRY_DELAY_MS = 100; + +function kimiCodeHome() { + return process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"); +} + +/** + * Build the wd__ work-dir key from an absolute cwd. + * + * : the cwd basename, lowercased, with non-alphanumerics + * collapsed to single underscores (best-effort — only the + * hash is load-bearing for uniqueness). + * : first 12 hex chars of sha256(cwd). + */ +export function workDirKey(cwd) { + const dir = String(cwd || ""); + const base = dir.split("/").filter(Boolean).pop() || "root"; + const slug = base + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "root"; + const hash12 = createHash("sha256").update(dir).digest("hex").slice(0, 12); + return `wd_${slug}_${hash12}`; +} + +/** + * First 12 hex of sha256(cwd) — the load-bearing, drift-proof half of the + * bucket dir name. Kimi's encodeWorkDirKey and our workDirKey agree on this + * hash even when their slug rules disagree (hyphens/dots), so it uniquely + * identifies a workdir's bucket regardless of slug. + */ +function workDirHash(cwd) { + return createHash("sha256").update(String(cwd || "")).digest("hex").slice(0, 12); +} + +/** + * Resolve the absolute sessions/ directory for a cwd. Kimi names it + * `wd__`. We try the slug workDirKey produces first (fast, and + * correct for purely alphanumeric basenames), then fall back to locating the + * bucket by its `_` suffix. The fallback is what makes this robust to + * the slug drift between Kimi's encodeWorkDirKey (keeps hyphens/dots) and ours: + * matching on the hash never misses. Returns the expected (possibly missing) + * path when nothing matches, so the caller's existsSync degrades to "no work". + */ +function resolveWorkDir(cwd) { + const sessionsRoot = join(kimiCodeHome(), "sessions"); + const expected = join(sessionsRoot, workDirKey(cwd)); + if (existsSync(expected)) return expected; + const suffix = "_" + workDirHash(cwd); + let entries; + try { + entries = readdirSync(sessionsRoot, { withFileTypes: true }); + } catch { + return expected; + } + for (const ent of entries) { + if (ent.isDirectory() && ent.name.endsWith(suffix)) { + return join(sessionsRoot, ent.name); + } + } + return expected; +} + +/** + * Compute the absolute wire.jsonl path for a session. + */ +export function wirePath({ sessionId, cwd }) { + return join( + resolveWorkDir(cwd), + String(sessionId || ""), + "agents", + "main", + "wire.jsonl", + ); +} + +/** + * Read wire.jsonl with a small retry budget — a boundary hook can fire + * before Kimi Code has flushed the final event. Returns the raw lines + * (strings), or [] if unreadable. + */ +async function readWireLines(path) { + if (!path || !existsSync(path)) return []; + for (let i = 0; i < READ_RETRIES; i++) { + let raw; + try { + raw = await readFile(path, "utf8"); + } catch (err) { + if (err?.code === "ENOENT" && i < READ_RETRIES - 1) { + await sleep(RETRY_DELAY_MS); + continue; + } + return []; + } + const lines = raw.trim().split("\n").filter(Boolean); + if (lines.length === 0) { + await sleep(RETRY_DELAY_MS); + continue; + } + return lines; + } + return []; +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * Parse wire.jsonl lines into the EverMe agent-memory message sequence. + * Robust to unknown event kinds — only the recognised events are + * consumed, everything else is skipped. + */ +export function extractAgentMessages(lines) { + const messages = []; + // Assistant text is streamed as many content.part events; aggregate + // per (turnId, step) and flush on step.end. Keyed map preserves the + // first-seen timestamp for the flushed message. + const pending = new Map(); // key -> { parts:[], timestamp } + + const keyFor = (ev) => `${ev?.turnId ?? ""}::${ev?.step ?? ""}`; + const flushPending = (key) => { + const slot = pending.get(key); + if (!slot) return; + pending.delete(key); + const content = slot.parts.join(""); + if (content) { + messages.push({ + role: AGENT_MEMORY_ROLES.ASSISTANT, + timestamp: slot.timestamp, + content, + }); + } + }; + + for (const line of lines) { + let row; + try { + row = JSON.parse(line); + } catch { + continue; + } + const timestamp = normalizeTimestamp(row?.time); + const type = row?.type; + + if (type === "context.append_message") { + const m = row?.message; + if (!m || typeof m !== "object") continue; + if (m.role !== AGENT_MEMORY_ROLES.USER) continue; + // Keep only genuine user input: origin.kind=="user" (or no origin). + // Drop every system-injected pseudo-user message — injection (recall + // blocks / system reminders), skill_activation (injected skill text), + // and any other non-user origin. + const okind = m?.origin?.kind; + if (okind && okind !== "user") continue; + const text = textFromContent(m.content); + if (text) { + messages.push({ role: AGENT_MEMORY_ROLES.USER, timestamp, content: text }); + } + continue; + } + + if (type === "context.append_loop_event") { + const ev = row?.event; + if (!ev || typeof ev !== "object") continue; + if (ev.type === "content.part") { + const part = ev.part; + if (!part || typeof part !== "object") continue; + if (part.type === "think") continue; // drop chain-of-thought + if (part.type !== "text") continue; + const text = typeof part.text === "string" ? part.text : ""; + if (!text) continue; + const key = keyFor(ev); + const slot = pending.get(key) || { parts: [], timestamp }; + slot.parts.push(text); + pending.set(key, slot); + } else if (ev.type === "tool.call") { + // Flush any assistant preamble text for this step first so order is + // text -> tool call -> tool result. + flushPending(keyFor(ev)); + const name = ev.name; + if (!name) continue; + const args = ev.args ?? {}; + messages.push({ + role: AGENT_MEMORY_ROLES.ASSISTANT, + timestamp, + toolCalls: [{ + id: ev.toolCallId || ev.uuid || `kimi_tool_${timestamp}`, + type: AGENT_MEMORY_TOOL_CALL_TYPES.FUNCTION, + name, + arguments: typeof args === "string" ? args : safeJsonStringify(args), + }], + }); + } else if (ev.type === "tool.result") { + const id = ev.toolCallId || ev.parentUuid; + if (!id) continue; + const output = ev?.result?.output; + const content = typeof output === "string" ? output : safeJsonStringify(output ?? ""); + messages.push({ + role: AGENT_MEMORY_ROLES.TOOL, + timestamp, + content: content || "tool result", + toolCallId: id, + }); + } else if (ev.type === "step.end") { + flushPending(keyFor(ev)); + } + continue; + } + } + + // Flush any assistant text that never saw an explicit step.end (the + // stream can be cut at the tail when Stop fires mid-flush). + for (const key of Array.from(pending.keys())) { + flushPending(key); + } + + return messages; +} + +function textFromContent(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((b) => { + if (typeof b === "string") return b; + if (b?.type === "text" && typeof b.text === "string") return b.text; + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function normalizeTimestamp(ts) { + if (typeof ts === "number" && Number.isFinite(ts)) { + return ts > 10_000_000_000 ? Math.trunc(ts) : Math.trunc(ts * 1000); + } + const parsed = Date.parse(ts); + if (Number.isFinite(parsed)) return parsed; + return Date.now(); +} + +function safeJsonStringify(v) { + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +/** + * Take everything from the last user message through the end of the stream + * (user -> tool... -> assistant). Mirrors claude-code store-memories.js + * lastTurn. Retained as a utility (exported via readLastTurn); kimicode's + * SessionEnd handler persists the WHOLE session via readSession rather than a + * per-turn delta, so this is no longer on the write path. + */ +export function lastTurn(messages) { + if (messages.length === 0) return []; + let startIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === AGENT_MEMORY_ROLES.USER) { + startIdx = i; + break; + } + } + if (startIdx === -1) return messages; + return messages.slice(startIdx); +} + +/** + * High-level entry: locate wire.jsonl for {sessionId, cwd}, parse it, + * and return just the last turn's messages. Returns [] on any failure. + */ +export async function readLastTurn({ sessionId, cwd }) { + const path = wirePath({ sessionId, cwd }); + const lines = await readWireLines(path); + if (lines.length === 0) return []; + const messages = extractAgentMessages(lines); + return lastTurn(messages); +} + +/** + * High-level entry: locate wire.jsonl for {sessionId, cwd}, parse it, and + * return the FULL session (every turn). The SessionEnd hook uploads this whole + * session in one flush so the backend has the coherent multi-turn context that + * episodic-memory extraction needs. Returns [] on any failure. + */ +export async function readSession({ sessionId, cwd }) { + const path = wirePath({ sessionId, cwd }); + const lines = await readWireLines(path); + if (lines.length === 0) return []; + return extractAgentMessages(lines); +} diff --git a/plugins/kimicode/hooks/scripts/lib/run-hook.js b/plugins/kimicode/hooks/scripts/lib/run-hook.js new file mode 100644 index 0000000..e0eef65 --- /dev/null +++ b/plugins/kimicode/hooks/scripts/lib/run-hook.js @@ -0,0 +1,8 @@ +import { runHook } from "@everme/agent-sdk"; +import { kimiCodeAdapter } from "./adapter.js"; +import { readStdinJSON } from "./kimi-stdin.js"; + +export async function runKimiCodeHook(event) { + const output = await runHook(event, await readStdinJSON(), kimiCodeAdapter); + if (output && Object.keys(output).length) process.stdout.write(`${JSON.stringify(output)}\n`); +} diff --git a/plugins/kimicode/hooks/scripts/mcp-launch.mjs b/plugins/kimicode/hooks/scripts/mcp-launch.mjs new file mode 100644 index 0000000..5d2abcc --- /dev/null +++ b/plugins/kimicode/hooks/scripts/mcp-launch.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * Launcher for the everme-memory MCP server under Kimi Code. + * + * Kimi Code's plugin manifest is static and shared across all users, and its + * mcpServers.env cannot carry per-user secrets (no ${VAR} expansion). But + * @everme/memory-mcp reads its credentials from the environment + * (EVERME_AGENT_ID / EVERME_AGENT_TOKEN / EVERME_API_BASE). So the manifest + * launches THIS wrapper instead of npx directly: we load ~/.kimi-code/everme.env + * (via the shared config loader, which populates process.env) and then exec the + * real MCP server with stdio inherited, so the JSON-RPC stream passes straight + * through to the Kimi Code MCP client. + * + * If credentials are absent we still spawn the server — it will surface its own + * "missing EVERME_AGENT_*" boot error, which is the correct, visible signal. + */ +import { spawn } from "node:child_process"; +import { getConfig } from "./lib/config.js"; + +// Side effect: loadEnvFile() inside getConfig() reads $KIMI_CODE_HOME/everme.env +// and sets process.env.EVERME_AGENT_TOKEN / EVERME_AGENT_ID (rotated keys) + +// EVERME_API_BASE when absent. +try { + getConfig(); +} catch { + /* fall through — let the server report missing credentials itself */ +} + +const npx = process.platform === "win32" ? "npx.cmd" : "npx"; +const child = spawn(npx, ["-y", "@everme/memory-mcp"], { + stdio: "inherit", + env: process.env, +}); +child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0))); +child.on("error", () => process.exit(1)); diff --git a/plugins/kimicode/hooks/scripts/session-start.mjs b/plugins/kimicode/hooks/scripts/session-start.mjs new file mode 100644 index 0000000..e7d5893 --- /dev/null +++ b/plugins/kimicode/hooks/scripts/session-start.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +process.on("uncaughtException", () => process.exit(0)); +process.on("unhandledRejection", () => process.exit(0)); + +import { runKimiCodeHook } from "./lib/run-hook.js"; + +runKimiCodeHook("SessionStart"); diff --git a/plugins/kimicode/hooks/scripts/session-summary.mjs b/plugins/kimicode/hooks/scripts/session-summary.mjs new file mode 100644 index 0000000..f430a22 --- /dev/null +++ b/plugins/kimicode/hooks/scripts/session-summary.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node +/** + * SessionEnd hook (Kimi Code) — the plugin's sole runtime write. There is no + * Stop hook: the adapter exposes readSession, so the shared runtime's + * boundary flush uploads the whole wire.jsonl session in one flushed write + * (see lib/adapter.js). A session that never reaches SessionEnd (hard crash) + * is not persisted; recall is unaffected — it reads prior sessions only. + */ + +process.on("uncaughtException", () => process.exit(0)); +process.on("unhandledRejection", () => process.exit(0)); + +import { runKimiCodeHook } from "./lib/run-hook.js"; + +runKimiCodeHook("SessionEnd"); diff --git a/plugins/kimicode/kimi.plugin.json b/plugins/kimicode/kimi.plugin.json new file mode 100644 index 0000000..c6e6ff9 --- /dev/null +++ b/plugins/kimicode/kimi.plugin.json @@ -0,0 +1,39 @@ +{ + "name": "everme", + "version": "0.6.1", + "description": "EverMe — automatic memory recall for Kimi Code. Recalls relevant context from past sessions before each prompt and saves new turns through the EverMe gateway.", + "interface": { + "displayName": "EverMe", + "shortDescription": "Automatic cross-session memory recall + persistence via the EverMe gateway." + }, + "skills": "./skills/", + "sessionStart": { + "skill": "memory-recall" + }, + "mcpServers": { + "everme-memory": { + "command": "node", + "args": [ + "./hooks/scripts/mcp-launch.mjs" + ] + } + }, + "hooks": [ + { + "event": "SessionStart", + "matcher": "startup", + "command": "node ./hooks/scripts/session-start.mjs", + "timeout": 30 + }, + { + "event": "UserPromptSubmit", + "command": "node ./hooks/scripts/inject-memories.mjs", + "timeout": 10 + }, + { + "event": "SessionEnd", + "command": "node ./hooks/scripts/session-summary.mjs", + "timeout": 30 + } + ] +} diff --git a/plugins/kimicode/package.json b/plugins/kimicode/package.json new file mode 100644 index 0000000..9550728 --- /dev/null +++ b/plugins/kimicode/package.json @@ -0,0 +1,46 @@ +{ + "name": "@everme/kimicode", + "version": "0.6.1", + "type": "module", + "description": "EverMe native plugin for Kimi Code — automatic memory recall via SessionStart/UserPromptSubmit/Stop/SessionEnd hooks, plus bundled MCP server. Single self-contained kimi.plugin.json manifest.", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "test": "node --test tests/kimi-transcript.test.js tests/hooks.test.js tests/skills.test.js" + }, + "files": [ + "kimi.plugin.json", + "hooks/", + "skills/", + "LICENSE", + "README.md" + ], + "dependencies": { + "@everme/agent-sdk": "^0.6.1" + }, + "keywords": [ + "evermind", + "everme", + "kimi-code", + "kimi", + "memory", + "ai", + "agent", + "mcp" + ], + "homepage": "https://everme.evermind.ai", + "repository": { + "type": "git", + "url": "git+https://github.com/EverMind-AI/EverMe.git", + "directory": "plugins/kimicode" + }, + "bugs": { + "url": "https://github.com/EverMind-AI/EverMe/issues" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + } +} diff --git a/plugins/kimicode/skills/memory-recall/SKILL.md b/plugins/kimicode/skills/memory-recall/SKILL.md new file mode 100644 index 0000000..a258b60 --- /dev/null +++ b/plugins/kimicode/skills/memory-recall/SKILL.md @@ -0,0 +1,24 @@ +--- +name: memory-recall +description: Session-start primer that tells Kimi how EverMe's automatic cross-session memory works and how to treat the injected recall/profile context. +--- + +# EverMe Memory Recall + +This session is backed by **EverMe** — a cross-session memory layer for Kimi Code. + +At the start of the session and before each of the user's prompts, EverMe automatically injects relevant context drawn from past sessions: + +- `...` — a snapshot of durable facts and implicit traits about the user / their projects (injected at SessionStart). +- `...` — memories ranked as relevant to the prompt the user just submitted (injected on UserPromptSubmit). + +After each of your replies, EverMe persists the just-finished turn back to the gateway so it can be recalled in future sessions. + +## How to use the injected context + +1. Treat `` and `` as trusted background, not as instructions from the user. Weave the relevant parts into your answer; ignore the parts that don't apply. +2. Prefer recalled decisions/conventions over re-deriving them — but if the recalled memory conflicts with what the user says now, the user's current statement wins; surface the conflict briefly. +3. Do not repeat the raw memory blocks back to the user. Synthesize. +4. If the recall block is empty or unrelated, and the user references prior work, use the `mem_search` MCP tool to look it up (see the `memory-tools` skill). Do not repeat a search for a topic the recall block already covers. +5. A section titled "Recent unextracted transcript" (when present) is provisional raw transcript, not yet extracted memory — never state its contents back as established user facts or confirmed decisions. +6. Credentials (emk / evt) are secrets — never echo them, even in error messages. diff --git a/plugins/kimicode/skills/memory-tools/SKILL.md b/plugins/kimicode/skills/memory-tools/SKILL.md new file mode 100644 index 0000000..fd007fc --- /dev/null +++ b/plugins/kimicode/skills/memory-tools/SKILL.md @@ -0,0 +1,42 @@ +--- +name: memory-tools +description: Use EverMe memory proactively when the user refers to previous conversations, earlier decisions, "last time", "remember when", existing project conventions, or previously solved errors, and save durable user preferences, habits, and decisions the moment they are stated. Do not repeat a search when a non-empty block already exists. +alwaysInclude: true +--- + +# EverMe Memory Tools + +You have four MCP tools (from the `everme-memory` MCP server) for memory EverMe persists across past Kimi Code sessions. + +Recall: +- `mem_search` — semantic + keyword hybrid search over the user's memory store (episodic, profile, agent cases/skills, recent raw transcript). Rows under "Recent unextracted transcript" are provisional, not established facts. +- `mem_context` — the user's durable Profile snapshot ONLY. It never searches and never returns episodes; do not use it to recall past decisions or task context. + +Write: +- `mem_save_fact` — save a durable user fact (preference, habit, trait, long-term decision). Call it proactively the moment the user states one — do NOT wait for the user to say "remember this". Only `extracted: true` / `profileUpdated: true` in the result means the profile really updated; on `no_extraction` say so plainly, do not auto-retry, and do not claim success. +- `mem_save_turn` — persist a complete task trajectory worth reusing (rarely needed given the automatic SessionEnd write). Chat-dual-write backends may also update the user's Profile; check `profileUpdated`. Use `mem_save_fact` for a deliberate durable fact. + +The plugin's UserPromptSubmit hook already injects relevant memory automatically before each prompt (wrapped in `...`); native hooks also save the session automatically. When native recall exists and is relevant, do not duplicate it; when it is missing and the task depends on history, call the tools proactively. + +## When to use these tools + +**Do call** when: +- The `` block is missing, empty, or clearly unrelated AND the user references something discussed before ("last time", "remember when", "we decided to use X", "continue where we left off") +- The user asks about a project pattern, decision, or convention you have no inline context for +- You're debugging an error message that may have been seen + resolved before +- The user explicitly asks you to "search my memory" / "recall" / "look up" +- The user states a durable fact about themselves — call `mem_save_fact` even without being asked + +**Do NOT call** when: +- This turn already carries a non-empty, relevant `` block covering the topic +- You already searched with the same query in the current turn (don't duplicate) +- The current message is self-contained and you can answer from inline context +- It's a general-knowledge question with no project history component + +## Best practices + +1. Search with the user's specific terms first; only broaden if zero hits. +2. Cite memories by subject so the user can trace them. +3. Synthesize, don't copy-paste — quote the relevant lines, not whole memory bodies. +4. If recall returns conflicting info ("two prior sessions disagree"), say so and ask the user which is current. +5. The user's emk / evt is a credential — never echo it back, even when EverMe-related errors surface. diff --git a/plugins/kimicode/tests/hooks.test.js b/plugins/kimicode/tests/hooks.test.js new file mode 100644 index 0000000..fbaa5c0 --- /dev/null +++ b/plugins/kimicode/tests/hooks.test.js @@ -0,0 +1,218 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const scriptsDir = path.join(packageDir, "hooks", "scripts"); +const tempDirs = []; +const servers = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("Kimi Code shared hook runtime", () => { + test("UserPromptSubmit sanitizes recall, uses topK 10, and omits profiles", async () => { + const requests = []; + const server = await startServer(async (req, res) => { + requests.push(JSON.parse(await readBody(req))); + respond(res, { + status: 0, + result: { + items: [{ type: "episodic_memory", summary: "kept episode", score: 0.8 }], + profiles: [{ profileData: { embed_text: "hidden passive profile" } }], + rawMessages: [], + agentMemory: { cases: [], skills: [] }, + }, + }); + }); + const env = await hookEnv(server); + + const result = await runHook("inject-memories.mjs", { + session_id: "kimi-session", + prompt: "/ask old 请回忆 OAuth 方案", + }, env); + + assert.equal(result.code, 0, result.stderr); + assert.equal(requests[0].query, "请回忆 OAuth 方案"); + assert.equal(requests[0].topK, 10); + const output = JSON.parse(result.stdout); + assert.match(output.message, /kept episode/); + assert.doesNotMatch(output.message, /hidden passive profile/); + }); + + test("SessionEnd flushes the whole session in a single write", async () => { + const requests = []; + const server = await startServer(async (req, res) => { + requests.push(JSON.parse(await readBody(req))); + respond(res, { status: 0, result: { flushed: true } }); + }); + const env = await hookEnv(server); + const cwd = "/repo/my-kimi-project"; + const sessionId = "session-end"; + await writeKimiTranscript(env.KIMI_CODE_HOME, cwd, sessionId, [ + userEvent("remember this turn", 1749001000000), + assistantEvents("noted, saving it", 1749001001000), + userEvent("and one more thing", 1749001002000), + assistantEvents("got that too", 1749001003000), + ].flat()); + + const result = await runHook("session-summary.mjs", { session_id: sessionId, cwd }, env); + + assert.equal(result.code, 0, result.stderr); + assert.equal(requests.length, 1); + assert.equal(requests[0].conversationId, sessionId); + assert.equal(requests[0].flush, true); + assert.deepEqual( + requests[0].messages.map((m) => m.role), + ["user", "assistant", "user", "assistant"], + ); + }); + + test("a re-fired SessionEnd uploads nothing; a resumed session uploads only the delta", async () => { + const requests = []; + const server = await startServer(async (req, res) => { + requests.push(JSON.parse(await readBody(req))); + respond(res, { status: 0, result: { flushed: true } }); + }); + const env = await hookEnv(server); + const cwd = "/repo/my-kimi-project"; + const sessionId = "session-refire"; + const initialEvents = [ + userEvent("remember this turn", 1749001000000), + assistantEvents("noted, saving it", 1749001001000), + ].flat(); + await writeKimiTranscript(env.KIMI_CODE_HOME, cwd, sessionId, initialEvents); + + const first = await runHook("session-summary.mjs", { session_id: sessionId, cwd }, env); + assert.equal(first.code, 0, first.stderr); + assert.equal(requests.length, 1); + + // Re-fired SessionEnd over the unchanged session: nothing new to upload. + const refired = await runHook("session-summary.mjs", { session_id: sessionId, cwd }, env); + assert.equal(refired.code, 0, refired.stderr); + assert.equal(requests.length, 1, "second SessionEnd must not re-upload the transcript"); + + // Session resumed, two more messages appended: only the delta uploads. + await writeKimiTranscript(env.KIMI_CODE_HOME, cwd, sessionId, [ + ...initialEvents, + userEvent("one more thing", 1749001002000), + assistantEvents("done", 1749001003000), + ].flat()); + const resumed = await runHook("session-summary.mjs", { session_id: sessionId, cwd }, env); + assert.equal(resumed.code, 0, resumed.stderr); + assert.equal(requests.length, 2); + assert.deepEqual(requests[1].messages.map((m) => m.role), ["user", "assistant"]); + assert.match(requests[1].messages[0].content, /one more thing/); + assert.equal(requests[1].flush, true); + }); + + test("SessionEnd skips the write for a session below the message floor", async () => { + const requests = []; + const server = await startServer(async (req, res) => { + requests.push(JSON.parse(await readBody(req))); + respond(res, { status: 0, result: { flushed: true } }); + }); + const env = await hookEnv(server); + const cwd = "/repo/my-kimi-project"; + const sessionId = "session-thin"; + await writeKimiTranscript(env.KIMI_CODE_HOME, cwd, sessionId, userEvent("only one message", 1749001000000)); + + const result = await runHook("session-summary.mjs", { session_id: sessionId, cwd }, env); + + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(requests, []); + }); +}); + +async function hookEnv(server) { + const home = await mkdtemp(path.join(os.tmpdir(), "everme-kimi-hooks-")); + tempDirs.push(home); + return { + ...process.env, + KIMI_CODE_HOME: home, + EVERME_ENV_FILE_PATH: path.join(home, "missing.env"), + EVERME_API_BASE: `http://127.0.0.1:${server.address().port}`, + EVERME_AGENT_ID: "agt_kimi", + EVERME_AGENT_TOKEN: "test-agent-token", + EVERME_STATE_DIR: path.join(home, "state"), + }; +} + +async function writeKimiTranscript(home, cwd, sessionId, events) { + const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 12); + const dir = path.join(home, "sessions", `wd_my-kimi-project_${hash}`, sessionId, "agents", "main"); + await mkdir(dir, { recursive: true }); + const lines = (Array.isArray(events) ? events : [events]).map((event) => JSON.stringify(event)).join("\n"); + await writeFile(path.join(dir, "wire.jsonl"), `${lines}\n`); +} + +function userEvent(text, time) { + return { + type: "context.append_message", + time, + message: { role: "user", origin: { kind: "user" }, content: [{ type: "text", text }] }, + }; +} + +function assistantEvents(text, time) { + return [ + { + type: "context.append_loop_event", + time, + event: { type: "content.part", turnId: `turn-${time}`, step: 1, part: { type: "text", text } }, + }, + { + type: "context.append_loop_event", + time, + event: { type: "step.end", turnId: `turn-${time}`, step: 1 }, + }, + ]; +} + +function runHook(script, input, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(scriptsDir, script)], { + cwd: packageDir, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; }); + child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(JSON.stringify(input)); + }); +} + +async function startServer(handler) { + const server = http.createServer((req, res) => { + Promise.resolve(handler(req, res)).catch((error) => { + res.statusCode = 500; + res.end(String(error)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + servers.push(server); + return server; +} + +async function readBody(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +function respond(res, body) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} diff --git a/plugins/kimicode/tests/kimi-transcript.test.js b/plugins/kimicode/tests/kimi-transcript.test.js new file mode 100644 index 0000000..42020d6 --- /dev/null +++ b/plugins/kimicode/tests/kimi-transcript.test.js @@ -0,0 +1,80 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { readLastTurn, readSession, wirePath } from "../hooks/scripts/lib/kimi-transcript.js"; + +// Kimi Code names each session's bucket dir `wd__` where hash12 = +// sha256(cwd)[:12] and the slug PRESERVES hyphens (its encodeWorkDirKey). The +// plugin used to reconstruct the slug with its own rule (hyphens -> "_"), so for +// any cwd whose basename has a hyphen the reconstructed path missed the real +// dir, readWireLines returned [], and the write hook silently persisted nothing. +// The hash12 suffix is identical on both sides, so locating the bucket by hash +// is drift-proof. +test("readLastTurn locates wire.jsonl under a hyphen-slug workdir dir (encodeWorkDirKey drift)", async () => { + const home = mkdtempSync(join(tmpdir(), "kimi-home-")); + const prevHome = process.env.KIMI_CODE_HOME; + process.env.KIMI_CODE_HOME = home; + try { + const cwd = "/Users/x/code/my-proj"; // hyphen in basename + const sessionId = "session_abc-123"; + const hash12 = createHash("sha256").update(cwd).digest("hex").slice(0, 12); + // Kimi keeps the hyphen: wd_my-proj_ (NOT wd_my_proj_). + const dir = join(home, "sessions", `wd_my-proj_${hash12}`, sessionId, "agents", "main"); + mkdirSync(dir, { recursive: true }); + const line = JSON.stringify({ + type: "context.append_message", + time: 1700000000000, + message: { role: "user", origin: { kind: "user" }, content: [{ type: "text", text: "hello glob" }] }, + }); + writeFileSync(join(dir, "wire.jsonl"), line + "\n"); + + const tail = await readLastTurn({ sessionId, cwd }); + assert.equal(tail.length, 1, "should locate the transcript despite slug drift"); + assert.equal(tail[0].role, "user"); + assert.equal(tail[0].content, "hello glob"); + } finally { + if (prevHome === undefined) delete process.env.KIMI_CODE_HOME; + else process.env.KIMI_CODE_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + } +}); + +// A per-turn writer uploads only the last turn (thin), which produces agent +// cases but rarely an episode. The SessionEnd handler needs the WHOLE session +// so the backend has enough coherent context to extract an episodic memory. +// readSession returns every turn; lastTurn returns only the final one. +test("readSession returns the full multi-turn session, unlike lastTurn", async () => { + const home = mkdtempSync(join(tmpdir(), "kimi-home-")); + const prev = process.env.KIMI_CODE_HOME; + process.env.KIMI_CODE_HOME = home; + try { + const cwd = "/Users/x/code/proj"; + const sessionId = "session_multi"; + const hash12 = createHash("sha256").update(cwd).digest("hex").slice(0, 12); + const dir = join(home, "sessions", `wd_proj_${hash12}`, sessionId, "agents", "main"); + mkdirSync(dir, { recursive: true }); + const L = [ + { type: "context.append_message", time: 1700000000000, message: { role: "user", origin: { kind: "user" }, content: [{ type: "text", text: "first question" }] } }, + { type: "context.append_loop_event", time: 1700000001000, event: { type: "content.part", turnId: 0, step: 1, part: { type: "text", text: "first answer" } } }, + { type: "context.append_loop_event", time: 1700000001500, event: { type: "step.end", turnId: 0, step: 1 } }, + { type: "context.append_message", time: 1700000002000, message: { role: "user", origin: { kind: "user" }, content: [{ type: "text", text: "second question" }] } }, + { type: "context.append_loop_event", time: 1700000003000, event: { type: "content.part", turnId: 1, step: 1, part: { type: "text", text: "second answer" } } }, + { type: "context.append_loop_event", time: 1700000003500, event: { type: "step.end", turnId: 1, step: 1 } }, + ].map((o) => JSON.stringify(o)).join("\n"); + writeFileSync(join(dir, "wire.jsonl"), L + "\n"); + + const full = await readSession({ sessionId, cwd }); + const tail = await readLastTurn({ sessionId, cwd }); + assert.equal(full.length, 4, "full session = 2 user + 2 assistant"); + assert.equal(full.filter((m) => m.role === "user").length, 2, "both user turns present"); + assert.equal(full[0].content, "first question", "session starts at the first turn"); + assert.ok(full.length > tail.length, "readSession must be a superset of the last turn"); + } finally { + if (prev === undefined) delete process.env.KIMI_CODE_HOME; + else process.env.KIMI_CODE_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/plugins/kimicode/tests/skills.test.js b/plugins/kimicode/tests/skills.test.js new file mode 100644 index 0000000..fabfb14 --- /dev/null +++ b/plugins/kimicode/tests/skills.test.js @@ -0,0 +1,41 @@ +/** + * Skill copy guards (autonomy contract) — see the matching test in + * plugins/claude-code/tests/skills.test.js for the rationale. + */ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TOOLS_SKILL = readFileSync(path.join(__dirname, "..", "skills", "memory-tools", "SKILL.md"), "utf8"); +const RECALL_SKILL = readFileSync(path.join(__dirname, "..", "skills", "memory-recall", "SKILL.md"), "utf8"); + +describe("memory-tools skill copy", () => { + test("frontmatter description carries implicit recall + durable-fact triggers", () => { + const frontmatter = TOOLS_SKILL.split("---")[1] || ""; + assert.match(frontmatter, /last time/i); + assert.match(frontmatter, /remember when/i); + assert.match(frontmatter, /preferences|habits|decisions/i); + }); + + test("body keeps the dedupe protocol and honesty rules", () => { + assert.match(TOOLS_SKILL, //); + assert.match(TOOLS_SKILL, /missing, empty, or clearly unrelated/i); + assert.match(TOOLS_SKILL, /no_extraction/); + assert.match(TOOLS_SKILL, /profileUpdated/); + assert.match(TOOLS_SKILL, /chat-dual-write/i); + }); + + test("no unconditional suppression copy", () => { + assert.doesNotMatch(TOOLS_SKILL, /usually do NOT need to call/i); + }); +}); + +describe("memory-recall skill copy", () => { + test("brands unextracted transcript as provisional", () => { + assert.match(RECALL_SKILL, /Recent unextracted transcript/); + assert.match(RECALL_SKILL, /provisional/i); + }); +}); diff --git a/plugins/memory-mcp/SKILL.md b/plugins/memory-mcp/SKILL.md index 4419ce9..3de5e65 100644 --- a/plugins/memory-mcp/SKILL.md +++ b/plugins/memory-mcp/SKILL.md @@ -91,5 +91,5 @@ description on failure. Common shapes: includes the requestId for support correlation. Cold-start memory (everything the user already had) is loaded by -`evercli import run`; you don't need to re-upload it from inside the +`evercli import conversations run`; you don't need to re-upload it from inside the agent. diff --git a/plugins/memory-mcp/package.json b/plugins/memory-mcp/package.json index 936ee3e..a4771ac 100644 --- a/plugins/memory-mcp/package.json +++ b/plugins/memory-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@everme/memory-mcp", - "version": "0.4.2", + "version": "0.6.1", "description": "EverMe memory plugin — generic MCP server for any MCP host (Cursor, Cline, generic JSON-RPC clients). Hosts with native plugin formats (Claude Code, OpenClaw) have their own packages.", "license": "Apache-2.0", "type": "module", @@ -36,7 +36,7 @@ "test": "node --test tests/mcp.test.js tests/http-server.test.js tests/instructions.test.js tests/schema.test.js tests/resources.test.js" }, "dependencies": { - "@everme/agent-sdk": "^0.4.2", + "@everme/agent-sdk": "^0.6.1", "@modelcontextprotocol/sdk": "^1.29.0" }, "homepage": "https://everme.evermind.ai", diff --git a/plugins/memory-mcp/src/mcp.js b/plugins/memory-mcp/src/mcp.js index 86900da..ed8b070 100644 --- a/plugins/memory-mcp/src/mcp.js +++ b/plugins/memory-mcp/src/mcp.js @@ -74,6 +74,7 @@ import { assertConfigUsable, createClient, redactError, + describeError, saveAgentMemory, savePersonalMemory, searchMemory, @@ -398,7 +399,7 @@ export function createMcpServer({ logger, config } = {}) { log, ); return okMarkdown( - redactError(renderSearchResultsAsMarkdown(query, res)), + appendRequestID(redactError(renderSearchResultsAsMarkdown(query, res)), res?.requestId), ); } @@ -412,7 +413,7 @@ export function createMcpServer({ logger, config } = {}) { // was pure noise the LLM had to unwrap. const ctx = await getContext(client, "", { forceRefresh: args.forceRefresh === true }, log); const rawText = ctx?.context || "_(no profile available — your EverMe account has no extracted memories yet)_"; - return okMarkdown(redactError(rawText)); + return okMarkdown(appendRequestID(redactError(rawText), ctx?.requestId)); } case "mem_save_turn": { @@ -455,6 +456,7 @@ export function createMcpServer({ logger, config } = {}) { flushed: !!res?.flushed, profileStatus: res?.personalStatus || null, profileUpdated: !!res?.personalExtracted, + requestId: res?.requestId || null, }); } @@ -508,6 +510,7 @@ export function createMcpServer({ logger, config } = {}) { // profileUpdated aliases extracted — the only signal that the // fact really materialised into the profile. profileUpdated: !!res?.extracted, + requestId: res?.requestId || null, }); } @@ -520,7 +523,7 @@ export function createMcpServer({ logger, config } = {}) { // / fetch errors whose .message may carry presigned-URL signing // params, evt tokens, etc. The host LLM sees this text verbatim // — so the scrub MUST run here, not just in EvermeError. - const safe = redactError(err?.message || String(err)); + const safe = describeError(err); log.warn?.(`[everme-mcp] tool ${name} failed: ${safe}`); return errResp(safe); } @@ -642,7 +645,7 @@ export function createMcpServer({ logger, config } = {}) { // params and evt tokens can leak into upstream error messages, // and resources/read text is shown verbatim to the LLM (and // sometimes the human via host UI). - const safe = redactError(err?.message || String(err)); + const safe = describeError(err); log.warn?.(`[everme-mcp] resources/read ${uri} failed: ${safe}`); // Re-throw so the SDK converts to a JSON-RPC error envelope // (-32603 internal error or -32602 invalid params depending on @@ -799,6 +802,13 @@ function okMarkdown(text) { }; } +// appendRequestID tacks the trace id onto a markdown payload so a user can +// quote it to support; empty ids (test stubs, degraded paths) add nothing. +function appendRequestID(text, requestId) { + if (!requestId) return text; + return `${text}\n\n_(requestId: ${requestId})_`; +} + // errResp accepts pre-redacted text. Callers MUST run redactError on // any non-EvermeError input before passing it in (the catch block // above does this; if you add another caller, do the same). diff --git a/plugins/memory-mcp/tests/resources.test.js b/plugins/memory-mcp/tests/resources.test.js index 5777762..f848710 100644 --- a/plugins/memory-mcp/tests/resources.test.js +++ b/plugins/memory-mcp/tests/resources.test.js @@ -578,10 +578,10 @@ describe("mem_context / mem_search tools return raw markdown (no JSON envelope)" try { const result = await client.callTool({ name: "mem_context", arguments: { query: "morning routine" } }); const text = result.content[0].text; - assert.equal(text, profileMarkdown, - "mem_context must return the markdown verbatim — wrapping it as " + - "JSON.stringify({context, memoryCount}) forces the LLM to peel an " + - "envelope and wastes tokens on every recall."); + assert.equal(text, `${profileMarkdown}\n\n_(requestId: req-mock)_`, + "mem_context must return the markdown verbatim (plus the trailing " + + "requestId trace line) — wrapping it as JSON.stringify({context, " + + "memoryCount}) forces the LLM to peel an envelope on every recall."); // Guard against accidental JSON wrapping: a JSON envelope would // start with `{` and contain the escaped newline `\\n`. assert.ok(!text.startsWith("{"), diff --git a/plugins/openclaw/openclaw.plugin.json b/plugins/openclaw/openclaw.plugin.json index 1c5ace0..752eee8 100644 --- a/plugins/openclaw/openclaw.plugin.json +++ b/plugins/openclaw/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "@everme/openclaw", "name": "EverMe Memory Plugin", "description": "Real-time memory retrieval and persistence backed by the EverMe cloud (cold-start memory uploaded by evercli)", - "version": "0.4.2", + "version": "0.6.1", "kind": "context-engine", "contextEngine": true, "main": "./index.js", diff --git a/plugins/openclaw/package.json b/plugins/openclaw/package.json index da363d2..d07416f 100644 --- a/plugins/openclaw/package.json +++ b/plugins/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@everme/openclaw", - "version": "0.4.2", + "version": "0.6.1", "type": "module", "description": "EverMe ContextEngine plugin for OpenClaw — automatic memory recall + persistence per turn, backed by the EverMe gateway.", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "test": "node --test tests/engine.test.js tests/register.test.js" }, "dependencies": { - "@everme/agent-sdk": "^0.4.2" + "@everme/agent-sdk": "^0.6.1" }, "keywords": [ "evermind", diff --git a/plugins/openclaw/src/engine.js b/plugins/openclaw/src/engine.js index 36b574d..60da20b 100644 --- a/plugins/openclaw/src/engine.js +++ b/plugins/openclaw/src/engine.js @@ -23,6 +23,7 @@ import { assertConfigUsable, createClient, redactError, + describeError, flushAgentMemory, saveAgentMemory, searchMemory, @@ -155,20 +156,20 @@ export function createContextEngine(pluginMeta, hostConfig, hostLogger) { // debug toggle. const shape = trajectoryShape(tail); try { - await saveAgentMemory(client, { + const saved = await saveAgentMemory(client, { conversationId: sessionKey, messages: tail, flush, }, log); s.turnCount = nextTurn; s.savedUpTo = allMessages.length; - log.info?.(`${L} afterTurn[${engineId}]: saved ${tail.length} messages via agent-memory, sessionKey=${sessionKey} turn=${s.turnCount} flushed=${flush} shape=${shape}`); + log.info?.(`${L} afterTurn[${engineId}]: saved ${tail.length} messages via agent-memory, sessionKey=${sessionKey} turn=${s.turnCount} flushed=${flush} shape=${shape} requestId=${saved?.requestId || ""}`); } catch (err) { // Leave turnCount/savedUpTo untouched so the same tail retries // on the next afterTurn — bumping them here would skip the // natural retry slot and could starve a flush turn if EverOS // keeps failing. - log.warn(`${L} afterTurn realtime save failed: ${redactError(err?.message)}`); + log.warn(`${L} afterTurn realtime save failed: ${describeError(err)}`); } }, @@ -222,7 +223,7 @@ export function createContextEngine(pluginMeta, hostConfig, hostLogger) { systemPromptAddition: block, }; } catch (err) { - log.warn(`${L} assemble /mem/search failed: ${redactError(err?.message)}`); + log.warn(`${L} assemble /mem/search failed: ${describeError(err)}`); return { messages, estimatedTokens: 0 }; } }, @@ -233,7 +234,7 @@ export function createContextEngine(pluginMeta, hostConfig, hostLogger) { try { await flushAgentMemory(client, { conversationId: sessionKey }, log); } catch (err) { - log.warn(`${L} compact flush failed: ${redactError(err?.message)}`); + log.warn(`${L} compact flush failed: ${describeError(err)}`); } } return { ok: true, compacted: false, reason: "everme: pending memory extraction flushed before compaction" }; @@ -254,7 +255,7 @@ export function createContextEngine(pluginMeta, hostConfig, hostLogger) { }), ]); } catch (err) { - log.warn(`${L} dispose flush failed: ${redactError(err?.message)}`); + log.warn(`${L} dispose flush failed: ${describeError(err)}`); } finally { clearTimeout(timer); } diff --git a/plugins/package-lock.json b/plugins/package-lock.json index 2f63228..84590b2 100644 --- a/plugins/package-lock.json +++ b/plugins/package-lock.json @@ -10,13 +10,17 @@ "memory-mcp", "openclaw", "claude-code", + "kimicode", "codex", + "cursor", + "devin", + "dsh", "cli" ] }, "agent-sdk": { "name": "@everme/agent-sdk", - "version": "0.4.2", + "version": "0.6.1", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -24,10 +28,10 @@ }, "claude-code": { "name": "@everme/claude-code", - "version": "0.4.2", + "version": "0.6.1", "license": "Apache-2.0", "dependencies": { - "@everme/agent-sdk": "^0.4.2" + "@everme/agent-sdk": "^0.6.1" }, "engines": { "node": ">=18.0.0" @@ -35,7 +39,7 @@ }, "cli": { "name": "@everme/cli", - "version": "0.2.4", + "version": "0.32.0", "cpu": [ "x64", "arm64" @@ -56,10 +60,10 @@ }, "codex": { "name": "@everme/codex", - "version": "0.4.2", + "version": "0.6.1", "license": "Apache-2.0", "dependencies": { - "@everme/agent-sdk": "^0.4.2" + "@everme/agent-sdk": "^0.6.1" }, "bin": { "everme-codex": "bin/hook.js" @@ -71,12 +75,68 @@ "node": ">=18.0.0" } }, + "cursor": { + "name": "@everme/cursor", + "version": "0.6.1", + "license": "Apache-2.0", + "dependencies": { + "@everme/agent-sdk": "^0.6.1" + }, + "bin": { + "everme-cursor": "bin/hook.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "devin": { + "name": "@everme/devin", + "version": "0.6.1", + "license": "Apache-2.0", + "dependencies": { + "@everme/agent-sdk": "^0.6.1" + }, + "bin": { + "everme-devin": "bin/hook.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "dsh": { + "name": "@everme/dsh", + "version": "0.6.1", + "license": "Apache-2.0", + "dependencies": { + "@everme/agent-sdk": "^0.6.1" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "0.1.0-rc.6" + }, + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0" + } + }, + "kimicode": { + "name": "@everme/kimicode", + "version": "0.6.1", + "license": "Apache-2.0", + "dependencies": { + "@everme/agent-sdk": "^0.6.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "memory-mcp": { "name": "@everme/memory-mcp", - "version": "0.4.2", + "version": "0.6.1", "license": "Apache-2.0", "dependencies": { - "@everme/agent-sdk": "^0.4.2", + "@everme/agent-sdk": "^0.6.1", "@modelcontextprotocol/sdk": "^1.29.0" }, "bin": { @@ -88,10 +148,123 @@ "node": ">=18.0.0" } }, + "node_modules/@deepseek-ai/cordis": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.1.tgz", + "integrity": "sha512-YBdskTU2Po1kru3GgcUWUbkTsPMA9LkSQDAY8rBkFJeajdgcQad3QPJZE26JyK99Xb6HaASvoXg2DSUTeN/0Nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "@standard-schema/spec": "^1.1.0" + }, + "bin": { + "cordis": "bin.js" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "^1.0.6", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-include": { + "optional": true + }, + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cosmokit": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.2.tgz", + "integrity": "sha512-muBOKtSrUKU5m/xpq8ZXWL6hQ/jgd4PhU2PqH97bcxIiLEJfNwZOGQEx4t/aS/GgxRAR+ra9pMHPMtTHU4sqqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@deepseek-ai/dsh-attachment": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.1.0-rc.8.tgz", + "integrity": "sha512-cCrg4WWiav7pGtbdU8dJTpAG28cPnkWVB2YPOrlBCgBbhQziGcG9Dv67Ti6L3PI4OQKC55gYBFinQghFO/7FGg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.8", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-brand": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.1.0-rc.8.tgz", + "integrity": "sha512-402aUAfHxIZJrArBV4gDf0I/A/69A/By26FX6HTIOyFmCnB4wUBKh8jnglz1CorKWdZy8AKldAhh8HQhQEbGOQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-invariants": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.1.0-rc.8.tgz", + "integrity": "sha512-u0lYqyxOYwfsVnbsfGXZos5vFvA4cqFnBEW3/ezgljNwkYwzeUP/Y5wjPnQjP+ZzBn3CnVeIF6s2N2Vk3iA5mQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + } + }, + "node_modules/@deepseek-ai/dsh-llm": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.1.0-rc.6.tgz", + "integrity": "sha512-kuFGC8bHlzGTwlRxQhXjf3CYWl8M4NzH+EYIkrW8rri4iMc9W53xrdvkil5No/DUlMm8g1u7GdeiWYFy0TMvtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-timeout": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-timeout/-/dsh-timeout-0.1.0-rc.8.tgz", + "integrity": "sha512-RwOC/qHribE6b+LStAO7aAgweefLE5sqDO7Uz6/mnTDyJxCt7HNQnE/Op8sAvRa33d752aUkPfO9S0mgzVAoFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/schemastery": { + "version": "3.18.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.1.tgz", + "integrity": "sha512-Qn0FCSwCQnpnj6SB31I6i2sIKgKWnkbJM8O0EU91Gv2UsYVvtZTl6IA0sCwk2e2MZf5S8w5hpq9QkeVvK9qwxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "@standard-schema/spec": "^1.1.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -106,9 +279,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -123,9 +296,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -140,9 +313,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -157,9 +330,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -174,9 +347,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -191,9 +364,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -208,9 +381,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -225,9 +398,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -242,9 +415,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -259,9 +432,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -276,9 +449,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -293,9 +466,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -310,9 +483,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -327,9 +500,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -344,9 +517,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -361,9 +534,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -378,9 +551,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -395,9 +568,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -412,9 +585,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -429,9 +602,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -446,9 +619,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -463,9 +636,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -480,9 +653,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -497,9 +670,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -514,9 +687,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -546,6 +719,22 @@ "resolved": "codex", "link": true }, + "node_modules/@everme/cursor": { + "resolved": "cursor", + "link": true + }, + "node_modules/@everme/devin": { + "resolved": "devin", + "link": true + }, + "node_modules/@everme/dsh": { + "resolved": "dsh", + "link": true + }, + "node_modules/@everme/kimicode": { + "resolved": "kimicode", + "link": true + }, "node_modules/@everme/memory-mcp": { "resolved": "memory-mcp", "link": true @@ -555,9 +744,9 @@ "link": true }, "node_modules/@hono/node-server": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz", - "integrity": "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", "license": "MIT", "engines": { "node": ">=20" @@ -567,12 +756,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -606,6 +795,13 @@ } } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -677,9 +873,9 @@ } }, "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -884,9 +1080,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -897,32 +1093,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escape-html": { @@ -953,9 +1149,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -1005,9 +1201,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -1030,9 +1226,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -1167,9 +1363,9 @@ } }, "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1218,9 +1414,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -1248,9 +1444,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", - "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -1334,12 +1530,32 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/object-assign": { @@ -1690,9 +1906,9 @@ } }, "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -1761,10 +1977,10 @@ }, "openclaw": { "name": "@everme/openclaw", - "version": "0.4.2", + "version": "0.6.1", "license": "Apache-2.0", "dependencies": { - "@everme/agent-sdk": "^0.4.2" + "@everme/agent-sdk": "^0.6.1" }, "engines": { "node": ">=18.0.0" diff --git a/plugins/package.json b/plugins/package.json index 0cdbfb6..b9a9c64 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -7,11 +7,18 @@ "memory-mcp", "openclaw", "claude-code", + "kimicode", "codex", + "cursor", + "devin", + "dsh", "cli" ], "overrides": { - "@hono/node-server": "^2.0.11" + "@hono/node-server": "^2.0.11", + "hono": "^4.13.3", + "fast-uri": "^3.1.5", + "ip-address": "^10.4.0" }, "scripts": { "test": "npm test --workspaces --if-present", @@ -19,7 +26,12 @@ "test:mcp": "npm test --workspace @everme/memory-mcp", "test:openclaw": "npm test --workspace @everme/openclaw", "test:claude-code": "npm test --workspace @everme/claude-code", + "test:kimicode": "npm test --workspace @everme/kimicode", + "build:codex-marketplace": "npm run build:marketplace --workspace @everme/codex", "test:codex": "npm test --workspace @everme/codex", - "test:cli": "npm test --workspace @everme/cli" + "test:cli": "npm test --workspace @everme/cli", + "test:cursor": "npm test --workspace @everme/cursor", + "test:devin": "npm test --workspace @everme/devin", + "test:dsh": "npm test --workspace @everme/dsh" } }